Models 44

DeepSeek V4 Pro 0813

by DeepSeekreasoning defaultClean
Open in new window

Running live in a sandboxed frame. Click inside it before using the keyboard.

How it went

Total time
5 min 29 s
Cost
$0.088
Output tokens
19,900
Spent thinking
9,872 tokens
Speed
60.6 tok/s
Console errors
0
First token after
696 ms
First visible text after
3 min 5 s
Prompt tokens
492
Code size
43.7 KB
Finish reason
stop
Served by
Wafer
Model id
deepseek/deepseek-v4-pro-0813
Reasoning sent
model default
Max output
943,718 tokens
Generated
Nov 12, 2026

What it was asked

The prompt
Build a polished Snake game in a single HTML file using vanilla JavaScript and a <canvas>. Requirements: - A grid of at least 20×20 cells, centered on the page. - Arrow keys and WASD control the snake. Quick successive key presses must both be honored, but the snake can never reverse onto itself. - Eating food adds 10 points and grows the snake by one cell; new food never spawns on the snake. - Game over when the snake hits a wall or itself, with an overlay showing the score; Space or Enter restarts, P pauses. - Visual polish: smooth movement, a snake with a distinct head and eyes facing its direction, and a small particle burst when eating. - Display the current score and the best score of the session.
System prompt, sent with every prompt
You are generating a self-contained web demo. It will be rendered automatically in a sandboxed iframe and compared side by side with the output of other AI models given the exact same task.

Output rules:
- Reply with exactly ONE fenced code block containing the complete file: ```html (or ```svg when the task asks for an SVG). No text before or after the code block.
- Everything must be inline: CSS in <style>, JavaScript in <script>. No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task.
- Do not use localStorage, sessionStorage, cookies, IndexedDB, alert(), confirm(), prompt() or window.open(): they are blocked in the sandbox and will throw.
- The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar.
- Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction.
- Everything that makes the demo interesting must be visible within the first seconds: nothing hidden behind a high score, a later level or a long play session.
- Games must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, so the game can be judged in action without playing.
- The iframe only receives keyboard events after the user clicks it: if the demo uses the keyboard, display a clear "Click or press a key to start" message.
- The code must run with zero errors in the console of the latest Chrome.

Code run-1.html · 1180 lines

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Snake Game</title>
    <style>
        :root {
            --bg: #0d1117;
            --surface: #161b22;
            --border-color: #30363d;
            --text-primary: #e6edf3;
            --text-secondary: #8b949e;
            --accent-green: #22c55e;
            --accent-green-dark: #15803d;
            --accent-red: #ef4444;
            --accent-yellow: #fbbf24;
            --overlay-bg: rgba(0, 0, 0, 0.65);
            --cell-size: 28px;
            --grid-size: 24;
            --canvas-size: calc(var(--cell-size) * var(--grid-size));
        }

        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            background: var(--bg);
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            font-family: 'Segoe UI', 'Roboto', system-ui, -apple-system, sans-serif;
            overflow: hidden;
            user-select: none;
            -webkit-user-select: none;
            -webkit-tap-highlight-color: transparent;
            padding: 12px;
        }

        .game-container {
            display: flex;
            flex-direction: column;
            align-items: center;
            gap: 14px;
            max-width: 100%;
        }

        .scoreboard {
            display: flex;
            align-items: center;
            gap: 28px;
            background: var(--surface);
            border: 1px solid var(--border-color);
            border-radius: 12px;
            padding: 10px 22px;
            box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
        }

        .scoreboard .score-item {
            display: flex;
            align-items: baseline;
            gap: 8px;
            font-size: 15px;
            font-weight: 600;
            color: var(--text-secondary);
            letter-spacing: 0.3px;
        }

        .scoreboard .score-value {
            font-size: 22px;
            font-weight: 800;
            color: var(--text-primary);
            min-width: 36px;
            text-align: right;
            transition: transform 0.15s ease;
        }

        .scoreboard .score-value.pop {
            transform: scale(1.25);
        }

        .scoreboard .best-value {
            color: var(--accent-yellow);
        }

        .scoreboard .divider {
            width: 1px;
            height: 24px;
            background: var(--border-color);
        }

        .canvas-wrapper {
            position: relative;
            width: var(--canvas-size);
            max-width: 100%;
            aspect-ratio: 1 / 1;
            border-radius: 16px;
            overflow: hidden;
            box-shadow:
                0 0 0 1px var(--border-color),
                0 8px 40px rgba(0, 0, 0, 0.55),
                0 0 60px rgba(34, 197, 94, 0.08);
        }

        canvas {
            display: block;
            width: 100%;
            height: 100%;
            background: #0a0e14;
        }

        .overlay {
            position: absolute;
            inset: 0;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            background: var(--overlay-bg);
            backdrop-filter: blur(4px);
            -webkit-backdrop-filter: blur(4px);
            opacity: 0;
            visibility: hidden;
            transition: opacity 0.3s ease, visibility 0.3s ease;
            z-index: 10;
            cursor: pointer;
        }

        .overlay.visible {
            opacity: 1;
            visibility: visible;
        }

        .overlay .overlay-title {
            font-size: 36px;
            font-weight: 800;
            color: var(--text-primary);
            letter-spacing: -0.5px;
            text-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);
        }

        .overlay .overlay-subtitle {
            font-size: 16px;
            font-weight: 500;
            color: var(--text-secondary);
            margin-top: 8px;
            letter-spacing: 0.3px;
        }

        .overlay .overlay-score {
            font-size: 48px;
            font-weight: 900;
            color: var(--accent-yellow);
            margin-top: 16px;
            text-shadow: 0 2px 16px rgba(251, 191, 36, 0.35);
        }

        .overlay .overlay-hint {
            font-size: 14px;
            font-weight: 500;
            color: var(--text-secondary);
            margin-top: 20px;
            padding: 8px 16px;
            background: rgba(255, 255, 255, 0.06);
            border-radius: 999px;
            letter-spacing: 0.4px;
            animation: pulseHint 2s ease-in-out infinite;
        }

        .overlay .overlay-hint kbd {
            background: rgba(255, 255, 255, 0.12);
            padding: 2px 8px;
            border-radius: 4px;
            font-family: inherit;
            font-size: 13px;
            font-weight: 700;
            color: var(--text-primary);
        }

        .overlay .overlay-best {
            font-size: 13px;
            color: var(--text-secondary);
            margin-top: 6px;
            letter-spacing: 0.3px;
        }

        .overlay .overlay-stats {
            display: flex;
            gap: 28px;
            margin-top: 16px;
        }

        .overlay .overlay-stat {
            text-align: center;
        }

        .overlay .overlay-stat-label {
            font-size: 11px;
            text-transform: uppercase;
            letter-spacing: 1px;
            color: var(--text-secondary);
        }

        .overlay .overlay-stat-value {
            font-size: 28px;
            font-weight: 800;
            color: var(--text-primary);
            margin-top: 4px;
        }

        @keyframes pulseHint {
            0%,
            100% {
                opacity: 0.75;
            }
            50% {
                opacity: 1;
            }
        }

        @media (max-width: 720px) {
            :root {
                --cell-size: 22px;
            }
            .scoreboard {
                padding: 8px 16px;
                gap: 18px;
            }
            .scoreboard .score-value {
                font-size: 18px;
            }
            .scoreboard .score-item {
                font-size: 13px;
                gap: 5px;
            }
            .overlay .overlay-title {
                font-size: 26px;
            }
            .overlay .overlay-score {
                font-size: 34px;
            }
            .overlay .overlay-hint {
                font-size: 12px;
                padding: 6px 12px;
            }
            .overlay .overlay-stat-value {
                font-size: 20px;
            }
            .overlay .overlay-subtitle {
                font-size: 13px;
            }
        }

        @media (max-width: 480px) {
            :root {
                --cell-size: 18px;
            }
            .scoreboard {
                padding: 6px 12px;
                gap: 12px;
                border-radius: 8px;
            }
            .scoreboard .score-value {
                font-size: 16px;
                min-width: 24px;
            }
            .scoreboard .score-item {
                font-size: 11px;
                gap: 3px;
            }
            .scoreboard .divider {
                height: 16px;
            }
            .overlay .overlay-title {
                font-size: 20px;
            }
            .overlay .overlay-score {
                font-size: 26px;
                margin-top: 8px;
            }
            .overlay .overlay-hint {
                font-size: 11px;
                padding: 5px 10px;
                margin-top: 12px;
            }
            .overlay .overlay-subtitle {
                font-size: 11px;
                margin-top: 4px;
            }
            .overlay .overlay-stat-value {
                font-size: 16px;
            }
            .overlay .overlay-stat-label {
                font-size: 9px;
            }
            .overlay .overlay-stats {
                gap: 14px;
                margin-top: 8px;
            }
            .overlay .overlay-best {
                font-size: 11px;
            }
        }
    </style>
</head>
<body>
    <div class="game-container">
        <div class="scoreboard">
            <div class="score-item">
                <span>SCORE</span>
                <span class="score-value" id="scoreDisplay">0</span>
            </div>
            <div class="divider"></div>
            <div class="score-item">
                <span>BEST</span>
                <span class="score-value best-value" id="bestDisplay">0</span>
            </div>
        </div>
        <div class="canvas-wrapper" id="canvasWrapper">
            <canvas id="gameCanvas"></canvas>
            <div class="overlay" id="overlay">
                <div class="overlay-title" id="overlayTitle">SNAKE</div>
                <div class="overlay-subtitle" id="overlaySubtitle">A classic reimagined</div>
                <div class="overlay-score" id="overlayScore" style="display:none;">0</div>
                <div class="overlay-stats" id="overlayStats" style="display:none;">
                    <div class="overlay-stat">
                        <div class="overlay-stat-label">Score</div>
                        <div class="overlay-stat-value" id="finalScore">0</div>
                    </div>
                    <div class="overlay-stat">
                        <div class="overlay-stat-label">Best</div>
                        <div class="overlay-stat-value" id="finalBest">0</div>
                    </div>
                    <div class="overlay-stat">
                        <div class="overlay-stat-label">Length</div>
                        <div class="overlay-stat-value" id="finalLength">3</div>
                    </div>
                </div>
                <div class="overlay-best" id="overlayBest"></div>
                <div class="overlay-hint" id="overlayHint">
                    <kbd>Click</kbd> or press <kbd>any key</kbd> to start
                </div>
            </div>
        </div>
    </div>

    <script>
        (function() {
            // ─── DOM Elements ───────────────────────────────────────────────
            const canvas = document.getElementById('gameCanvas');
            const ctx = canvas.getContext('2d');
            const overlay = document.getElementById('overlay');
            const overlayTitle = document.getElementById('overlayTitle');
            const overlaySubtitle = document.getElementById('overlaySubtitle');
            const overlayScore = document.getElementById('overlayScore');
            const overlayStats = document.getElementById('overlayStats');
            const overlayBest = document.getElementById('overlayBest');
            const overlayHint = document.getElementById('overlayHint');
            const scoreDisplay = document.getElementById('scoreDisplay');
            const bestDisplay = document.getElementById('bestDisplay');
            const finalScoreEl = document.getElementById('finalScore');
            const finalBestEl = document.getElementById('finalBest');
            const finalLengthEl = document.getElementById('finalLength');

            // ─── Constants ─────────────────────────────────────────────────
            const GRID_SIZE = 24;
            const CELL_SIZE = 28;
            const CANVAS_SIZE = GRID_SIZE * CELL_SIZE; // 672
            const TICK_INTERVAL = 150; // ms per game tick
            const BASE_EYE_OFFSET = 9;
            const BASE_PUPIL_RADIUS = 2.8;
            const PARTICLE_COUNT = 22;
            const PARTICLE_LIFETIME = 0.7; // seconds
            const DEMO_RESTART_DELAY = 900; // ms

            // ─── Canvas Setup ──────────────────────────────────────────────
            const dpr = Math.min(window.devicePixelRatio || 1, 2);
            canvas.width = CANVAS_SIZE * dpr;
            canvas.height = CANVAS_SIZE * dpr;
            canvas.style.width = CANVAS_SIZE + 'px';
            canvas.style.height = CANVAS_SIZE + 'px';
            ctx.scale(dpr, dpr);

            // ─── Game State ────────────────────────────────────────────────
            let snake = []; // array of {x, y} grid coords, head first
            let prevSnake = []; // previous tick's snake for interpolation
            let direction = 'right';
            let directionQueue = [];
            let food = { x: 0, y: 0 };
            let score = 0;
            let bestScore = 0;
            let gameState = 'menu'; // 'menu' | 'playing' | 'paused' | 'gameover'
            let tickProgress = 0;
            let lastTickTime = 0;
            let particles = [];
            let demoRestartTimer = 0;
            let demoRestarting = false;
            let pumpTimer = 0;
            let foodPulsePhase = 0;

            // ─── Helper Functions ──────────────────────────────────────────
            function lerp(a, b, t) {
                return a + (b - a) * t;
            }

            function gridToPixel(gx, gy) {
                return {
                    x: gx * CELL_SIZE + CELL_SIZE / 2,
                    y: gy * CELL_SIZE + CELL_SIZE / 2
                };
            }

            function cloneSnake(s) {
                return s.map(seg => ({ x: seg.x, y: seg.y }));
            }

            function isOutOfBounds(x, y) {
                return x < 0 || x >= GRID_SIZE || y < 0 || y >= GRID_SIZE;
            }

            function isOnSnake(x, y, skipHead) {
                const start = skipHead ? 1 : 0;
                for (let i = start; i < snake.length; i++) {
                    if (snake[i].x === x && snake[i].y === y) return true;
                }
                return false;
            }

            function isSnakeCell(x, y) {
                return isOnSnake(x, y, false);
            }

            function randomFreeCell() {
                let attempts = 0;
                while (attempts < 2000) {
                    const x = Math.floor(Math.random() * GRID_SIZE);
                    const y = Math.floor(Math.random() * GRID_SIZE);
                    if (!isSnakeCell(x, y)) return { x, y };
                    attempts++;
                }
                // Fallback: scan all cells
                for (let y = 0; y < GRID_SIZE; y++) {
                    for (let x = 0; x < GRID_SIZE; x++) {
                        if (!isSnakeCell(x, y)) return { x, y };
                    }
                }
                return { x: 0, y: 0 }; // grid full (shouldn't happen)
            }

            function spawnFood() {
                food = randomFreeCell();
            }

            function resetGame() {
                const midX = Math.floor(GRID_SIZE / 2);
                const midY = Math.floor(GRID_SIZE / 2);
                snake = [
                    { x: midX, y: midY },
                    { x: midX - 1, y: midY },
                    { x: midX - 2, y: midY }
                ];
                prevSnake = cloneSnake(snake);
                direction = 'right';
                directionQueue = [];
                score = 0;
                particles = [];
                tickProgress = 0;
                spawnFood();
                updateScoreDisplay();
                demoRestarting = false;
                demoRestartTimer = 0;
            }

            function startPlayerGame() {
                resetGame();
                gameState = 'playing';
                overlay.classList.remove('visible');
                lastTickTime = performance.now();
                tickProgress = 0;
            }

            function startDemoGame() {
                resetGame();
                gameState = 'menu';
                overlay.classList.add('visible');
                overlayTitle.textContent = 'SNAKE';
                overlaySubtitle.textContent = 'A classic reimagined';
                overlayScore.style.display = 'none';
                overlayStats.style.display = 'none';
                overlayBest.textContent = 'Best this session: ' + bestScore;
                overlayHint.innerHTML = '<kbd>Click</kbd> or press <kbd>any key</kbd> to start';
                lastTickTime = performance.now();
                tickProgress = 0;
                demoRestarting = false;
                demoRestartTimer = 0;
            }

            function showGameOver() {
                gameState = 'gameover';
                if (score > bestScore) {
                    bestScore = score;
                    bestDisplay.textContent = bestScore;
                }
                updateScoreDisplay();
                overlay.classList.add('visible');
                overlayTitle.textContent = 'GAME OVER';
                overlaySubtitle.textContent = '';
                overlayScore.style.display = 'block';
                overlayScore.textContent = score;
                overlayStats.style.display = 'flex';
                finalScoreEl.textContent = score;
                finalBestEl.textContent = bestScore;
                finalLengthEl.textContent = snake.length;
                overlayBest.textContent = 'Best this session: ' + bestScore;
                overlayHint.innerHTML = '<kbd>Space</kbd> or <kbd>Enter</kbd> to restart';
            }

            function togglePause() {
                if (gameState === 'playing') {
                    gameState = 'paused';
                    overlay.classList.add('visible');
                    overlayTitle.textContent = 'PAUSED';
                    overlaySubtitle.textContent = 'Take a breath';
                    overlayScore.style.display = 'none';
                    overlayStats.style.display = 'none';
                    overlayBest.textContent = '';
                    overlayHint.innerHTML = 'Press <kbd>P</kbd> to resume';
                } else if (gameState === 'paused') {
                    gameState = 'playing';
                    overlay.classList.remove('visible');
                    lastTickTime = performance.now();
                    tickProgress = 0;
                }
            }

            function updateScoreDisplay() {
                scoreDisplay.textContent = score;
                bestDisplay.textContent = bestScore;
                // Pop animation
                scoreDisplay.classList.add('pop');
                clearTimeout(scoreDisplay._popTimer);
                scoreDisplay._popTimer = setTimeout(() => {
                    scoreDisplay.classList.remove('pop');
                }, 160);
            }

            function spawnFoodParticles(px, py) {
                for (let i = 0; i < PARTICLE_COUNT; i++) {
                    const angle = (Math.PI * 2 * i) / PARTICLE_COUNT + Math.random() * 0.5 - 0.25;
                    const speed = 120 + Math.random() * 180;
                    particles.push({
                        x: px,
                        y: py,
                        vx: Math.cos(angle) * speed,
                        vy: Math.sin(angle) * speed,
                        age: 0,
                        lifetime: PARTICLE_LIFETIME * (0.5 + Math.random() * 0.5),
                        size: 3 + Math.random() * 4,
                        color: Math.random() < 0.5 ?
                            `hsl(${35 + Math.random() * 20}, 100%, ${55 + Math.random() * 20}%)` :
                            `hsl(${0 + Math.random() * 15}, 100%, ${60 + Math.random() * 15}%)`
                    });
                }
            }

            function updateParticles(dt) {
                for (let i = particles.length - 1; i >= 0; i--) {
                    const p = particles[i];
                    p.age += dt;
                    if (p.age >= p.lifetime) {
                        particles.splice(i, 1);
                        continue;
                    }
                    p.x += p.vx * dt;
                    p.y += p.vy * dt;
                    p.vx *= 0.96;
                    p.vy *= 0.96;
                }
            }

            function drawParticles() {
                for (const p of particles) {
                    const alpha = 1 - p.age / p.lifetime;
                    const r = p.size * (1 - p.age / p.lifetime * 0.6);
                    ctx.save();
                    ctx.globalAlpha = alpha;
                    ctx.fillStyle = p.color;
                    ctx.beginPath();
                    ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
                    ctx.fill();
                    // Glow
                    ctx.shadowColor = p.color;
                    ctx.shadowBlur = r * 2;
                    ctx.fill();
                    ctx.restore();
                }
            }

            // ─── Direction Validation ─────────────────────────────────────
            function getOpposite(dir) {
                switch (dir) {
                    case 'up':
                        return 'down';
                    case 'down':
                        return 'up';
                    case 'left':
                        return 'right';
                    case 'right':
                        return 'left';
                    default:
                        return null;
                }
            }

            function isValidDirection(newDir, refDir) {
                if (newDir === refDir) return false;
                if (newDir === getOpposite(refDir)) return false;
                return true;
            }

            function queueDirection(newDir) {
                if (gameState !== 'playing') return;
                const refDir = directionQueue.length > 0 ? directionQueue[directionQueue.length - 1] : direction;
                if (isValidDirection(newDir, refDir)) {
                    directionQueue.push(newDir);
                    // Limit queue length to avoid overflow
                    if (directionQueue.length > 4) directionQueue.shift();
                }
            }

            // ─── Demo AI ──────────────────────────────────────────────────
            function getDemoDirection() {
                const head = snake[0];
                const dx = food.x - head.x;
                const dy = food.y - head.y;
                const absDx = Math.abs(dx);
                const absDy = Math.abs(dy);

                // Build candidate directions
                const candidates = [];
                if (absDx > absDy) {
                    candidates.push(dx > 0 ? 'right' : 'left');
                    candidates.push(dy > 0 ? 'down' : 'up');
                    candidates.push(dy < 0 ? 'up' : 'down');
                    candidates.push(dx < 0 ? 'left' : 'right');
                } else {
                    candidates.push(dy > 0 ? 'down' : 'up');
                    candidates.push(dx > 0 ? 'right' : 'left');
                    candidates.push(dx < 0 ? 'left' : 'right');
                    candidates.push(dy < 0 ? 'up' : 'down');
                }

                // Remove duplicates and invalid (reversing) directions
                const uniqueCandidates = [];
                for (const c of candidates) {
                    if (!uniqueCandidates.includes(c)) {
                        uniqueCandidates.push(c);
                    }
                }

                // Filter out reversing directions
                const safeCandidates = uniqueCandidates.filter(c => {
                    if (c === getOpposite(direction)) return false;
                    const nx = head.x + (c === 'right' ? 1 : c === 'left' ? -1 : 0);
                    const ny = head.y + (c === 'down' ? 1 : c === 'up' ? -1 : 0);
                    if (isOutOfBounds(nx, ny)) return false;
                    // Check self collision - skip tail if not growing
                    // We don't know if the snake will grow, so be conservative
                    if (isOnSnake(nx, ny, true)) return false;
                    // Check dead-end: from the new position, can the snake move anywhere?
                    const futureHead = { x: nx, y: ny };
                    let hasSafeMove = false;
                    const moveDirs = ['up', 'down', 'left', 'right'];
                    for (const md of moveDirs) {
                        if (md === getOpposite(c)) continue;
                        const fx = futureHead.x + (md === 'right' ? 1 : md === 'left' ? -1 : 0);
                        const fy = futureHead.y + (md === 'down' ? 1 : md === 'up' ? -1 : 0);
                        if (isOutOfBounds(fx, fy)) continue;
                        // Check against future body (approximate)
                        let willCollide = false;
                        for (let i = 1; i < snake.length; i++) {
                            if (snake[i].x === fx && snake[i].y === fy) {
                                willCollide = true;
                                break;
                            }
                        }
                        if (!willCollide) {
                            hasSafeMove = true;
                            break;
                        }
                    }
                    return hasSafeMove;
                });

                if (safeCandidates.length > 0) {
                    return safeCandidates[0];
                }

                // Fallback: any safe direction (even if dead-end)
                const fallback = uniqueCandidates.filter(c => {
                    if (c === getOpposite(direction)) return false;
                    const nx = head.x + (c === 'right' ? 1 : c === 'left' ? -1 : 0);
                    const ny = head.y + (c === 'down' ? 1 : c === 'up' ? -1 : 0);
                    if (isOutOfBounds(nx, ny)) return false;
                    return !isOnSnake(nx, ny, true);
                });

                if (fallback.length > 0) {
                    return fallback[0];
                }

                return null; // No safe move
            }

            // ─── Game Tick ────────────────────────────────────────────────
            function gameTick() {
                // Resolve direction from queue or AI
                let newDirection = direction;

                if (gameState === 'playing') {
                    if (directionQueue.length > 0) {
                        newDirection = directionQueue.shift();
                    }
                } else if (gameState === 'menu') {
                    const aiDir = getDemoDirection();
                    if (aiDir) {
                        newDirection = aiDir;
                    } else {
                        // AI is trapped
                        if (demoRestarting) return;
                        demoRestarting = true;
                        demoRestartTimer = DEMO_RESTART_DELAY;
                        return;
                    }
                }

                direction = newDirection;

                // Store previous snake for interpolation
                prevSnake = cloneSnake(snake);

                // Calculate new head position
                const head = snake[0];
                let newHeadX = head.x;
                let newHeadY = head.y;
                switch (direction) {
                    case 'up':
                        newHeadY--;
                        break;
                    case 'down':
                        newHeadY++;
                        break;
                    case 'left':
                        newHeadX--;
                        break;
                    case 'right':
                        newHeadX++;
                        break;
                }

                // Check wall collision
                if (isOutOfBounds(newHeadX, newHeadY)) {
                    if (gameState === 'playing') {
                        showGameOver();
                        return;
                    } else if (gameState === 'menu') {
                        if (demoRestarting) return;
                        demoRestarting = true;
                        demoRestartTimer = DEMO_RESTART_DELAY;
                        return;
                    }
                }

                // Check self collision (skip the tail since it will move unless growing)
                const willGrow = (newHeadX === food.x && newHeadY === food.y);
                const collisionCheckLen = willGrow ? snake.length : snake.length - 1;
                for (let i = 0; i < collisionCheckLen; i++) {
                    if (snake[i].x === newHeadX && snake[i].y === newHeadY) {
                        if (gameState === 'playing') {
                            showGameOver();
                            return;
                        } else if (gameState === 'menu') {
                            if (demoRestarting) return;
                            demoRestarting = true;
                            demoRestartTimer = DEMO_RESTART_DELAY;
                            return;
                        }
                    }
                }

                // Move snake: insert new head, remove tail unless growing
                snake.unshift({ x: newHeadX, y: newHeadY });
                if (!willGrow) {
                    snake.pop();
                }

                // Check food collision
                if (willGrow) {
                    score += 10;
                    if (score > bestScore) {
                        bestScore = score;
                        bestDisplay.textContent = bestScore;
                    }
                    updateScoreDisplay();
                    const foodPx = gridToPixel(food.x, food.y);
                    spawnFoodParticles(foodPx.x, foodPx.y);
                    spawnFood();
                }
            }

            // ─── Rendering ────────────────────────────────────────────────
            function drawGrid() {
                // Background
                ctx.fillStyle = '#0a0e14';
                ctx.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);

                // Grid lines
                ctx.strokeStyle = 'rgba(255, 255, 255, 0.025)';
                ctx.lineWidth = 1;
                for (let i = 0; i <= GRID_SIZE; i++) {
                    const pos = i * CELL_SIZE;
                    ctx.beginPath();
                    ctx.moveTo(pos, 0);
                    ctx.lineTo(pos, CANVAS_SIZE);
                    ctx.stroke();
                    ctx.beginPath();
                    ctx.moveTo(0, pos);
                    ctx.lineTo(CANVAS_SIZE, pos);
                    ctx.stroke();
                }

                // Subtle vignette
                const vignette = ctx.createRadialGradient(
                    CANVAS_SIZE / 2, CANVAS_SIZE / 2, CANVAS_SIZE * 0.3,
                    CANVAS_SIZE / 2, CANVAS_SIZE / 2, CANVAS_SIZE * 0.75
                );
                vignette.addColorStop(0, 'rgba(0, 0, 0, 0)');
                vignette.addColorStop(1, 'rgba(0, 0, 0, 0.35)');
                ctx.fillStyle = vignette;
                ctx.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
            }

            function getInterpolatedSegment(i, t) {
                const curr = snake[i];
                let prev;
                if (i < prevSnake.length) {
                    prev = prevSnake[i];
                } else {
                    prev = curr; // New tail segment (growth) doesn't move
                }
                const px = lerp(prev.x, curr.x, t);
                const py = lerp(prev.y, curr.y, t);
                const pixelPos = gridToPixel(px, py);
                return pixelPos;
            }

            function drawSnake(t) {
                const len = snake.length;
                if (len === 0) return;

                // Draw body segments from tail to head
                for (let i = len - 1; i >= 1; i--) {
                    const pos = getInterpolatedSegment(i, t);
                    const radius = CELL_SIZE * 0.38 + (len - i) * 0.35;
                    const clampedR = Math.min(radius, CELL_SIZE * 0.46);
                    const colorProgress = i / len;
                    const r = Math.round(lerp(20, 34, colorProgress));
                    const g = Math.round(lerp(160, 197, colorProgress));
                    const b = Math.round(lerp(70, 94, colorProgress));

                    ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
                    ctx.beginPath();
                    ctx.arc(pos.x, pos.y, clampedR, 0, Math.PI * 2);
                    ctx.fill();

                    // Subtle highlight
                    ctx.fillStyle = `rgba(255, 255, 255, 0.08)`;
                    ctx.beginPath();
                    ctx.arc(pos.x - clampedR * 0.2, pos.y - clampedR * 0.25, clampedR * 0.5, 0, Math.PI * 2);
                    ctx.fill();
                }

                // Draw head
                const headPos = getInterpolatedSegment(0, t);
                const headRadius = CELL_SIZE * 0.46;
                const headGradient = ctx.createRadialGradient(
                    headPos.x - headRadius * 0.3, headPos.y - headRadius * 0.3, headRadius * 0.2,
                    headPos.x, headPos.y, headRadius
                );
                headGradient.addColorStop(0, '#4ade80');
                headGradient.addColorStop(0.6, '#22c55e');
                headGradient.addColorStop(1, '#15803d');

                ctx.fillStyle = headGradient;
                ctx.beginPath();
                ctx.arc(headPos.x, headPos.y, headRadius, 0, Math.PI * 2);
                ctx.fill();

                // Head glow
                ctx.shadowColor = 'rgba(34, 197, 94, 0.5)';
                ctx.shadowBlur = 14;
                ctx.fillStyle = 'rgba(34, 197, 94, 0.25)';
                ctx.beginPath();
                ctx.arc(headPos.x, headPos.y, headRadius + 2, 0, Math.PI * 2);
                ctx.fill();
                ctx.shadowBlur = 0;
                ctx.shadowColor = 'transparent';

                // Eyes
                const eyeOffset = BASE_EYE_OFFSET;
                const pupilRadius = BASE_PUPIL_RADIUS;
                let eye1X, eye1Y, eye2X, eye2Y;
                const perpendicularOffset = headRadius * 0.5;

                switch (direction) {
                    case 'right':
                        eye1X = headPos.x + headRadius * 0.45;
                        eye1Y = headPos.y - perpendicularOffset;
                        eye2X = headPos.x + headRadius * 0.45;
                        eye2Y = headPos.y + perpendicularOffset;
                        break;
                    case 'left':
                        eye1X = headPos.x - headRadius * 0.45;
                        eye1Y = headPos.y - perpendicularOffset;
                        eye2X = headPos.x - headRadius * 0.45;
                        eye2Y = headPos.y + perpendicularOffset;
                        break;
                    case 'up':
                        eye1X = headPos.x - perpendicularOffset;
                        eye1Y = headPos.y - headRadius * 0.45;
                        eye2X = headPos.x + perpendicularOffset;
                        eye2Y = headPos.y - headRadius * 0.45;
                        break;
                    case 'down':
                        eye1X = headPos.x - perpendicularOffset;
                        eye1Y = headPos.y + headRadius * 0.45;
                        eye2X = headPos.x + perpendicularOffset;
                        eye2Y = headPos.y + headRadius * 0.45;
                        break;
                }

                // White of eyes
                ctx.fillStyle = '#ffffff';
                ctx.beginPath();
                ctx.arc(eye1X, eye1Y, pupilRadius * 1.9, 0, Math.PI * 2);
                ctx.fill();
                ctx.beginPath();
                ctx.arc(eye2X, eye2Y, pupilRadius * 1.9, 0, Math.PI * 2);
                ctx.fill();

                // Pupils
                ctx.fillStyle = '#0a0e14';
                ctx.beginPath();
                ctx.arc(eye1X, eye1Y, pupilRadius, 0, Math.PI * 2);
                ctx.fill();
                ctx.beginPath();
                ctx.arc(eye2X, eye2Y, pupilRadius, 0, Math.PI * 2);
                ctx.fill();

                // Tiny eye glint
                ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
                ctx.beginPath();
                ctx.arc(eye1X - pupilRadius * 0.4, eye1Y - pupilRadius * 0.4, pupilRadius * 0.55, 0, Math.PI * 2);
                ctx.fill();
                ctx.beginPath();
                ctx.arc(eye2X - pupilRadius * 0.4, eye2Y - pupilRadius * 0.4, pupilRadius * 0.55, 0, Math.PI * 2);
                ctx.fill();
            }

            function drawFood(t) {
                const foodPx = gridToPixel(food.x, food.y);
                const pulseScale = 1 + Math.sin(foodPulsePhase) * 0.1;
                const foodRadius = CELL_SIZE * 0.34 * pulseScale;

                // Glow
                const glow = ctx.createRadialGradient(
                    foodPx.x, foodPx.y, foodRadius * 0.3,
                    foodPx.x, foodPx.y, foodRadius * 2.8
                );
                glow.addColorStop(0, 'rgba(239, 68, 68, 0.4)');
                glow.addColorStop(0.5, 'rgba(239, 68, 68, 0.1)');
                glow.addColorStop(1, 'rgba(239, 68, 68, 0)');
                ctx.fillStyle = glow;
                ctx.beginPath();
                ctx.arc(foodPx.x, foodPx.y, foodRadius * 2.8, 0, Math.PI * 2);
                ctx.fill();

                // Food body
                const foodGradient = ctx.createRadialGradient(
                    foodPx.x - foodRadius * 0.25, foodPx.y - foodRadius * 0.3, foodRadius * 0.15,
                    foodPx.x, foodPx.y, foodRadius
                );
                foodGradient.addColorStop(0, '#f87171');
                foodGradient.addColorStop(0.55, '#ef4444');
                foodGradient.addColorStop(1, '#b91c1c');
                ctx.fillStyle = foodGradient;
                ctx.beginPath();
                ctx.arc(foodPx.x, foodPx.y, foodRadius, 0, Math.PI * 2);
                ctx.fill();

                // Highlight
                ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
                ctx.beginPath();
                ctx.arc(foodPx.x - foodRadius * 0.25, foodPx.y - foodRadius * 0.3, foodRadius * 0.3, 0, Math.PI * 2);
                ctx.fill();

                // Stem
                ctx.strokeStyle = '#14532d';
                ctx.lineWidth = 2;
                ctx.beginPath();
                ctx.moveTo(foodPx.x, foodPx.y - foodRadius);
                ctx.quadraticCurveTo(foodPx.x + foodRadius * 0.2, foodPx.y - foodRadius * 1.4, foodPx.x + foodRadius *
                    0.5, foodPx.y - foodRadius * 1.3);
                ctx.stroke();

                // Leaf
                ctx.fillStyle = '#22c55e';
                ctx.beginPath();
                ctx.ellipse(foodPx.x + foodRadius * 0.55, foodPx.y - foodRadius * 1.25, foodRadius * 0.4, foodRadius * 0.22,
                    -0.4, 0, Math.PI * 2);
                ctx.fill();
                ctx.fillStyle = '#16a34a';
                ctx.beginPath();
                ctx.ellipse(foodPx.x + foodRadius * 0.4, foodPx.y - foodRadius * 1.15, foodRadius * 0.28, foodRadius * 0.16,
                    0.3, 0, Math.PI * 2);
                ctx.fill();
            }

            function render(t) {
                drawGrid();
                drawSnake(t);
                drawFood(t);
                drawParticles();
            }

            // ─── Main Loop ────────────────────────────────────────────────
            function mainLoop(timestamp) {
                requestAnimationFrame(mainLoop);

                const dt = Math.min((timestamp - lastTickTime) / 1000, 0.5);
                lastTickTime = timestamp;

                foodPulsePhase += dt * 5;

                // Update particles
                updateParticles(dt);

                // Handle demo restart
                if (demoRestarting) {
                    demoRestartTimer -= dt * 1000;
                    if (demoRestartTimer <= 0) {
                        demoRestarting = false;
                        if (gameState === 'menu') {
                            resetGame();
                            lastTickTime = performance.now();
                            tickProgress = 0;
                        }
                    }
                }

                // Only tick when playing or in menu (demo)
                if (gameState === 'playing' || gameState === 'menu') {
                    tickProgress += dt * (1000 / TICK_INTERVAL);
                    if (tickProgress >= 1) {
                        tickProgress -= 1;
                        if (tickProgress > 1) tickProgress = 1;
                        gameTick();
                    }
                }

                // Clamp tickProgress for rendering
                const renderT = Math.min(Math.max(tickProgress, 0), 1);
                render(renderT);
            }

            // ─── Input Handling ────────────────────────────────────────────
            const KEY_DIRECTIONS = {
                'ArrowUp': 'up',
                'ArrowDown': 'down',
                'ArrowLeft': 'left',
                'ArrowRight': 'right',
                'w': 'up',
                'W': 'up',
                's': 'down',
                'S': 'down',
                'a': 'left',
                'A': 'left',
                'd': 'right',
                'D': 'right'
            };

            document.addEventListener('keydown', (e) => {
                const key = e.key;

                // Prevent page scrolling on arrow keys and space
                if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(key)) {
                    e.preventDefault();
                }

                if (gameState === 'menu') {
                    // Any key starts the game
                    startPlayerGame();
                    return;
                }

                if (gameState === 'gameover') {
                    if (key === ' ' || key === 'Enter') {
                        e.preventDefault();
                        startPlayerGame();
                    }
                    return;
                }

                if (gameState === 'paused') {
                    if (key === 'p' || key === 'P') {
                        togglePause();
                    }
                    return;
                }

                if (gameState === 'playing') {
                    if (key === 'p' || key === 'P') {
                        togglePause();
                        return;
                    }
                    const mappedDir = KEY_DIRECTIONS[key];
                    if (mappedDir) {
                        queueDirection(mappedDir);
                    }
                }
            });

            // Click on overlay to start/restart
            overlay.addEventListener('click', () => {
                if (gameState === 'menu') {
                    startPlayerGame();
                } else if (gameState === 'gameover') {
                    startPlayerGame();
                } else if (gameState === 'paused') {
                    togglePause();
                }
            });

            // Also click on canvas wrapper to start if not clicking overlay
            document.getElementById('canvasWrapper').addEventListener('click', (e) => {
                if (e.target === overlay) return;
                if (gameState === 'menu') {
                    startPlayerGame();
                } else if (gameState === 'gameover') {
                    startPlayerGame();
                } else if (gameState === 'paused') {
                    togglePause();
                }
            });

            // ─── Initialization ───────────────────────────────────────────
            function init() {
                resetGame();
                gameState = 'menu';
                overlay.classList.add('visible');
                overlayTitle.textContent = 'SNAKE';
                overlaySubtitle.textContent = 'A classic reimagined';
                overlayScore.style.display = 'none';
                overlayStats.style.display = 'none';
                overlayBest.textContent = 'Best this session: 0';
                overlayHint.innerHTML = '<kbd>Click</kbd> or press <kbd>any key</kbd> to start';
                lastTickTime = performance.now();
                tickProgress = 0;
                requestAnimationFrame(mainLoop);
            }

            init();
        })();
    </script>
</body>
</html>
Reasoning trace (9,872 tokens)
The user wants a polished Snake game in a single HTML file. Let me build this with all the requirements:

1. Grid of at least 20×20 cells, centered on the page
2. Arrow keys and WASD control, with input queue (quick successive key presses honored), no reversing
3. Food: +10 points, grows snake by 1, never spawns on snake
4. Game over on wall/self collision, overlay showing score, Space/Enter restarts, P pauses
5. Visual polish: smooth movement (interpolated), distinct head with eyes facing direction, particle burst on eating
6. Current score and best score (session best)
7. Self-playing demo mode behind a "Click or press a key to start" message

Let me design this carefully:

**Grid**: 24×24 cells, each cell ~28px, canvas ~672×672, centered with CSS flexbox.

**Smooth movement**: Instead of discrete jumps, I'll interpolate the snake's position between grid cells using a timer. The snake moves at a fixed speed but positions are interpolated between previous and current grid positions for smooth rendering.

**Input queue**: Store queued directions. Process one per tick. Validate against reversing.

**Food**: Random spawn not on snake. Different look (maybe pulsing).

**Particles**: On eating, spawn particles at the food location that burst outward and fade.

**Head with eyes**: Draw the head as a rounded rect with eyes based on facing direction.

**Best score**: Session-based, stored in a variable (not localStorage).

**Demo mode**: Simple AI that moves toward food, avoiding walls and itself. Shows "Click or press a key to start" overlay.

**Pause**: P key toggles pause.

**Game over overlay**: Shows score, best score, "Press Space or Enter to restart".

Let me structure the code:

- Game state management
- Input handling with queue
- Update loop with smooth interpolation
- Rendering with canvas
- Particle system
- Demo AI

Let me think about the smooth movement more carefully. I'll use a fixed timestep approach where the snake moves one cell per "tick", but the rendering interpolates between the previous position and current position based on how far through the current tick we are.

Actually, for smooth movement, I'll track:
- `snake` array of grid positions
- `prevSnake` array of grid positions (from before the last tick)
- `tickProgress` (0 to 1) indicating how far through the current tick we are
- Each tick, the snake moves one cell, and we store the previous positions

For rendering, interpolate each segment between prevSnake[i] and snake[i] using tickProgress.

This gives smooth continuous movement while maintaining the grid-based game logic.

For the input queue: when a key is pressed, validate it against the last direction in the queue (or current direction if queue is empty). If valid, add to queue. Each tick, shift from queue. This ensures quick successive presses are honored.

Let me also think about the demo AI:
- Simple algorithm: determine the direction toward food, check if safe (not hitting wall or self), if not safe, try other directions in order of preference.
- A simple greedy approach with basic wall/self avoidance.

For the pause functionality, P toggles pause. When paused, show a pause overlay.

Let me also add a subtle grid pattern on the canvas for visual appeal.

Colors:
- Background: dark green (#1a1a2e or similar)
- Grid lines: subtle
- Snake body: gradient from green to darker green
- Snake head: brighter green with eyes
- Food: red/orange with pulsing effect
- Particles: yellow/orange

Let me write the code now.

Actually, for the eyes to face the direction, I need to know the current direction. The eyes should be positioned based on which way the snake is moving.

Let me carefully plan the canvas size. Grid is 24×24, cell size 28px, so canvas is 672×672. That's a good size for 1280×800 viewport.

On mobile (375px wide), I'll need to scale it down. I can use CSS max-width and responsive sizing, or I can make the canvas resize based on viewport. Let me use CSS to handle that - set the canvas to a max size and scale it down proportionally.

Actually, a simpler approach: use CSS to size the canvas container, and the canvas element itself has a fixed size but is displayed at a scaled size using CSS. Or I can make the cell size dynamic based on viewport.

Let me use a fixed cell size for the canvas resolution (28px per cell, 24×24 grid = 672×672 canvas), and use CSS to scale it down on smaller screens while maintaining aspect ratio.

For the demo mode: I'll implement a simple AI that plays the game. It tries to move toward the food, and if the direct path is blocked, it tries to turn.

Demo AI strategy:
1. Get the current direction
2. Check if moving toward food is safe
3. If yes, go toward food
4. If not, try clockwise/counterclockwise turns
5. Fallback to any safe direction

For particles: when food is eaten, spawn 15-20 particles at the food position with random velocities, they fade out over ~0.5 seconds.

Let me also add a subtle shadow/glow effect for polish.

Now let me write the full code:

```javascript
// Game constants
const GRID_SIZE = 24;
const CELL_SIZE = 28;
const CANVAS_SIZE = GRID_SIZE * CELL_SIZE; // 672
const TICK_INTERVAL = 150; // ms per tick

// Game state
let snake, prevSnake, direction, queuedDirections, food, score, bestScore, gameState, tickProgress, particles, lastTickTime;

// gameState: 'menu', 'playing', 'paused', 'gameover'
```

Let me refine the smooth rendering approach. Each tick, the snake moves. I'll store the previous positions and current positions, and interpolate.

For the snake segments: snake[0] is head, snake[1..n] are body. When moving, the new head position is computed, the body follows (each segment takes the position of the one before it), and the tail is removed unless growing.

For interpolation, I need prevSnake to be the positions from before the tick. So:
- At the start of a tick: prevSnake = [...snake] (deep copy)
- Then compute new snake positions
- During rendering, lerp between prevSnake[i] and snake[i] based on tickProgress

Actually, there's a subtlety: when the snake grows, the length changes. prevSnake needs to handle this. When growing, the tail stays in place for one tick. So prevSnake would have one fewer element. I'll handle this by making prevSnake have the same length but with the tail repeated or something.

Actually, a simpler approach: prevSnake is a copy of snake before the tick. When the snake grows, snake gets one longer, so prevSnake has one fewer entry. For the new segment (the tail that stays), its prev position is the same as its current position (it doesn't move). So I can pad prevSnake with a duplicate of the last position.

Let me think... when growing:
- Before tick: snake has N segments, prevSnake has N entries (copy of snake)
- After tick: snake has N+1 segments. The new head is added, and the rest shift but the last one stays (the tail doesn't move during growth).
- So for rendering, the first N segments of the new snake correspond to prevSnake entries, and the last (new tail) has prev position = its current position (no movement).

I'll handle this in the rendering: for segment i, prev position is prevSnake[i] if i < prevSnake.length, else snake[i].

OK, let me now write the complete code.

For the input queue:
- `directionQueue` array of directions
- When a key is pressed, get the new direction
- Check if it's valid (not reversing relative to the last queued direction or current direction)
- If valid, add to queue
- Each tick, shift from queue and use as the new direction

For the demo AI:
- Runs during 'menu' state as a self-playing demo
- Simple AI: try to go toward food, avoiding walls and self
- When the demo snake dies, restart automatically

Let me also make the food visually appealing with a pulsing effect.

I'll use devicePixelRatio for crisp rendering on high-DPI displays.

Actually, for simplicity and since the canvas is fixed at 672×672, I'll use simple 2D context rendering. The canvas resolution is 672×672 which is fine.

Let me think about the responsive design. On a 375px wide screen, 672px canvas would be too big. I'll use CSS to scale it:
- `canvas { max-width: 100%; height: auto; }` with a wrapper that has padding.

Actually, for the canvas to scale properly, I should set the CSS width/height. I can use `max-width: min(90vw, 672px)` and let the height auto-adjust. The canvas will be displayed at a smaller size but still have the full resolution for drawing.

Or better: I can use CSS to set the canvas display size relative to the viewport. On small screens, the canvas will be smaller but still proportional.

Let me handle this with a wrapper that uses `max-width: min(95vw, 672px)` and the canvas inside with `width: 100%`.

For the overlay messages, I'll draw them directly on the canvas or use HTML overlays. HTML overlays are easier for button clicks and styling.

Actually, I'll use HTML overlays for the start screen and game over screen, and draw the pause message on canvas or use HTML.

Let me use HTML overlays positioned over the canvas for the menu and game over states, and a pause state indicator.

Let me now write the full file.

One more thing: the snake should never reverse. This means:
- If current direction is 'up', you can't go 'down'
- If current direction is 'down', you can't go 'up'
- If current direction is 'left', you can't go 'right'
- If current direction is 'right', you can't go 'left'

For the input queue, the reference direction for validation is the last queued direction (or current direction if queue is empty).

Now let me write the code. I'll structure it cleanly.

For the demo AI, I'll make it reasonably smart:
1. Look at the direction toward food
2. Try that direction first if it's safe
3. If not safe, try alternative directions
4. Use wall and self-collision detection

Let me implement the AI with a simple search: try the direction toward food, if unsafe, try perpendicular directions, if still unsafe, try any safe direction.

A more robust AI would do pathfinding, but for a demo, a greedy approach with basic avoidance should be fine. The demo will occasionally die, which is fine - it restarts automatically.

Actually, for a better demo, let me make the AI a bit smarter using a simple heuristic: for each possible direction, check if the next cell is safe (not wall, not snake body), and prefer directions that move toward the food. If no safe direction, die (game over → auto restart in demo mode).

Let me code this up now.

For the particles:
- When food is eaten, spawn ~20 particles at the food's pixel position
- Each particle has a random velocity (outward burst)
- Particles fade out over ~0.5-0.8 seconds
- Draw as small circles with fading alpha

I also want to add a subtle glow effect to the food and the snake's head.

Here's my plan for the code structure:

```
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');

// Constants
// Game state
// Input handling
// Game logic (tick, move snake, check collisions, place food)
// Rendering (draw snake, food, particles, grid)
// Particles system
// Demo AI
// Main loop (requestAnimationFrame)
// Event listeners
```

Let me also handle the tick timing. I'll use a timestamp-based system where:
- Each tick is TICK_INTERVAL ms
- `tickProgress` goes from 0 to 1 within each tick
- When tickProgress reaches 1, the snake moves (game tick)
- If the game is in 'menu' state (demo), the AI drives the snake

For the main loop, I'll use `requestAnimationFrame` and track time.

Wait, for the demo mode, the snake should keep moving (self-playing). In menu state, the game runs with AI controls. When the player clicks or presses a key, the game transitions to 'playing' state with player controls.

Let me refine the state machine:
- 'menu': demo AI plays, overlay says "Click or press a key to start"
- 'playing': player controls
- 'paused': game paused, overlay says "Paused - Press P to resume"
- 'gameover': overlay shows score, "Press Space or Enter to restart"

In 'menu' state, when the demo snake dies, it auto-restarts after a brief delay.

OK, I think I've planned enough. Let me write the full code.

For the eyes on the head, I need to know which direction the snake is facing. The eyes should be two small white circles with black pupils, positioned according to the direction.

When moving:
- Up: eyes on the top of the head
- Down: eyes on the bottom
- Left: eyes on the left
- Right: eyes on the right

For the head shape, I'll draw a rounded rectangle or circle. A circle is simpler and looks good. For a more snake-like appearance, I could use a slightly elongated shape in the direction of movement.

Let me use a rounded rectangle for the head, slightly elongated in the direction of movement.

Actually, for simplicity and visual quality, I'll draw the head as a circle with eyes. The eyes face the direction of movement. This is clean and polished.

For the body, I'll draw each segment as a rounded rectangle or circle, slightly smaller than the head. The body can have a gradient from bright green (near head) to darker green (tail).

For smooth interpolation rendering, each segment's position is interpolated between its previous and current grid position. The position is calculated as: `(prevPos * (1 - t) + currPos * t) * CELL_SIZE + CELL_SIZE/2`.

Let me also add subtle board grid lines to give the canvas a nice texture.

Now let me write the complete HTML file. I'll be very careful with the details.

Actually, for the cell size, let me reconsider. 24×24 grid at 28px = 672px canvas. On a 1280×800 viewport, this is fine. On 375px wide, it'll need to scale down. I'll handle this with CSS `max-width: 100%`.

Let me also think about the score display. I'll display it above the canvas, along with the best score. Using HTML for this is cleaner than drawing on canvas.

Structure:
```
<div class="game-container">
  <div class="scoreboard">
    <span>Score: <span id="score">0</span></span>
    <span>Best: <span id="best">0</span></span>
  </div>
  <div class="canvas-wrapper">
    <canvas></canvas>
    <div class="overlay" id="overlay">...</div>
  </div>
</div>
```

OK, I'll write this now. Let me make sure everything is polished.

One thing about the "quick successive key presses" requirement: I need to implement a direction queue. When multiple keys are pressed between ticks, all valid ones should be queued. Each tick processes one direction from the queue.

For the queue validation: when adding a direction to the queue, I check it against the last direction in the queue (or current direction if queue is empty). This ensures no reversing within the queue.

Wait, actually the standard approach is: you can't reverse relative to the currently applied direction. So if the snake is moving right, you can press up then left quickly. The up direction is applied (snake starts moving up), and then left is applied. This is valid because at the time left is applied, the snake is moving up, and left is perpendicular to up.

But if you're moving right and press left, that's a reversal and should be ignored.

So the queue validation should check against the last direction in the queue OR the current direction if the queue is empty. Each time the snake changes direction, the "reference" direction updates.

I think the cleanest approach:
- `currentDirection` is the direction the snake is currently moving
- `directionQueue` is a queue of pending directions
- When a direction is added, it's validated against the last item in the queue (or currentDirection if queue is empty)
- Each tick, shift from queue and set as currentDirection (if queue is not empty)

This way, if the player presses Up then Left quickly while moving Right:
- Up is validated against Right → valid (perpendicular), added to queue
- Left is validated against Up → valid (perpendicular), added to queue
- Tick 1: shift Up → currentDirection = Up, snake moves up
- Tick 2: shift Left → currentDirection = Left, snake moves left

This works correctly!

Now let me also think about the food spawn. It should never spawn on the snake. I'll generate a random position and check if it's on the snake. If it is, try again. For a 24×24 grid (576 cells) with a relatively short snake, random placement should work fine.

OK, I'm ready to write the code. Let me also add a nice color scheme and some visual flair.

Colors:
- Background: #0f1a2e (dark navy)
- Grid lines: rgba(255, 255, 255, 0.03)
- Snake body: gradient from #4ade80 (bright green) to #16a34a (darker green)
- Snake head: #22c55e (bright green)
- Eyes: white with dark pupils
- Food: #ef4444 (red) with glow
- Particles: #fbbf24 (amber/yellow) fading out

Actually, let me go with a more classic look:
- Background: #111827 (dark gray)
- Grid: subtle lines
- Snake: greens
- Food: red with glow

Alright, let me finalize and write the code.

For the "smooth movement" requirement, I'm using interpolation. The tick interval is 150ms, and within that interval, the snake's position smoothly transitions from the previous grid position to the new one. This gives a very smooth appearance.

For the demo AI, I'll make it simple but effective. On each tick:
1. Calculate direction to food (dx, dy)
2. Candidate directions sorted by proximity to food direction
3. For each candidate, check if the resulting position is safe (not wall, not self)
4. Pick the first safe direction
5. If no safe direction exists, the snake dies (which triggers game over → auto restart in demo)

Let me also add a brief pause after the demo snake dies before auto-restarting, so the player can see the game over state briefly.

Actually, in menu/demo mode, I'll skip the game over screen and just auto-restart. Or maybe show a brief "demo restarting..." message. I think auto-restarting quickly is fine.

For the "P" to pause: when in 'playing' state, pressing P toggles 'paused'. When in 'paused', pressing P resumes.

For "Space or Enter to restart" on game over: both should work.

Let me also handle the "Click or press a key to start" message. When the user clicks the canvas or presses any control key (arrows, WASD, space, enter), the game transitions from 'menu' to 'playing'.

Now, one important detail: the iframe only receives keyboard events after the user clicks it. So the initial "Click or press a key to start" message needs to be clickable or the user needs to click somewhere. I'll make the overlay clickable and also listen for keyboard events.

Let me also ensure the demo mode starts immediately on page load and is visible in the first 3 seconds.

Alright, I think I've planned thoroughly. Let me write the complete code now.

One more thing - for the particle system, I'll create particles with:
- Position (x, y in pixels)
- Velocity (vx, vy in pixels per second)
- Lifetime (seconds)
- Current age (seconds)
- Color
- Size

Particles update in the main loop (dt-based) and are drawn with fading opacity.

Let me write the complete HTML file now.

Actually, I want to make the demo AI reasonably good so it looks impressive. Let me think about a simple but effective algorithm.

Simple greedy AI:
1. Direction toward food: calculate dx = food.x - head.x, dy = food.y - head.y
2. Preferred directions: the axis with the larger absolute difference is preferred first
3. For each preferred direction, check safety (not wall, not snake body)
4. If safe, choose it. If not, try the perpendicular directions.
5. If still not safe, try any safe direction.
6. If no safe direction, die.

This is simple and the snake will go toward food most of the time, occasionally making bad decisions that lead to death. For a demo, that's fine - the snake plays, occasionally dies, and restarts.

Actually, to avoid the demo looking dumb by running into walls constantly, let me add a simple look-ahead: before choosing a direction, check if moving in that direction would lead to a dead end (no safe moves from the next position). If so, avoid that direction.

This is a bit more complex but would make the demo much better. Let me implement a 1-step lookahead:
- For each possible direction, simulate moving one step
- From the new position, check if there's at least one safe direction to move
- If not, this direction leads to a dead end, avoid it
- Prefer directions that move toward food and don't lead to dead ends

This should make the demo look quite good. The snake will generally survive longer and make smarter decisions.

Let me implement this. But I'll keep it relatively simple - just a 1-step lookahead for dead-end detection.

Actually, even simpler: just check if the next position is safe (not wall, not self). This works well for most cases. The snake will occasionally trap itself, but that's realistic and fine for a demo.

Let me go with the simple greedy approach with 1-step safety check. Good enough.

OK, I've been planning too long. Let me write the code now.

I also need to make sure the canvas doesn't cause a horizontal scrollbar on mobile. I'll use `max-width: 100%` on the canvas and ensure the container has appropriate padding.

For the canvas rendering, I'll use `canvas.width = 672` and `canvas.height = 672` (24 cells × 28px). The CSS will scale it down if needed.

Let me also add `touch-action: none` on the canvas for mobile, and handle touch events for swiping? The requirements don't mention touch controls, so I'll skip that. But I should ensure the game is still playable on mobile via the demo mode at least.

Actually, the requirements only mention arrow keys and WASD for controls, plus Space/Enter/P. So I'll focus on keyboard controls.

Now, let me write the full code. I'll be comprehensive and detailed.

For the eyes, I need to place them based on the direction:
- Right: eyes at (headX + offset, headY - eyeOffset) and (headX + offset, headY + eyeOffset)
- Left: eyes at (headX - offset, headY - eyeOffset) and (headX - offset, headY + eyeOffset)
- Up: eyes at (headX - eyeOffset, headY - offset) and (headX + eyeOffset, headY - offset)
- Down: eyes at (headX - eyeOffset, headY + offset) and (headX + eyeOffset, headY + offset)

Where offset is about 30-35% of the cell size and eyeOffset is about 25% of cell size.

Let me finalize cell size. 28px is good. Eyes could be at about 8-10px offset.

OK, writing the code now. I'll produce a complete, polished file.

One final note about the game over overlay: it should show the final score and best score, and prompt "Press Space or Enter to restart". I'll use an HTML overlay for this with a semi-transparent background.

For the pause overlay, I'll use a simpler HTML overlay or just a canvas-drawn indicator. Using HTML is cleaner.

Alright, let me write the code. I'll aim for clean, well-structured JavaScript.

Actually, I realize I should make the demo mode start immediately with the AI playing behind the "Click or press a key to start" overlay. The overlay should be semi-transparent so the game is visible behind it. The snake should be moving (AI-controlled) and the food should be visible.

Here's another important consideration: the game tick rate for the demo should be the same as normal gameplay (150ms per tick). This gives consistent visuals whether in demo or player mode.

Now, one thing to be careful about: in the demo mode, the snake should look active and playing. If the demo snake dies, it should auto-restart after a short delay. This creates an engaging background for the start screen.

Let me also make the "Click or press a key to start" overlay clearly visible but not covering the entire game. Maybe a semi-transparent dark overlay with the text in the center.

OK, writing the actual code now. Here's my comprehensive plan:

HTML structure:
- Main container (centered with flexbox)
- Scoreboard (score + best score)
- Canvas wrapper
  - Canvas element
  - Overlay div (for menu/gameover/pause states)

CSS:
- Dark theme
- Centered layout
- Scoreboard styling
- Canvas responsive sizing
- Overlay styling with semi-transparent backgrounds

JavaScript:
- Constants
- State management
- Input handling (keyboard events, queue)
- Game logic (tick function)
- Rendering (draw function with interpolation)
- Particles
- Demo AI
- Main loop
- Event listeners

Let me write this all out now. I'll also handle the edge case where the snake grows and the prevSnake array needs to accommodate the growth.

For food rendering: I'll draw it as a red circle with a glow effect. The glow can be a radial gradient.

For the snake body: I'll draw segments from tail to head, with the head last (on top). Each segment is a circle or rounded rect. The body segments get slightly smaller toward the tail for a natural look. Colors transition from green at the head to darker green at the tail.

Actually, for the rounded rectangle approach: snake body segments look better as squares/rounded rects that connect to form a smooth body. But circles are simpler and look fine. Let me use circles for the body and a slightly larger circle for the head with eyes.

For the smooth interpolation rendering, the interpolated positions are used for drawing. This means the snake appears to slide smoothly across the grid.

I also want to add a subtle trail/glow effect for the food to make it more noticeable.

OK, finally writing the code. Let me be very precise.

One more thing: for the best score, since we can't use localStorage, I'll keep it as a variable in memory (session best). This means it resets when the page reloads, which is fine per the requirements ("best score of the session").

Let me also handle the case where the player presses a key while in 'gameover' state - Space or Enter restarts, other keys do nothing.

And for the 'paused' state, P resumes, other keys don't work unless they're P to resume.

Actually, for cleaner UX: in 'paused' state, any direction key or P resumes. Or just P. I'll go with just P (and clicking the overlay).

OK, writing the complete code now. This will be a long file but well-structured.

Hmm, I should also make sure that when the game transitions from 'menu' (demo) to 'playing' (player), the snake and food are reset to initial state. Otherwise the player inherits the demo's game state, which could be mid-game. Actually, I think resetting is cleaner - the player starts fresh.

But wait, maybe it's better to keep the demo's game state? That would be smoother. The demo plays, the player takes over, and the game continues. That's actually pretty cool for a self-playing demo. The player can see the game in action and then take control.

I'll go with resetting to a fresh state when the player starts. It's cleaner and more predictable. The "Click or press a key to start" → fresh game is a standard pattern.

Actually, let me think about this differently. The demo is there to show the game in action. When the user clicks, they want to play. Starting fresh is the expected behavior. So I'll reset the game state when the user transitions from menu to playing.

OK, one final consideration: should the game start at a specific snake length? I'll start with a length of 3 (head + 2 body segments) at the center of the grid, moving right. This is standard.

Let me now write the complete code. Here goes:

Actually, I need to be careful about how I handle the tick interval. In demo mode, I want the AI to make decisions at the same rate as the player would. The tick interval is 150ms, so the AI evaluates and moves every 150ms. This is fine.

For the main loop, I'll use `requestAnimationFrame` and track elapsed time. The tick timer accumulates time, and when it exceeds TICK_INTERVAL, a game tick occurs and the timer resets.

`tickProgress` = timeSinceLastTick / TICK_INTERVAL, clamped to [0, 1]. This is used for interpolation.

When a game tick occurs:
1. Shift direction from queue (if any)
2. Calculate new head position
3. Check for wall/self collision
4. Check for food collision
5. Update snake positions
6. Update prevSnake
7. If food eaten, spawn new food + particles + grow snake

For the growth mechanic: when food is eaten, the snake grows. Instead of moving the tail, the tail stays in place for one tick. In my implementation, I'll do this: when food is eaten, do NOT remove the tail. The snake array gets longer by 1. The prevSnake needs to handle this by padding with the same position as the tail (which doesn't move).

For the prevSnake to handle growth:
- Before tick: prevSnake = snake.map(s => ({...s})) (deep copy)
- After tick: snake may be longer. For segments beyond prevSnake's length, their prev position is the same as current position (they didn't move in this tick).

In the rendering loop, for segment i:
- If i < prevSnake.length: interpolate between prevSnake[i] and snake[i]
- Else: no interpolation (use snake[i] directly)

This works correctly.

OK, I'm going to write the complete code now. Here it is.

For drawing, I want to make it look really polished. I'll add:
- A subtle grid pattern on the background
- Rounded corners for the board
- A shadow/glow around the food
- Smooth color transitions on the snake body
- Eyes on the head that track direction
- Particle effects on eating

Let me also add a nice border around the canvas.

I'll structure the drawing code with clear functions:
- `drawBackground()` - clear and draw grid
- `drawSnake()` - draw body segments and head with eyes
- `drawFood()` - draw food with glow
- `drawParticles()` - draw particles

Each function uses the interpolated positions.

For the interpolation, I'll prepare all positions first, then draw. This keeps the rendering code clean.

Let me code it all up now. I'll aim for a single, well-organized file.

One more consideration: for high-DPI displays, I should scale the canvas. I'll use `devicePixelRatio` for this to ensure crisp rendering. The canvas resolution will be CELL_SIZE * GRID_SIZE * dpr, and I'll scale the context.

Actually, for simplicity, I'll skip DPR scaling and just use the standard resolution. The canvas will be 672×672, and on high-DPI displays it might look slightly blurry, but it's acceptable for a game demo.

Alternatively, I can set the canvas resolution to 672 * 2 = 1344 and scale down with CSS. This would give crisp rendering. But it complicates the code slightly. Let me keep it simple with the standard resolution for now.

Actually, let me do the DPR handling properly since visual polish is a requirement:

```javascript
const dpr = window.devicePixelRatio || 1;
canvas.width = CANVAS_SIZE * dpr;
canvas.height = CANVAS_SIZE * dpr;
canvas.style.width = CANVAS_SIZE + 'px';
canvas.style.height = CANVAS_SIZE + 'px';
ctx.scale(dpr, dpr);
```

Then I can draw using logical coordinates (0 to CANVAS_SIZE). This gives crisp rendering on all displays.

For the CSS scaling on smaller screens, I'll use `max-width: 100%` and let the height auto-adjust. The canvas has `width: 672px; height: 672px` set inline via style, but CSS `max-width: 100%` will override it on small screens, and `height: auto` will maintain aspect ratio.

Actually, I realize that if I set canvas.style.width = '672px' and canvas.style.height = '672px' in JavaScript, then CSS `max-width: 100%` should still work to scale it down. The height might need `height: auto` to maintain aspect ratio, but since canvas has an intrinsic aspect ratio, this should work.

Hmm, but canvas doesn't have intrinsic aspect ratio in the same way as images. Let me use a CSS approach where the canvas element has `width: 100%; max-width: 672px; aspect-ratio: 1/1;` and the actual canvas width/height attributes handle the resolution.

Actually, the simplest approach: set `canvas.width = 672 * dpr` and `canvas.height = 672 * dpr` for resolution, and use CSS to control display size. The canvas's CSS dimensions default to its width/height attributes if not overridden, but `max-width: 100%` and `height: auto` will scale it proportionally.

Let me just set the canvas CSS to `max-width: 100%; height: auto; display: block;` and set the width/height attributes correctly. The browser will scale it proportionally.

Wait, I need to be more careful. If I set `canvas.width = 672 * dpr` and `canvas.height = 672 * dpr`, the canvas element will have a display size of 672*dpr × 672*dpr by default (CSS pixels). Then `max-width: 100%` will scale it down on small screens.

Actually, I think the standard pattern is:
1. Set canvas.width and canvas.height for resolution
2. Use CSS to control the display size

Let me set canvas.width = CANVAS_SIZE * dpr and canvas.height = CANVAS_SIZE * dpr, then use CSS:
```css
canvas {
  max-width: 100%;
  height: auto;
}
```

This should maintain the aspect ratio when scaled down. The canvas will be displayed at 672px width (matching CANVAS_SIZE) on desktop, and scaled down on mobile.

But wait, the canvas's default CSS size would be 672*dpr × 672*dpr (in CSS pixels, since width/height attributes are in device pixels). That would be 1344 × 1344 on a dpr=2 display. I need to constrain this.

OK, let me use a wrapper approach. The wrapper has a max-width, and the canvas inside uses `width: 100%; height: auto;`:

```html
<div class="canvas-wrapper">
  <canvas></canvas>
</div>
```

```css
.canvas-wrapper {
  max-width: 672px;
  width: 100%;
  aspect-ratio: 1/1;
}
canvas {
  width: 100%;
  height: 100%;
  display: block;
}
```

This is cleaner. The canvas fills the wrapper, and the wrapper handles sizing.

For the canvas resolution, I'll use the DPR approach:
```javascript
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = CANVAS_SIZE * dpr;
canvas.height = CANVAS_SIZE * dpr;
ctx.scale(dpr, dpr);
```

This sets the internal resolution to CANVAS_SIZE * dpr (up to 2x for crisp rendering), and the CSS handles the display size. I clamp dpr to 2 to avoid excessive resolution on high-DPI devices.

OK, I think I've overthought this. Let me just write the code. I'll use dpr=1 for simplicity and rely on the fact that 672×672 at native resolution looks fine on most screens. If needed, the user can see it scaled down on mobile where resolution matters less.

Actually, you know what, let me just keep it simple and not worry about DPR. The canvas will be 672×672, which is plenty for a game. On high-DPI displays, it might be slightly less crisp but it's perfectly fine for a demo.

Let me finalize the approach and write the code.

Final plan:
- Canvas: 672×672 (24×24 grid, 28px cells)
- CSS wrapper with max-width for responsive scaling
- No DPR scaling (simple)
- All game logic in JavaScript
- Clean, well-organized code

Now writing the complete file.

One more thing: I need to make sure the overlay for the start message is clickable. I'll make the overlay clickable and also listen for keyboard events on the document/window.

Also, for the pause state, I should make sure the player can pause and resume. P toggles pause. When paused, the game stops ticking but stays rendered.

I should also handle the case where the user pauses during the demo. Actually, the demo should not be pausable by the user since they haven't taken control yet. Or should it? The requirements say "P pauses" as a general game feature. I'll only enable pause during 'playing' state.

Alright, here's the final code. Let me write it out carefully.

Note: I'll use `preventDefault()` on arrow keys to prevent page scrolling.

Also, for the demo AI restart: when the demo snake dies, I'll wait about 1 second before restarting. During this time, the game over overlay is shown briefly.

Actually, in menu/demo mode, I'll just restart immediately without showing the game over overlay. The game over overlay only applies to the player's game. In demo mode, the snake dies and quickly restarts, continuing the demo.

Let me implement this: in demo mode, when the snake dies, set a restart timer of ~0.8 seconds. When the timer expires, reset the game and continue the demo.

OK, I'm going to write the actual code now. No more planning.

For the scoreboard, I'll display it above the canvas with nice styling. The score updates in real-time.

For the "best score" tracking, I'll use a module-level variable that tracks the highest score achieved in the current page session.

Let me also think about sound effects. While the requirements don't mention them, they could add polish. However, I'd need to use the Web Audio API to generate sounds programmatically (no external files). This adds complexity. I'll skip sound for now to keep the code focused on the visual aspects.

Alright, here's the complete code. I'll write it in a clean, organized manner.

For the food rendering, I want it to pulse - maybe using a sine wave that oscillates the size slightly. This adds visual interest.

For the particles, I'll spawn them in a burst pattern (radial distribution) at the food position when eaten.

For the snake's rendering with interpolation, here's the approach:
- `snake` is an array of {x, y} grid positions (head first)
- `prevSnake` is the previous tick's snake positions
- `tickProgress` (0-1) is how far through the current tick we are
- For each segment i, the interpolated pixel position is:
  - prevPos = (i < prevSnake.length) ? prevSnake[i] : snake[i]
  - currPos = snake[i]
  - x = lerp(prevPos.x, currPos.x, tickProgress) * CELL_SIZE + CELL_SIZE/2
  - y = lerp(prevPos.y, currPos.y, tickProgress) * CELL_SIZE + CELL_SIZE/2

The lerp gives smooth movement. The `tickProgress` eases from 0 to 1 within each tick.

I could use an easing function for the interpolation to make it even smoother (e.g., easeInOut), but linear is usually fine for this purpose. Actually, linear interpolation with a fixed tick rate gives perfectly smooth movement at constant speed. Easing would make the speed vary within each tick, which would look weird for a snake that should move at constant speed.

OK, final code time. I'll write this as cleanly as possible.

I'd also like to actually make the movement feel slightly springy at the start and end of each tick for extra polish, but that might be overkill. Linear is fine.

Let me also think about what happens with the food visually. I want to draw it as a red apple-like circle with a small highlight. Maybe a slight glow around it.

Now writing the code. I'll structure it with clear sections and comments.

Wait, I also need to ensure the game is visible on first paint (within ~3 seconds). The demo mode should be running immediately when the page loads, with the snake moving and food visible behind the "Click or press a key to start" overlay.

OK, here's the complete code:

One detail I want to get right: the transition from demo to player mode. When the user clicks or presses a key:
1. If in 'menu' state, transition to 'playing'
2. Reset the game to a fresh state
3. Clear the direction queue
4. Start the game

For the game reset, I'll initialize the snake to the center of the grid with 3 segments, moving to the right. Food is placed randomly.

For the demo AI, I'll use the same game state and just control the direction instead of the player. The demo runs in the same game loop.

One subtle thing: in demo mode, when the game transitions to player mode, I should NOT reset the game. Instead, the player takes over from the current state. This is smoother and more impressive. The demo plays, the player clicks, and takes control mid-game.

Wait, but then the player might inherit a game state where the snake is about to die. That could be frustrating. Let me think...

I think for the cleanest UX, I'll reset the game when transitioning from demo to player mode. This gives the player a fresh start. The demo is just there to show the game mechanics in action.

Actually, you know what, let me keep it simple: reset on transition. The player gets a fresh game. This is the most predictable and user-friendly approach.

One more thing: I want the demo to look good. I'll make the AI reasonably smart, and the demo snake should move at a reasonable speed (150ms per tick). The demo should showcase the smooth movement, the food eating, and the particle effects.

OK, I'm finally going to write the code now. I've been planning way too long. Let me just do it.

Actually, one more consideration for the input handling. The overlay is an HTML element positioned over the canvas. When the user clicks the overlay (to start), the click event fires on the overlay. I need to handle this and transition to the playing state.

For keyboard events, I'll listen on the document/window level. This catches all key presses regardless of focus. However, the iframe only receives keyboard events after the user clicks within it. So the "Click or press a key to start" message must be accompanied by a click handler that starts the game.

For the pause key (P), I'll listen for it in the keydown handler and handle it appropriately based on the current state.

Let me finalize and write the code now.

Alright, here's what I'll produce. It'll be comprehensive, polished, and well-commented.

One last thought: for the grid, 24×24 cells is larger than the minimum 20×20. This gives more room for the snake to move. The cell size of 28px gives a canvas of 672×672, which is a good size.

I could also use a slightly different cell size. 30px would give 720×720. Let me use 28px for a slightly smaller canvas that fits better in the viewport.

OK, actually, I want to use 25px cells for a 24×24 grid = 600×600 canvas. That's a bit more compact and fits better on smaller screens. Or 26px for 624×624. Let me use 26px. Actually, let me just go with 28px for a 672×672 canvas. It's fine.

Ok, truly writing the code now.