Models 44

DeepSeek V4.1 Flash 1017

by DeepSeekReasoningClean
Open in new window

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

How it went

Total time
2 min 30 s
Cost
$0.016
Output tokens
32,518
Spent thinking
20,204 tokens
Speed
217.4 tok/s
Console errors
0
First token after
666 ms
First visible text after
1 min 51 s
Prompt tokens
518
Code size
23.2 KB
Finish reason
stop
Served by
Wafer
Model id
deepseek/deepseek-v4.1-flash-1017
Reasoning sent
{"effort":"high"}
Max output
64,000 tokens
Generated
Nov 11, 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 · 706 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Snake</title>
<style>
  *{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
  html,body{height:100%}
  body{
    margin:0; padding:10px;
    background: radial-gradient(900px 620px at 50% -8%, #14304a 0%, #0a1526 42%, #05080f 100%);
    color:#e8f4ff;
    font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    display:flex; align-items:center; justify-content:center;
    min-height:100vh; overflow:hidden;
    user-select:none; -webkit-user-select:none;
    -webkit-font-smoothing:antialiased;
  }
  .app{display:flex; flex-direction:column; align-items:stretch}
  .hud{display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:10px}
  .brand{
    font-size:17px; font-weight:900; letter-spacing:.26em; white-space:nowrap;
    background:linear-gradient(90deg,#8bffc4,#2fe08a 55%,#48c6ff);
    -webkit-background-clip:text; background-clip:text; color:transparent;
  }
  .scores{display:flex; gap:8px}
  .pill{
    display:flex; align-items:center; gap:7px; padding:6px 12px; border-radius:999px;
    background:rgba(255,255,255,.045); border:1px solid rgba(255,255,255,.09);
    box-shadow:0 2px 10px rgba(0,0,0,.28);
  }
  .pill .label{font-size:9px; font-weight:800; letter-spacing:.15em; color:#7e93ab}
  .pill .val{
    font-size:17px; font-weight:800; color:#c9ffe3; font-variant-numeric:tabular-nums;
    min-width:1.1em; text-align:right; display:inline-block;
  }
  .pill.best .val{color:#ffd166}
  .board{position:relative; line-height:0; border-radius:16px}
  canvas{
    display:block; border-radius:16px; background:#070c16;
    box-shadow:
      0 0 0 1px rgba(120,220,180,.16),
      0 26px 60px rgba(0,0,0,.62),
      0 0 90px rgba(47,224,138,.10),
      inset 0 0 70px rgba(0,0,0,.55);
  }
  .overlay{
    position:absolute; inset:0; border-radius:16px;
    display:flex; align-items:center; justify-content:center;
    padding:14px; background:rgba(4,8,16,.42);
    opacity:0; pointer-events:none; transition:opacity .22s ease;
  }
  .overlay.dim{background:rgba(4,8,16,.72)}
  .overlay.show{opacity:1}
  .card{
    text-align:center; max-width:100%;
    padding:20px 24px; border-radius:18px;
    background:linear-gradient(180deg, rgba(20,32,50,.97), rgba(9,15,26,.97));
    border:1px solid rgba(90,210,160,.22);
    box-shadow:0 26px 60px rgba(0,0,0,.62), inset 0 1px 0 rgba(255,255,255,.06);
    animation:pop .26s cubic-bezier(.2,1.3,.5,1);
  }
  @keyframes pop{from{transform:scale(.93);opacity:.35}to{transform:scale(1);opacity:1}}
  .kicker{
    font-size:10px; font-weight:800; letter-spacing:.19em; color:#2fe08a;
    display:flex; align-items:center; justify-content:center; gap:7px;
  }
  .kicker.danger{color:#ff5c7a}
  .kicker .dot{width:7px;height:7px;border-radius:50%;background:currentColor;box-shadow:0 0 10px currentColor;animation:blink 1.15s ease-in-out infinite}
  @keyframes blink{0%,100%{opacity:1}50%{opacity:.2}}
  .card h2{
    margin:9px 0 9px; font-weight:800; line-height:1.25; letter-spacing:-.01em;
    color:#eafff4; font-size:clamp(17px, 4.2vw, 23px);
  }
  .card p{margin:5px 0 0; font-size:12px; color:#8fa6bd; line-height:1.7}
  .card p b{color:#ffd166}
  .big-score{font-size:clamp(26px,6vw,34px); font-weight:900; color:#7dffb0; margin-left:4px; vertical-align:-3px; text-shadow:0 0 24px rgba(47,224,138,.5)}
  kbd{
    font-family:inherit; font-size:10.5px; font-weight:700; padding:2px 6px; border-radius:6px;
    background:rgba(255,255,255,.08); border:1px solid rgba(255,255,255,.13);
    border-bottom-width:2px; color:#cfe6ff; display:inline-block; line-height:1.35;
  }
  .hint{
    margin-top:10px; display:flex; flex-wrap:wrap; gap:5px; row-gap:6px;
    align-items:center; justify-content:center;
    font-size:11px; color:#7d90a6; text-align:center;
  }
  .hint .sep{opacity:.45}
</style>
</head>
<body>
  <div class="app" id="app">
    <div class="hud">
      <div class="brand">SNAKE</div>
      <div class="scores">
        <div class="pill"><span class="label">SCORE</span><span class="val" id="score">0</span></div>
        <div class="pill best"><span class="label">BEST</span><span class="val" id="best">0</span></div>
      </div>
    </div>
    <div class="board">
      <canvas id="c"></canvas>
      <div class="overlay" id="overlay"></div>
    </div>
    <div class="hint">
      <kbd>&uarr;</kbd><kbd>&larr;</kbd><kbd>&darr;</kbd><kbd>&rarr;</kbd>
      <span>/</span>
      <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd>
      <span>move</span>
      <span class="sep">&middot;</span>
      <kbd>P</kbd><span>pause</span>
      <span class="sep">&middot;</span>
      <kbd>Space</kbd><span>restart</span>
    </div>
  </div>

<script>
(function () {
  'use strict';

  /* ------------------------------------------------------------------ */
  /*  Constants & state                                                  */
  /* ------------------------------------------------------------------ */
  const COLS = 24, ROWS = 24;
  const DIRS = [{x:0,y:-1},{x:0,y:1},{x:-1,y:0},{x:1,y:0}];
  const K = (x, y) => y * COLS + x;

  const canvas   = document.getElementById('c');
  const ctx      = canvas.getContext('2d');
  const appEl    = document.getElementById('app');
  const overlayEl= document.getElementById('overlay');
  const scoreEl  = document.getElementById('score');
  const bestEl   = document.getElementById('best');

  let cell = 24, boardPx = 576;

  let snake = [], prevSnake = [];
  let dir = {x:1, y:0}, queue = [];
  let food = null;
  let particles = [], floats = [];
  let score = 0, best = 0;
  let state = 'demo';               // demo | demoOver | playing | paused | over
  let stepMs = 135, acc = 0, t = 0, deadTimer = 0;
  let timeMs = 0, lastTime = 0;

  /* ------------------------------------------------------------------ */
  /*  Layout                                                             */
  /* ------------------------------------------------------------------ */
  function resize() {
    const vw = window.innerWidth;
    const vh = window.innerHeight;
    const availW = vw - 26;
    const availH = vh - 190;
    let size = Math.min(availW, 640, availH);
    if (!isFinite(size) || size < 180) size = Math.max(150, Math.min(availW, 640));

    cell = Math.max(7, Math.floor(size / COLS));
    size = cell * COLS;
    boardPx = size;

    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    canvas.style.width  = size + 'px';
    canvas.style.height = size + 'px';
    canvas.width  = Math.round(size * dpr);
    canvas.height = Math.round(size * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    appEl.style.width = size + 'px';
  }

  /* ------------------------------------------------------------------ */
  /*  Game setup                                                         */
  /* ------------------------------------------------------------------ */
  function spawnFood() {
    const occupied = new Set();
    for (const s of snake) occupied.add(K(s.x, s.y));
    const free = [];
    for (let y = 0; y < ROWS; y++) {
      for (let x = 0; x < COLS; x++) {
        if (!occupied.has(K(x, y))) free.push({x, y});
      }
    }
    if (!free.length) return null;
    return free[(Math.random() * free.length) | 0];
  }

  function resetGame() {
    const cy = Math.floor(ROWS / 2);
    snake = [{x:8,y:cy}, {x:7,y:cy}, {x:6,y:cy}];
    prevSnake = snake.map(s => ({x:s.x, y:s.y}));
    dir = {x:1, y:0};
    queue = [];
    particles = [];
    floats = [];
    acc = 0; t = 0;
    food = spawnFood();
  }

  function startDemo() {
    resetGame();
    state = 'demo';
    deadTimer = 0;
    score = 0;
    stepMs = 135;
    updateHud(false);
    showOverlay(demoHTML(), false);
  }

  function startGame() {
    resetGame();
    score = 0;
    stepMs = 135;
    state = 'playing';
    updateHud(false);
    hideOverlay();
    lastTime = performance.now();
    acc = 0; t = 0;
  }

  /* ------------------------------------------------------------------ */
  /*  Simulation step                                                    */
  /* ------------------------------------------------------------------ */
  function step() {
    let nd = dir;
    if (state === 'demo') {
      const ai = aiChooseDir();
      if (ai) nd = ai;
    } else if (queue.length) {
      nd = queue.shift();
    }
    dir = nd;

    const head = snake[0];
    const nx = head.x + nd.x;
    const ny = head.y + nd.y;

    if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) { gameOver(); return; }

    const willGrow = !!(food && nx === food.x && ny === food.y);
    const limit = willGrow ? snake.length : snake.length - 1;
    for (let i = 0; i < limit; i++) {
      if (snake[i].x === nx && snake[i].y === ny) { gameOver(); return; }
    }

    const ns = [{x:nx, y:ny}];
    for (let i = 0; i < snake.length; i++) ns.push(snake[i]);
    if (!willGrow) ns.pop();

    // previous positions for smooth interpolation
    const ps = new Array(ns.length);
    for (let i = 0; i < ns.length; i++) {
      const src = i < snake.length ? snake[i] : snake[snake.length - 1];
      ps[i] = {x: src.x, y: src.y};
    }
    prevSnake = ps;
    snake = ns;

    if (willGrow) onEat(nx, ny);
  }

  function onEat(gx, gy) {
    burst(gx, gy);
    floats.push({x:(gx + 0.5) * cell, y:(gy + 0.35) * cell, life: 1, text: '+10'});
    if (state === 'playing') {
      score += 10;
      stepMs = Math.max(72, 135 - Math.floor(score / 60) * 7);
      if (score > best) best = score;
      updateHud(true);
    }
    food = spawnFood();
  }

  function gameOver() {
    if (state === 'demo') {
      state = 'demoOver';
      deadTimer = 620;
      t = 1;
    } else {
      if (score > best) best = score;
      state = 'over';
      t = 1;
      updateHud(false);
      showOverlay(overHTML(), true);
    }
  }

  /* ------------------------------------------------------------------ */
  /*  Input                                                              */
  /* ------------------------------------------------------------------ */
  const KEYMAP = {
    arrowup:{x:0,y:-1}, arrowdown:{x:0,y:1}, arrowleft:{x:-1,y:0}, arrowright:{x:1,y:0},
    w:{x:0,y:-1}, s:{x:0,y:1}, a:{x:-1,y:0}, d:{x:1,y:0}
  };

  function pushDir(nd) {
    const base = queue.length ? queue[queue.length - 1] : dir;
    if (nd.x === -base.x && nd.y === -base.y) return;   // no reversal
    if (nd.x === base.x && nd.y === base.y) return;     // no duplicates
    if (queue.length < 3) queue.push(nd);               // buffer quick inputs
  }

  window.addEventListener('keydown', function (e) {
    const k = e.key;
    const lk = k.length === 1 ? k.toLowerCase() : k.toLowerCase();
    if (k === ' ' || k.indexOf('Arrow') === 0) e.preventDefault();

    if (state === 'demo' || state === 'demoOver') { startGame(); return; }

    if (state === 'over') {
      if (k === ' ' || k === 'Enter') startGame();
      return;
    }

    if (lk === 'p') {
      if (state === 'playing') { state = 'paused'; showOverlay(pausedHTML(), true); }
      else if (state === 'paused') { state = 'playing'; hideOverlay(); lastTime = performance.now(); }
      return;
    }

    if (state === 'paused') return;

    const nd = KEYMAP[lk];
    if (nd) { pushDir(nd); e.preventDefault(); }
  }, {passive:false});

  document.addEventListener('pointerdown', function () {
    if (state === 'demo' || state === 'demoOver' || state === 'over') startGame();
  });

  /* ------------------------------------------------------------------ */
  /*  Overlays                                                           */
  /* ------------------------------------------------------------------ */
  function showOverlay(html, dim) {
    overlayEl.innerHTML = html;
    overlayEl.classList.toggle('dim', !!dim);
    overlayEl.classList.add('show');
  }
  function hideOverlay() { overlayEl.classList.remove('show'); }

  function demoHTML() {
    return '<div class="card">' +
      '<div class="kicker"><span class="dot"></span>DEMO PLAYING</div>' +
      '<h2>Click or press a key<br>to start</h2>' +
      '<p><kbd>&uarr;</kbd><kbd>&larr;</kbd><kbd>&darr;</kbd><kbd>&rarr;</kbd> or <kbd>WASD</kbd> to move</p>' +
      '</div>';
  }
  function overHTML() {
    return '<div class="card">' +
      '<div class="kicker danger">GAME OVER</div>' +
      '<h2>Score<span class="big-score">' + score + '</span></h2>' +
      '<p>Best this session: <b>' + best + '</b></p>' +
      '<p>Press <kbd>Space</kbd> or <kbd>Enter</kbd> to play again</p>' +
      '</div>';
  }
  function pausedHTML() {
    return '<div class="card">' +
      '<div class="kicker">PAUSED</div>' +
      '<h2>Take a breath</h2>' +
      '<p>Press <kbd>P</kbd> to resume</p>' +
      '</div>';
  }

  function updateHud(pop) {
    scoreEl.textContent = score;
    bestEl.textContent = best;
    if (pop && scoreEl.animate) {
      scoreEl.animate(
        [{transform:'scale(1.45)'}, {transform:'scale(1)'}],
        {duration:240, easing:'ease-out'}
      );
    }
  }

  /* ------------------------------------------------------------------ */
  /*  Effects                                                            */
  /* ------------------------------------------------------------------ */
  function burst(gx, gy) {
    const cx = (gx + 0.5) * cell;
    const cy = (gy + 0.5) * cell;
    const scale = cell / 24;
    for (let i = 0; i < 20; i++) {
      const a = Math.random() * Math.PI * 2;
      const sp = (0.6 + Math.random() * 3.2) * scale;
      particles.push({
        x: cx, y: cy,
        vx: Math.cos(a) * sp,
        vy: Math.sin(a) * sp,
        r: cell * (0.055 + Math.random() * 0.10),
        life: 1,
        decay: 1.5 + Math.random() * 1.4,
        color: Math.random() < 0.45 ? '#ffd166' : (Math.random() < 0.6 ? '#ff5c7a' : '#ff9db0')
      });
    }
    particles.push({ ring: true, x: cx, y: cy, life: 1, decay: 2.6, r: cell * 0.2, color: '#ff9db0' });
  }

  function updateEffects(dt) {
    const s = dt / 1000;
    const drag = Math.pow(0.90, dt / 16.67);
    for (let i = particles.length - 1; i >= 0; i--) {
      const p = particles[i];
      p.life -= p.decay * s;
      if (p.life <= 0) { particles.splice(i, 1); continue; }
      if (!p.ring) {
        p.vx *= drag; p.vy *= drag;
        p.x += p.vx * (dt / 16.67);
        p.y += p.vy * (dt / 16.67);
      }
    }
    for (let i = floats.length - 1; i >= 0; i--) {
      const f = floats[i];
      f.life -= s * 1.25;
      f.y -= s * 26;
      if (f.life <= 0) floats.splice(i, 1);
    }
  }

  /* ------------------------------------------------------------------ */
  /*  Rendering                                                          */
  /* ------------------------------------------------------------------ */
  function draw(tv) {
    const S = cell, W = boardPx;

    ctx.fillStyle = '#070c16';
    ctx.fillRect(0, 0, W, W);

    ctx.fillStyle = 'rgba(130,220,255,0.028)';
    for (let y = 0; y < ROWS; y++) {
      for (let x = 0; x < COLS; x++) {
        if ((x + y) & 1) ctx.fillRect(x * S, y * S, S, S);
      }
    }

    const rg = ctx.createRadialGradient(W * 0.5, W * 0.42, 0, W * 0.5, W * 0.5, W * 0.8);
    rg.addColorStop(0, 'rgba(47,224,138,0.07)');
    rg.addColorStop(1, 'rgba(0,0,0,0)');
    ctx.fillStyle = rg;
    ctx.fillRect(0, 0, W, W);

    drawFood();
    drawSnake(tv);
    drawEffects();
  }

  function drawFood() {
    if (!food) return;
    const S = cell;
    const cx = (food.x + 0.5) * S;
    const cy = (food.y + 0.5) * S;
    const pulse = 1 + 0.11 * Math.sin(timeMs / 165);
    const r = S * 0.30 * pulse;

    const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, S * 1.0);
    g.addColorStop(0, 'rgba(255,80,120,0.50)');
    g.addColorStop(0.45, 'rgba(255,80,120,0.14)');
    g.addColorStop(1, 'rgba(255,80,120,0)');
    ctx.fillStyle = g;
    ctx.beginPath(); ctx.arc(cx, cy, S * 1.0, 0, Math.PI * 2); ctx.fill();

    const bg = ctx.createRadialGradient(cx - r * 0.32, cy - r * 0.38, r * 0.08, cx, cy, r * 1.25);
    bg.addColorStop(0, '#ffb3c2');
    bg.addColorStop(0.55, '#ff4d6d');
    bg.addColorStop(1, '#c81a3f');
    ctx.fillStyle = bg;
    ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fill();

    ctx.fillStyle = 'rgba(255,255,255,0.8)';
    ctx.beginPath(); ctx.arc(cx - r * 0.3, cy - r * 0.36, r * 0.24, 0, Math.PI * 2); ctx.fill();
  }

  function drawSnake(tv) {
    const S = cell;
    if (!snake.length) return;

    const pts = new Array(snake.length);
    for (let i = 0; i < snake.length; i++) {
      const s = snake[i];
      const p = prevSnake[i] || s;
      pts[i] = {
        x: (p.x + (s.x - p.x) * tv + 0.5) * S,
        y: (p.y + (s.y - p.y) * tv + 0.5) * S
      };
    }

    ctx.save();
    ctx.lineJoin = 'round';
    ctx.lineCap = 'round';

    if (pts.length > 1) {
      ctx.beginPath();
      ctx.moveTo(pts[0].x, pts[0].y);
      for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);

      const tail = pts[pts.length - 1];
      const dist = Math.hypot(tail.x - pts[0].x, tail.y - pts[0].y);
      let strokeStyle;
      if (dist < 1) {
        strokeStyle = '#2fe08a';
      } else {
        const g = ctx.createLinearGradient(pts[0].x, pts[0].y, tail.x, tail.y);
        g.addColorStop(0, '#a5ffd0');
        g.addColorStop(0.35, '#35e58f');
        g.addColorStop(1, '#0d8a63');
        strokeStyle = g;
      }

      ctx.shadowColor = 'rgba(53,229,143,0.55)';
      ctx.shadowBlur = S * 0.85;
      ctx.strokeStyle = strokeStyle;
      ctx.lineWidth = S * 0.78;
      ctx.stroke();

      ctx.shadowBlur = 0;
      ctx.strokeStyle = 'rgba(215,255,235,0.16)';
      ctx.lineWidth = S * 0.30;
      ctx.stroke();
    }

    // head
    const h = pts[0];
    const dx = dir.x, dy = dir.y;
    const px = -dy, py = dx;
    const hr = S * 0.45;

    const hg = ctx.createRadialGradient(
      h.x - dx * hr * 0.35, h.y - dy * hr * 0.35, hr * 0.05,
      h.x, h.y, hr
    );
    hg.addColorStop(0, '#c6ffe2');
    hg.addColorStop(0.6, '#4cea9b');
    hg.addColorStop(1, '#22c07a');
    ctx.fillStyle = hg;
    ctx.beginPath(); ctx.arc(h.x, h.y, hr, 0, Math.PI * 2); ctx.fill();

    // eyes
    for (let s = -1; s <= 1; s += 2) {
      const ex = h.x + dx * S * 0.10 + px * s * S * 0.165;
      const ey = h.y + dy * S * 0.10 + py * s * S * 0.165;
      ctx.fillStyle = '#ffffff';
      ctx.beginPath(); ctx.arc(ex, ey, S * 0.115, 0, Math.PI * 2); ctx.fill();
      ctx.fillStyle = '#06231a';
      ctx.beginPath();
      ctx.arc(ex + dx * S * 0.045, ey + dy * S * 0.045, S * 0.058, 0, Math.PI * 2);
      ctx.fill();
    }

    ctx.restore();
  }

  function drawEffects() {
    const S = cell;
    for (const p of particles) {
      const a = Math.max(0, Math.min(1, p.life));
      if (p.ring) {
        ctx.globalAlpha = a * 0.65;
        ctx.strokeStyle = p.color;
        ctx.lineWidth = Math.max(1, S * 0.09);
        ctx.beginPath();
        ctx.arc(p.x, p.y, S * 0.2 + (1 - a) * S * 0.85, 0, Math.PI * 2);
        ctx.stroke();
      } else {
        ctx.globalAlpha = a;
        ctx.fillStyle = p.color;
        ctx.beginPath();
        ctx.arc(p.x, p.y, p.r * (0.35 + a * 0.95), 0, Math.PI * 2);
        ctx.fill();
      }
    }
    ctx.globalAlpha = 1;

    if (floats.length) {
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      for (const f of floats) {
        const a = Math.max(0, Math.min(1, f.life));
        ctx.globalAlpha = a;
        ctx.fillStyle = '#ffe08a';
        ctx.font = '800 ' + Math.round(S * 0.72) + 'px ui-sans-serif, system-ui, sans-serif';
        ctx.fillText(f.text, f.x, f.y);
      }
      ctx.globalAlpha = 1;
    }
  }

  /* ------------------------------------------------------------------ */
  /*  Demo AI                                                            */
  /* ------------------------------------------------------------------ */
  function bfsFirstStep(start, goal, blocked) {
    const N = COLS * ROWS;
    const prev = new Int32Array(N).fill(-2);
    const si = K(start.x, start.y);
    const gi = K(goal.x, goal.y);
    const q = new Int32Array(N);
    let qh = 0, qt = 0;
    prev[si] = -1;
    q[qt++] = si;

    while (qh < qt) {
      const idx = q[qh++];
      if (idx === gi) {
        let node = idx;
        while (prev[node] !== -1 && prev[node] !== si) node = prev[node];
        if (prev[node] === -1) return null;
        const nx = node % COLS;
        const ny = (node - nx) / COLS;
        return {x: nx - start.x, y: ny - start.y};
      }
      const cx = idx % COLS;
      const cy = (idx - cx) / COLS;
      for (let k = 0; k < 4; k++) {
        const d = DIRS[k];
        const nx = cx + d.x, ny = cy + d.y;
        if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
        const ni = K(nx, ny);
        if (prev[ni] !== -2) continue;
        if (blocked.has(ni)) continue;
        prev[ni] = idx;
        q[qt++] = ni;
      }
    }
    return null;
  }

  function floodCount(sx, sy, blocked) {
    const seen = new Set([K(sx, sy)]);
    const stack = [[sx, sy]];
    let count = 0;
    while (stack.length) {
      const cur = stack.pop();
      count++;
      if (count > 600) break;
      for (let k = 0; k < 4; k++) {
        const d = DIRS[k];
        const nx = cur[0] + d.x, ny = cur[1] + d.y;
        if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
        const ni = K(nx, ny);
        if (seen.has(ni) || blocked.has(ni)) continue;
        seen.add(ni);
        stack.push([nx, ny]);
      }
    }
    return count;
  }

  function aiChooseDir() {
    if (!food) return dir;
    const head = snake[0];
    const blocked = new Set();
    for (let i = 0; i < snake.length - 1; i++) blocked.add(K(snake[i].x, snake[i].y));

    const stepDir = bfsFirstStep(head, food, blocked);
    if (stepDir) return stepDir;

    let bestDir = null, bestSpace = -1;
    for (let k = 0; k < 4; k++) {
      const d = DIRS[k];
      if (d.x === -dir.x && d.y === -dir.y) continue;
      const nx = head.x + d.x, ny = head.y + d.y;
      if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
      if (blocked.has(K(nx, ny))) continue;
      const space = floodCount(nx, ny, blocked);
      if (space > bestSpace) { bestSpace = space; bestDir = d; }
    }
    return bestDir || dir;
  }

  /* ------------------------------------------------------------------ */
  /*  Main loop                                                          */
  /* ------------------------------------------------------------------ */
  function frame(now) {
    const dt = Math.min(now - lastTime, 100);
    lastTime = now;
    timeMs = now;

    if (state === 'playing' || state === 'demo') {
      acc += dt;
      let guard = 0;
      while (acc >= stepMs && guard++ < 5) {
        acc -= stepMs;
        step();
        if (state !== 'playing' && state !== 'demo') break;
      }
      if (state === 'playing' || state === 'demo') t = Math.min(acc / stepMs, 1);
      else t = 1;
    } else if (state === 'demoOver') {
      deadTimer -= dt;
      t = 1;
      if (deadTimer <= 0) startDemo();
    }

    if (state !== 'paused') updateEffects(dt);
    draw(t);
    requestAnimationFrame(frame);
  }

  /* ------------------------------------------------------------------ */
  /*  Boot                                                               */
  /* ------------------------------------------------------------------ */
  window.addEventListener('resize', resize);
  resize();
  lastTime = performance.now();
  startDemo();
  requestAnimationFrame(frame);
})();
</script>
</body>
</html>
Reasoning trace (20,204 tokens)
Let me build a polished Snake game in a single HTML file.

Key requirements:
- Grid at least 20x20, centered.
- Arrow keys + WASD. Quick successive key presses honored (input queue), no reversal.
- Eating food +10, grows by one, food never on snake.
- Game over on wall/self, overlay with score, Space/Enter restarts, P pauses.
- Visual polish: smooth movement (interpolated), distinct head with eyes facing direction, particle burst on eat.
- Score + best score of session.
- Self-playing demo mode behind "Click or press a key to start" message.
- No horizontal scrollbar, responsive down to 375px.

Design approach:

I'll do a smooth interpolated snake. Grid 24x24 maybe. The rendering: each segment rendered as rounded rect, with interpolation between previous positions and current positions using a progress value t that goes 0..1 over the step duration.

Approach: Snake state is a list of cells (grid coordinates). Each step, we compute new positions. For smooth rendering, keep `prev` array of cell positions matching current snake body, and render lerp(prev[i], cur[i], t). Actually a common approach: render each segment at lerp of its previous position and its current position. Since the snake moves as a whole, each segment i moves from its old cell to the new cell.

Careful with growth: when growing, the tail doesn't move. Handle by having arrays of same length; when growing, new tail appended so prev array for the new tail = same position.

Let me structure:

```js
snake = [{x,y}, ...] // head first
prevSnake = [...] // same length, position before last step
```

On step:
- compute new head = head + dir
- check collision
- newSnake = [newHead, ...snake]  (with tail popped unless growing)
- prevSnake for interpolation: for each index i in newSnake, prev position = snake[i] if exists else snake[snake.length-1] (for the new tail when growing).

Wait: newSnake[i] = snake[i-1] for i>=1. So prevSnake[i] = position of newSnake[i] before the step = snake[i] if i < snake.length else tail. Hmm, newSnake[i] is the body cell; before step that cell (at index i-1) was at snake[i-1]. Ugh, let's just do it simply: for each i, prevPos[i] = snake[i] ? snake[i] : lastOrSomething.

Actually simpler: newSnake[i] should interpolate from snake[i] to newSnake[i]. Since newSnake[i] = snake[i-1] for i≥1, and snake[i] is the cell the segment currently occupies. So each segment slides from its old cell to the cell ahead of it. That's the classic visual. For the head: newSnake[0] = new head, prev = snake[0]. For the tail when not growing: newSnake[last] = snake[last-1], prev = snake[last]. Good. When growing, newSnake has length snake.length+1; the extra tail index i = snake.length: newSnake[i] = snake[snake.length-1] (the old tail), and prev = same → it stays put. Fine.

So prevSnake[i] = snake[i] if i < snake.length else snake[snake.length-1].

Wait but newSnake[newSnake.length-1] when not growing = snake[snake.length-2]... hmm, let's verify: newSnake = [newHead, ...snake]; if not growing, pop the last → newSnake = [newHead, snake[0], ..., snake[n-2]]. So newSnake[i] = snake[i-1] for i ≥ 1, and index last = n-1 → snake[n-2]. prevSnake[last] = snake[n-1] (old tail). Good, so tail slides from old tail to snake[n-2]. Correct.

Rendering with t: pos[i] = lerp(prevSnake[i], newSnake[i], t). That's continuous and correct.

For the head, when turning, the head moves from its old cell to the new cell. Fine.

Also, when the snake wraps? No wrapping — walls kill. OK.

Speed: start at like 8 cells/sec... Actually 20x24 grid. Let's use a fixed step duration that decreases slightly with score? Requirement doesn't ask, but nice. Keep it modest: baseStepMs = 130, min 70.

Rendering style: dark background with subtle grid, neon-ish. Rounded segments, gradient color from head to tail. Head with eyes (two white dots with dark pupils) offset in direction of travel.

Particles: on eat, spawn ~14 particles at food pos with random velocities, fading, with the food's color.

Food: pulsing circle with glow.

Also add a subtle "score popup" +10 maybe.

Input queue: queue of directions, max length 2. On keydown, compute direction; ignore if it reverses relative to the last queued direction (or current dir if queue empty). Push.

Pause: P toggles.

Game over: overlay with score, best. Space or Enter restarts. Note: after game over, pressing space restarts.

Demo mode: AI plays. Show "Click or press a key to start" overlay. Any key/click starts the game (clears demo, resets). Hmm, but requirement says "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 demo AI plays snake; overlay text on top. When user presses key or clicks, the real game starts (reset to fresh state).

The AI: simple greedy with safety — BFS to food avoiding body, or greedy choosing direction that reduces distance and is safe. Let's do a BFS pathfinding over the grid avoiding snake body (excluding tail which will move). If no path, pick the safe direction maximizing free space (flood fill). Keep it simple but decent.

For demo mode, on game over in demo, auto-restart the demo. Also the demo shouldn't run too fast otherwise it's hard to see. Use the same speed.

Let me carefully implement the AI.

```js
function aiChooseDir() {
  const head = snake[0];
  const dirs = [{x:0,y:-1},{x:0,y:1},{x:-1,y:0},{x:1,y:0}];
  const blocked = new Set(snake.slice(0,-1).map(s=>s.x+','+s.y)); // tail moves away
  // BFS from head to food
  ...
}
```

Simplest effective: BFS on free cells to find shortest path to food; take first step. If none, use flood fill to pick the direction with most reachable space. Also avoid immediate reverse.

I'll implement BFS with a queue over the grid; occupancy set from snake body excluding tail (but including tail is safer... excluding tail is fine-ish). Let's exclude the tail only if not growing. Keep simple: treat body cells except last as blocked.

Let me write:

```js
function bfsFirstStep(from, target, blockedSet) {
  const W = COLS, H = ROWS;
  const key = (x,y)=>y*W+x;
  const prev = new Int32Array(W*H).fill(-1);
  const seen = new Uint8Array(W*H);
  const q = [key(from.x, from.y)];
  seen[q[0]] = 1;
  let qi = 0;
  while (qi < q.length) {
    const cur = q[qi++];
    const cx = cur % W, cy = (cur - cx)/W;
    if (cx === target.x && cy === target.y) {
      // walk back
      let node = cur;
      while (prev[node] !== -1 && prev[node] !== key(from.x, from.y)) node = prev[node];
      // hmm, if prev[node] === start... 
      ...
    }
    for each dir: nx,ny in bounds and not blocked and not seen → seen, prev, push
  }
  return null;
}
```

Careful with backtracking: better to store first-step direction. Alternative: store prev, then walk back from target until prev is the start; the node where prev == start is the first step. If the target == start... not possible (food not on snake).

Let me do:
```
let node = target;
while (prev[node] !== startKey) { node = prev[node]; if (node === -1) break; }
```
But prev[target] could equal startKey directly. The loop stops when prev[node] === startKey; then node is the first step. If prev[target] === -1 that's a failure. Good.

Edge case: startKey === targetKey can't happen.

Fallback: if no path, choose among safe directions the one with the largest flood-fill area.

Flood fill: count reachable cells from the neighbor cell using blocked set.

Also need to prevent the AI from killing itself constantly; with BFS avoiding body it's usually fine.

Now the rendering canvas size. Let's use COLS=24, ROWS=24? A 24x24 grid at cell size ~26px = 624px. Viewport 1280x800, need room for header/score. Let's compute canvas size responsive: fit within min(window.innerWidth - 40, window.innerHeight - 180). Clamp.

Use devicePixelRatio scaling for crispness.

Let me design layout:

```
<div class="wrap">
  <header>
    <h1>SNAKE</h1>
    <div class="scores">
      <div class="score-box"><span>SCORE</span><strong id="score">0</strong></div>
      <div class="score-box"><span>BEST</span><strong id="best">0</strong></div>
    </div>
  </header>
  <div class="board-wrap">
    <canvas id="game"></canvas>
    <div class="overlay" id="overlay">...</div>
  </div>
  <div class="hint">Arrows / WASD to move · P pause · Space restart</div>
</div>
```

The overlay: centered card with title, subtitle, blinking "Click or press a key to start".

Overlays states: 'demo' (start message), 'paused', 'gameover'.

Styling: dark navy gradient bg, neon green snake, glowing.

Let me write the code.

```js
const COLS = 24, ROWS = 24;
let cell = 26; // computed
```

Canvas: set width = COLS*cell*dpr etc.

Let me handle sizing: compute CSS size `boardSize = Math.floor(Math.min(maxW, maxH))`, then cell = boardSize / COLS (may be fractional → use boardSize = Math.floor(size/COLS)*COLS for crisp cells). Actually with dpr scaling fractional cell is fine.

Let's do:
```js
function resize() {
  const maxW = Math.min(window.innerWidth - 32, 700);
  const maxH = window.innerHeight - 220;
  let size = Math.max(260, Math.min(maxW, maxH));
  cell = Math.floor(size / COLS);
  size = cell * COLS;
  const dpr = window.devicePixelRatio || 1;
  canvas.style.width = size + 'px';
  canvas.style.height = size + 'px';
  canvas.width = Math.round(size*dpr);
  canvas.height = Math.round(size*dpr);
  ctx.setTransform(dpr,0,0,dpr,0,0);
  boardPx = size;
}
```
With COLS=ROWS=24 and cell computed. On a 375px-wide phone: maxW = 343, maxH maybe 800-220=580 → size 343, cell = floor(343/24)=14 → 336px board. Fine.

Height 800: maxH = 580, maxW = 700 → size = 580, cell = 24 → 576. Good.

Now the game loop with requestAnimationFrame and accumulator.

```js
let last = performance.now(), acc = 0;
function frame(now) {
  const dt = Math.min(now - last, 100);
  last = now;
  if (state === 'playing' || state === 'demo') {
    acc += dt;
    while (acc >= stepMs) { acc -= stepMs; step(); }
    t = Math.min(acc / stepMs, 1); // hmm, careful after step
  } else {
    // paused / gameover: keep t
  }
  draw(t);
  requestAnimationFrame(frame);
}
```

Hmm, the interpolation: after a step, prevSnake = old, snake = new, t goes 0→1 as acc accumulates. So t = acc/stepMs. But at the moment right after step, acc is small → t small. Good. But when game over, freeze t at whatever. Actually on death we should stop stepping but the last step already applied. Let's just set t = 1 on game over.

Careful: `while (acc >= stepMs)` may run multiple steps if laggy; fine.

Particles update with dt regardless (even when paused? no, only when playing/demo). Let's update particles always but with the frame dt — simpler and looks fine. Actually during pause particles moving is weird; only update when not paused.

Draw function:

```js
function draw(t) {
  // background
  ctx.clearRect... fill bg
  // grid lines subtle
  // food
  // snake
  // particles
}
```

Background: fill with #0b1220-ish, or draw a checkerboard pattern of subtle tiles.

Grid: draw thin lines every cell with rgba(255,255,255,0.03). Or checkerboard: alternate cells with slight tint. I'll do a subtle checkerboard — looks polished.

Snake drawing: For each segment, compute interpolated pixel center, draw a rounded rect of size cell*0.86 with radius. Color: HSL interpolate from bright green head to teal tail. Add glow via shadowBlur on head maybe.

Actually a nicer approach: draw the snake as a thick rounded path through the interpolated points (lineJoin round, lineCap round), then draw the head circle. But with a growing snake, the path through centers looks nice and smooth. However, sharp turns in the path would be rounded by lineJoin. Using a path with lineWidth = cell*0.8 gives a smooth connected snake. Then draw head as a circle with eyes.

But when the snake doubles back adjacent to itself the path may overlap — that's fine visually.

I think the segment-based approach with slight overlap also works. Let's do the path approach for smoothness:

```js
ctx.lineWidth = cell * 0.82;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
// gradient along the snake using createLinearGradient? Better: stroke in segments with per-segment color.
```

Per-segment color with a path is tricky. Alternative: stroke the path multiple times from tail to head with decreasing... no.

Better: draw individual circles/rounded rects at each interpolated position, with per-index color. With overlapping circles of diameter ~0.85*cell, the snake looks continuous. Since interpolation is linear per segment, consecutive centers are ≤1 cell apart, so circles of 0.85 cell overlap nicely enough? Adjacent centers at distance = cell (when moving along the body, the interpolated positions of consecutive segments are exactly 1 cell apart at all times because they move in parallel... actually not exactly: at a turn, segment i and i+1 move in different directions, so the distance can be less than a cell. During a straight run distance is exactly cell). Circles of radius 0.42*cell at distance cell apart leave gaps: 0.84*cell diameter vs 1 cell spacing → tiny gaps of 0.16 cell. Hmm. So use rounded rect segments of size 1.02*cell? That would look chunky at corners but overlapping is fine since they're same color-ish.

Alternative: draw the path stroke with a gradient. `ctx.createLinearGradient` from head position to tail position — approximates the fade. That works decently: gradient from bright green at head to darker teal at tail. But when the snake curves back, the linear gradient isn't along the body. Still acceptable and looks smooth.

Hmm. Let me do a hybrid: stroke the path 3 times with decreasing lineWidth and different alpha/colors to get a nice look:
- Outer: lineWidth cell*0.86, color dark teal (edge)
- Inner: lineWidth cell*0.7, gradient

Actually simplest good-looking: stroke the path with a linear gradient (head→tail) and lineWidth = cell*0.8, plus a lighter highlight stroke at width cell*0.34 with lower alpha, offset by -0.1 cell to give a gloss. Hmm, offset on a path is hard.

I'll go: 
1. Shadow/glow: stroke with shadowColor green, shadowBlur 12, lineWidth 0.8cell.
2. Main stroke with the gradient.
3. A thin lighter core stroke at 0.3 * cell width with white alpha 0.12 for a subtle sheen.

Then the head: circle of radius 0.46*cell with a bright green fill and slight glow, plus eyes: two white circles with dark pupils, positioned perpendicular to the direction of travel, offset forward.

Eye positions: dir vector (dx,dy). Perp = (-dy, dx). eyeOff = 0.16*cell forward... Let's compute: eyeCenter = headCenter + dir*0.12cell ± perp*0.17cell, radius 0.13cell, pupil radius 0.06cell offset slightly forward.

Also a tongue? Nah, keep the eyes.

Food: circle with pulsing radius, glow, and rotating sparkle. Also a slight ring.

Particles: array of {x,y,vx,vy,life,maxLife,color,size}. Draw as small squares/circles with alpha = life/maxLife. Gravity? no, just drag.

Now game logic.

```js
let snake, prevSnake, dir, queue, food, score, best, state, acc, stepMs, t, particles;
```

state: 'demo' | 'playing' | 'paused' | 'over'.

Init:
```js
function reset(newState) {
  snake = [{x:8,y:12},{x:7,y:12},{x:6,y:12}];
  prevSnake = snake.map(s=>({...s}));
  dir = {x:1,y:0};
  queue = [];
  food = spawnFood();
  score = 0; // only for playing
  ...
}
```

Wait, in demo mode should the score count? The demo has no score display maybe; show 0. Actually best score tracks session best of the player. During demo, don't update score display. Hmm but the score display shows numbers; during demo show 0. Let's keep `score` at 0 during demo and skip updating. Actually the AI eats food; if the score shows growing numbers that's a bit odd but also fun. I'd rather keep the demo score at 0 and show real score. Hmm, "Display the current score and the best score of the session" — the demo's score isn't the player's. I'll have the demo not affect displayed score. But then food eating in demo still triggers particles — nice, keeps the visual.

Actually, hmm, if the demo shows score 0 the whole time it's fine.

Let me make demo behave like playing but with score hidden/zero. Simpler: separate variable `demoScore`? Just don't increment score in demo. Best stays 0 until the player plays.

At start (click/key): state='playing', reset with score 0, stepMs = 140 decreasing with score.

Wait — should the demo auto-restart when it dies? Yes, after a short delay.

spawnFood: collect free cells, pick random. If none (win), handle: state 'over' with a win message? Rare. Just return null and handle.

Step function:

```js
function step() {
  let nd = dir;
  if (state === 'demo') {
    nd = aiChooseDir();
  } else if (queue.length) {
    nd = queue.shift();
  }
  dir = nd;
  const head = snake[0];
  const nx = head.x + dir.x, ny = head.y + dir.y;
  if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) return die();
  // self collision: check cells except the tail (which will move) — unless growing
  const willGrow = food && nx === food.x && ny === food.y;
  const limit = willGrow ? snake.length : snake.length - 1;
  for (let i = 0; i < limit; i++) if (snake[i].x === nx && snake[i].y === ny) return die();
  
  const newSnake = [{x:nx,y:ny}, ...snake];
  if (!willGrow) newSnake.pop();
  prevSnake = newSnake.map((s,i) => i < snake.length ? {...snake[i]} : {...snake[snake.length-1]});
  snake = newSnake;
  if (willGrow) {
    score += 10;
    burst(food.x, food.y);
    if (score > best) { best = score; }
    updateHud();
    food = spawnFood();
    // speed up slightly
    stepMs = Math.max(70, 140 - Math.floor(score/50)*8);
  }
}
```

Hmm wait, careful with prevSnake when growing: newSnake length = snake.length+1. For i = snake.length (last), prev = snake[snake.length-1] = old tail = newSnake[i]... newSnake[i] for i = snake.length is snake[snake.length-1] which is the old tail. So it stays put — correct.

But wait: prevSnake[i] = snake[i] for i < snake.length. For i < snake.length, newSnake[i] = snake[i-1] (i≥1) so it slides forward. Good.

Edge case: the queue direction reversal check happens when enqueuing.

Input handling:

```js
function pushDir(nd) {
  const base = queue.length ? queue[queue.length-1] : dir;
  if (nd.x === -base.x && nd.y === -base.y) return;
  if (nd.x === base.x && nd.y === base.y) return;
  if (queue.length < 3) queue.push(nd);
}
```

Good — allows quick successive presses to both be honored (up then left, etc.).

Also for demo, the queue isn't used.

die():
```js
function die() {
  if (state === 'demo') { demoTimer = 0.9; state='demo-dead'... }
```
Hmm, simpler: set a flag `restartIn = 1.0` and keep state 'demo' but skip stepping. Let's add a `deadTimer` and a state 'demoEnd'. Or just: on demo death, immediately reset after a delay using a timer variable.

```js
let pendingReset = 0;
function die() {
  if (state === 'demo') { pendingReset = 800; state = 'demo'; /* freeze */ demoFrozen = true; }
  else { state = 'over'; showOverlay('over'); }
}
```
I'll use a small state machine: state values: 'demo', 'playing', 'paused', 'over', 'demoPause'. Hmm, let me just handle it with a `deadTimer` in ms and a boolean.

Simpler: introduce `state` = 'demo' | 'demo-over' | 'playing' | 'paused' | 'over'. In the frame loop, if state === 'demo-over', decrement deadTimer and when ≤0, startDemo(). Also in demo-over, don't step. The overlay shows the start message always in demo/demo-over.

On death in playing: state='over', overlay with the score, best.

Restart: Space/Enter when state === 'over' → startGame(). Also Space/Enter in demo → startGame(). P when playing → paused; P when paused → playing.

Also clicking the canvas or anywhere → startGame if in demo/over.

Also: "Click or press a key to start" message must be visible. Since the demo plays behind it, the overlay must be lightweight — a semi-transparent backdrop with a card, not fully covering. Let's make the overlay a centered card with the text, with a semi transparent bg, and the demo animation around it visible.

Actually to keep the demo visible, I'll make the overlay card small and positioned near the bottom center, or just a translucent full overlay with backdrop-filter? Backdrop-filter blur would obscure the game. Let's use a card with a translucent dark background and a border, positioned centered, with the "Click or press a key to start" text and a pulsing hint. Snake visible on the sides. But the card in the middle covers the middle. Hmm.

Alternative: The start message is at the top of the board area, above the canvas, as a banner? The requirement: "behind a 'Click or press a key to start' message" — a message overlay. I'll do a full-board overlay with rgba(6,10,20,0.55) so the game is visible through it, plus a centered card. That's fine — the demo is visible, dimmed. Actually with 0.55 opacity dark, the game would be hard to see. Use 0.35.

Hmm, but the screenshot at 3 seconds: it shows the dimmed demo with the message. That's decent. But the game's polish (snake with eyes, particles) would be more visible without the dim. Let me make the overlay cover only the lower third? 

Compromise: use a card overlay without a full backdrop — just the card with rounded corners and a strong shadow, sitting in the middle of the board. The game is fully visible around it. The card says "SNAKE — Click or press a key to start". That's clean. But the snake might be hidden behind the card in the middle... The card could be placed with `align-items: flex-end` so it sits near the bottom of the board. Then it covers the bottom part. Hmm.

I'll go with a light full-board dim (rgba(3,7,18,0.45)) + card. Snake has glow so it'll show through. Actually, let me test mentally: dark background #0a0f1c, snake bright green — through a 45% black overlay it's still clearly visible. That's fine, and it emphasizes the message. I'll use 0.4.

Hmm, but I want the screenshot to look impressive. The demo plays, snake moves, with a message. I think dim 0.35 + card is best. And particles burst behind.

Actually here's another thought: make the overlay dim only cover with a radial gradient (transparent in the center where the snake is?) No, keep it simple.

Let me go with: overlay full-size, background rgba(5,9,18,0.55), and the card has its own solid-ish background. Snake visible dimly. Fine. Hmm, I want to reduce it to 0.45.

Actually — a cool idea: the message is a pill at the top-center of the board, and the demo runs fully visible below. That looks great and the "Click or press a key to start" is clear. But conventionally overlays are centered...

Decision: use a centered card with a translucent dim over the board, opacity 0.38. Good enough and clearly visible.

Now, for game over overlay, the same dim but stronger (0.7) with the score.

Let me now write the draw code carefully.

```js
function draw(t) {
  const S = cell;
  // bg
  ctx.fillStyle = '#0a0f1c';
  ctx.fillRect(0,0,boardPx,boardPx);
  // checkerboard
  for (let y=0;y<ROWS;y++) for(let x=0;x<COLS;x++) if ((x+y)%2===0) { ctx.fillStyle='rgba(255,255,255,0.018)'; ctx.fillRect(x*S,y*S,S,S); }
  // grid lines? maybe subtle border
  // vignette
  ...
}
```

Maybe add a border/glow around the board via CSS on the canvas (box-shadow inset). Canvas can't easily have inset shadow... CSS box-shadow works on canvas elements. Yes, `box-shadow: inset 0 0 60px rgba(0,0,0,.8), 0 0 40px rgba(46,230,140,.15)`.

Food pulse: `const pulse = 1 + 0.12*Math.sin(now/180)`.

Food position: center of cell.

```js
function cellCenter(c) { return { x: (c.x+0.5)*cell, y: (c.y+0.5)*cell }; }
```

Draw food: 
- glow: radial gradient circle radius cell*0.9 with rgba(255,90,120,0.35) → transparent.
- core circle radius cell*0.32*pulse, fill gradient from #ff7a7a to #ff3b6b.
- tiny white highlight.

Snake: build points array:
```js
const pts = snake.map((s,i) => {
  const p = prevSnake[i] || s;
  const x = (p.x + (s.x - p.x)*t + 0.5) * cell;
  const y = (p.y + (s.y - p.y)*t + 0.5) * cell;
  return {x,y};
});
```

Handle the case where the snake dies and prev positions... fine.

Path stroke:
```js
ctx.save();
ctx.lineJoin='round'; ctx.lineCap='round';
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i=1;i<pts.length;i++) ctx.lineTo(pts[i].x, pts[i].y);
```
With a 2-point path, lineTo works.

Gradient: from head to tail (last point).
```js
const g = ctx.createLinearGradient(pts[0].x, pts[0].y, pts[pts.length-1].x, pts[pts.length-1].y);
g.addColorStop(0, '#7dffb0'); g.addColorStop(0.5,'#2fe08a'); g.addColorStop(1,'#0e9f6e');
```
If head and tail are the same point (snake length 1), createLinearGradient with identical points → degenerate; browsers handle it (uses the last stop?). To be safe, if pts.length < 2 use a solid color. Snake length ≥ 3 always, but positions could coincide after growth. Fine, just guard with distance check.

Stroke with shadow:
```js
ctx.shadowColor = 'rgba(47,224,138,0.55)'; ctx.shadowBlur = 16;
ctx.strokeStyle = g; ctx.lineWidth = cell*0.78; ctx.stroke();
ctx.shadowBlur = 0;
```
Then a lighter inner stroke: `ctx.strokeStyle='rgba(255,255,255,0.10)'; ctx.lineWidth = cell*0.30; ctx.stroke();`

Hmm, the inner stroke centered on the same path would look like a lighter core stripe. That's nice.

Then head: draw a circle at pts[0] with radius cell*0.44, fill bright gradient, plus eyes.

Direction for the eyes: use the interpolated head movement direction = pts[0] - pts[1] normalized (if the distance is > 0.01), else `dir`.

Actually during a turn, the head's visual direction should be the current dir. Use `dir` (the current direction of movement). Good, but during the interpolation the head is between the old cell and the new cell moving in `dir`. Yes, dir is right.

Eyes: 
```js
const hx = pts[0].x, hy = pts[0].y;
const dx = dir.x, dy = dir.y;
const px = -dy, py = dx;
for (const s of [-1, 1]) {
  const ex = hx + dx*cell*0.10 + px*s*cell*0.17;
  const ey = hy + dy*cell*0.10 + py*s*cell*0.17;
  ctx.beginPath(); ctx.arc(ex,ey,cell*0.12,0,7); ctx.fillStyle='#fff'; ctx.fill();
  // pupil
  ctx.beginPath(); ctx.arc(ex + dx*cell*0.045, ey + dy*cell*0.045, cell*0.055, 0, 7); ctx.fillStyle='#08120c'; ctx.fill();
}
```

Head fill: '#a8ffcf' → maybe radial gradient.

Also maybe add a slight "neck" so the head circle blends. The stroke already covers it.

Particles: 
```js
for (const p of particles) {
  const a = p.life / p.maxLife;
  ctx.globalAlpha = a;
  ctx.fillStyle = p.color;
  ctx.beginPath(); ctx.arc(p.x, p.y, p.r * (0.4 + a*0.6), 0, 7); ctx.fill();
}
ctx.globalAlpha = 1;
```
Particles in pixel coordinates, spawned at the food center with random angle/speed, drag and slight fade. Also add a small shockwave ring: store as particle type 'ring'.

Score popup: a floating "+10" text. Nice touch. Add to a `floats` array with life, drawing text at the position rising and fading.

Now AI.

```js
function aiChooseDir() {
  const head = snake[0];
  const dirs = [{x:0,y:-1},{x:0,y:1},{x:-1,y:0},{x:1,y:0}];
  const body = new Set();
  // exclude the tail cell if not growing (tail will move)
  for (let i=0;i<snake.length-1;i++) body.add(snake[i].x + ',' + snake[i].y);
  const blocked = body; // cells blocked for the next head move (excluding tail)
  ...
}
```
Hmm, but for pathfinding, treating the tail as free is optimistic but usually fine.

BFS:

```js
function bfsStep(start, goal, blocked) {
  const W = COLS, H = ROWS, N = W*H;
  const prev = new Int32Array(N).fill(-2);
  const startIdx = start.y*W + start.x;
  const q = [startIdx]; prev[startIdx] = -1;
  let qi = 0;
  while (qi < q.length) {
    const idx = q[qi++];
    if (idx === goal.y*W + goal.x) {
      let node = idx;
      while (prev[node] !== -1 && prev[node] !== startIdx) node = prev[node];
      // if prev[node] === -1 then node === start (goal==start)
      if (prev[node] === -1) return null;
      const nx = node % W, ny = (node - nx)/W;
      return {x: nx-start.x, y: ny-start.y};
    }
    const cx = idx % W, cy = (idx - cx)/W;
    for (const d of dirs) {
      const nx = cx+d.x, ny = cy+d.y;
      if (nx<0||ny<0||nx>=W||ny>=H) continue;
      const ni = ny*W+nx;
      if (prev[ni] !== -2) continue;
      if (blocked.has(nx+','+ny) && ni !== goal...) 
      ...
    }
  }
}
```

Hmm, careful: blocked cells can't be entered, except the goal (food never on the snake, so no conflict). Set prev[ni] = idx and push.

Also must not enter the cell where the snake's head currently is? Not needed.

Then the final answer: the first step direction. Let me verify the backtrack loop:
- If goal's prev is startIdx → loop doesn't run (prev[idx]===startIdx) → node = goal → direction = goal - start. Correct.
- If prev[node] === -1, that means node === startIdx, which means goal === start. Then we return null. Actually if goal === startIdx the function returns at the first check... we check `if (idx === goal...)` at pop time. If start === goal, we'd immediately hit it with prev[startIdx] = -1 → the while loop condition `prev[node] !== -1` fails immediately, then `if (prev[node] === -1) return null;`. OK.

Then safety check: the returned direction must not lead into a blocked cell (it won't, since it's a valid BFS step... unless the goal is adjacent and it's fine).

Fallback when BFS fails: for each of the 4 dirs, skip reversal, skip out of bounds, skip body; flood fill count from that cell; pick max. If all yield 0, pick any valid (maybe just reverse-safe) — snake dies, fine.

Flood fill:
```js
function floodCount(sx, sy, blocked) {
  const W=COLS,H=ROWS;
  const seen = new Set([sx+','+sy]);
  const stack = [[sx,sy]];
  let count = 0;
  while (stack.length) {
    const [x,y] = stack.pop();
    count++;
    for (const d of dirs) {
      const nx=x+d.x, ny=y+d.y;
      if (nx<0||ny<0||nx>=W||ny>=H) continue;
      const k = nx+','+ny;
      if (seen.has(k)||blocked.has(k)) continue;
      seen.add(k); stack.push([nx,ny]);
    }
  }
  return count;
}
```
Cap at some max for performance — grid is 576 cells, fine.

Performance: the AI runs at most ~14 steps/sec. Trivial.

Now, HUD updates. DOM elements for score/best. Update on change.

Best score "of the session" — just a variable, no localStorage.

Now let's handle the frame loop and state transitions.

```js
let state = 'demo';
let stepMs = 130;
let acc = 0, t = 0;
let lastTime = performance.now();
let deadTimer = 0;
```

frame:
```js
function frame(now) {
  const dt = Math.min(now - lastTime, 120);
  lastTime = now;
  nowMs = now;

  if (state === 'playing' || state === 'demo') {
    acc += dt;
    let guard = 0;
    while (acc >= stepMs && guard++ < 4) { acc -= stepMs; step(); if (state !== 'playing' && state !== 'demo') break; }
    t = Math.min(acc / stepMs, 1);
  } else if (state === 'demoOver') {
    deadTimer -= dt;
    if (deadTimer <= 0) startDemo();
    t = 1;
  }
  
  if (state !== 'paused') updateParticles(dt);
  draw(t);
  requestAnimationFrame(frame);
}
```

Wait, on 'over' state particles should still fade. state !== 'paused' covers it.

Hmm, but on game over, t should be 1 so the snake is at its final position. When die() is called, we set t = 1? The die happens during step, so acc has already been decremented. After breaking out of the while loop, `t = Math.min(acc/stepMs,1)` — acc is small → t ≈ 0. That would render the snake at the previous position (not yet collided) — actually that's fine visually since the snake dies before moving. But then the head never visually enters the wall. Freezing at t=0 loses nothing. Hmm, but it might look like the snake is 1 cell behind. Whatever, it's fine. Actually let's set t=1 on death for a settled look. I'll set a variable `frozenT = 1` — eh, simpler: in the frame loop, if state === 'over' → t = 1. But wait, then the snake jumps to the last valid position (which it already is). Fine.

Hmm, careful: when state is 'over', prevSnake/snake still hold the last state; t=1 renders snake at final positions. Good.

Now the demo death: state = 'demoOver', deadTimer = 700.

startDemo(): reset snake, food, state='demo', acc=0, t=0, particles=[], dir={x:1,y:0}, queue=[].

startGame(): reset, state='playing', score=0, stepMs=130, hide overlay, update HUD.

Since the demo and the game share the snake array, starting the game resets everything.

Overlay handling:
```js
function showOverlay(html) { overlay.innerHTML = ...; overlay.classList.add('show'); }
```
I'll build overlay content in JS per state, or have 3 pre-made divs and toggle. Simpler: one overlay div, set innerHTML per state.

Let me write the overlay markup:

demo:
```html
<div class="card">
  <div class="kicker">SNAKE</div>
  <div class="big">Click or press a key<br>to start</div>
  <div class="sub">Arrows / WASD · P pause · Space restart</div>
</div>
```
Hmm, "SNAKE" is already the h1 title. Let me use the kicker as "DEMO PLAYING".

Actually nice: kicker = "▶ DEMO MODE", big = "Click or press a key to start", sub = controls.

over:
```html
<div class="card">
  <div class="kicker">GAME OVER</div>
  <div class="big">Score <span class="hl">120</span></div>
  <div class="sub">Best <b>150</b></div>
  <div class="sub">Press Space or Enter to play again</div>
</div>
```

paused:
```html
<div class="card"><div class="big">Paused</div><div class="sub">Press P to resume</div></div>
```

Styling: card with backdrop-filter blur maybe (allowed). Semi-transparent dark, border 1px rgba, rounded 18px, padding.

Font: system-ui / -apple-system. No external fonts.

Colors: bg #070b14 with a radial gradient of deep blue/green. Accent green #2fe08a, food #ff4d6d.

Header: title "SNAKE" with letter-spacing, gradient text.

Score boxes: rounded pills with label and value in monospace.

Let me write the CSS.

```css
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
  background: radial-gradient(1200px 800px at 50% -10%, #12233a 0%, #0a1020 45%, #05070e 100%);
  color: #e6f2ff;
  font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  display: flex; align-items: center; justify-content: center;
  min-height: 100vh; overflow: hidden;
  user-select: none;
  -webkit-font-smoothing: antialiased;
}
```

Careful with `overflow:hidden` on body — should prevent scrollbars. But if content is taller than the viewport on 375px... Since we size the canvas to fit the viewport height minus 220, it should fit. Let's compute: on a 375×667 viewport, maxH = 667-220 = 447, maxW = 343 → size 343 → cell 14 → 336. Header ~60px + hud ~60 + hint 30 + gaps ~40 = ~530 < 667. OK.

But on a very short viewport (e.g. 800x500), maxH = 280 → size 280 → cell 11 → 264px board. Fine.

I'll use `overflow: hidden` on body but with `min-height:100vh` and flex centering. Risk: if content overflows, it's clipped rather than scrollable. Acceptable; let's make sure sizes fit.

Actually, safer: `overflow: hidden` on html/body, and the wrap uses a scale-limited layout. It should be fine.

Layout:
```html
<div class="app">
  <div class="hud">
    <div class="brand">SNAKE</div>
    <div class="scores">
      <div class="pill"><span class="label">SCORE</span><span class="val" id="score">0</span></div>
      <div class="pill best"><span class="label">BEST</span><span class="val" id="best">0</span></div>
    </div>
  </div>
  <div class="board">
    <canvas id="c"></canvas>
    <div class="overlay" id="overlay"></div>
  </div>
  <div class="hint"><kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd> or <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> · <kbd>P</kbd> pause · <kbd>Space</kbd> restart</div>
</div>
```

The board wraps the canvas and the overlay is absolutely positioned over it.

The canvas CSS size is set by JS. The overlay sizes to the board's box.

Let's write resize to also handle the window resize event.

Now, a subtle issue: on window resize, the canvas is resized but cell changes; positions are grid-based so it re-renders fine. Particles are in pixel coords → they'd be misplaced, but they're short-lived. Fine.

Let me now write everything out concretely.

```js
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const overlayEl = document.getElementById('overlay');
const scoreEl = document.getElementById('score');
const bestEl = document.getElementById('best');

const COLS = 24, ROWS = 24;
let cell = 24, boardPx = 576;

const DIRS = [{x:0,y:-1},{x:0,y:1},{x:-1,y:0},{x:1,y:0}];

let snake, prevSnake, dir, queue, food, particles, floats;
let score = 0, best = 0;
let state = 'demo';
let stepMs = 135;
let acc = 0, t = 0;
let deadTimer = 0;
let timeMs = 0;
```

resize():
```js
function resize() {
  const vw = window.innerWidth, vh = window.innerHeight;
  const availW = Math.min(vw - 28, 720);
  const availH = vh - 200;
  let size = Math.min(availW, Math.max(240, availH));
  cell = Math.max(9, Math.floor(size / COLS));
  size = cell * COLS;
  boardPx = size;
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
  canvas.style.width = size + 'px';
  canvas.style.height = size + 'px';
  canvas.width = Math.round(size * dpr);
  canvas.height = Math.round(size * dpr);
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
```

Hmm, `Math.min(availW, Math.max(240, availH))` — if availH is small, we want size = max(240, availH)? That would make it overflow. Let's do `let size = Math.min(availW, availH)` and clamp min 220. Then cell = floor(size/COLS). Hmm on a 1280x800: availH = 600, availW = 720 → size 600 → cell 25 → 600. Board is 600px. Total height: hud (~56) + board 600 + hint (~30) + gaps (~30) = 716 < 800. Good.

Actually let me reduce: availH = vh - 210 → 590, cell 24 → 576. Good.

Wait, I need to double check the app's total height: hud row maybe 60px, gap 14, board 576, gap 12, hint 24 → 686. Plus body padding. Fits in 800. 

On 375x667: availW = 347, availH = 457 → size 347 → cell 14 → 336. Total: 60+14+336+12+24 = 446 < 667. Good.

The hint on 375px might wrap; that's fine (no horizontal scrollbar since it wraps).

Now, the food spawn:

```js
function spawnFood() {
  const occupied = new Set(snake.map(s => s.x + ',' + s.y));
  const free = [];
  for (let y=0;y<ROWS;y++) for (let x=0;x<COLS;x++) {
    const k = x+','+y;
    if (!occupied.has(k)) free.push({x,y});
  }
  if (!free.length) return null;
  return free[Math.floor(Math.random()*free.length)];
}
```

Note: spawnFood is called after the snake update so it excludes the new snake. Good.

resetGame():
```js
function resetGame() {
  const cy = Math.floor(ROWS/2);
  snake = [{x:8,y:cy},{x:7,y:cy},{x:6,y:cy}];
  prevSnake = snake.map(s=>({...s}));
  dir = {x:1,y:0};
  queue = [];
  particles = [];
  floats = [];
  acc = 0; t = 0;
  food = null;
  food = spawnFood();
}
```

Careful: spawnFood uses `snake`, which is set. Good.

step():
```js
function step() {
  let nd = dir;
  if (state === 'demo') {
    const ai = aiChooseDir();
    if (ai) nd = ai;
  } else if (queue.length) {
    nd = queue.shift();
  }
  dir = nd;

  const head = snake[0];
  const nx = head.x + nd.x, ny = head.y + nd.y;

  if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) return gameOver();
  
  const willGrow = food && nx === food.x && ny === food.y;
  const limit = willGrow ? snake.length : snake.length - 1;
  for (let i = 0; i < limit; i++) {
    if (snake[i].x === nx && snake[i].y === ny) return gameOver();
  }

  const ns = [{x:nx,y:ny}, ...snake];
  if (!willGrow) ns.pop();
  const ps = ns.map((s, i) => i < snake.length ? {...snake[i]} : {...snake[snake.length-1]});
  prevSnake = ps;
  snake = ns;

  if (willGrow) {
    onEat(nx, ny);
  }
}
```

Wait, there's a subtle bug: `prevSnake = ps` must be computed BEFORE `snake = ns`. Yes it is (ps uses the old snake). Good.

onEat(x,y):
```js
function onEat(x, y) {
  burst(x, y);
  floats.push({x:(x+0.5)*cell, y:(y+0.5)*cell, life: 1, text:'+10'});
  if (state === 'playing') {
    score += 10;
    stepMs = Math.max(72, 135 - Math.floor(score/60) * 7);
    if (score > best) { best = score; }
    updateHud();
  }
  food = spawnFood();
}
```

Note the float and burst use `cell` at the time of eating; if resized mid-flight they'd be off. Fine.

gameOver():
```js
function gameOver() {
  if (state === 'demo') {
    state = 'demoOver';
    deadTimer = 650;
  } else {
    state = 'over';
    if (score > best) best = score;
    updateHud();
    setOverlay(overlayHTML());
  }
}
```

Hmm — on demo death I want a small burst too? Not necessary. Maybe a red flash. Skip.

Actually, for demoOver, t should be 1.

setOverlay(html): sets innerHTML and adds 'show'. hideOverlay removes 'show'.

updateHud(): scoreEl.textContent = score; bestEl.textContent = best; And add a little pop animation on the score change.

Now the input:

```js
const KEYMAP = {
  ArrowUp:{x:0,y:-1}, ArrowDown:{x:0,y:1}, ArrowLeft:{x:-1,y:0}, ArrowRight:{x:1,y:0},
  w:{x:0,y:-1}, s:{x:0,y:1}, a:{x:-1,y:0}, d:{x:1,y:0},
  W:..., // use e.key.toLowerCase()
};
```

Handle keydown:
```js
window.addEventListener('keydown', (e) => {
  const k = e.key;
  if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(k)) e.preventDefault();
  
  if (state === 'demo' || state === 'demoOver') {
    // any key starts
    startGame(); // but careful: if it's an arrow key, also queue that direction? Sure, ignore.
    return;
  }
  if (state === 'over') {
    if (k === ' ' || k === 'Enter') { startGame(); }
    return;
  }
  if (k === 'p' || k === 'P') {
    if (state === 'playing') { state='paused'; setOverlay(pausedHTML()); }
    else if (state === 'paused') { state='playing'; hideOverlay(); }
    return;
  }
  if (state === 'paused') return;
  const d = KEYS[k.toLowerCase()] ... 
});
```

Careful: 'ArrowUp'.toLowerCase() = 'arrowup'. So make the map keys lowercase: arrowup, etc.

Also cap: after `state==='playing'`, handle direction input.

Also on start, should the arrow key that started the game also set the direction? Not necessary.

Also P during demo: should it start the game? "any key starts" would include P. That's fine.

Click handler on document: if state is demo/demoOver/over → startGame().

Also, don't start on click when paused? Clicking while paused resumes? Let's leave paused alone (or resume on click — nicer). I'll just handle demo/over.

Now, the keydown listener is on `window`. The iframe needs a click to focus first — hence the message. But the click also starts the game. Fine, that's the requirement.

Hmm, but then after starting, keyboard events work since the user clicked. Good.

Now `startGame()`:
```js
function startGame() {
  resetGame();
  state = 'playing';
  score = 0;
  stepMs = 135;
  best = Math.max(best, 0);
  updateHud();
  hideOverlay();
  lastTime = performance.now(); // avoid a big dt jump
}
```
Also reset acc=0, t=0 (done in resetGame).

startDemo():
```js
function startDemo() {
  resetGame();
  state = 'demo';
  deadTimer = 0;
  setOverlay(demoHTML());
}
```

Call startDemo() at init.

Now drawing.

```js
function draw(tv) {
  const S = cell;
  const W = boardPx;
  ctx.clearRect(0, 0, W, W);
  
  // base
  ctx.fillStyle = '#080d18';
  ctx.fillRect(0, 0, W, W);
  
  // checker
  ctx.fillStyle = 'rgba(120,200,255,0.030)';
  for (let y=0;y<ROWS;y++) for (let x=0;x<COLS;x++) if ((x+y)&1) ctx.fillRect(x*S,y*S,S,S);
  
  // hmm, drawing 288 rects per frame * 60fps = fine.
```
Actually, better to pre-render the board background to an offscreen canvas once per resize. 288 fillRects per frame is fine, but let's be clean and use a cached background canvas. Eh — 288 rects at 60fps is trivial for Chrome. Keep it simple, but I'll use a cached offscreen canvas to be extra smooth. Actually simpler: draw the checkerboard into a pattern once. Let me just do the loops; it's fine.

Hmm, with `cell` at 24, 24x24 = 576 rects. Still fine.

Food drawing with pulse: use `timeMs`.

```js
function drawFood() {
  if (!food) return;
  const cx = (food.x+0.5)*cell, cy = (food.y+0.5)*cell;
  const pulse = 1 + 0.10 * Math.sin(timeMs/170);
  const r = cell*0.30*pulse;
  // glow
  const g = ctx.createRadialGradient(cx,cy,0,cx,cy,cell*0.95);
  g.addColorStop(0,'rgba(255,80,120,0.55)');
  g.addColorStop(0.5,'rgba(255,80,120,0.16)');
  g.addColorStop(1,'rgba(255,80,120,0)');
  ctx.fillStyle = g;
  ctx.beginPath(); ctx.arc(cx,cy,cell*0.95,0,Math.PI*2); ctx.fill();
  // body
  const bg = ctx.createRadialGradient(cx - r*0.3, cy - r*0.35, r*0.1, cx, cy, r*1.3);
  bg.addColorStop(0,'#ff9db0');
  bg.addColorStop(1,'#e8244f');
  ctx.fillStyle = bg;
  ctx.beginPath(); ctx.arc(cx,cy,r,0,Math.PI*2); ctx.fill();
  // highlight
  ctx.fillStyle = 'rgba(255,255,255,0.75)';
  ctx.beginPath(); ctx.arc(cx - r*0.32, cy - r*0.38, r*0.22, 0, Math.PI*2); ctx.fill();
}
```

Snake drawing:

```js
function drawSnake(tv) {
  const S = cell;
  if (!snake.length) return;
  const pts = snake.map((s,i) => {
    const p = prevSnake[i] || s;
    return {
      x: (p.x + (s.x-p.x)*tv + 0.5) * S,
      y: (p.y + (s.y-p.y)*tv + 0.5) * S
    };
  });
  
  ctx.save();
  ctx.lineJoin = 'round';
  ctx.lineCap = 'round';
  
  if (pts.length > 1) {
    ctx.beginPath();
    ctx.moveTo(pts[0].x, pts[0].y);
    for (let i=1;i<pts.length;i++) ctx.lineTo(pts[i].x, pts[i].y);
    
    const tail = pts[pts.length-1];
    const g = ctx.createLinearGradient(pts[0].x, pts[0].y, tail.x, tail.y);
    g.addColorStop(0, '#9dffcb');
    g.addColorStop(0.35,'#35e58f');
    g.addColorStop(1, '#0f8f66');
    
    ctx.shadowColor = 'rgba(53,229,143,0.55)';
    ctx.shadowBlur = S*0.7;
    ctx.strokeStyle = g;
    ctx.lineWidth = S*0.76;
    ctx.stroke();
    
    ctx.shadowBlur = 0;
    ctx.strokeStyle = 'rgba(220,255,240,0.16)';
    ctx.lineWidth = S*0.30;
    ctx.stroke();
  }
  ...
}
```

Hmm: shadowBlur with a gradient stroke — fine.

One problem: when the head and tail positions are identical (degenerate gradient), Chrome uses... if x0==x1 && y0==y1, the gradient is undefined; per spec, if the start and end points are the same, the gradient paints nothing? Actually the spec says if x0=x1 and y0=y1 then nothing is painted. Hmm. Snake length ≥ 3 typically, so head ≠ tail unless it's coiled. Guard: if distance < 1, use a solid color fill.

Head:
```js
  const h = pts[0];
  const dx = dir.x, dy = dir.y;
  const hr = S*0.44;
  const hg = ctx.createRadialGradient(h.x - dx*hr*0.3, h.y - dy*hr*0.3, hr*0.1, h.x, h.y, hr);
  hg.addColorStop(0,'#b6ffd8');
  hg.addColorStop(1,'#2fe08a');
  ctx.fillStyle = hg;
  ctx.beginPath(); ctx.arc(h.x,h.y,hr,0,Math.PI*2); ctx.fill();
  
  // eyes
  const px = -dy, py = dx;
  for (const s of [-1,1]) {
    const ex = h.x + dx*S*0.09 + px*s*S*0.165;
    const ey = h.y + dy*S*0.09 + py*s*S*0.165;
    ctx.beginPath(); ctx.arc(ex,ey,S*0.115,0,Math.PI*2);
    ctx.fillStyle = '#fff'; ctx.fill();
    ctx.beginPath(); ctx.arc(ex + dx*S*0.045, ey + dy*S*0.045, S*0.055,0,Math.PI*2);
    ctx.fillStyle = '#06231a'; ctx.fill();
  }
  ctx.restore();
```

Note `dir` during the interpolation: after a step, dir is the new direction, and the head is moving from the old cell to the new one in that direction, so eyes face correctly.

But wait — during 'over' state, dir is the direction that killed. Fine.

Particles:

```js
function burst(gx, gy) {
  const cx = (gx+0.5)*cell, cy = (gy+0.5)*cell;
  for (let i=0;i<18;i++) {
    const a = Math.random()*Math.PI*2;
    const sp = (0.6 + Math.random()*2.2) * cell * 0.06;
    particles.push({
      x: cx, y: cy,
      vx: Math.cos(a)*sp, vy: Math.sin(a)*sp,
      r: cell*(0.06 + Math.random()*0.10),
      life: 1, decay: 0.9 + Math.random()*0.9,
      color: Math.random() < 0.5 ? '#ff5c7a' : '#ffd166'
    });
  }
  particles.push({ ring: true, x: cx, y: cy, life: 1, decay: 2.4, r: cell*0.2, color:'#ff9db0' });
}
```

Update:
```js
function updateParticles(dt) {
  const f = dt/16.67;
  for (let i=particles.length-1;i>=0;i--) {
    const p = particles[i];
    p.life -= p.decay * (dt/1000) * 1.6;  // hmm
    ...
  }
}
```
Let me simplify: life decreases by decay * (dt/1000) where decay ~2.2 → ~0.45s lifetime for a decay of 2.2. For particles, decay 1.6-2.8 → 0.35-0.6s. Ring decay 2.4.

```js
function updateParticles(dt) {
  const s = dt/1000;
  for (let i = particles.length-1; i>=0; i--) {
    const p = particles[i];
    p.life -= p.decay * s;
    if (p.life <= 0) { particles.splice(i,1); continue; }
    if (!p.ring) {
      p.x += p.vx * s * 60 * ... 
```

Hmm, velocities: I set sp in px/frame-ish units. Let me define velocities in px per second and multiply by dt/1000. Set sp = (60 + Math.random()*220) * (cell/24) px/s. Then p.x += p.vx * s. And add drag: p.vx *= Math.pow(0.06, s)?? Let's do p.vx *= (1 - 3*s) clamped. Simpler: p.vx *= 0.94 per 16.67ms → use Math.pow(0.94, dt/16.67).

Let me just write:
```js
const k = Math.pow(0.90, dt/16.67);
p.vx *= k; p.vy *= k;
p.x += p.vx * (dt/16.67);
p.y += p.vy * (dt/16.67);
```
with vx in px per frame at 60fps: 0.5 to 3 px/frame. That's slow-ish; times cell/24 scale.

I'll use: sp = (0.8 + Math.random()*3.0) * (cell/24) * 1.6 px/frame. At cell 24 → up to ~5.8 px/frame → 350px/s. Good spread over ~0.4s ≈ 50px. Reasonable for a 24px cell.

Ring: radius grows from cell*0.2 to cell*0.9, alpha fades.

Drawing particles:
```js
for (const p of particles) {
  const a = Math.max(0, Math.min(1, p.life));
  if (p.ring) {
    ctx.globalAlpha = a * 0.7;
    ctx.strokeStyle = p.color;
    ctx.lineWidth = Math.max(1, cell*0.09);
    ctx.beginPath();
    ctx.arc(p.x, p.y, cell*0.2 + (1-a)*cell*0.8, 0, Math.PI*2);
    ctx.stroke();
  } else {
    ctx.globalAlpha = a;
    ctx.fillStyle = p.color;
    ctx.beginPath();
    ctx.arc(p.x, p.y, p.r * (0.35 + a*0.9), 0, Math.PI*2);
    ctx.fill();
  }
}
ctx.globalAlpha = 1;
```

Floats:
```js
for (const f of floats) { ... }
```
Draw "+10" text rising with fade. Text size ~cell*0.7, bold, color '#ffe08a'.

Update floats similar: life -= dt/700, y -= dt*0.03.

OK.

Now, the AI function. Let me write it carefully.

```js
const KEY = (x,y) => y*COLS + x;

function aiChooseDir() {
  if (!food) return dir;
  const head = snake[0];
  const blocked = new Set();
  for (let i=0;i<snake.length-1;i++) blocked.add(KEY(snake[i].x, snake[i].y));
  // also block the tail cell? The tail will move unless growing. Keep it free but risky. 
  
  const step = bfsFirstStep(head, food, blocked);
  if (step) return step;
  
  // fallback: safest direction
  let bestDir = null, bestScore = -1;
  for (const d of DIRS) {
    if (d.x === -dir.x && d.y === -dir.y) continue;
    const nx = head.x+d.x, ny = head.y+d.y;
    if (nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
    if (blocked.has(KEY(nx,ny))) continue;
    const space = floodCount(nx, ny, blocked);
    if (space > bestScore) { bestScore = space; bestDir = d; }
  }
  return bestDir || dir;
}
```

Hmm, there's an issue: BFS to the food might route through a path that's technically free but leads to a trap. The greedy BFS is fine for a demo. But there's a risk the AI dies often, restarting the demo. That's OK — actually it's better if the demo survives long, and it usually does with BFS on an open grid. As the snake grows it gets harder. Since the demo runs only ~3 seconds before the screenshot, it's fine. But the demo may run for a while if nobody interacts. Let's also cap the demo snake length? No. Fine.

One more safety improvement: prefer BFS but also verify the chosen step doesn't create immediate death. BFS already avoids the body.

Actually, there's a bug risk: `blocked` excludes the tail, but if the snake is going to grow, the tail stays. Minor.

floodCount with a Set of strings — cap the count.

bfsFirstStep implementation:

```js
function bfsFirstStep(start, goal, blocked) {
  const N = COLS*ROWS;
  const prev = new Int32Array(N).fill(-2);
  const si = KEY(start.x, start.y), gi = KEY(goal.x, goal.y);
  prev[si] = -1;
  const q = new Int32Array(N); let qh = 0, qt = 0;
  q[qt++] = si;
  while (qh < qt) {
    const idx = q[qh++];
    if (idx === gi) {
      let node = idx;
      while (prev[node] !== -1 && prev[node] !== si) node = prev[node];
      if (prev[node] === -1) return null;
      const nx = node % COLS, ny = (node - nx)/COLS;
      return {x: nx - start.x, y: ny - start.y};
    }
    const cx = idx % COLS, cy = (idx - cx)/COLS;
    for (let k=0;k<4;k++) {
      const d = DIRS[k];
      const nx = cx+d.x, ny = cy+d.y;
      if (nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
      const ni = KEY(nx,ny);
      if (prev[ni] !== -2) continue;
      if (blocked.has(ni)) continue;
      prev[ni] = idx;
      q[qt++] = ni;
    }
  }
  return null;
}
```

Wait — there's a subtlety in the backtrack loop. Suppose the path is start → A → B → goal. prev[A]=si, prev[B]=A, prev[goal]=B. Loop: node=goal, prev[goal]=B ≠ -1 and ≠ si → node=B. prev[B]=A ≠ -1 and ≠ si → node=A. prev[A]=si → exit. node=A, direction = A - start. Correct.

Another case: goal is the very first neighbor: prev[goal]=si → loop exits immediately, node=goal. Correct.

Edge: what if the goal is unreachable? We return null after the queue empties.

Also `if (prev[node] === -1) return null;` — when node === si, meaning goal === si. But we return at `idx === gi` before... if si === gi we'd pop si and immediately enter the `idx===gi` branch: node=si, prev[si]=-1, loop condition `prev[node] !== -1` is false → exit loop, then `if (prev[node] === -1) return null`. Good.

Note: `blocked.has(ni)` — blocked contains keys as numbers now (KEY). Good, I used numbers.

Now: `if (!food) return dir;` — food null means win. Ignore.

One more thing about the demo: the AI might immediately pick the direction opposite to the current dir? BFS from head to food never returns a reversal because the neck cell is blocked (snake[1] is included in blocked when length ≥ 2). Yes, since blocked includes snake[0..len-2], which includes snake[1]. Good, so the AI can't reverse.

Now startDemo and the initial call.

Let me also handle a subtle thing: the demo needs `prevSnake` initialized properly, and the food.

Also handle the case where the game is paused and the user presses restart keys: on 'paused', Space should maybe restart? The spec says Space/Enter restarts on game over. On pause, P resumes. I'll also allow space to restart from pause — no, keep it clean. Actually allow Space/Enter to restart only from 'over'. And from 'demo'. Good.

Now the overlay HTML functions.

```js
function demoHTML() {
  return `<div class="card">
    <div class="kicker"><span class="dot"></span>DEMO PLAYING</div>
    <h2>Click or press a key<br>to start</h2>
    <p><kbd>↑</kbd><kbd>←</kbd><kbd>↓</kbd><kbd>→</kbd> / <kbd>WASD</kbd> move &nbsp;·&nbsp; <kbd>P</kbd> pause</p>
  </div>`;
}
```

Careful with the kbd styling inside the card.

over:
```js
function overHTML() {
  return `<div class="card">
    <div class="kicker danger">GAME OVER</div>
    <h2>Score <span class="big-score">${score}</span></h2>
    <p>Best this session: <b>${best}</b></p>
    <p class="cta">Press <kbd>Space</kbd> or <kbd>Enter</kbd> to play again</p>
  </div>`;
}
```

paused:
```js
function pausedHTML() {
  return `<div class="card"><div class="kicker">PAUSED</div><h2>Take a breath</h2><p>Press <kbd>P</kbd> to resume</p></div>`;
}
```

Overlay CSS:
```css
.overlay {
  position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
  background: rgba(4,8,16,0.42);
  opacity: 0; pointer-events: none;
  transition: opacity .25s ease;
  border-radius: inherit;
  padding: 16px;
}
.overlay.show { opacity: 1; }
```
Since it's pointer-events:none, clicks pass through to the document handler. Good.

But the dim background dims the game 42% — that's ok.

Hmm — actually for the game-over the dim should be stronger: add a class 'dim' → background rgba(4,8,16,0.7).

Card:
```css
.card {
  background: linear-gradient(180deg, rgba(18,28,45,0.96), rgba(10,16,28,0.96));
  border: 1px solid rgba(90,200,150,0.25);
  border-radius: 18px;
  padding: 22px 26px;
  text-align: center;
  box-shadow: 0 24px 60px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.03) inset;
  max-width: 100%;
  animation: pop .3s ease;
}
@keyframes pop { from { transform: scale(.94); opacity: .4 } to { transform: scale(1); opacity: 1 } }
```

Note: since the overlay is re-created on each state change, the animation restarts. Fine.

Title h1 "SNAKE": use a gradient text.

Header layout: title on the left, score pills on the right, in a row of width = board width.

```css
.hud { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; margin-bottom: 12px; }
```

The app container width should match the board width. Since the canvas size is set by JS, I'll set the app's width via JS? Or use flex column with align-items: center. The HUD would then be as wide as the board if I constrain it: `.app { display: flex; flex-direction: column; align-items: center; }` and `.hud { width: 100%; }` where .app width = board width... but .app width is determined by its widest child (the board). Hmm, .hud width:100% of .app which is determined by the board (canvas) — circular but CSS handles it: the app's width is auto (shrink to fit) in a flex container body... 

Body is `display:flex; justify-content:center; align-items:center`. The .app is a flex item with width auto → shrink-to-fit based on content. The canvas has an explicit width, the hud with width:100% resolves against the app's width... In shrink-to-fit, percentage widths of children are treated as auto during intrinsic sizing, so the hud's intrinsic width (its content) could exceed the board width. E.g. the hint line with all the kbd's might be wider than the board → that would stretch .app. Then the hud would be wider than the board. Not terrible, but let's set explicit widths.

Better: set `.app { width: min-content }`? Hmm.

Simplest: in resize(), also set the app's width to the board size + padding:
```js
app.style.width = size + 'px';
```
Then .hud width:100% = the board width. And the hint gets `white-space: normal` (wraps within the app width). 

But if the board is 336px on mobile and the hint is long, it wraps into ~3 lines. Fine.

Let's make the hint text shorter: "↑↓←→ / WASD move · P pause". Use kbd elements? Simpler text with small caps. I'll include a few kbd chips.

Now the score pill styling:
```css
.pill { display:flex; align-items:center; gap:8px; padding:8px 14px; border-radius: 999px; background: rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); }
.pill .label { font-size: 10px; letter-spacing: .14em; color: #7f93ad; font-weight: 700; }
.pill .val { font-size: 20px; font-weight: 800; font-variant-numeric: tabular-nums; color: #d8ffe9; }
.pill.best .val { color: #ffd166; }
```

Add a pop animation on the score when it changes: add a class then remove it. Use `scoreEl.animate` ? Web Animations API is available in Chrome. Or CSS class toggle with a reflow. I'll use element.animate for simplicity:
```js
scoreEl.animate([{transform:'scale(1.35)'},{transform:'scale(1)'}], {duration:220, easing:'ease-out'});
```
Careful: animate() on a span works. Fine.

Actually let me avoid the animate API edge cases (it's fine in Chrome). Keep it.

Title:
```css
.brand { font-size: 22px; font-weight: 900; letter-spacing: .28em; background: linear-gradient(90deg,#7dffb0,#2fe08a 60%,#38bdf8); -webkit-background-clip: text; background-clip: text; color: transparent; }
```
Hmm, on 375px the brand + two pills might be too wide: brand ~ "SNAKE" at 22px with 0.28em spacing ≈ 6 chars * (14+6) ≈ 120px. Pills ~ 90 + 90 = 180 + gap. Total ~320 < 336. Tight but OK. Let me reduce the brand size on small screens with a media query.

Or make the brand smaller overall: 18px, letter-spacing .22em. And the pills more compact. Good.

Let me also add a "live" indicator? Not needed.

Now write the final frame loop and initialization.

```js
let lastTime = performance.now();
let timeMs = 0;

function frame(now) {
  const dt = Math.min(now - lastTime, 100);
  lastTime = now;
  timeMs = now;

  if (state === 'playing' || state === 'demo') {
    acc += dt;
    let guard = 0;
    while (acc >= stepMs && guard++ < 5) {
      acc -= stepMs;
      step();
      if (state !== 'playing' && state !== 'demo') break;
    }
    t = Math.min(acc / stepMs, 1);
  } else if (state === 'demoOver') {
    deadTimer -= dt;
    t = 1;
    if (deadTimer <= 0) { startDemo(); }
  } else {
    t = 1;
  }

  if (state !== 'paused') updateEffects(dt);
  draw(t);
  requestAnimationFrame(frame);
}
```

Careful: when state becomes 'over' inside the while loop, we break; then `t = Math.min(acc/stepMs,1)` runs since the if-block is still executing. That sets t to something small. Then draw renders at t small. Hmm — I set t=1 in the else branch only. Let me restructure:

```js
if (state === 'playing' || state === 'demo') {
  acc += dt;
  ...
  t = Math.min(acc/stepMs, 1);
  if (state === 'over' || state === 'demoOver') t = 1;  // hmm
}
```
Actually simpler: at the top of draw, if state is 'over' or 'demoOver' or 'paused', t = 1. Let me just handle it in draw: `const tv = (state === 'over' || state === 'demoOver' || state === 'paused') ? 1 : t;`

Hmm, but on game over the snake would then jump to t=1 (the final valid position, which is where it already is at t≈0... actually at t≈0 the snake is rendered at the previous positions, one step behind). Jumping forward one cell on death is fine/expected.

Wait, but on death we never applied the new snake positions (we returned before mutating). So snake/prevSnake still hold the state before the fatal move, and t would render it mid-move. Setting t=1 snaps it to the last valid state. Fine.

For 'paused', t keeps its value — actually freezing at the current t is nicer. But it doesn't matter much. I'll just leave t as is for paused (freeze mid-step is fine, it's smooth). Hmm, but when paused, the loop's else branch sets t = 1 → a jump. Let me not touch t in the else branch; just leave it.

Revised:
```js
if (state === 'playing' || state === 'demo') {
  acc += dt;
  let guard = 0;
  while (acc >= stepMs && guard++ < 5) { acc -= stepMs; step(); if (state!=='playing'&&state!=='demo') break; }
  if (state === 'playing' || state === 'demo') t = Math.min(acc/stepMs, 1);
  else t = 1;
} else if (state === 'demoOver') {
  deadTimer -= dt;
  if (deadTimer <= 0) startDemo();
}
```
Good.

Now the effects update: particles and floats.

```js
function updateEffects(dt) {
  const s = dt/1000, k = Math.pow(0.88, dt/16.67);
  for (let i=particles.length-1;i>=0;i--) {
    const p = particles[i];
    p.life -= p.decay * s;
    if (p.life <= 0) { particles.splice(i,1); continue; }
    if (!p.ring) {
      p.vx *= k; p.vy *= k;
      p.x += p.vx * (dt/16.67);
      p.y += p.vy * (dt/16.67);
    }
  }
  for (let i=floats.length-1;i>=0;i--) {
    const f = floats[i];
    f.life -= s*1.4;
    f.y -= s*22;
    if (f.life <= 0) floats.splice(i,1);
  }
}
```

Note: `p.vx * (dt/16.67)` with vx in px/frame. OK.

Let's finalize the hunt for bugs:

1. `prevSnake[i] || s` in drawSnake — if prevSnake is shorter than snake it falls back. They're the same length always.

2. In resetGame, particles and floats are cleared. Good.

3. On the very first frame after startDemo, t=0 and prev==cur → no motion. Fine.

4. Overlay: initial state 'demo' with the overlay shown.

Order of init: resize() → startDemo() → requestAnimationFrame(frame).

resize needs `cell` before spawning food? spawnFood uses grid coords only. Fine.

5. The click handler: `document.addEventListener('mousedown', ...)`. Also touch. Use 'pointerdown'. And make sure clicking doesn't trigger when the state is playing.

6. The keydown handler must call preventDefault for arrows/space to stop scrolling.

7. `e.key` for space is ' '. Also 'Spacebar' in old browsers — ignore.

Let me now also double check the direction queue logic with a concrete case: snake moving right, dir={1,0}. User presses Up quickly then Left. First: base = dir (queue empty) = {1,0}; up {0,-1} is not a reverse (check: nd.x === -base.x && nd.y === -base.y → 0===-1? no) → push up. Second: base = queue last = {0,-1}; left {-1,0}: reverse check: -base = {0,1}; nd = {-1,0} ≠ → push left. Queue = [up, left]. So over two steps the snake goes up then left. 

But wait — there's a subtlety: the reverse check uses the last queued direction, which is correct because by the time that direction is applied, that will be the current direction.

But there's a scenario: the queue has [up] and the current dir is right. Now the user presses Down. base = up. Down is the reverse of up → rejected. Correct, because after the up move, going down would reverse.

Good.

Now the growth check `snake[i]` loop with limit — when not growing, limit = length-1, so we check indices 0..length-2, which excludes the tail cell (which is moving away). Correct. When growing, limit = length, checking all including the tail. Correct.

Edge case: snake length 3 moving right; the head at (8,cy) and the neck at (7,cy). Moving up is not a reverse. OK.

8. The food could spawn under the snake during demo? No, spawnFood excludes the snake.

9. Score display: hide/zero during demo. On startGame, score=0 → displayed 0. During demo, the score element shows 0 (from the last game or initial). Actually after a game over and clicking restart... hmm, the demo isn't restarted unless the page reloads. state goes demo → playing → over → playing. Never back to demo. So the demo only exists at page load. Good.

Wait — should the demo resume after a game over? No: after game over, pressing space restarts the game.

But what if the user never starts and the demo dies? demoOver → startDemo after 650ms. Good.

10. Best score: updated on eat and on game over.

11. Overlay on game over: the score is included.

Now, let me write the draw of the board border. Maybe add a subtle inner border glow with CSS on the canvas:
```css
canvas { border-radius: 16px; display:block; box-shadow: inset 0 0 0 1px rgba(120,220,180,0.15), inset 0 0 60px rgba(0,0,0,0.6), 0 20px 60px rgba(0,0,0,0.55), 0 0 80px rgba(47,224,138,0.08); }
```
Note: inset box-shadow on a canvas paints over the canvas content? Actually box-shadow inset is painted on top of the element's background but... For a replaced element like canvas, the inset shadow renders above the content? Hmm. In CSS, box-shadow inset is painted above the background and below the content for non-replaced elements. For replaced elements (canvas), the shadow is drawn... I believe on top of the content? Let me recall: for `<img>` with inset box-shadow, the shadow appears above the image. Yes, I'm fairly confident inset shadows are painted over replaced content in Chrome. That's fine — a subtle darkening at the edges looks like a vignette. Good, that's the desired effect.

Add `border-radius: 16px` on the canvas — will the drawing be clipped? Yes, border-radius clips the content of the canvas element in Chrome (since it's replaced content, border-radius clips it). Good.

Also add a wrapper div with padding and a gradient border? Keep it simple.

12. Prevent text selection / touch callout. Also `touch-action: none` on the board to prevent scrolling gestures. Body has overflow hidden anyway.

Let's also add mobile controls? Not required. Skip.

Now, one concern: the canvas at 3 seconds in demo mode. The overlay dims at 0.42 — the snake is still visible. Good.

Let me finalize the CSS for the overlay card text.

```css
.card h2 { margin: 8px 0 10px; font-size: 22px; line-height: 1.25; font-weight: 800; letter-spacing: -0.01em; color: #eafff4; }
.card p { margin: 6px 0 0; font-size: 12.5px; color: #8fa6bd; line-height: 1.6; }
.kicker { font-size: 10.5px; font-weight: 800; letter-spacing: .18em; color: #2fe08a; display: flex; align-items: center; justify-content: center; gap: 7px; }
.kicker .dot { width: 7px; height: 7px; border-radius: 50%; background: #2fe08a; box-shadow: 0 0 10px #2fe08a; animation: blink 1.2s infinite; }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.25} }
kbd { font-family: inherit; font-size: 11px; font-weight: 700; padding: 2px 6px; border-radius: 6px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.12); border-bottom-width: 2px; color: #cfe6ff; }
```

For the game over card: kicker color red, big score huge.

```css
.big-score { font-size: 34px; font-weight: 900; color: #7dffb0; display: inline-block; margin-left: 6px; }
```

Let's write it all now. I'll be careful with the HTML structure.

One more consideration: the hint bar at the bottom. Let me write:

```html
<div class="hint">
  <kbd>↑</kbd><kbd>←</kbd><kbd>↓</kbd><kbd>→</kbd> / <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> move
  <span class="sep">·</span> <kbd>P</kbd> pause <span class="sep">·</span> <kbd>Space</kbd> restart
</div>
```

That could be long: at 336px it wraps. Fine with `text-align:center; flex-wrap: wrap;`.

Actually, the hint is below the board and doesn't need to be within the app's width... but I set .app width = board size. The hint wraps. OK.

Let me write the final code.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Snake</title>
<style>...</style>
</head>
<body>
<div class="app" id="app">
  <div class="hud">
    <div class="brand">SNAKE</div>
    <div class="scores">
      <div class="pill"><span class="label">SCORE</span><span class="val" id="score">0</span></div>
      <div class="pill best"><span class="label">BEST</span><span class="val" id="best">0</span></div>
    </div>
  </div>
  <div class="board" id="board">
    <canvas id="c"></canvas>
    <div class="overlay" id="overlay"></div>
  </div>
  <div class="hint">...</div>
</div>
<script>...</script>
</body>
</html>
```

The .board is position:relative; the canvas is display:block; the overlay is absolute inset 0.

Now JS in full.

```js
(function(){
  'use strict';
  const COLS = 24, ROWS = 24;
  const canvas = document.getElementById('c');
  const ctx = canvas.getContext('2d');
  const appEl = document.getElementById('app');
  const boardEl = document.getElementById('board');
  const overlayEl = document.getElementById('overlay');
  const scoreEl = document.getElementById('score');
  const bestEl = document.getElementById('best');

  let cell = 24, boardPx = 576;

  const DIRS = [{x:0,y:-1},{x:0,y:1},{x:-1,y:0},{x:1,y:0}];
  const K = (x,y) => y*COLS + x;

  let snake = [], prevSnake = [], dir = {x:1,y:0}, queue = [];
  let food = null;
  let particles = [], floats = [];
  let score = 0, best = 0;
  let state = 'demo';
  let stepMs = 135, acc = 0, t = 0, deadTimer = 0, timeMs = 0, lastTime = 0;

  ...
})();
```

resize():
```js
function resize(){
  const vw = Math.max(320, window.innerWidth);
  const vh = window.innerHeight;
  const availW = vw - 28;
  const availH = vh - 196;
  let size = Math.min(availW, 640, Math.max(230, availH));
  ...
}
```
Hmm, if availH is huge (tall screen), cap at 640. If availH is small, size could go below 230 by the min with availW... Let's do:

```js
let size = Math.min(availW, 640, availH);
size = Math.max(size, 210);
```
If availH = 300 → size = 300. If availW = 347 → 347. On a 1280x800: min(1252, 640, 604) = 604 → cell = floor(604/24) = 25 → 600. Good.

on 375x667: min(347, 640, 471) = 347 → cell 14 → 336. Good.

cell = Math.max(8, Math.floor(size/COLS)); size = cell*COLS.

Then set canvas size, app width = size.

```js
appEl.style.width = size + 'px';
```
Hmm, but on mobile with a 336px board and a 375px viewport, the app is 336 wide, centered. Fine.

Add `body { padding: 12px }`? With box-sizing border-box and flex centering, the app at 336 + padding 12*2 = 360 < 375. Good. Let me include padding 10px on the body and factor that into availW (vw - 28 covers it).

dpr: `Math.min(window.devicePixelRatio||1, 2)`.

Full draw:

```js
function draw(){
  const S = cell, W = boardPx;
  ctx.clearRect(0,0,W,W);
  
  // background
  ctx.fillStyle = '#070c16';
  ctx.fillRect(0,0,W,W);
  
  // checker
  ctx.fillStyle = 'rgba(130,220,255,0.028)';
  for (let y=0;y<ROWS;y++){
    for (let x=0;x<COLS;x++){
      if ((x+y)&1) ctx.fillRect(x*S, y*S, S, S);
    }
  }
  
  // subtle radial light in center
  const rg = ctx.createRadialGradient(W*0.5, W*0.42, 0, W*0.5, W*0.5, W*0.8);
  rg.addColorStop(0,'rgba(47,224,138,0.06)');
  rg.addColorStop(1,'rgba(0,0,0,0)');
  ctx.fillStyle = rg;
  ctx.fillRect(0,0,W,W);
  
  drawFood();
  drawSnake();
  drawEffects();
}
```

drawSnake uses `t` — I'll pass tv.

```js
function drawSnake(tv){
  const S = cell;
  const pts = snake.map((s,i)=>{
    const p = prevSnake[i] || s;
    return { x:(p.x + (s.x-p.x)*tv + 0.5)*S, y:(p.y + (s.y-p.y)*tv + 0.5)*S };
  });
  ...
}
```

All good. Let me also add a "danger" pulse when the head is near a wall? No, keep it.

Final check on the game-over overlay: the card shows score & best. Requirement satisfied.

Now let me also verify: "Quick successive key presses must both be honored" — queue length 3 handles up to 3 buffered turns. Good.

Let me write the complete file.

One more detail — `requestAnimationFrame(frame)` initial: set `lastTime = performance.now()` right before.

Also handle visibility change to avoid a huge dt: dt is clamped to 100ms. Good.

Writing the final code now.

For the demo overlay text on small screens: h2 font-size 22 → maybe 18 on small. Use clamp: `font-size: clamp(17px, 4.2vw, 23px)`. 

Let me write out the CSS fully.

```css
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{height:100%}
body{
  margin:0; padding:10px;
  background: radial-gradient(900px 600px at 50% -8%, #14304a 0%, #0a1526 42%, #05080f 100%) fixed;
  color:#e8f4ff;
  font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  display:flex; align-items:center; justify-content:center;
  min-height:100vh; overflow:hidden;
  user-select:none; -webkit-user-select:none;
}
.app{display:flex; flex-direction:column; align-items:stretch;}
.hud{display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:10px;}
.brand{
  font-size:17px; font-weight:900; letter-spacing:.26em;
  background:linear-gradient(90deg,#8bffc4,#2fe08a 55%,#48c6ff);
  -webkit-background-clip:text; background-clip:text; color:transparent;
  white-space:nowrap;
}
.scores{display:flex; gap:8px;}
.pill{display:flex; align-items:center; gap:7px; padding:6px 12px; border-radius:999px;
  background:rgba(255,255,255,.045); border:1px solid rgba(255,255,255,.09);
  box-shadow:0 2px 10px rgba(0,0,0,.25);}
.pill .label{font-size:9px; font-weight:800; letter-spacing:.15em; color:#7e93ab;}
.pill .val{font-size:17px; font-weight:800; color:#c9ffe3; font-variant-numeric:tabular-nums; min-width:1.2em; text-align:right;}
.pill.best .val{color:#ffd166;}
.board{position:relative; line-height:0; border-radius:16px;}
canvas{display:block; border-radius:16px; background:#070c16;
  box-shadow: 0 0 0 1px rgba(120,220,180,.14), 0 24px 60px rgba(0,0,0,.6), 0 0 90px rgba(47,224,138,.09);
}
```

Hmm, `body` has `overflow:hidden` and `min-height:100vh` with `height:100%` on html. With flex centering. Good.

The `.board` line-height:0 avoids inline gaps.

Overlay CSS as above.

The hint:
```css
.hint{margin-top:10px; text-align:center; font-size:11px; color:#7d90a6; display:flex; flex-wrap:wrap; gap:5px; align-items:center; justify-content:center;}
.hint kbd{...}
```

Now writing the JS fully. Let me be careful about the order of function definitions — function declarations are hoisted, so it's fine.

Let me write it.

```js
(function () {
  'use strict';

  const COLS = 24, ROWS = 24;
  const DIRS = [{x:0,y:-1},{x:0,y:1},{x:-1,y:0},{x:1,y:0}];
  const K = (x, y) => y * COLS + x;

  const canvas = document.getElementById('c');
  const ctx = canvas.getContext('2d');
  const appEl = document.getElementById('app');
  const overlayEl = document.getElementById('overlay');
  const scoreEl = document.getElementById('score');
  const bestEl = document.getElementById('best');

  let cell = 24, boardPx = 576;

  let snake = [], prevSnake = [];
  let dir = {x:1,y:0}, queue = [];
  let food = null;
  let particles = [], floats = [];
  let score = 0, best = 0;
  let state = 'demo';
  let stepMs = 135, acc = 0, t = 0, deadTimer = 0;
  let timeMs = 0, lastTime = 0;

  /* ---------- layout ---------- */
  function resize() {
    const vw = window.innerWidth;
    const vh = window.innerHeight;
    const availW = vw - 26;
    const availH = vh - 190;
    let size = Math.min(availW, 640, availH);
    if (size < 200) size = Math.min(availW, Math.max(160, availH));
    cell = Math.max(7, Math.floor(size / COLS));
    size = cell * COLS;
    boardPx = size;
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    canvas.style.width = size + 'px';
    canvas.style.height = size + 'px';
    canvas.width = Math.round(size * dpr);
    canvas.height = Math.round(size * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    appEl.style.width = size + 'px';
  }
```

Hmm the fallback `if (size < 200)` is confusing. Just: `let size = Math.max(180, Math.min(availW, 640, availH));` — if availH is 150 (tiny), size=180 which overflows. But such viewports are unrealistic. Use Math.max(180, ...) and accept.

Actually with availH possibly negative for very short windows... whatever, clamp to 180 minimum.

Then `cell = Math.floor(size/COLS)` could be 7 at size 180 → 168px board.

OK.

Now everything else as planned. Let me write out the final file.

For the `updateHud` with animation:

```js
function updateHud(pop) {
  scoreEl.textContent = score;
  bestEl.textContent = best;
  if (pop && scoreEl.animate) {
    scoreEl.animate([{transform:'scale(1.4)', color:'#ffffff'},{transform:'scale(1)', color:'#c9ffe3'}], {duration:260, easing:'ease-out'});
  }
}
```
Hmm, animating `color` in keyframes works via WAAPI. Fine. But scoreEl has a CSS color; the animation overrides temporarily. OK.

Careful: `scoreEl.animate` — naming a local function `animate` is not an issue.

Actually there could be an issue: repeated rapid calls stack animations; fine.

Now, `setOverlay(html, dim)`:
```js
function showOverlay(html, dim) {
  overlayEl.innerHTML = html;
  overlayEl.classList.toggle('dim', !!dim);
  overlayEl.classList.add('show');
}
function hideOverlay() { overlayEl.classList.remove('show'); }
```

Now writing draw effects.

I think we're ready. Let me write out the full file carefully.

Also: on `startGame` triggered by a keydown, I should reset `lastTime = performance.now()` so dt isn't huge. It's clamped anyway.

Another consideration: the first key press that starts the game shouldn't also be processed as a direction. In my handler, I return after startGame. Good.

Also should the click start the game? Yes.

Let me write `document.addEventListener('pointerdown', () => { if (state === 'demo' || state === 'demoOver' || state === 'over') startGame(); })`.

Hmm, but pointerdown on the page also focuses the iframe. Good.

One risk: on game over the user clicks → restarts. That's fine.

Now writing everything.

Final detail on the head eyes when dir is (0,0)? Never.

Let me now also double-check `drawSnake` when `snake.length === 1` (impossible, min 3).

Writing out now.

Actually one more: in `step()`, for the demo, `aiChooseDir()` reads the current `snake` and `dir`. But the demo's `queue` is unused. Good.

And the AI could return a direction that reverses if `bestDir` fallback... The fallback skips reversals. And BFS never returns a reversal. Good.

Writing the final answer.

Let me write the whole thing out now in one go, carefully.

```js
  /* ---------- game setup ---------- */
  function resetGame() {
    const cy = Math.floor(ROWS / 2);
    snake = [{x:8,y:cy},{x:7,y:cy},{x:6,y:cy}];
    prevSnake = snake.map(s => ({x:s.x, y:s.y}));
    dir = {x:1,y:0};
    queue = [];
    particles = [];
    floats = [];
    acc = 0;
    t = 0;
    food = spawnFood();
  }
```

spawnFood uses snake — set before the call. Good.

startDemo:
```js
  function startDemo() {
    resetGame();
    state = 'demo';
    deadTimer = 0;
    score = 0;
    updateHud(false);
    showOverlay(demoHTML(), false);
  }
```
Hmm, resetting score to 0 on demo (only happens at load). Fine.

startGame:
```js
  function startGame() {
    resetGame();
    score = 0;
    stepMs = 135;
    state = 'playing';
    updateHud(false);
    hideOverlay();
    lastTime = performance.now();
    acc = 0; t = 0;
  }
```

Now the HUD best stays.

step() as designed.

Everything's ready. Writing.

Wait, one more thought on `stepMs` decreasing: `stepMs = Math.max(72, 135 - Math.floor(score/60)*7)`. score 60 → 128, 120 → 121... at score 600 → 135-70=65 → clamps at 72. Fine.

Hmm, actually the initial 135ms per step is ~7.4 cells/sec. That's a decent speed. With cell 25px, the head moves 185 px/s. Fine.

Now for the demo, the same speed applies. Good.

Writing the final file.