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
1 min 24 s
Cost
$0.027
Output tokens
39,085
Spent thinking
29,861 tokens
Speed
468.6 tok/s
Console errors
0
First token after
770 ms
First visible text after
1 min 7 s
Prompt tokens
518
Code size
24.9 KB
Finish reason
stop
Served by
DekaLLM
Model id
deepseek/deepseek-v4.1-flash-1017
Reasoning sent
{"effort":"max"}
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 · 816 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Snake</title>
<style>
  :root{
    --stage: min(92vw, 66dvh, 620px);
    --ink: #e9f4ff;
  }
  *{ box-sizing: border-box; }
  html,body{ height:100%; }
  body{
    margin:0;
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    color: var(--ink);
    background: radial-gradient(1200px 620px at 50% -12%, #17273f 0%, #0b1220 48%, #05080f 100%);
    display:flex; align-items:center; justify-content:center;
    overflow:hidden;
    -webkit-font-smoothing: antialiased;
  }
  .app{ display:flex; flex-direction:column; align-items:center; gap:11px; padding:12px; }
  .hud{ width:var(--stage); display:flex; align-items:center; justify-content:space-between; gap:10px; }
  .brand{
    font-size:13px; font-weight:800; letter-spacing:.44em; color:#9df7c4;
    text-shadow:0 0 22px rgba(80,240,170,.6); padding-left:2px;
  }
  .stats{ display:flex; gap:8px; }
  .stat{
    display:flex; flex-direction:column; align-items:flex-end; gap:1px;
    padding:4px 11px; border-radius:10px; min-width:70px;
    background:rgba(255,255,255,.045);
    border:1px solid rgba(140,200,255,.12);
  }
  .stat .k{ font-size:8.5px; letter-spacing:.2em; color:#7d93ad; }
  .stat .v{ font-size:17px; font-weight:700; font-variant-numeric:tabular-nums; line-height:1.05; }
  #bestVal{ color:#7ef0ae; }

  .stage{
    position:relative; width:var(--stage); height:var(--stage);
    border-radius:18px; overflow:hidden; background:#091120;
    box-shadow:
      0 0 0 1px rgba(110,230,180,.16),
      0 30px 60px -24px rgba(0,0,0,.95),
      0 0 90px -40px rgba(60,230,150,.7);
  }
  canvas{ display:block; width:100%; height:100%; }

  .badge{
    position:absolute; top:10px; left:10px;
    padding:4px 9px; border-radius:7px;
    font-size:9px; letter-spacing:.22em; font-weight:700;
    color:#9ff5c4; background:rgba(18,58,44,.6);
    border:1px solid rgba(110,240,180,.3);
    opacity:0; transition:opacity .25s; pointer-events:none;
  }
  .badge.show{ opacity:1; }

  .prompt{
    position:absolute; left:50%; bottom:14px; transform:translateX(-50%);
    display:flex; align-items:center; gap:8px;
    padding:8px 15px; border-radius:999px;
    font-size:12.5px; letter-spacing:.03em; white-space:nowrap;
    color:#d6ffe8; background:rgba(6,14,24,.82);
    border:1px solid rgba(110,240,180,.32);
    box-shadow:0 0 30px rgba(40,220,140,.18), inset 0 0 18px rgba(40,220,140,.08);
    opacity:0; transition:opacity .3s; pointer-events:none;
    animation:breathe 2.4s ease-in-out infinite;
  }
  .prompt.show{ opacity:1; }
  .prompt .dot{
    width:7px; height:7px; border-radius:50%; background:#6dfab0;
    box-shadow:0 0 10px #6dfab0; animation:blink 1.2s ease-in-out infinite;
  }
  @keyframes breathe{
    0%,100%{ transform:translateX(-50%) translateY(0); }
    50%{ transform:translateX(-50%) translateY(-3px); }
  }
  @keyframes blink{ 0%,100%{opacity:1} 50%{opacity:.3} }

  .overlay{
    position:absolute; inset:0; display:grid; place-items:center;
    background:radial-gradient(circle at 50% 45%, rgba(8,16,28,.55), rgba(4,8,14,.88));
    backdrop-filter:blur(2px);
    opacity:0; transition:opacity .25s; pointer-events:none;
  }
  .overlay.show{ opacity:1; }
  .panel{
    text-align:center; padding:20px 26px; border-radius:16px;
    background:rgba(10,18,30,.92);
    border:1px solid rgba(130,240,190,.2);
    box-shadow:0 20px 50px -20px #000, 0 0 60px -30px rgba(60,240,150,.9);
  }
  .panel h2{ margin:0 0 10px; font-size:22px; letter-spacing:.2em; font-weight:800; }
  .panel h2.over{ color:#ff8095; text-shadow:0 0 22px rgba(255,80,110,.65); }
  .panel h2.pause{ color:#8ef3bd; text-shadow:0 0 22px rgba(80,240,160,.55); }
  .panel p{ margin:5px 0; font-size:12.5px; color:#a8bdd4; letter-spacing:.04em; }
  .panel p b{ color:#eaf6ff; font-size:17px; }
  .hint{ font-size:10.5px; color:#6d8299; letter-spacing:.09em; text-align:center; }
  .hint b{ color:#9fb5cb; font-weight:600; }
</style>
</head>
<body>
<div class="app" id="app">
  <div class="hud">
    <div class="brand">SNAKE</div>
    <div class="stats">
      <div class="stat"><span class="k">SCORE</span><span class="v" id="scoreVal">0</span></div>
      <div class="stat"><span class="k">BEST</span><span class="v" id="bestVal">0</span></div>
    </div>
  </div>

  <div class="stage" id="stage">
    <canvas id="game"></canvas>
    <div class="badge" id="badge">DEMO</div>
    <div class="prompt" id="prompt"><span class="dot"></span>Click or press a key to start</div>
    <div class="overlay" id="overlay">
      <div class="panel">
        <h2 id="ovTitle" class="over">GAME OVER</h2>
        <p id="ovScore">Score <b>0</b></p>
        <p id="ovSub">Press Space or Enter to restart</p>
      </div>
    </div>
  </div>

  <div class="hint"><b>&uarr;&darr;&larr;&rarr;</b> / <b>WASD</b> move &nbsp;·&nbsp; <b>P</b> pause &nbsp;·&nbsp; <b>Space</b> restart</div>
</div>

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

  /* ---------------- constants ---------------- */
  const COLS = 24, ROWS = 24, N = COLS * ROWS;
  const DIRS = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
  const C_HEAD = [122, 248, 174];
  const C_TAIL = [15, 108, 70];

  /* ---------------- dom ---------------- */
  const canvas   = document.getElementById('game');
  const ctx      = canvas.getContext('2d');
  const stage    = document.getElementById('stage');
  const scoreEl  = document.getElementById('scoreVal');
  const bestEl   = document.getElementById('bestVal');
  const overlay  = document.getElementById('overlay');
  const ovTitle  = document.getElementById('ovTitle');
  const ovScore  = document.getElementById('ovScore');
  const ovSub    = document.getElementById('ovSub');
  const promptEl = document.getElementById('prompt');
  const badgeEl  = document.getElementById('badge');

  const board = document.createElement('canvas');
  const bctx  = board.getContext('2d');

  let W = 320, H = 320, cell = 320 / COLS, dpr = 1;

  /* ---------------- state ---------------- */
  let mode = 'demo';                 // demo | playing | paused | gameover | demoDead
  let snake = [], prev = [];
  let dir = {x:1,y:0}, queue = [];
  let food = {x:0,y:0};
  let score = 0, best = 0;
  let stepTime = 130, acc = 0, t = 0;
  let particles = [], pops = [];
  let flash = 0, shock = null, demoDeadT = 0;

  /* ---------------- sizing ---------------- */
  function resize() {
    const r = stage.getBoundingClientRect();
    if (r.width < 2) return;
    W = r.width;
    H = r.height;
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    canvas.width  = Math.round(W * dpr);
    canvas.height = Math.round(H * dpr);
    cell = W / COLS;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    renderBoard();
  }

  function renderBoard() {
    board.width  = Math.max(1, canvas.width);
    board.height = Math.max(1, canvas.height);
    bctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    bctx.clearRect(0, 0, W, H);

    bctx.fillStyle = '#091120';
    bctx.fillRect(0, 0, W, H);

    // subtle checkerboard
    bctx.fillStyle = 'rgba(255,255,255,0.017)';
    for (let y = 0; y < ROWS; y++) {
      for (let x = 0; x < COLS; x++) {
        if ((x + y) & 1) bctx.fillRect(x * cell, y * cell, cell + 0.6, cell + 0.6);
      }
    }

    // grid lines
    bctx.strokeStyle = 'rgba(110,200,255,0.055)';
    bctx.lineWidth = 1;
    bctx.beginPath();
    for (let x = 1; x < COLS; x++) { bctx.moveTo(x * cell, 0); bctx.lineTo(x * cell, H); }
    for (let y = 1; y < ROWS; y++) { bctx.moveTo(0, y * cell); bctx.lineTo(W, y * cell); }
    bctx.stroke();

    // vignette
    const g = bctx.createRadialGradient(W/2, H/2, Math.min(W,H) * 0.14, W/2, H/2, Math.min(W,H) * 0.78);
    g.addColorStop(0, 'rgba(45,150,220,0.06)');
    g.addColorStop(1, 'rgba(0,0,0,0.44)');
    bctx.fillStyle = g;
    bctx.fillRect(0, 0, W, H);
  }

  /* ---------------- game setup ---------------- */
  function reset(newMode) {
    mode = newMode;
    const cx = COLS >> 1, cy = ROWS >> 1;
    snake = [];
    for (let i = 0; i < 4; i++) snake.push({ x: cx - i, y: cy });
    prev = snake.map(s => ({ x: s.x, y: s.y }));
    dir = { x: 1, y: 0 };
    queue = [];
    score = 0;
    stepTime = 130;
    acc = 0;
    t = 0;
    particles = [];
    pops = [];
    flash = 0;
    shock = null;
    demoDeadT = 0;
    scoreEl.textContent = '0';
    food = spawnFood();
    syncUI();
  }

  function spawnFood() {
    const occ = new Uint8Array(N);
    for (let i = 0; i < snake.length; i++) occ[snake[i].y * COLS + snake[i].x] = 1;
    const empty = [];
    for (let i = 0; i < N; i++) if (!occ[i]) empty.push(i);
    if (!empty.length) return { x: -1, y: -1 };
    const i = empty[(Math.random() * empty.length) | 0];
    return { x: i % COLS, y: (i / COLS) | 0 };
  }

  function startGame() {
    reset('playing');
  }

  /* ---------------- ui ---------------- */
  function syncUI() {
    if (mode === 'gameover') {
      ovTitle.textContent = 'GAME OVER';
      ovTitle.className = 'over';
      ovScore.innerHTML = 'Score <b>' + score + '</b>';
      ovSub.textContent = 'Press Space or Enter to restart';
      overlay.classList.add('show');
      promptEl.classList.remove('show');
      badgeEl.classList.remove('show');
    } else if (mode === 'paused') {
      ovTitle.textContent = 'PAUSED';
      ovTitle.className = 'pause';
      ovScore.innerHTML = 'Score <b>' + score + '</b>';
      ovSub.textContent = 'Press P to resume';
      overlay.classList.add('show');
      promptEl.classList.remove('show');
      badgeEl.classList.remove('show');
    } else if (mode === 'playing') {
      overlay.classList.remove('show');
      promptEl.classList.remove('show');
      badgeEl.classList.remove('show');
    } else {
      overlay.classList.remove('show');
      promptEl.classList.add('show');
      badgeEl.classList.add('show');
    }
  }

  /* ---------------- core step ---------------- */
  function step() {
    if (mode === 'demo') {
      const d = aiChooseDir();
      if (d) dir = d;
    } else if (queue.length) {
      const nd = queue.shift();
      if (!(nd.x === -dir.x && nd.y === -dir.y)) dir = nd;
    }

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

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

    const willGrow = (nx === food.x && ny === food.y);

    // self (tail cell is vacated unless growing)
    const limit = willGrow ? snake.length : snake.length - 1;
    for (let i = 0; i < limit; i++) {
      if (snake[i].x === nx && snake[i].y === ny) { die(); return; }
    }

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

    const lastOld = oldSnake[oldSnake.length - 1];
    const newPrev = new Array(newSnake.length);
    for (let i = 0; i < newSnake.length; i++) {
      const o = oldSnake[i] || lastOld;
      newPrev[i] = { x: o.x, y: o.y };
    }

    snake = newSnake;
    prev = newPrev;

    if (willGrow) {
      score += 10;
      scoreEl.textContent = score;
      if (mode !== 'demo') {
        best = Math.max(best, score);
        bestEl.textContent = best;
      }
      stepTime = Math.max(80, 130 - score * 0.9);
      burst(food.x, food.y, 20, 8);
      pops.push({ x: food.x, y: food.y, life: 1 });
      food = spawnFood();
    }
  }

  function die() {
    const h = snake[0];
    const cx = (h.x + 0.5) * cell, cy = (h.y + 0.5) * cell;

    flash = 1;
    shock = { x: cx, y: cy, r: cell * 0.4, life: 1 };
    burst(h.x, h.y, 16, 338);

    if (mode === 'demo') {
      mode = 'demoDead';
      demoDeadT = 0.85;
    } else {
      best = Math.max(best, score);
      bestEl.textContent = best;
      mode = 'gameover';
      t = 1;
    }
    syncUI();
  }

  /* ---------------- particles ---------------- */
  function burst(gx, gy, count, hueBase) {
    const px = (gx + 0.5) * cell;
    const py = (gy + 0.5) * cell;
    for (let i = 0; i < count; i++) {
      const a = Math.random() * Math.PI * 2;
      const sp = (0.8 + Math.random() * 3.4) * cell * 3;
      const life = 0.35 + Math.random() * 0.45;
      particles.push({
        x: px, y: py,
        vx: Math.cos(a) * sp,
        vy: Math.sin(a) * sp - cell * 2,
        life: life, max: life,
        size: cell * (0.08 + Math.random() * 0.16),
        hue: hueBase + Math.random() * 42,
        light: 58 + Math.random() * 24
      });
    }
  }

  /* ---------------- demo AI ---------------- */
  function bfsStep(head, occ) {
    const came = new Int32Array(N).fill(-2);
    const start = head.y * COLS + head.x;
    came[start] = -1;
    const q = [start];
    let qi = 0, goal = -1;

    while (qi < q.length) {
      const cur = q[qi++];
      const cx = cur % COLS, cy = (cur / COLS) | 0;
      if (cx === food.x && cy === food.y) { goal = cur; break; }
      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 = ny * COLS + nx;
        if (came[ni] !== -2 || occ[ni]) continue;
        came[ni] = cur;
        q.push(ni);
      }
    }
    if (goal < 0) return null;

    let cur = goal;
    let guard = 0;
    while (came[cur] !== start) {
      cur = came[cur];
      if (cur < 0 || guard++ > N) return null;
    }
    return { x: (cur % COLS) - head.x, y: ((cur / COLS) | 0) - head.y };
  }

  function floodCount(sx, sy, occ) {
    const seen = new Uint8Array(N);
    const start = sy * COLS + sx;
    seen[start] = 1;
    const stack = [start];
    let n = 0;
    while (stack.length) {
      const cur = stack.pop();
      n++;
      const cx = cur % COLS, cy = (cur / COLS) | 0;
      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 = ny * COLS + nx;
        if (seen[ni] || occ[ni]) continue;
        seen[ni] = 1;
        stack.push(ni);
      }
    }
    return n;
  }

  function aiChooseDir() {
    const head = snake[0];
    const occ = new Uint8Array(N);
    for (let i = 0; i < snake.length - 1; i++) occ[snake[i].y * COLS + snake[i].x] = 1;

    const opts = [];
    for (let k = 0; k < 4; k++) {
      const d = DIRS[k];
      if (d.x === -dir.x && d.y === -dir.y) continue;
      const x = head.x + d.x, y = head.y + d.y;
      if (x < 0 || y < 0 || x >= COLS || y >= ROWS || occ[y * COLS + x]) continue;
      opts.push(d);
    }
    if (!opts.length) return dir;

    // food directly ahead
    for (let i = 0; i < opts.length; i++) {
      const d = opts[i];
      if (head.x + d.x === food.x && head.y + d.y === food.y) return d;
    }

    const bfs = bfsStep(head, occ);
    let bestD = opts[0], bestScore = -Infinity;

    for (let i = 0; i < opts.length; i++) {
      const d = opts[i];
      const x = head.x + d.x, y = head.y + d.y;
      const area = floodCount(x, y, occ);
      const dist = Math.abs(x - food.x) + Math.abs(y - food.y);
      let s = area * 12 - dist;
      if (bfs && d.x === bfs.x && d.y === bfs.y) s += 400 + area * 6;
      if (d.x === dir.x && d.y === dir.y) s += 3;
      if (s > bestScore) { bestScore = s; bestD = d; }
    }
    return bestD;
  }

  /* ---------------- update ---------------- */
  function update(dt) {
    // particles
    for (let i = particles.length - 1; i >= 0; i--) {
      const p = particles[i];
      p.life -= dt;
      if (p.life <= 0) { particles.splice(i, 1); continue; }
      p.vy += 620 * dt;
      p.vx *= Math.max(0, 1 - 2.5 * dt);
      p.vy *= Math.max(0, 1 - 1.4 * dt);
      p.x += p.vx * dt;
      p.y += p.vy * dt;
    }
    // floating "+10"
    for (let i = pops.length - 1; i >= 0; i--) {
      pops[i].life -= dt * 1.5;
      if (pops[i].life <= 0) pops.splice(i, 1);
    }
    if (flash > 0) flash = Math.max(0, flash - dt * 2.2);
    if (shock) {
      shock.life -= dt * 1.8;
      shock.r += dt * 430;
      if (shock.life <= 0) shock = null;
    }

    if (mode === 'demo' || mode === 'playing') {
      acc += dt * 1000;
      let guard = 0;
      while (acc >= stepTime && (mode === 'demo' || mode === 'playing') && guard++ < 5) {
        acc -= stepTime;
        step();
      }
      if (mode === 'demo' || mode === 'playing') t = Math.min(1, acc / stepTime);
      else { t = 1; acc = 0; }
    } else if (mode === 'demoDead') {
      t = 1;
      demoDeadT -= dt;
      if (demoDeadT <= 0) reset('demo');
    }
  }

  /* ---------------- drawing ---------------- */
  function mixColor(a, b, u) {
    const r = Math.round(a[0] + (b[0] - a[0]) * u);
    const g = Math.round(a[1] + (b[1] - a[1]) * u);
    const bl = Math.round(a[2] + (b[2] - a[2]) * u);
    return 'rgb(' + r + ',' + g + ',' + bl + ')';
  }

  function drawSnake(now) {
    const n = snake.length;
    const pts = new Array(n);
    for (let i = 0; i < n; i++) {
      const s = snake[i];
      const p = prev[i] || s;
      pts[i] = {
        x: (p.x + (s.x - p.x) * t + 0.5) * cell,
        y: (p.y + (s.y - p.y) * t + 0.5) * cell
      };
    }

    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';

    if (n > 1) {
      // outer glow
      ctx.save();
      ctx.beginPath();
      ctx.moveTo(pts[0].x, pts[0].y);
      for (let i = 1; i < n; i++) ctx.lineTo(pts[i].x, pts[i].y);
      ctx.strokeStyle = 'rgba(70,240,160,0.5)';
      ctx.lineWidth = cell * 0.5;
      ctx.shadowColor = 'rgba(60,240,150,0.95)';
      ctx.shadowBlur = cell * 1.15;
      ctx.stroke();
      ctx.restore();

      // dark casing
      ctx.beginPath();
      ctx.moveTo(pts[0].x, pts[0].y);
      for (let i = 1; i < n; i++) ctx.lineTo(pts[i].x, pts[i].y);
      ctx.strokeStyle = 'rgba(5,38,24,0.88)';
      ctx.lineWidth = cell * 0.92;
      ctx.stroke();

      // tapered, gradient body (tail -> head)
      const segs = n - 1;
      for (let i = n - 1; i > 0; i--) {
        const j = (n - 1) - i;
        const u = segs > 1 ? j / (segs - 1) : 1;
        const w = cell * (0.34 + 0.5 * Math.pow(u, 0.55));
        ctx.strokeStyle = mixColor(C_TAIL, C_HEAD, u);
        ctx.lineWidth = w;
        ctx.beginPath();
        ctx.moveTo(pts[i].x, pts[i].y);
        ctx.lineTo(pts[i - 1].x, pts[i - 1].y);
        ctx.stroke();
      }

      // subtle sheen along the spine
      ctx.save();
      ctx.globalAlpha = 0.10;
      ctx.strokeStyle = '#ffffff';
      ctx.lineWidth = cell * 0.16;
      ctx.beginPath();
      ctx.moveTo(pts[0].x, pts[0].y);
      for (let i = 1; i < n; i++) ctx.lineTo(pts[i].x, pts[i].y);
      ctx.stroke();
      ctx.restore();
    }

    // ---- head ----
    const hx = pts[0].x, hy = pts[0].y;
    const dx = dir.x, dy = dir.y;
    const r = cell * 0.44;

    // tongue flick
    if (Math.sin(now * 0.011) > 0.45) {
      const bx = hx + dx * r * 0.85, by = hy + dy * r * 0.85;
      const tx = hx + dx * r * 1.72, ty = hy + dy * r * 1.72;
      ctx.save();
      ctx.strokeStyle = '#ff5f7e';
      ctx.lineWidth = Math.max(1, cell * 0.07);
      ctx.lineCap = 'round';
      ctx.beginPath();
      ctx.moveTo(bx, by);
      ctx.lineTo(tx, ty);
      const px = -dy, py = dx;
      ctx.moveTo(tx, ty);
      ctx.lineTo(tx + px * r * 0.3 + dx * r * 0.2, ty + py * r * 0.3 + dy * r * 0.2);
      ctx.moveTo(tx, ty);
      ctx.lineTo(tx - px * r * 0.3 + dx * r * 0.2, ty - py * r * 0.3 + dy * r * 0.2);
      ctx.stroke();
      ctx.restore();
    }

    const hg = ctx.createRadialGradient(
      hx - dx * r * 0.3 - r * 0.15, hy - dy * r * 0.3 - r * 0.15, r * 0.12,
      hx, hy, r * 1.12
    );
    hg.addColorStop(0, '#c8ffe0');
    hg.addColorStop(0.5, '#5fe694');
    hg.addColorStop(1, '#1c9459');
    ctx.fillStyle = hg;
    ctx.beginPath();
    ctx.arc(hx, hy, r, 0, Math.PI * 2);
    ctx.fill();
    ctx.strokeStyle = 'rgba(5,38,24,0.9)';
    ctx.lineWidth = cell * 0.06;
    ctx.stroke();

    // eyes
    const perpX = -dy, perpY = dx;
    const ex = hx + dx * r * 0.24;
    const ey = hy + dy * r * 0.24;
    const off = r * 0.46;
    for (let s = 1; s >= -1; s -= 2) {
      const cxp = ex + perpX * off * s;
      const cyp = ey + perpY * off * s;
      ctx.fillStyle = '#ffffff';
      ctx.beginPath();
      ctx.arc(cxp, cyp, r * 0.30, 0, Math.PI * 2);
      ctx.fill();
      ctx.fillStyle = '#0a1a12';
      ctx.beginPath();
      ctx.arc(cxp + dx * r * 0.13, cyp + dy * r * 0.13, r * 0.155, 0, Math.PI * 2);
      ctx.fill();
    }
  }

  function drawFood(now) {
    if (food.x < 0) return;
    const cx = (food.x + 0.5) * cell;
    const cy = (food.y + 0.5) * cell;
    const pulse = 1 + Math.sin(now * 0.006) * 0.08;
    const r = cell * 0.3 * pulse;

    // expanding ring
    const ph = (now * 0.0014) % 1;
    ctx.save();
    ctx.globalAlpha = 0.55 * (1 - ph);
    ctx.strokeStyle = '#ff6b83';
    ctx.lineWidth = cell * 0.09;
    ctx.beginPath();
    ctx.arc(cx, cy, r + ph * cell * 0.6, 0, Math.PI * 2);
    ctx.stroke();
    ctx.restore();

    // glowing orb
    ctx.save();
    ctx.shadowColor = 'rgba(255,70,100,0.9)';
    ctx.shadowBlur = cell * 1.2;
    const g = ctx.createRadialGradient(cx - r * 0.4, cy - r * 0.45, r * 0.1, cx, cy, r * 1.1);
    g.addColorStop(0, '#ffe2d6');
    g.addColorStop(0.35, '#ff7f8f');
    g.addColorStop(1, '#c31d3e');
    ctx.fillStyle = g;
    ctx.beginPath();
    ctx.arc(cx, cy, r, 0, Math.PI * 2);
    ctx.fill();
    ctx.restore();

    // tiny leaf
    ctx.save();
    ctx.translate(cx + r * 0.25, cy - r * 1.0);
    ctx.rotate(-0.55);
    ctx.fillStyle = '#5ee08c';
    ctx.beginPath();
    ctx.ellipse(0, 0, r * 0.55, r * 0.27, 0, 0, Math.PI * 2);
    ctx.fill();
    ctx.restore();
  }

  function drawParticles() {
    if (!particles.length) return;
    ctx.save();
    for (let i = 0; i < particles.length; i++) {
      const p = particles[i];
      const a = Math.max(0, p.life / p.max);
      ctx.globalAlpha = a;
      ctx.fillStyle = 'hsl(' + p.hue.toFixed(0) + ',100%,' + p.light.toFixed(0) + '%)';
      ctx.beginPath();
      ctx.arc(p.x, p.y, p.size * (0.35 + a * 0.65), 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.restore();
  }

  function drawPops() {
    if (!pops.length) return;
    ctx.save();
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.font = 'bold ' + Math.round(cell * 0.66) + 'px ui-monospace, monospace';
    for (let i = 0; i < pops.length; i++) {
      const p = pops[i];
      const a = Math.max(0, Math.min(1, p.life));
      ctx.globalAlpha = a;
      ctx.fillStyle = '#b6ffd2';
      ctx.shadowColor = 'rgba(80,255,170,0.9)';
      ctx.shadowBlur = cell * 0.6;
      ctx.fillText('+10', (p.x + 0.5) * cell, (p.y + 0.5) * cell - (1 - a) * cell * 1.25);
    }
    ctx.restore();
  }

  function draw(now) {
    ctx.clearRect(0, 0, W, H);
    if (board.width > 1) ctx.drawImage(board, 0, 0, W, H);

    drawFood(now);
    drawSnake(now);
    drawParticles();
    drawPops();

    if (shock) {
      ctx.save();
      ctx.globalAlpha = Math.max(0, shock.life) * 0.75;
      ctx.strokeStyle = '#ff8098';
      ctx.lineWidth = Math.max(1, cell * 0.18 * shock.life);
      ctx.beginPath();
      ctx.arc(shock.x, shock.y, shock.r, 0, Math.PI * 2);
      ctx.stroke();
      ctx.restore();
    }
    if (flash > 0) {
      ctx.fillStyle = 'rgba(255,70,100,' + (flash * 0.3).toFixed(3) + ')';
      ctx.fillRect(0, 0, W, H);
    }
  }

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

  function queueDir(d) {
    const lastD = queue.length ? queue[queue.length - 1] : dir;
    if (d.x === lastD.x && d.y === lastD.y) return;
    if (d.x === -lastD.x && d.y === -lastD.y) return;
    if (queue.length >= 3) return;
    queue.push(d);
  }

  function onKey(e) {
    const k = e.key;
    const lk = k.length === 1 ? k.toLowerCase() : k.toLowerCase();
    const isDir = !!KEYMAP[lk];

    if (isDir || k === ' ' || k === 'Enter' || lk === 'p') e.preventDefault();
    if (e.repeat) return;

    if (mode === 'demo' || mode === 'demoDead') {
      startGame();
      return;
    }

    if (mode === 'playing') {
      if (isDir) { queueDir(KEYMAP[lk]); return; }
      if (lk === 'p') { mode = 'paused'; syncUI(); return; }
      return;
    }

    if (mode === 'paused') {
      if (lk === 'p' || k === ' ' || k === 'Enter') { mode = 'playing'; syncUI(); }
      return;
    }

    if (mode === 'gameover') {
      if (k === ' ' || k === 'Enter') { startGame(); }
    }
  }

  document.addEventListener('keydown', onKey, { passive: false });

  document.addEventListener('pointerdown', function () {
    if (mode === 'demo' || mode === 'demoDead' || mode === 'gameover') startGame();
  });

  /* ---------------- loop ---------------- */
  let lastT = performance.now();
  function loop(now) {
    const dt = Math.min(0.05, Math.max(0, (now - lastT) / 1000));
    lastT = now;
    update(dt);
    draw(now);
    requestAnimationFrame(loop);
  }

  /* ---------------- boot ---------------- */
  if (window.ResizeObserver) {
    new ResizeObserver(function () { resize(); }).observe(stage);
  }
  window.addEventListener('resize', resize);

  resize();
  reset('demo');
  requestAnimationFrame(loop);
})();
</script>
</body>
</html>
Reasoning trace (29,861 tokens)
Let me build a polished Snake game in a single HTML file.

Key requirements:
- Grid ≥ 20×20, centered.
- Arrow keys + WASD, input queue (quick successive presses honored), no reversal.
- Food +10 points, grows by one, spawns not on snake.
- Game over on wall/self hit, overlay with score, Space or Enter restarts, P pauses.
- Smooth movement (interpolated rendering between steps), head with eyes facing direction, particle burst on eating.
- Score + best score of session.
- Self-playing demo mode behind "Click or press a key to start" message (from the general instructions: games must start in self-playing demo mode).
- Screenshot at ~3s must show something meaningful.

Design:
- Canvas sized to fit viewport. Grid 24×24 maybe. Cell size computed from available space.
- Actually let's use a fixed logical grid of 24x24 and scale canvas via CSS to fit. Use devicePixelRatio for crispness.
- Snake rendering: rounded segments with gradient, head with eyes, tongue maybe. Smooth: interpolate positions between grid steps using t = accumulator/stepDuration.

For smooth movement with interpolation, the snake body moves from cell to cell. Standard approach: keep snake as array of grid cells (head first). For rendering, interpolate each segment between its previous position and current position. Simplest: keep `prevSnake` array and lerp each segment toward its current position. Since the snake moves by one cell per step, each segment's previous position is the cell it occupied before. Easier: render each segment at lerp(prevPos[i], pos[i], t). Need prev array of same length as current. When growing, the tail segment appears — handle by having prev for new tail = same position (no movement).

Actually simpler common trick: keep snake cells array; when stepping, we compute new head and optionally remove tail. For rendering, keep two arrays: `snake` (current) and `prevSnake` (positions before the step, aligned index-wise). If snake grows, prevSnake gets an extra element equal to the last element.

Let me implement:

```js
function step() {
  // apply next direction from queue
  ...
  const newHead = {x: head.x+dir.x, y: head.y+dir.y};
  // check wall/self
  // growing?
  prev = snake.map(s=>({...s}));  // hmm
}
```

Let me think in terms of: at time of step, snake array is current. We compute next snake array. Then prevSnake = old snake array (aligned to new array indices: for i in new array, prev position is old[i] if exists else old[old.length-1]).

So: newSnake[0] = newHead, newSnake[i] = snake[i-1] for i>=1. Then prevSnake aligned: prevAligned[0] = snake[0] (head from old), prevAligned[i] = snake[i] ?? snake[snake.length-1]. Actually prevAligned[i] should be the position that segment i occupied before. Segment i in the new array was previously at snake[i] (since newSnake[i] = snake[i-1], so the cell that's now at index i was at index i-1... hmm wait.

Let me index differently: Segment identity isn't preserved; the snake is a path. Visually, each "segment slot" i moves to where slot i-1 was. So for rendering slot i, it moves from old snake[i] to new snake[i] = old snake[i-1]. Since old snake[i] and old snake[i-1] are adjacent, interpolation just slides each slot along the path. That works nicely.

For i = snake.length-1 (last slot in new array), if grown, new snake[i] = old snake[i-1] with i-1 = last old index... old snake[last] doesn't exist. So prevAligned[last] = old snake[old.length-1] (stays put) — good, tail stays then moves.

If not grown, new array length = old length, new snake[last] = old snake[last-1], prevAligned[last] = old snake[last] — tail slides forward. 

So prevAligned[i] = oldSnake[i] ?? oldSnake[oldSnake.length-1].

Great, that's clean.

Rendering with rounded rect segments: draw each segment as a rounded rect of size ~cell*0.86 centered at the interpolated point. But separate rounded rects leave gaps between segments when moving... Since they overlap when adjacent (cell spacing = cell size, size 0.86 cell => gap of 0.14 cell between consecutive). Hmm, that gives a dotted look. Many polished snake games draw the body as a continuous path. Let's do a hybrid: draw a thick rounded polyline through interpolated segment centers, with a gradient color, then draw the head as a rounded shape with eyes.

Drawing a continuous path: build points = interpolated positions of all segments (in order head→tail), then stroke with lineWidth = cell*0.78, lineJoin round, lineCap round. That gives smooth snake. But when the snake is long and winding, this looks great. Add a lighter inner stroke for highlight? Maybe stroke with gradient? A single color with a lighter inner line offset... Let's do:
1. Stroke path with dark outline (cell*0.84) in a darker green.
2. Stroke with main color (cell*0.7).
3. Optionally stroke with lighter color thinner (cell*0.3) with some alpha for a shine.

Actually, to make the snake visually appealing with a gradient along its length, I can use a linear gradient across the whole canvas... not along body. Alternative: draw segments as circles with per-segment color lerp from head color to tail color. But then the continuous look... Circles of radius 0.42*cell spaced 1 cell apart don't touch. Hmm, but with rounded line stroke we get continuous.

Better approach: draw the path with round joins/caps repeatedly with varying width per segment? Complex.

Alternative polish: draw the body as a continuous stroke with lineWidth = 0.76*cell, using a gradient created from head to tail positions (linear gradient between head and tail points) — that shifts as snake moves but looks fine. Then overlay darker "scale" dots? Keep it simple and clean.

Let me do:
- shadowBlur for glow.
- Body stroke: gradient from #7CFC9A-ish at head to #1E7A46 at tail... Let's pick a nice palette: background dark navy #0b1020 with subtle grid. Snake: emerald gradient #5ef2a0 → #16a34a. Food: coral/red apple-ish with glow #ff5f6d → #ffb86b. Particles same hue.

- Head: circle/rounded square slightly larger, drawn at head interpolated position, with two eyes positioned perpendicular to direction, plus pupils facing direction. Also a small tongue maybe when close to food. Keep eyes.

Let's compute head direction for eye placement: use current direction dir (the direction of travel). Eyes at head center offset perpendicular ±0.22*cell, and forward 0.18*cell. Pupils offset further forward.

Also the head rotation should align with direction — but eyes just placed by direction vector, fine.

Game loop with requestAnimationFrame and delta time. Step duration: starts at 130ms, speeds up slightly as score increases? Requirement doesn't say. Maybe keep constant 120ms for predictability, or slowly increase speed with score, capped. Demo AI plays. Let's do stepTime starting at 135ms, decreasing to min 75ms as score increases (each food -2ms). Fine.

Demo/AI mode: snake plays itself with a simple AI: BFS or greedy pathfinding avoiding walls/body. Use BFS to find shortest path to food avoiding snake body (excluding tail which will move). Fallback: choose any safe direction (flood fill to maximize space). Let's implement a decent AI:

- Compute occupied set from snake cells (excluding tail if not growing).
- BFS from head to food over free cells.
- If path found, take first step. Also verify safety: after moving, is there still space? Simplify: BFS gives path; take first step. Risk of trapping itself exists but demo will just restart on game over — that's fine and actually shows the game over overlay. Hmm, but it visually might be better for demo to survive a while. BFS to food is generally OK for a 24x24 with short snake.

Actually with the snake short at the start, BFS works well. As it grows, it may trap itself. To be safer, after computing the BFS first step, do a flood-fill from the new head to count reachable area; if reachable area < snake length, prefer a different move. Let's implement a simpler robust approach:

1. Get list of safe directions (not wall, not body-except-tail).
2. If any direction lands on food, take it (if safe).
3. Otherwise, run BFS to food; if path exists and the first move leaves reachable space >= snake length, take it.
4. Else, pick the safe direction that maximizes flood-fill reachable area (with tie-break: closer to food / keep going straight).

That's decent and not too much code. Let's do it.

Queue for input: array of pending directions, max 2-3 entries. On step, pop first valid one (not reverse of current dir, not equal to current dir maybe allowed but pointless). Standard: buffer up to 2.

Requirement: "Quick successive key presses must both be honored, but the snake can never reverse onto itself." So queue must hold multiple. I'll keep queue of up to 3.

Push logic: compare against last queued direction (or current direction if queue empty). If new dir is opposite of that, ignore. If same as that, ignore (dedupe). Else push.

Game over: overlay showing score, "Press Space or Enter to restart". Also P pauses with overlay "Paused".

Best score of session: track in variable (not localStorage — banned). Show "BEST" in HUD.

Also: the demo mode starts automatically; overlay says "Click or press a key to start". On any key or click → start playing (fresh game). Space/Enter restart after game over. In demo mode, after game over, the AI restarts itself after a delay and keeps going. That's good for the screenshot.

Wait: "Click or press a key to start" — clicking the canvas starts. But careful: clicking should not interfere with restart logic. Let's use a state machine:

states: 'demo' (AI playing, overlay with start prompt), 'playing', 'paused', 'gameover'.

In demo mode, the AI plays; if it dies, show a brief overlay? No — better: in demo, on death, immediately reset after ~900ms and continue with a small "demo" badge. So the demo never shows a stuck game-over screen. Good.

When user presses a key or clicks in demo → transition to 'playing' with a fresh snake and score 0, best preserved.

In 'playing': arrow/WASD change direction; P pauses; game over on collision → 'gameover' state with overlay; Space/Enter restarts → 'playing' fresh.

In 'paused': P or Space resumes? Requirement: P pauses. Let's have P toggle pause, and Space/Enter also resume from pause (since Enter/Space restart... but restarting from a paused game would be odd). Let's: in paused, P resumes, Space/Enter resumes too. Fine.

In 'gameover': Space/Enter restart.

HUD: Score, Best, plus maybe length. Also a subtle title "SNAKE".

Layout: centered canvas with rounded border/glow, HUD above.

Responsive: compute canvas size = min(available width, available height). Use a container with CSS aspect-ratio 1/1 and max sizes. Resize with ResizeObserver or window resize: set canvas.width = size*dpr, etc. Use CSS for display size.

Simplest: 
```html
<div class="wrap">
  <header>...score...</header>
  <div class="stage" id="stage"><canvas id="game"></canvas><div class="overlay" id="overlay">...</div></div>
  <footer>hints</footer>
</div>
```
CSS: .stage { width: min(88vw, 62vh, 640px); aspect-ratio: 1; position: relative; }
Hmm, with header/footer we need vertical space. Let's do: `--size: min(90vw, calc(100vh - 190px), 660px)`. Use clamp. Let me use JS to compute canvas CSS size on resize based on window.innerHeight - headerHeight - footerHeight. Simpler with CSS:

```css
.stage { width: min(92vw, calc(100dvh - 180px), 680px); aspect-ratio: 1; }
```
dvh is fine in latest Chrome.

Overlay positioned absolutely covering the stage, with backdrop blur, showing title, message, and a pulsing "Click or press a key to start".

Now canvas rendering details.

Grid: 24 x 24 = 576 cells. cell = size / 24.

Background: fill with dark color, draw subtle checkerboard or grid lines with low alpha. Also a vignette.

Food: pulsing glow circle with a highlight; maybe an apple-ish shape: circle with stem/leaf. Let's do: radial-gradient circle red-orange, plus a small leaf, plus animated ring pulse. Keep it simple but polished: draw glow (shadowBlur), circle with radius 0.3*cell, plus a small highlight dot, and an expanding ring based on time.

Particles: on eat, spawn ~14 particles with random velocity from food position, gravity-ish, fading out over ~500ms, colored in warm hues. Update and draw each frame.

Also a "+10" floating text popup maybe. Nice touch.

Smooth movement interpolation: t = accumulator / stepTime clamped 0..1. Compute interpolated positions.

But careful: when the snake wraps? No wrap, walls kill. Good.

Edge case: at game over, we stop interpolating; freeze at t=1 or just draw last positions. Actually game over occurs at the step where the new head hits a wall — we shouldn't move into the wall. On collision we just don't apply the move and end the game. Render with t=1 of previous step. Fine — but then the head sits at its last cell. Maybe add a death flash effect: shake? Let's add a red flash overlay + the head turning pale. Keep simple: a screen flash of red that fades, and particles at head. Not necessary but nice. Let's add a quick shockwave ring at the collision point.

Let me now write code.

```js
const COLS = 24, ROWS = 24;
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
let cell = 20, dpr = 1;

function resize() {
  const rect = canvas.getBoundingClientRect();
  dpr = Math.min(window.devicePixelRatio || 1, 2);
  canvas.width = Math.round(rect.width * dpr);
  canvas.height = Math.round(rect.height * dpr);
  cell = rect.width / COLS;
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
```
Careful: ctx.setTransform resets each resize; then in draw we save/restore as needed. Set transform once per resize, and draw uses CSS pixel coordinates (0..rect.width). But we also setTransform in draw? No, just once per resize. Any ctx.scale in draw must be within save/restore.

Note canvas getBoundingClientRect width equals CSS width. Good, square.

Game state:

```js
const state = {
  mode: 'demo', // demo | playing | paused | gameover
  snake: [], // [{x,y}]
  prev: [],  // aligned previous positions
  dir: {x:1,y:0},
  queue: [],
  food: {x,y},
  score: 0,
  best: 0,
  stepTime: 135,
  acc: 0,
  t: 0,   // interpolation factor
  particles: [],
  pops: [],
  demoDeathTimer: 0,
  flash: 0,
  shock: null,
};
```

Wait, better to keep acc and compute t. Let's use:
- `acc` accumulates dt while playing/demo.
- when acc >= stepTime: acc -= stepTime; step();
- t = clamp(acc/stepTime, 0, 1).

Rendering interpolation uses prev[] → snake[] with t.

Init:

```js
function reset(mode) {
  const cy = Math.floor(ROWS/2), cx = Math.floor(COLS/2);
  snake = [{x:cx,y:cy},{x:cx-1,y:cy},{x:cx-2,y:cy},{x:cx-3,y:cy}];
  prev = snake.map(s=>({...s}));
  dir = {x:1,y:0};
  queue = [];
  score = 0;
  stepTime = 135;
  acc = 0;
  particles = [];
  pops = [];
  food = spawnFood();
  flash = 0;
  ...
}
```

Careful: prev aligned initially same positions → no movement visible at start.

spawnFood: collect free cells, pick random.

step():
```js
function step() {
  // consume queue
  if (queue.length) {
    const nd = queue.shift();
    if (nd.x !== -dir.x || nd.y !== -dir.y) dir = nd;
  }
  const head = snake[0];
  const nx = head.x + dir.x, ny = head.y + dir.y;
  // wall
  if (nx<0||ny<0||nx>=COLS||ny>=ROWS) return die(nx,ny);
  // self: check all except tail (tail moves away) unless growing
  const willGrow = (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(nx,ny);
  
  const old = snake;
  const newSnake = [{x:nx,y:ny}];
  for (let i=0;i<old.length - (willGrow?0:1); i++) newSnake.push(old[i]);
  // hmm careful
```
Wait: new snake should be [newHead, ...old] and if not growing, drop the last. So:
```js
  const newSnake = [{x:nx,y:ny}, ...old];
  if (!willGrow) newSnake.pop();
```
Then aligned prev: prevNew[i] = old[i] ?? old[old.length-1].
```js
  const prevNew = newSnake.map((_,i)=> old[i] ? {...old[i]} : {...old[old.length-1]});
```
For i=0: old[0] is the old head — correct (new head moves from old head position). For i>=1: old[i] which is the previous position of the slot that is now at i-1... wait no. newSnake[i] = old[i-1]. Its previous position should be old[i] (the slot i's old position). Yes, prevNew[i] = old[i]. For the last index when growing, old[i] is undefined → use old[last]. Correct.

Hmm, but for the new head (i=0), prevNew[0] = old[0], the old head position. Correct.

Then check food:
```js
  if (willGrow) {
    score += 10;
    best = Math.max(best, score);
    stepTime = Math.max(72, 135 - score*1.1); // maybe
    burst(food.x, food.y);
    pops.push({x:food.x, y:food.y, life:1, text:'+10'});
    food = spawnFood();
  }
```
Careful: spawnFood must not spawn on the new snake. It uses current snake array; set snake = newSnake before spawning.

Then snake = newSnake; prev = prevNew.

die(x, y): 
- mode → 'gameover' (if playing) or in demo handle.
- flash = 1; spawn a shock ring at head.
- best updated already.
- For demo: set a timer (demoRestart = 1.0) and change mode to... hmm, mode 'demo' should reset after delay. Let's keep mode 'demo' but set `state.dead = true` and a timer; the AI won't step while dead; after 900ms reset('demo').

Actually let's unify: state.mode: 'demo' | 'playing' | 'paused' | 'gameover'. On death: if mode==='demo' → state.demoDeadTimer = 0.9 and set a flag `state.over = true` (still mode demo). Let's add state.alive boolean. Simpler: on death set state.mode = (wasDemo ? 'demoDead' : 'gameover'). Then demoDead: countdown then reset('demo').

Hmm, but overlay in demo... In demoDeath, show nothing special (or a subtle "restarting" ). Actually, on demo death it might be nice to show the gameover overlay briefly? No — keep it clean; the demo overlay always shows "Click or press a key to start", and it's always visible in demo mode. Hmm, if the demo overlay covers the screen, we can't see the game playing! 

Important: The "Click or press a key to start" message must not block the view of the game. So make it a small banner at the bottom / or a translucent panel that doesn't cover much. Requirement says display a clear message. Let's put a pill-shaped banner near the bottom of the stage with semi-transparent background and pulse animation, and keep the board fully visible. And maybe dim the board slightly? No — the demo should be judged in action, so keep it bright.

So overlay in demo: small pill at bottom center: "▶ Click or press a key to start". Also a small "DEMO" badge top-left of the stage.

Game over overlay: full stage overlay with dark blur, big "GAME OVER", score, best, "Press Space or Enter to restart". Paused similar.

Since the demo game-over shouldn't show that, handle demoDead separately: brief flash then reset.

OK.

Now AI.

```js
function aiThink() {
  const head = snake[0];
  const dirs = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
  // occupancy grid
  const occ = new Uint8Array(COLS*ROWS);
  for (let i=0;i<snake.length-1;i++) occ[snake[i].y*COLS+snake[i].x] = 1; // exclude tail (it moves)
  // Note: if the snake will grow, tail stays, but approx fine.
  const idx = (x,y)=> y*COLS+x;
  const blocked = (x,y)=> x<0||y<0||x>=COLS||y>=ROWS||occ[idx(x,y)];
  
  const candidates = dirs.filter(d => !(d.x===-dir.x && d.y===-dir.y) && !blocked(head.x+d.x, head.y+d.y));
  if (!candidates.length) return null; // dead
  // prefer food directly
  const foodDir = candidates.find(d => head.x+d.x===food.x && head.y+d.y===food.y);
  if (foodDir) return foodDir;
  
  // BFS
  const start = idx(head.x, head.y);
  const q = [start];
  const came = new Int32Array(COLS*ROWS).fill(-1);
  const seen = new Uint8Array(COLS*ROWS);
  seen[start]=1;
  let found=-1;
  while(q.length){
    const cur = q.shift();  // shift is O(n) but grid is small (576) - fine
    const cx = cur%COLS, cy=(cur/COLS)|0;
    if (cx===food.x && cy===food.y) { found=cur; break; }
    for (const d of dirs) {
      const nx=cx+d.x, ny=cy+d.y;
      if (nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
      const ni=idx(nx,ny);
      if (seen[ni]||occ[ni]) continue;
      seen[ni]=1; came[ni]=cur; q.push(ni);
    }
  }
  let firstStep = null;
  if (found>=0) {
    let cur = found;
    while (came[cur] !== start && came[cur] !== -1) cur = came[cur];
    // cur is the cell right after start
    const cx = cur%COLS, cy=(cur/COLS)|0;
    firstStep = {x: cx-head.x, y: cy-head.y};
  }
  // safety: flood fill from resulting head
  if (firstStep) {
    const nx = head.x+firstStep.x, ny=head.y+firstStep.y;
    // simulate occupancy after move
    const occ2 = ...; // body minus tail plus new head
    if (spaceFrom(nx,ny,occAfter) >= Math.min(snake.length+1, 8)) return firstStep; 
  }
  // fallback: maximize space
  ...
}
```

This is getting complex but manageable. Let's simplify the safety check: compute reachable free area from the candidate move, using the current occupancy (excluding tail). Require area >= snake.length (so the snake can fit in the space). If BFS-first-step fails safety, fall back to best-space direction.

Flood fill function:
```js
function floodCount(sx, sy, occ) {
  const seen = new Uint8Array(COLS*ROWS);
  const stack=[sy*COLS+sx]; seen[sy*COLS+sx]=1; let n=0;
  while(stack.length){ const c=stack.pop(); n++;
    const cx=c%COLS, cy=(c/COLS)|0;
    for(const d of DIRS){ const nx=cx+d.x, ny=cy+d.y;
      if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
      const ni=ny*COLS+nx; if(seen[ni]||occ[ni]) continue; seen[ni]=1; stack.push(ni); } }
  return n;
}
```

Good enough for demo. Occ excludes tail; when moving, the new head adds a cell but the tail frees one — approximately neutral.

Let me define occ for AI as snake body excluding last element (tail). Then flood from candidate with that occ (candidate cell itself is free unless it's food... food isn't in occ). Fine.

Let's write the AI:

```js
function aiChooseDir() {
  const head = snake[0];
  const occ = new Uint8Array(COLS*ROWS);
  for (let i=0;i<snake.length-1;i++) occ[snake[i].y*COLS+snake[i].x]=1;
  const free = (x,y)=> x>=0&&y>=0&&x<COLS&&y<ROWS&&!occ[y*COLS+x];
  const options = DIRS.filter(d=>!(d.x===-dir.x&&d.y===-dir.y) && free(head.x+d.x, head.y+d.y));
  if (!options.length) return dir; // will crash next step
  
  // direct food
  const toFood = options.find(d=> head.x+d.x===food.x && head.y+d.y===food.y);
  if (toFood) return toFood;
  
  // BFS toward food
  let best = null;
  ... as above ...
  
  // pick the option maximizing flood area, tie-break closest distance to food
  let bestOpt = options[0], bestScore = -1;
  for (const d of options) {
    const nx=head.x+d.x, ny=head.y+d.y;
    const area = floodCount(nx,ny,occ);
    const dist = Math.abs(nx-food.x)+Math.abs(ny-food.y);
    const score = area*100 - dist;   // area dominates
    if (score>bestScore){bestScore=score;bestOpt=d;}
  }
  return bestOpt;
}
```

And the BFS first step candidate should also be checked for safety. Let's include BFS path step as candidate with a bonus: if BFS first step's flood area >= snake.length, prefer it. I'll compute areas for all options and use BFS path step if its area is sufficient.

Simplify:
```js
const bfsStep = bfsFirstStep(head, occ);
const areas = options.map(d => ({d, area: floodCount(head.x+d.x, head.y+d.y, occ), dist: ...}));
let choice;
if (bfsStep) {
  const e = areas.find(a=>a.d.x===bfsStep.x&&a.d.y===bfsStep.y);
  if (e && e.area >= Math.min(snake.length+2, 30)) choice = e.d;
}
if (!choice) {
  areas.sort((a,b)=> b.area-a.area || a.dist-b.dist);
  choice = areas[0].d;
}
return choice;
```
Hmm, choose the max-area sort but also prefer straight-ish. Fine.

Also the AI should not step directly into the food if... no it's fine.

The AI runs only in demo mode. It updates `dir` directly before the step. Actually better: the AI sets a queued direction; but simpler to just set dir at the start of step in demo mode. Let's do: in step(), if mode is demo, dir = aiChooseDir() (with the no-reverse rule already enforced in options).

Now the render.

```js
function draw(now) {
  // clear
  ctx.clearRect(0,0,W,H);  // W,H in CSS px
  // background
  drawBoard();
  drawFood(now);
  drawSnake(t);
  drawParticles();
  drawPops();
  ...
}
```

drawBoard: fill with #0b1220; draw grid lines with rgba(255,255,255,0.03) every cell; checkerboard subtle alternate cells with rgba(255,255,255,0.012). Maybe a radial vignette. Also border rounded — handled by CSS overflow hidden on the stage.

Let's do subtle checkerboard + grid lines.

drawFood: position at cell center, pulse = 1 + 0.08*sin(now*0.006). Glow with shadowBlur. Draw an apple: circle radius 0.3*cell in #ff5c72 with radial gradient highlight, plus a stem line and a leaf. Or simpler: a rounded diamond?

Let's draw the classic apple:
- ctx.shadowColor = 'rgba(255,90,110,0.9)'; shadowBlur = 18.
- radial gradient from #ff8b9a (top-left) to #e5344f.
- circle radius r = 0.3*cell*pulse.
- stem: small brown line from top.
- leaf: small ellipse green.

Plus an expanding ring: radius = r + (t%1)*r*1.6, alpha fading. Use a time-based phase.

Good.

drawSnake:
- Build interpolated points array: for i in snake: p = lerp(prev[i], snake[i], t) → center in pixels: (p.x+0.5)*cell.
- If length >= 2: stroke path.
  - ctx.lineJoin='round'; lineCap='round';
  - Shadow: ctx.shadowColor='rgba(74,222,128,0.45)'; shadowBlur = 14; stroke with dark outline color and width cell*0.86; then reset shadow.
  - Actually order: draw outer (darker, e.g. #0e5c33) width 0.86*cell, then main gradient width 0.68*cell, then highlight width 0.22*cell with rgba(255,255,255,0.12) offset? A highlight stroke along the same path would be centered, making the middle lighter. That could look like a shiny tube — decent.

  Gradient: create linear gradient from head point to tail point, colors #7dfab0 → #14b866 → maybe #0e7a45.

Hmm, using a gradient between head and tail points: when the snake is coiled, the gradient direction is arbitrary but still looks nice.

Alternatively, draw per-segment circles with lerped color and radius tapering toward the tail. With circles radius 0.5*cell they'd be tangent to each other (touching exactly), giving a bumpy "caterpillar" look. Actually a tapering body: radius from 0.46 at head to 0.3 at tail. With 1 cell spacing, circles of radius 0.46 overlap slightly with the next (0.45) if consecutive... adjacent circle centers 1 cell apart, radii sum ≈ 0.9 < 1, so gaps. Not good.

The continuous stroke is the way. Then add a head circle on top: radius 0.46*cell, drawn with the same gradient-ish color, plus eyes.

Tapering: can't easily taper a stroke. Could stroke multiple times with decreasing width from tail... Actually you can: draw the path in reverse from tail to head with gradually decreasing width by splitting the polyline into segments and stroking each with its own width (with round caps, overlapping). That gives a tapered tube. Let's try: for i from tail to head, stroke each sub-segment with width = lerp(0.5, 0.9, i/len) * cell, round caps. Since consecutive segments share endpoints with round caps, the joint will be smooth. Overlapping caps at same point with different widths is fine (union). But each stroke has its own color too — nice, we can taper both width and color!

But shadowBlur per segment would be expensive (24*... segments = snake length up to 576). Too slow. Do the shadow pass with a single full-path stroke (uniform width, dark) then the tapered segments without shadow.

Actually simplest: single stroke with uniform width plus gradient along path is fine. But tapering looks nicer. Let's do tapering with per-segment stroke, no shadow. Number of segments = snake length, typically < 60. Fine.

Implementation:
```js
const pts = interpolated centers (array of {x,y} in px), index 0 = head.
// draw from tail to head
for (let i = pts.length-1; i>0; i--) {
  const a = pts[i], b = pts[i-1];
  const u = i / (pts.length-1); // 0 at tail, 1 at head... careful
  ...
}
```
Hmm, let's define u = 1 - i/(len-1) → at i=0 (head) u=1; at tail u=0. width = cell * (0.55 + 0.32*u) → tail 0.55, head 0.87. Hmm, actually a snake narrows at the tail. Head width ~0.82*cell, tail ~0.42.

Color lerp: from tail color #0e7a45 to head color #58e08a. Use a helper lerpColor.

But per-segment strokes with round caps create visible dots? No, they're continuous.

Perf: creating a color string per segment per frame — fine for < 100 segments.

Let's precompute colors in a small palette array to avoid string building? It's fine.

Actually, I realize there might be a subtle issue: with round caps and varying widths, at the joint the wider circle may bulge over the narrower one, creating a smooth taper. It should look good.

Also add a lighter inner highlight: stroke each segment with a thinner line (width*0.35) offset? Offsetting is hard. Skip; instead draw small light dots along the body? Hmm, "scales" could be nice: every other segment, draw a subtle lighter circle at 0.12*cell radius with alpha 0.15. Let's skip for perf/simplicity, but maybe add a subtle top highlight by drawing a lighter stroke with width*0.3 along the same path (centered) with alpha 0.18 — gives a "glass tube" sheen. Let's try that as a second pass with globalAlpha 0.18 and white. Hmm, a centered light line looks like a highlight along the spine. It's OK.

I'll do: main tapered segments, then a thin light spine stroke (single path, width cell*0.18, rgba(255,255,255,0.14), round caps). Might look decent.

Head:
- position = pts[0] (interpolated head center).
- Draw circle radius cell*0.44 with a radial gradient (#8dffbf → #3ecf7e).
- Eyes: direction vector d = dir (current). Perp p = (-d.y, d.x). Eye centers = head + d*0.12*cell ± p*0.19*cell. Eye radius 0.115*cell white; pupil radius 0.06*cell dark, offset by d*0.045*cell.
- Maybe a tongue: a small forked red line extending forward, flickering with time. Cute. Let's add: when sin(now*0.008) > 0.6, draw tongue.

Also the head should be drawn after body so it's on top. Since we draw tail→head, the head circle at the end anyway.

Death: turn the head color grayish? Skip; but add a red flash overlay.

Particles: {x, y (px), vx, vy, life, maxLife, size, hue}. Update with dt in seconds: x += vx*dt etc., vy += 480*dt, life -= dt. Draw as circles with alpha = life/maxLife and globalAlpha.

Pops: "+10" text floating up and fading.

Now the main loop:

```js
let last = performance.now();
function loop(now) {
  const dt = Math.min(0.05, (now - last)/1000);
  last = now;
  update(dt, now);
  draw(now);
  requestAnimationFrame(loop);
}
```

update:
```js
function update(dt, now) {
  // particles always update
  ...
  if (mode==='playing' || mode==='demo') {
    acc += dt*1000;
    while (acc >= stepTime) { acc -= stepTime; step(); if (mode!=='playing'&&mode!=='demo') { acc=0; break; } }
    t = Math.min(1, acc/stepTime);
  } else if (mode==='demoDead') {
    t = 1;
    demoDeathTimer -= dt;
    if (demoDeathTimer<=0) reset('demo');
  } else if (mode==='paused' || mode==='gameover') {
    t = 1; // freeze at final
  }
}
```
Hmm: on gameover, we want to freeze at t=1 of the previous step (positions already = snake). Since prev[] and snake[] differ when a step happened, setting t=1 renders at snake positions. Good.

Wait, but on game over, the step() that detected the collision didn't update snake/prev, so snake/prev still hold the pre-collision state with the head at the old position, prev the older. Setting t=1 snaps the head forward to its current cell. Fine.

Also `flash` decay and shockwave ring update.

For paused, maybe dim the board. The overlay handles it.

Now, input handling:

```js
document.addEventListener('keydown', e => {
  const k = e.key.toLowerCase();
  if (['arrowup','arrowdown','arrowleft','arrowright',' '].includes(k)) e.preventDefault();
  ...
});
```
Careful with e.key for space being ' '. Also 'Enter' → 'enter'.

Mapping:
```js
const keyDir = {
  arrowup:{x:0,y:-1}, w:{x:0,y:-1},
  arrowdown:{x:0,y:1}, s:{x:0,y:1},
  arrowleft:{x:-1,y:0}, a:{x:-1,y:0},
  arrowright:{x:1,y:0}, d:{x:1,y:0}
};
```

Handler:
```js
function onKey(e) {
  const k = e.key;
  const lower = k.toLowerCase();
  if (k === ' ' || k === 'Enter' || k.startsWith('Arrow') || ['w','a','s','d'].includes(lower)) e.preventDefault();
  
  if (mode === 'demo' || mode === 'demoDead') {
    // any key starts the game? The prompt says click or press a key to start.
    startGame();
    // and also apply direction if it's an arrow? Maybe not, avoid confusion. Just start.
    return;
  }
  if (keyDir[lower]) {
    if (mode==='playing') queueDir(keyDir[lower]);
    return;
  }
  if (lower === 'p') {
    if (mode==='playing'){ mode='paused'; updateOverlay(); }
    else if (mode==='paused'){ mode='playing'; last=performance.now(); updateOverlay(); }
    return;
  }
  if (k === ' ' || k === 'Enter') {
    if (mode==='gameover') startGame();
    else if (mode==='paused') { mode='playing'; updateOverlay(); }
    return;
  }
}
```

Hmm, "any key starts the game" in demo — but P in demo? Just start. Fine. Requirement from general instructions: "Click or press a key to start". Yes.

Careful: `last` timing — since we use rAF's timestamp each frame, no need to reset `last`. dt is computed from rAF timestamps, always continuous. Good, remove the `last` resets.

The `acc` should reset on start to avoid an immediate step.

queueDir:
```js
function queueDir(d) {
  const lastDir = queue.length ? queue[queue.length-1] : dir;
  if (d.x === -lastDir.x && d.y === -lastDir.y) return;
  if (d.x === lastDir.x && d.y === lastDir.y) return;
  if (queue.length >= 3) return;
  queue.push(d);
}
```

Wait: if the queue is empty, lastDir = dir (the direction of the last step). Good.

Edge: dir is updated at step time from the queue. If a player presses Up then Left quickly while moving Right: queue = [Up, Left]. At next step, dir = Up (Left still queued). Next step, dir = Left. Both honored. 

But careful: `dir` field — I use `dir` as a global and it's also the direction the snake moves this step. When the queue is consumed at step start, dir changes. Fine.

Restart: startGame() sets mode='playing', reset snake, score 0, best preserved.

Best score: update when eating and on death. In demo, should best update? "best score of the session" — the demo scores shouldn't count maybe. Let's only count player scores. In demo mode, don't update best. Good.

Overlay DOM:

```html
<div class="stage">
  <canvas></canvas>
  <div class="hud-badge" id="demoBadge">DEMO</div>
  <div class="overlay" id="overlay" hidden>...</div>
  <div class="prompt" id="prompt">Click or press a key to start</div>
</div>
```

Hmm, HUD (score/best) above the stage in the header. Let's design:

```
<header class="hud">
  <div class="brand">SNAKE</div>
  <div class="stats">
    <div class="stat"><span class="label">SCORE</span><span class="value" id="score">0</span></div>
    <div class="stat"><span class="label">BEST</span><span class="value" id="best">0</span></div>
  </div>
</header>
<div class="stage">...</div>
<footer class="hint">Arrows / WASD to move · P to pause · Space to restart</footer>
```

Layout: flex column, centered, gap. The stage size must fit in the viewport with the header/footer. Use CSS:

```css
:root { --stage: min(92vw, calc(100dvh - 170px), 660px); }
.stage { width: var(--stage); height: var(--stage); }
```

With a header of ~60px and footer ~40px plus gaps and padding, 170px of reserve should be OK. Let's use clamp: `min(92vw, 68dvh, 640px)` — 68dvh on an 800px height = 544px; header 60 + footer 40 + gaps 40 = 140; total 684 < 800. Good. On mobile 375×667: 92vw=345, 68dvh=453 → 345. Fine.

Let's use `--stage: min(92vw, 66dvh, 640px)`.

Hmm, on a wide short window (1280x800): 66dvh = 528. Good.

Overall page: body flex center, background dark with a radial gradient.

Now writing all the code carefully.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Snake</title>
<style>...</style>
</head>
<body>
<div class="app">
  <header class="hud"> ... </header>
  <div class="stage" id="stage">
    <canvas id="game"></canvas>
    <div class="badge" id="badge">DEMO</div>
    <div class="prompt" id="prompt"><span class="pulse"></span>Click or press a key to start</div>
    <div class="overlay" id="overlay">
      <div class="panel">
        <h2 id="ovTitle">GAME OVER</h2>
        <p id="ovScore">Score 0</p>
        <p id="ovSub">Press Space or Enter to restart</p>
      </div>
    </div>
  </div>
  <footer class="hint">...</footer>
</div>
<script> ... </script>
</body>
</html>
```

CSS details:

```css
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
  background: radial-gradient(circle at 50% 0%, #16233a 0%, #0a0f1a 55%, #06080e 100%);
  color: #e8f2ff;
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  display: flex; align-items: center; justify-content: center;
  overflow: hidden;
  -webkit-font-smoothing: antialiased;
}
.app { display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 16px; }
```

HUD: 

```css
.hud { width: var(--stage); display: flex; align-items: center; justify-content: space-between; }
.brand { font-size: 15px; letter-spacing: .38em; font-weight: 700; color: #9ff5c4; text-shadow: 0 0 18px rgba(80,240,160,.5); }
.stats { display: flex; gap: 10px; }
.stat { display:flex; flex-direction: column; align-items:flex-end; background: rgba(255,255,255,.045); border:1px solid rgba(255,255,255,.08); border-radius: 10px; padding: 5px 12px; min-width: 74px; }
.stat .label { font-size: 9px; letter-spacing: .18em; color: #7f93ad; }
.stat .value { font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; color:#eaf6ff; }
```

Hmm, `--stage` is defined on :root.

Stage:

```css
.stage {
  position: relative;
  width: var(--stage); height: var(--stage);
  border-radius: 18px;
  overflow: hidden;
  background: #0a1120;
  box-shadow: 0 0 0 1px rgba(120,220,180,.14), 0 24px 60px -20px rgba(0,0,0,.9), 0 0 80px -30px rgba(64,224,150,.4);
}
canvas { display: block; width: 100%; height: 100%; }
```

Badge: absolute top 10 left 10, small pill, uppercase letterspaced, color #8ef3bd with bg rgba.

Prompt: absolute bottom 14, centered, pill:

```css
.prompt {
  position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%);
  display: flex; align-items: center; gap: 8px;
  padding: 8px 16px; border-radius: 999px;
  background: rgba(8,16,26,.82);
  border: 1px solid rgba(120,240,180,.28);
  color: #c8ffe0; font-size: 13px; letter-spacing: .04em;
  backdrop-filter: blur(6px);
  animation: breathe 2s ease-in-out infinite;
  white-space: nowrap;
  pointer-events: none;
}
```
On narrow screens (375px) the text at 13px might be ~250px wide — fits within 345px stage. OK.

Overlay:

```css
.overlay {
  position: absolute; inset: 0; display: grid; place-items: center;
  background: rgba(6,10,18,.72);
  backdrop-filter: blur(3px);
  opacity: 0; pointer-events: none; transition: opacity .22s ease;
  text-align: center;
}
.overlay.show { opacity: 1; }
```

Panel with title, score.

Now the JS. Let me write it carefully.

```js
(() => {
  const COLS = 24, ROWS = 24;
  const DIRS = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
  const canvas = document.getElementById('game');
  const ctx = canvas.getContext('2d');
  const stage = document.getElementById('stage');
  const scoreEl = document.getElementById('score');
  const bestEl = document.getElementById('best');
  const overlay = document.getElementById('overlay');
  const ovTitle = document.getElementById('ovTitle');
  const ovScore = document.getElementById('ovScore');
  const ovSub = document.getElementById('ovSub');
  const promptEl = document.getElementById('prompt');
  const badgeEl = document.getElementById('badge');

  let W = 0, H = 0, cell = 20, dpr = 1;
```

resize:
```js
  function resize() {
    const r = stage.getBoundingClientRect();
    W = r.width; H = r.height;
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    canvas.width = Math.max(1, Math.round(W*dpr));
    canvas.height = Math.max(1, Math.round(H*dpr));
    cell = W / COLS;
    ctx.setTransform(dpr,0,0,dpr,0,0);
  }
```
Note H should equal W because the stage is square. cell = W/COLS. But then the vertical grid is H/ROWS = W/24 too. Fine.

Game vars:
```js
  let mode = 'demo';
  let snake, prev, dir, queue, food, score, best = 0, stepTime, acc, t, particles, pops, flash, shock, demoDeadT;
```

reset:
```js
  function reset(newMode) {
    mode = newMode;
    const cy = (ROWS>>1), cx = (COLS>>1);
    snake = [];
    for (let i=0;i<4;i++) snake.push({x: cx-i, y: cy});
    prev = snake.map(s => ({...s}));
    dir = {x:1,y:0};
    queue = [];
    score = 0;
    stepTime = 130;
    acc = 0;
    t = 0;
    particles = [];
    pops = [];
    flash = 0;
    shock = null;
    food = spawnFood();
    syncHud();
    syncOverlay();
  }
```

spawnFood:
```js
  function spawnFood() {
    const occ = new Set(snake.map(s => s.y*COLS + s.x));
    const free = [];
    for (let i=0;i<COLS*ROWS;i++) if (!occ.has(i)) free.push(i);
    if (!free.length) return {x:-1,y:-1};
    const i = free[(Math.random()*free.length)|0];
    return {x: i%COLS, y: (i/COLS)|0};
  }
```

step:
```js
  function step() {
    if (mode === 'demo') {
      const d = aiChooseDir();
      if (d) dir = d;
    } else if (queue.length) {
      const nd = queue.shift();
      if (!(nd.x === -dir.x && nd.y === -dir.y)) 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(nx, ny, head);
    const willGrow = (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(nx, ny, head);

    const newSnake = [{x:nx,y:ny}];
    for (let i=0;i<snake.length;i++) newSnake.push(snake[i]);
    if (!willGrow) newSnake.pop();
    const lastOld = snake[snake.length-1];
    const newPrev = newSnake.map((_, i) => {
      const o = snake[i];
      return o ? {x:o.x, y:o.y} : {x:lastOld.x, y:lastOld.y};
    });
    snake = newSnake;
    prev = newPrev;

    if (willGrow) {
      score += 10;
      if (mode !== 'demo') { best = Math.max(best, score); bestEl.textContent = best; }
      scoreEl.textContent = score;
      stepTime = Math.max(70, 130 - score * 1.2);
      burst(food.x, food.y);
      pops.push({x:food.x, y:food.y, life:1});
      food = spawnFood();
    }
  }
```
Hmm, careful with best in demo — I set mode check. But wait: if mode is 'demo', score increases but best isn't updated. Fine.

Wait, but the score display during demo would show the demo's score, and the player's best stays. That's a bit confusing. Maybe in demo mode show "—" or just show the demo score. Hmm, the HUD score is "current score". During demo, showing the AI's score is fine and gives life to the demo. Let's keep it but mark the badge "DEMO". Actually, showing an increasing score in the demo is nice, showing motion. But when the player starts, it resets to 0. Might be confusing but acceptable. 

Alternatively, in demo mode show the best as the AI's best. Nah, keep it simple: demo shows its own score in SCORE, best stays 0 until the player plays. Hmm, "best score of the session" — with the AI playing, the session best... Let's just have demo update `best` too! Then the demo shows a best score of the session building up. But then the player's "best" starts at the AI's best, which is unfair/confusing.

I'll go with: demo updates score display, best only from player. When the player starts, score resets. That's clean. To avoid confusion, during demo the score value is dimmed? Nah.

Hmm, actually simplest & cleanest: during demo, HUD shows the AI score in a dimmer color and the badge says DEMO. Let's add a class on the app in demo mode: `.app.demo .stat.score .value { color: #7f93ad }`. Eh, minor. I'll just leave it.

die:
```js
  function die(nx, ny, head) {
    // impact particles at head
    const px = (head.x + 0.5 + dir.x*0.5) * cell;
    ...
    flash = 1;
    shock = {x: (head.x+0.5)*cell, y: (head.y+0.5)*cell, r: cell*0.4, life: 1};
    if (mode === 'demo') { mode = 'demoDead'; demoDeadT = 0.9; }
    else { mode = 'gameover'; if (mode==='gameover') {...} syncOverlay(); }
    best = Math.max(best, score) ... // only for player
  }
```

Careful with the order: I set mode then check. Let me write:

```js
function die(hx, hy) {
  flash = 1;
  const cx = (hx + 0.5) * cell, cy = (hy + 0.5) * cell;
  shock = {x: cx, y: cy, life: 1};
  burstAt(cx, cy, 16, '#ff6b7d');
  if (mode === 'demo') {
    mode = 'demoDead';
    demoDeadT = 0.85;
  } else {
    best = Math.max(best, score);
    bestEl.textContent = best;
    mode = 'gameover';
    syncOverlay();
  }
}
```
Where hx, hy are the collided cell (outside the board for walls). That's fine for particle position. Actually for wall collisions, the head is at the edge and the impact cell is outside; particles will spawn just outside the board — the board is clipped by the stage overflow so it's OK, but maybe better to spawn at the head's cell. Let's pass the head cell instead.

Let me define die() using the current head: `const h = snake[0]`. Use head cell center + half a cell in dir? Just use head cell center. Fine.

Now AI. Let me write it.

```js
  function aiChooseDir() {
    const head = snake[0];
    const occ = new Uint8Array(COLS*ROWS);
    for (let i=0;i<snake.length-1;i++) occ[snake[i].y*COLS + snake[i].x] = 1;
    const isFree = (x,y) => x>=0 && y>=0 && x<COLS && y<ROWS && !occ[y*COLS+x];
    const opts = [];
    for (const d of DIRS) {
      if (d.x === -dir.x && d.y === -dir.y) continue;
      const x = head.x+d.x, y = head.y+d.y;
      if (isFree(x,y)) opts.push(d);
    }
    if (!opts.length) return dir;

    // direct food
    for (const d of opts) if (head.x+d.x===food.x && head.y+d.y===food.y) return d;

    const stepToFood = bfsStep(head, occ);
    // measure space for each option
    let bestD = opts[0], bestScore = -1e9;
    for (const d of opts) {
      const x = head.x+d.x, y = head.y+d.y;
      const area = floodCount(x, y, occ);
      const dist = Math.abs(x-food.x) + Math.abs(y-food.y);
      let s = area * 12 - dist;
      if (stepToFood && d.x===stepToFood.x && d.y===stepToFood.y) s += 400 + area*6;
      // small preference for continuing straight
      if (d.x===dir.x && d.y===dir.y) s += 3;
      if (s > bestScore) { bestScore = s; bestD = d; }
    }
    return bestD;
  }
```

Hmm, hard-coded weights. Let's think: area ~ up to 550. area*12 up to 6600, dist up to 48. The BFS bonus +400 + area*6 up to ~3700. So a BFS step with a decent area wins over a non-BFS step with a slightly larger area. That's reasonable.

Requirement for the BFS step to be taken: its total = 12a + 400 + 6a - dist = 18a + 400 - dist. Another option with area b: 12b - dist2. BFS wins if 18a + 400 > 12b, i.e., roughly a > (12b - 400)/18 = 0.667b - 22. So the BFS step needs ~2/3 of the best area. Reasonable.

bfsStep:
```js
  function bfsStep(head, occ) {
    const N = COLS*ROWS;
    const came = new Int32Array(N).fill(-2);
    const start = head.y*COLS + head.x;
    came[start] = -1;
    const q = [start];
    let headIdx = 0;
    let goal = -1;
    while (headIdx < q.length) {
      const cur = q[headIdx++];
      const cx = cur % COLS, cy = (cur / COLS) | 0;
      if (cx === food.x && cy === food.y) { goal = cur; break; }
      for (const d of DIRS) {
        const nx = cx+d.x, ny = cy+d.y;
        if (nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
        const ni = ny*COLS+nx;
        if (came[ni] !== -2 || occ[ni]) continue;
        came[ni] = cur;
        q.push(ni);
      }
    }
    if (goal < 0) return null;
    let cur = goal;
    while (came[cur] !== start) {
      cur = came[cur];
      if (cur < 0) return null;
    }
    return { x: cur%COLS - head.x, y: ((cur/COLS)|0) - head.y };
  }
```
Careful: if goal === start (food on head, impossible), the while loop would break immediately with cur = goal = start → step of 0. Not possible since the food never spawns on the snake.

Note came[ni] !== -2 checks: initial fill -2, start = -1. Good.

floodCount:
```js
  function floodCount(sx, sy, occ) {
    const seen = new Uint8Array(COLS*ROWS);
    const start = sy*COLS+sx;
    seen[start] = 1;
    const stack = [start];
    let n = 0;
    while (stack.length) {
      const cur = stack.pop();
      n++;
      const cx = cur % COLS, cy = (cur/COLS)|0;
      for (const d of DIRS) {
        const nx = cx+d.x, ny = cy+d.y;
        if (nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
        const ni = ny*COLS+nx;
        if (seen[ni] || occ[ni]) continue;
        seen[ni] = 1;
        stack.push(ni);
      }
    }
    return n;
  }
```

The AI is only called once per step — cheap enough.

Particles:
```js
  function burst(cx, cy) {  // grid cell
    const x = (cx+0.5)*cell, y = (cy+0.5)*cell;
    burstAt(x, y, 18);
  }
  function burstAt(x, y, n) {
    for (let i=0;i<n;i++) {
      const a = Math.random()*Math.PI*2;
      const sp = 60 + Math.random()*220;
      particles.push({
        x, y,
        vx: Math.cos(a)*sp, vy: Math.sin(a)*sp - 40,
        life: 0.45 + Math.random()*0.5,
        max: 1,
        size: 1.5 + Math.random()*3,
        hue: 10 + Math.random()*40 // orange-red
      });
    }
  }
```
Set max = life at creation. Let me store life and maxLife.

Colors: use `hsl(${hue}, 100%, ${60+...}%)`.

Update particles:
```js
  for (let i=particles.length-1;i>=0;i--) {
    const p = particles[i];
    p.life -= dt;
    if (p.life <= 0) { particles.splice(i,1); continue; }
    p.x += p.vx*dt; p.y += p.vy*dt;
    p.vy += 520*dt;
    p.vx *= (1 - 2.2*dt);
    p.vy *= (1 - 1.2*dt);   // hmm, drag on vy too
  }
```
Actually gravity plus drag is fine.

Draw particles with globalAlpha.

Pops ("+10"): {x,y,life:1} in grid coords; update life -= dt*1.6; draw text at (x+0.5)*cell, (y+0.5)*cell - (1-life)*cell*1.2, alpha = life.

Now drawing.

```js
  function draw(now) {
    ctx.clearRect(0,0,W,H);
    // board bg
    ctx.fillStyle = '#0a1120';
    ctx.fillRect(0,0,W,H);
    drawGrid();
    drawFood(now);
    drawSnake(now);
    drawParticles();
    drawPops();
    if (flash > 0) { ctx.fillStyle = `rgba(255,80,100,${flash*0.35})`; ctx.fillRect(0,0,W,H); }
    if (shock) { ring }
  }
```

drawGrid:
```js
  function drawGrid() {
    const c = cell;
    ctx.save();
    // checker
    for (let y=0;y<ROWS;y++) for (let x=0;x<COLS;x++) {
      if ((x+y)&1) { ctx.fillStyle='rgba(255,255,255,0.018)'; ctx.fillRect(x*c, y*c, c, c); }
    }
    // grid lines
    ctx.strokeStyle='rgba(120,220,255,0.045)';
    ctx.lineWidth = 1;
    ctx.beginPath();
    for (let x=1;x<COLS;x++){ ctx.moveTo(x*c, 0); ctx.lineTo(x*c, H); }
    for (let y=1;y<ROWS;y++){ ctx.moveTo(0, y*c); ctx.lineTo(W, y*c); }
    ctx.stroke();
    ctx.restore();
  }
```
Note: 48 lines — fine. Checkerboard loops 576 fills per frame — that's OK but let's precompute the checkerboard into an offscreen canvas once per resize for performance. Actually 576 fillRects per frame at 60fps is fine in Chrome. But to be safe, render the board to an offscreen canvas at resize and drawImage. Let's do that — it's cleaner and faster.

```js
  let boardCanvas = document.createElement('canvas');
  function renderBoard() {
    boardCanvas.width = canvas.width; boardCanvas.height = canvas.height;
    const b = boardCanvas.getContext('2d');
    b.setTransform(dpr,0,0,dpr,0,0);
    b.fillStyle = '#0a1120';
    b.fillRect(0,0,W,H);
    for (...) checker
    grid lines
    // vignette
    const g = b.createRadialGradient(W/2, H/2, Math.min(W,H)*0.2, W/2, H/2, Math.min(W,H)*0.75);
    g.addColorStop(0, 'rgba(80,200,255,0.05)');
    g.addColorStop(1, 'rgba(0,0,0,0.35)');
    b.fillStyle = g; b.fillRect(0,0,W,H);
  }
```
Then in draw: `ctx.drawImage(boardCanvas, 0, 0, W, H)`. Careful with the transform: since ctx has a dpr transform, drawImage with dest w/h = W,H works (it's in CSS px). The source is the full bitmap. Yes: ctx.drawImage(boardCanvas, 0, 0, W, H) draws the whole image scaled to W×H CSS px. Good.

Hmm, but the board canvas fill uses 'source-over' by default with opaque fill. Fine.

drawSnake: 

```js
  function drawSnake(now) {
    const pts = [];
    for (let i=0;i<snake.length;i++) {
      const s = snake[i], p = prev[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;
      pts.push({x, y});
    }
    if (pts.length > 1) {
      // tapered body
      const n = pts.length;
      ctx.lineCap = 'round';
      ctx.lineJoin = 'round';
      // dark outline pass first (single path)
      ctx.beginPath();
      ctx.moveTo(pts[0].x, pts[0].y);
      for (let i=1;i<n;i++) ctx.lineTo(pts[i].x, pts[i].y);
      ctx.strokeStyle = 'rgba(6,40,26,0.9)';
      ctx.lineWidth = cell*0.9;
      ctx.stroke();
      
      // gradient segments from tail to head
      for (let i=n-1;i>0;i--) {
        const u = 1 - i/(n-1); // 0 tail..1 head
        ...
      }
    }
  }
```
Hmm wait, `u`: at i = n-1 (tail), u = 0; at i = 1, u = 1 - 1/(n-1) which is close to 1. Fine, roughly.

For each segment i (from tail to head), drawing the line from pts[i] to pts[i-1] with width lerp(tailW, headW, u) and color lerp(tailC, headC, u).

Segment widths: head 0.82*cell, tail 0.30*cell? That might get too thin. Let's do head 0.84, tail 0.34, with a slight curve: w = cell*(0.34 + 0.5*Math.pow(u,0.7)). Hmm, at u=0 tail → 0.34. At u=1 → 0.84. With a power curve the body stays thick longer.

Colors: tail #0e6b3d, head #63f0a0. lerp in RGB.

Let me define:
```js
const HEAD_C = [104, 240, 160];
const TAIL_C = [16, 118, 74];
```
And mix.

For each segment also add a subtle lighter core? Let's add the spine highlight after all segments: a single path stroke with white alpha 0.10 and width cell*0.2. Hmm, might look like a weird stripe. Let's instead add small "scale" highlight circles at every other point with tiny radius. Eh.

Alternative: draw the same tapered segments a second time with a slightly smaller width and a lighter color mixed toward white at low alpha — that gives a "sheen" along the center. I'll do: after drawing the main segments, loop again with width*0.42 and color rgba(255,255,255,0.10). Hmm, that would be a white line down the middle. It can look nice, like a light reflection. Let's use alpha 0.08 and width factor 0.35. Subtle.

Hmm, honestly the gradient alone is nice. Let me skip the sheen and instead add a soft glow: before drawing the body, set shadowColor and shadowBlur and stroke the full path once. That gives an outer glow. Let's do:

```js
ctx.save();
ctx.shadowColor = 'rgba(60,240,150,0.45)';
ctx.shadowBlur = cell*0.9;
ctx.beginPath(); full path; ctx.lineWidth = cell*0.7; ctx.strokeStyle = '#2fbf74'; ctx.stroke();
ctx.restore();
```
Then draw the tapered segments on top (no shadow).

That's a nice glow. Perf: shadowBlur on a stroked path once per frame — acceptable.

Then the head:
```js
    const head = pts[0];
    // direction (use dir but interpolate? use dir)
    const dx = dir.x, dy = dir.y;
    const r = cell*0.44;
    // head glow
    const hg = ctx.createRadialGradient(head.x - dx*r*0.3, head.y - dy*r*0.3, r*0.15, head.x, head.y, r*1.15);
    hg.addColorStop(0, '#b6ffd4');
    hg.addColorStop(0.55, '#4fe08d');
    hg.addColorStop(1, '#1f9a5c');
    ctx.fillStyle = hg;
    ctx.beginPath(); ctx.arc(head.x, head.y, r, 0, Math.PI*2); ctx.fill();
    ctx.strokeStyle = 'rgba(6,40,26,0.9)'; ctx.lineWidth = cell*0.06; ctx.stroke();
```
Hmm, outline on the head — the body already has a dark outline pass; adding one to the head is consistent.

Then eyes:
```js
    const px = -dy, py = dx;
    const ex = head.x + dx*r*0.22, ey = head.y + dy*r*0.22;
    const off = r*0.46;
    for (const s of [1,-1]) {
      const cx = ex + px*off*s, cy = ey + py*off*s;
      ctx.fillStyle = '#fff';
      ctx.beginPath(); ctx.arc(cx, cy, r*0.30, 0, Math.PI*2); ctx.fill();
      ctx.fillStyle = '#0b1a12';
      ctx.beginPath(); ctx.arc(cx + dx*r*0.12, cy + dy*r*0.12, r*0.155, 0, Math.PI*2); ctx.fill();
    }
```
Wait, the eye offset perpendicular: off = r*0.46 with r = 0.44*cell → 0.2*cell from the head center laterally. Eye radius 0.3*0.44 = 0.132 cell. Total lateral extent 0.2+0.132 = 0.33 cell < 0.44. OK.

Also make the eyes slightly forward: ex = head + d*r*0.25.

Pupil offset forward by r*0.13.

Tongue: 
```js
if (Math.sin(now*0.012) > 0.5) drawTongue
```
Draw a red line from head + d*r*0.95 forward to head + d*r*1.5, with a fork. Might extend outside the board when against a wall — clipped by the stage. It's fine. Let's include it, it's cute. Maybe skip to avoid clutter... I'll include it, subtle.

Actually, with the tongue flickering, the demo looks alive. Include.

drawFood:
```js
  function drawFood(now) {
    const cx = (food.x+0.5)*cell, cy = (food.y+0.5)*cell;
    const pulse = 1 + Math.sin(now*0.006)*0.07;
    const r = cell*0.31*pulse;
    ctx.save();
    // outer pulse ring
    const phase = (now*0.0016) % 1;
    ctx.beginPath();
    ctx.arc(cx, cy, cell*0.3 + phase*cell*0.5, 0, Math.PI*2);
    ctx.strokeStyle = `rgba(255,110,130,${0.35*(1-phase)})`;
    ctx.lineWidth = cell*0.06;
    ctx.stroke();
    // glow
    ctx.shadowColor = 'rgba(255,80,110,0.9)';
    ctx.shadowBlur = cell*1.0;
    const g = ctx.createRadialGradient(cx - r*0.35, cy - r*0.4, r*0.15, cx, cy, r*1.05);
    g.addColorStop(0, '#ffd0c0');
    g.addColorStop(0.35, '#ff6b7d');
    g.addColorStop(1, '#c81e3d');
    ctx.fillStyle = g;
    ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2); ctx.fill();
    ctx.restore();
    // stem + leaf
    ctx.save();
    ctx.strokeStyle = '#7ddc9a'; ... 
  }
```
Leaf: small ellipse rotated. Let's keep the food as a glowing orb with a little leaf — good.

Actually, careful with shadowBlur and restore: shadow settings are in the save/restore.

Pops drawing:
```js
  function drawPops() {
    for (const p of pops) {
      const a = Math.max(0, p.life);
      ctx.globalAlpha = a;
      ctx.fillStyle = '#a8ffc9';
      ctx.font = `bold ${Math.round(cell*0.62)}px ui-monospace, monospace`;
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      ctx.fillText('+10', (p.x+0.5)*cell, (p.y+0.5)*cell - (1-a)*cell*1.1);
      ctx.globalAlpha = 1;
    }
  }
```

Now syncOverlay:

```js
  function syncOverlay() {
    if (mode === 'gameover') {
      ovTitle.textContent = 'GAME OVER';
      ovScore.innerHTML = `Score <b>${score}</b>`;
      ovSub.textContent = 'Press Space or Enter to restart';
      overlay.classList.add('show');
      promptEl.classList.remove('show');
      badgeEl.classList.remove('show');
    } else if (mode === 'paused') {
      ovTitle.textContent = 'PAUSED';
      ovScore.innerHTML = `Score <b>${score}</b>`;
      ovSub.textContent = 'Press P to resume';
      overlay.classList.add('show');
    } else if (mode === 'playing') {
      overlay.classList.remove('show');
      promptEl.classList.remove('show');
      badgeEl.classList.remove('show');
    } else { // demo / demoDead
      overlay.classList.remove('show');
      promptEl.classList.add('show');
      badgeEl.classList.add('show');
    }
  }
```

The prompt is shown during demo. Good.

Also "Click or press a key to start" — set the text once in HTML.

Event listeners:

```js
  window.addEventListener('keydown', onKey, {passive:false});
```

Also handle click on the stage:
```js
  stage.addEventListener('pointerdown', () => {
    if (mode === 'demo' || mode === 'demoDead') startGame();
    else if (mode === 'gameover') startGame();
  });
```
Hmm, clicking after game over restarts — reasonable. But a stray click might restart accidentally. It's fine.

But careful: the prompt says "Click or press a key to start" and clicking anywhere on the page? Let's attach to the document for demo start. But the overlay panel covers the stage; pointer events on the overlay... the overlay has pointer-events: none when hidden. When shown (gameover), clicking should restart. Let's attach the click listener to the document body, handling both demo start and gameover restart.

"Click or press a key to start" — a click anywhere. OK.

Also prevent scrolling with arrow keys: preventDefault in onKey when it's a game key.

Now `startGame`:
```js
  function startGame() {
    reset('playing');
  }
```
reset sets mode and syncs.

Also on start, maybe a "GO!" pop? Skip.

Now the main loop and initialization order: define everything, then resize(), renderBoard(), reset('demo'), requestAnimationFrame(loop).

Resize observer: 
```js
  const ro = new ResizeObserver(() => { resize(); renderBoard(); });
  ro.observe(stage);
```
ResizeObserver is available. Good. Also handle window resize (dpr changes) — the ResizeObserver on the stage will fire when the stage size changes. If only the dpr changes (moving between monitors) — ignore.

Now potential issue: ResizeObserver fires initially before reset, so boardCanvas is rendered. Then reset. Fine.

One thing: in `resize`, if called before the layout is stable, W might be 0. Guard.

Let me now also double check the `t` interpolation on game over: after die(), mode = 'gameover', so the update sets t = 1. That would snap the head forward one cell? No: snake still holds the position from the last successful step, prev holds the position before that. If the death happened mid-interpolation (acc between 0 and stepTime), rendering at t=1 snaps forward by up to one cell — the head visually jumps to the cell where it was heading. But that cell is where the snake's head currently is (the last valid cell). Wait no.

Let's trace: after a successful step, snake[0] = the new head cell, prev[0] = the old head cell. Then the interpolation t goes 0→1 over the step duration, so visually the head moves from the old cell to the new cell. If the collision is detected on the next step, that happens when acc >= stepTime, i.e., t was ~1 already. So the head is already visually at snake[0]. Setting t=1 keeps it there. Good — no jump.

But: on collision, the head is at the cell adjacent to the wall; the visual head is at t≈1 at that cell. Good.

Now, there is a subtle issue with the collision detection for the "self" check: `limit = willGrow ? snake.length : snake.length - 1`. The tail cell will be vacated unless growing, so moving into the tail cell is OK. Good.

Another subtlety: after the queue is consumed at the start of the step, `dir` changes, and the render uses `dir` for the eyes. That means the eyes snap to the new direction instantly at the step boundary. Since the head also moves at that instant, it's fine.

Hmm, but during the interpolation the head is sliding diagonally... no — the head moves from cell A to cell B, which is a straight line in the direction of movement. But the direction changed at the start of this step, meaning snake[0] is the cell reached in the new direction, and prev[0] is the old head. Yes, so the segment is along the new direction. Good, eyes match.

Now, let's double check the interpolation of the head when the direction changes: prev[0] = the old head (from the previous step), snake[0] = the new head. Correct.

Now let me handle an edge case in the initial state: prev === snake positions → t doesn't matter. Good.

Another consideration: `stepTime` decreases with score. At score 50 → 130-60 = 70 (min). That's fast. Maybe cap at 80. Let's use `Math.max(80, 130 - score*0.9)`. At score 55 → 80. Fine.

Hmm, the AI needs to keep up — it's fine, it computes per step.

Let me reconsider the demo difficulty: 24×24 grid with a snake of length ~4 growing. The AI should survive long. With the flood-fill safety, decent.

One risk: the AI gets into a loop where it never reaches the food (oscillating). The BFS bonus should prevent that mostly. And the snake will just wander — visually fine.

Let's also make sure the demo doesn't look stuck when the AI can't find a path: it picks max area, keeps moving. Fine.

Now writing the CSS for the overlay panel:

```css
.panel { padding: 22px 30px; border-radius: 16px; background: rgba(10,18,30,.86); border: 1px solid rgba(120,240,180,.18); box-shadow: 0 18px 40px -18px #000; }
.panel h2 { margin: 0 0 8px; font-size: 24px; letter-spacing: .18em; color: #ff7d8f; }
```
For paused, the title color could be #8ef3bd. Let's add classes.

Score display: `Score <b>42</b>`.

Now write out the final code.

Additional detail: on the first paint (within 3s), the demo should be visibly playing. reset('demo') starts the snake moving right. The AI moves it toward the food. Score increases over time. Good.

Let me also add a subtle animated gradient border to the stage? Keep it simple.

Let me write the full code now.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Snake</title>
<style>
  :root{
    --stage: min(92vw, 66dvh, 640px);
    --ink:#e9f4ff;
  }
  *{box-sizing:border-box}
  html,body{height:100%}
  body{
    margin:0;
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    background:
      radial-gradient(1200px 600px at 50% -10%, #16263f 0%, #0b1220 45%, #060a12 100%);
    color:var(--ink);
    display:flex; align-items:center; justify-content:center;
    overflow:hidden;
    -webkit-font-smoothing:antialiased;
  }
  .app{display:flex; flex-direction:column; align-items:center; gap:12px; padding:14px}
  .hud{width:var(--stage); display:flex; align-items:center; justify-content:space-between; gap:10px}
  .brand{font-size:14px; font-weight:800; letter-spacing:.42em; color:#9df7c4; text-shadow:0 0 22px rgba(80,240,170,.55); padding-left:2px}
  .stats{display:flex; gap:8px}
  .stat{display:flex; flex-direction:column; align-items:flex-end; gap:1px; padding:4px 11px; border-radius:10px; background:rgba(255,255,255,.045); border:1px solid rgba(140,200,255,.12); min-width:72px}
  .stat .k{font-size:9px; letter-spacing:.2em; color:#7d93ad}
  .stat .v{font-size:17px; font-weight:700; font-variant-numeric:tabular-nums; line-height:1.05}
  #bestVal{color:#7ef0ae}

  .stage{position:relative; width:var(--stage); height:var(--stage); border-radius:18px; overflow:hidden; background:#0a1120;
    box-shadow:0 0 0 1px rgba(110,230,180,.16), 0 30px 60px -24px rgba(0,0,0,.95), 0 0 90px -40px rgba(60,230,150,.65)}
  canvas{display:block; width:100%; height:100%}
  ...
</style>
```

Note: `min(92vw, 66dvh, 640px)` — dvh supported. Fine.

Careful: the hud width equals the stage width; on a narrow screen the brand + stats must fit. Brand "SNAKE" at 14px with .42em spacing ≈ 5 chars * ~14*1.4 ≈ 98px. Stats: 2 × ~72 + gap 8 = 152. Total ~250 + gap. Fits in 345. Good.

Now the JS. Let me write it all out fully and carefully.

```js
(() => {
  'use strict';
  const COLS = 24, ROWS = 24, N = COLS * ROWS;
  const DIRS = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];

  const canvas = document.getElementById('game');
  const ctx = canvas.getContext('2d');
  const stage = document.getElementById('stage');
  const scoreEl = document.getElementById('scoreVal');
  const bestEl = document.getElementById('bestVal');
  const overlay = document.getElementById('overlay');
  const ovTitle = document.getElementById('ovTitle');
  const ovScore = document.getElementById('ovScore');
  const ovSub = document.getElementById('ovSub');
  const promptEl = document.getElementById('prompt');
  const badgeEl = document.getElementById('badge');
  const app = document.getElementById('app');

  let W = 1, H = 1, cell = 20, dpr = 1;

  const board = document.createElement('canvas');
  const bctx = board.getContext('2d');

  // ---------- state ----------
  let mode = 'demo';
  let snake = [], prev = [], dir = {x:1,y:0}, queue = [];
  let food = {x:0,y:0}, score = 0, best = 0;
  let stepTime = 130, acc = 0, t = 0;
  let particles = [], pops = [];
  let flash = 0, shock = null, demoDeadT = 0;

  ...
})();
```

I'll write the functions in order.

One more thought on the `t` used for rendering: In the demo/gameover states, t should be defined. Initialize t = 0.

Also for paused, freeze at the current t (don't change it). In update, only update t when playing/demo. When paused, t keeps its last value. Good — no need to force t=1.

For gameover, freeze t as well (it's ~1 already). Actually after die() we set mode='gameover', and the update no longer touches t; it stays where it was (close to 1 since the step just happened). Fine.

Hmm, but the step happens when acc >= stepTime, then acc -= stepTime, so acc is near 0 → t near 0 at the moment of a step. Wait! Order in update:

```js
acc += dt*1000;
while (acc >= stepTime) { acc -= stepTime; step(); }
t = Math.min(1, acc/stepTime);
```

If a step leads to death, acc has already been decremented, so t ≈ 0 at death, and the render would show the head at prev[0] — the old head cell, one cell back. That's actually correct-ish: the snake "was" moving and it stops. But it looks like it jumped back a cell. Hmm.

Better: on death, set t = 1 so it renders at the final snake positions. In die(), set a flag or just set `t = 1`. But update() sets t after step()... Let me restructure:

```js
if (mode === 'playing' || mode === 'demo') {
  acc += dt * 1000;
  let guard = 0;
  while (acc >= stepTime && (mode === 'playing' || mode === 'demo') && guard++ < 4) {
    acc -= stepTime;
    step();
  }
  if (mode === 'playing' || mode === 'demo') t = Math.min(1, acc / stepTime);
  else t = 1;
}
```

Setting t=1 on death renders the snake at its final positions. Since prev and snake differ by one cell, the head snaps forward one cell — but the head is already there visually because t was ~1 before the step... 

Hmm wait. Let me re-examine. At the moment of the step, t was 1 (visually the head is at snake[0]). Then step() updates snake to the new head, so now the visual would be at the new cell if t=1. But if we set t=1 after death and the snake wasn't updated (collision), then t=1 renders at the current snake[0] — which is where it visually was before the step. Correct! No jump. 

And in the normal case where a step occurs and the snake does move, t is reset to ~0, so the head interpolates from prev to snake. Right.

So setting t=1 on death is correct. 

Also, the demoDead state: t stays 1. Good.

Now: `while` loop with a guard so that if a step triggers a reset (e.g., demoDead → reset), we don't loop forever. Since we check the mode in the condition, fine.

Now particles update should happen regardless of mode.

Let me write the whole update:

```js
  function update(dt, now) {
    // particles
    for (let i = particles.length - 1; i >= 0; i--) {
      const p = particles[i];
      p.life -= dt;
      if (p.life <= 0) { particles.splice(i, 1); continue; }
      p.vy += 620 * dt;
      p.vx *= Math.max(0, 1 - 2.5 * dt);
      p.vy *= Math.max(0, 1 - 1.4 * dt);
      p.x += p.vx * dt;
      p.y += p.vy * dt;
    }
    for (let i = pops.length - 1; i >= 0; i--) {
      const p = pops[i];
      p.life -= dt * 1.5;
      if (p.life <= 0) pops.splice(i, 1);
    }
    if (flash > 0) flash = Math.max(0, flash - dt * 2.2);
    if (shock) { shock.life -= dt * 1.8; shock.r += dt * 420; if (shock.life <= 0) shock = null; }

    if (mode === 'demo' || mode === 'playing') {
      acc += dt * 1000;
      let guard = 0;
      while (acc >= stepTime && (mode === 'demo' || mode === 'playing') && guard++ < 5) {
        acc -= stepTime;
        step();
      }
      if (mode === 'demo' || mode === 'playing') t = Math.min(1, acc / stepTime);
      else { t = 1; acc = 0; }
    } else if (mode === 'demoDead') {
      t = 1;
      demoDeadT -= dt;
      if (demoDeadT <= 0) reset('demo');
    }
  }
```

Wait: there's an issue with `dt*1000` and the first frame after reset — acc = 0, fine.

Another: when the tab is backgrounded, dt is clamped to 0.05 → fine.

Now `draw`.

Let me write `lerpColor`:

```js
  const C_HEAD = [110, 246, 168];
  const C_TAIL = [14, 112, 72];
  function mixColor(a, b, u, alpha) {
    const r = Math.round(a[0] + (b[0]-a[0])*u);
    const g = Math.round(a[1] + (b[1]-a[1])*u);
    const bl = Math.round(a[2] + (b[2]-a[2])*u);
    return alpha === undefined ? `rgb(${r},${g},${bl})` : `rgba(${r},${g},${bl},${alpha})`;
  }
```
mixColor(from tail to head): mixColor(C_TAIL, C_HEAD, u).

Per segment color strings created ~50 per frame — fine.

Now the tapered body drawing:

```js
  function drawSnake(now) {
    const n = snake.length;
    const pts = new Array(n);
    for (let i = 0; i < n; i++) {
      const s = snake[i], p = prev[i] || s;
      pts[i] = {
        x: (p.x + (s.x - p.x) * t + 0.5) * cell,
        y: (p.y + (s.y - p.y) * t + 0.5) * cell
      };
    }

    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';

    // glow pass on the whole path
    if (n > 1) {
      ctx.save();
      ctx.beginPath();
      ctx.moveTo(pts[0].x, pts[0].y);
      for (let i = 1; i < n; i++) ctx.lineTo(pts[i].x, pts[i].y);
      ctx.strokeStyle = 'rgba(70,240,160,0.55)';
      ctx.lineWidth = cell * 0.5;
      ctx.shadowColor = 'rgba(60,240,150,0.9)';
      ctx.shadowBlur = cell * 1.1;
      ctx.stroke();
      ctx.restore();

      // dark casing
      ctx.beginPath();
      ctx.moveTo(pts[0].x, pts[0].y);
      for (let i = 1; i < n; i++) ctx.lineTo(pts[i].x, pts[i].y);
      ctx.strokeStyle = 'rgba(5,38,24,0.85)';
      ctx.lineWidth = cell * 0.92;
      ctx.stroke();
    }
```
Hmm, the casing with width 0.92 around a path that might overlap itself... For a snake body that's adjacent to itself, the casing will overlap the neighboring segment and create dark bands between the coils. That may look OK (defines the body) but could look messy. Actually, the dark casing is drawn first, then the body on top. Where the snake lies parallel adjacent, the casing of one part will cover part of the neighboring body? No — the casing is drawn for the whole path first, then the body is drawn for the whole path on top. So the body always wins over the casing. Good, the casing only shows around the outside. 

But there's a subtlety: with width 0.92 and cell spacing of 1, adjacent parallel parts' casings merge into a dark region between them — that's fine and looks like a border.

Then the tapered segments:

```js
      for (let i = n - 1; i > 0; i--) {
        const u = 1 - (i - 1) / Math.max(1, n - 1); // ~0 at tail .. 1 at head
        const w = cell * (0.36 + 0.48 * Math.pow(u, 0.55));
        ctx.strokeStyle = mixColor(C_TAIL, C_HEAD, u);
        ctx.lineWidth = w;
        ctx.beginPath();
        ctx.moveTo(pts[i].x, pts[i].y);
        ctx.lineTo(pts[i-1].x, pts[i-1].y);
        ctx.stroke();
      }
```
Careful with `u` when n is small (n=4): i from 3 to 1; u = 1 - (i-1)/3: i=3 → u=1-2/3=0.333; i=2 → 0.667; i=1 → 1. Hmm, so the tail segment has u=0.333 rather than 0. The tail should be thin. Let's define u by index from the tail: segIndex j = n-1-i (0 at the tail-most segment, n-2 at the head-most). u = j/(n-2) if n>2 else 1. Then the tail segment u=0, head segment u=1. Good.

```js
      const segs = n - 1;
      for (let i = n - 1; i > 0; i--) {
        const j = (n - 1) - i;         // 0 = tail-most segment
        const u = segs > 1 ? j / (segs - 1) : 1;
        ...
      }
```
With n=4: segs=3, i=3 → j=0 → u=0; i=2 → j=1 → u=0.5; i=1 → j=2 → u=1. 

Now the head. Also the "neck": since the head circle covers the top of the first segment, it blends.

Let me now write drawFood, drawParticles, drawPops.

Also add a subtle "eat flash" ring at the food's position — handled by the shock? No, shock is for death. For eating, the burst is enough, plus the pop.

Let me write the code for real now.

Also, I should double-check `ctx.setTransform(dpr,0,0,dpr,0,0)` in resize, and bctx similarly in renderBoard. Note: board.width = canvas.width (a physical pixel size). In renderBoard, set the same transform so drawing in CSS px works.

Now, the vignette in renderBoard: use radial gradient. Good.

Final concern: `dvh` in the CSS `--stage`. In Chrome 108+, dvh is supported. Fine.

Another concern: The instructions mention "The iframe only receives keyboard events after the user clicks it". So I must display "Click or press a key to start". Done with the prompt pill. But note that pressing a key won't work until the user clicks the iframe; the click itself starts the game (pointerdown on document). Good.

Let me write the final code.

```js
document.addEventListener('pointerdown', (e) => {
  if (mode === 'demo' || mode === 'demoDead' || mode === 'gameover') {
    // don't start if clicking something interactive... nothing interactive
    startGame();
  }
});
```
Hmm, but on gameover, an accidental click restarts — that's fine and expected.

Wait, there's a subtlety: when the game is over and the user presses Space, we restart. Also clicking restarts. Fine.

But: if the user clicks during play, nothing should happen. Right.

One more: with pointerdown on the document for the gameover restart, clicking the overlay works since the overlay is a child of the document.

Now writing the final HTML.

Let me think about the HUD IDs: scoreVal, bestVal.

Write it:

```html
<div class="app" id="app">
  <div class="hud">
    <div class="brand">SNAKE</div>
    <div class="stats">
      <div class="stat"><span class="k">SCORE</span><span class="v" id="scoreVal">0</span></div>
      <div class="stat"><span class="k">BEST</span><span class="v" id="bestVal">0</span></div>
    </div>
  </div>
  <div class="stage" id="stage">
    <canvas id="game"></canvas>
    <div class="badge" id="badge">DEMO</div>
    <div class="prompt" id="prompt"><span class="dot"></span>Click or press a key to start</div>
    <div class="overlay" id="overlay">
      <div class="panel">
        <h2 id="ovTitle">GAME OVER</h2>
        <p id="ovScore">Score 0</p>
        <p id="ovSub">Press Space or Enter to restart</p>
      </div>
    </div>
  </div>
  <div class="hint"><b>↑↓←→</b> / <b>WASD</b> move · <b>P</b> pause · <b>Space</b> restart</div>
</div>
```

The hint at the bottom: 375px wide — the text with separators might overflow. Use smaller font (11px) and allow wrapping. `text-align:center; max-width:var(--stage)`. Fine.

Let me make the hint concise: "Arrows / WASD · P pause · Space restart". At 11px monospace, ~40 chars × 6.6px = 264px. Fits in 345. Good.

Now the full JS. Writing it out.

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

  const COLS = 24, ROWS = 24, N = COLS * ROWS;
  const DIRS = [{ x: 1, y: 0 }, { x: -1, y: 0 }, { x: 0, y: 1 }, { x: 0, y: -1 }];
  const C_HEAD = [120, 248, 172];
  const C_TAIL = [16, 110, 72];

  const canvas = document.getElementById('game');
  const ctx = canvas.getContext('2d');
  const stage = document.getElementById('stage');
  const scoreEl = document.getElementById('scoreVal');
  const bestEl = document.getElementById('bestVal');
  const overlay = document.getElementById('overlay');
  const ovTitle = document.getElementById('ovTitle');
  const ovScore = document.getElementById('ovScore');
  const ovSub = document.getElementById('ovSub');
  const promptEl = document.getElementById('prompt');
  const badgeEl = document.getElementById('badge');

  const board = document.createElement('canvas');
  const bctx = board.getContext('2d');

  let W = 300, H = 300, cell = 12.5, dpr = 1;

  // ---------------- state ----------------
  let mode, snake, prev, dir, queue, food, score, best = 0,
      stepTime, acc, t, particles, pops, flash, shock, demoDeadT;

  function resize() {
    const r = stage.getBoundingClientRect();
    if (r.width < 1) return;
    W = r.width; H = r.height;
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    canvas.width = Math.round(W * dpr);
    canvas.height = Math.round(H * dpr);
    cell = W / COLS;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    renderBoard();
  }

  function renderBoard() {
    board.width = canvas.width || 1;
    board.height = canvas.height || 1;
    bctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    bctx.clearRect(0, 0, W, H);
    bctx.fillStyle = '#091120';
    bctx.fillRect(0, 0, W, H);

    // checkerboard
    bctx.fillStyle = 'rgba(255,255,255,0.017)';
    for (let y = 0; y < ROWS; y++) {
      for (let x = 0; x < COLS; x++) {
        if ((x + y) & 1) bctx.fillRect(x * cell, y * cell, cell + 0.5, cell + 0.5);
      }
    }
    // grid
    bctx.strokeStyle = 'rgba(110,200,255,0.055)';
    bctx.lineWidth = 1;
    bctx.beginPath();
    for (let x = 1; x < COLS; x++) { bctx.moveTo(x * cell + .0, 0); bctx.lineTo(x * cell, H); }
    for (let y = 1; y < ROWS; y++) { bctx.moveTo(0, y * cell); bctx.lineTo(W, y * cell); }
    bctx.stroke();

    // vignette
    const g = bctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.15, W / 2, H / 2, Math.min(W, H) * 0.78);
    g.addColorStop(0, 'rgba(40,150,220,0.06)');
    g.addColorStop(1, 'rgba(0,0,0,0.42)');
    bctx.fillStyle = g;
    bctx.fillRect(0, 0, W, H);
  }
```

Wait — an issue: the `cell + 0.5` in the checkerboard to avoid seams. Also `x*cell` may be fractional; fine.

Continue:

```js
  // ---------------- game ----------------
  function reset(newMode) {
    mode = newMode;
    const cx = (COLS >> 1), cy = (ROWS >> 1);
    snake = [];
    for (let i = 0; i < 4; i++) snake.push({ x: cx - i, y: cy });
    prev = snake.map(s => ({ x: s.x, y: s.y }));
    dir = { x: 1, y: 0 };
    queue = [];
    score = 0;
    stepTime = 130;
    acc = 0;
    t = 0;
    particles = [];
    pops = [];
    flash = 0;
    shock = null;
    demoDeadT = 0;
    food = spawnFood();
    scoreEl.textContent = '0';
    syncUI();
  }
```

syncUI sets the overlay/prompt/badge based on mode.

```js
  function syncUI() {
    if (mode === 'gameover') {
      ovTitle.textContent = 'GAME OVER';
      ovTitle.className = 'over';
      ovScore.innerHTML = 'Score <b>' + score + '</b>';
      ovSub.textContent = 'Press Space or Enter to restart';
      overlay.classList.add('show');
      promptEl.classList.remove('show');
      badgeEl.classList.remove('show');
    } else if (mode === 'paused') {
      ovTitle.textContent = 'PAUSED';
      ovTitle.className = 'pause';
      ovScore.innerHTML = 'Score <b>' + score + '</b>';
      ovSub.textContent = 'Press P to resume';
      overlay.classList.add('show');
      promptEl.classList.remove('show');
      badgeEl.classList.remove('show');
    } else if (mode === 'playing') {
      overlay.classList.remove('show');
      promptEl.classList.remove('show');
      badgeEl.classList.remove('show');
    } else {
      overlay.classList.remove('show');
      promptEl.classList.add('show');
      badgeEl.classList.add('show');
    }
  }
```

Now `spawnFood`, `step`, `die`.

```js
  function spawnFood() {
    const empty = [];
    const occ = new Uint8Array(N);
    for (const s of snake) occ[s.y * COLS + s.x] = 1;
    for (let i = 0; i < N; i++) if (!occ[i]) empty.push(i);
    if (!empty.length) return { x: -1, y: -1 };
    const i = empty[(Math.random() * empty.length) | 0];
    return { x: i % COLS, y: (i / COLS) | 0 };
  }
```

Note: `snake` must be defined before spawnFood is called in reset. Yes.

step and die as designed.

Now, the `best` update in step when eating (only when playing).

Now let's write the draw functions.

I also want to double check the `prev` alignment code once more with the actual array construction:

```js
const newSnake = [{x:nx,y:ny}];
for (let i=0;i<snake.length;i++) newSnake.push(snake[i]);
if (!willGrow) newSnake.pop();
const lastOld = snake[snake.length-1];
const newPrev = new Array(newSnake.length);
for (let i=0;i<newSnake.length;i++) {
  const o = snake[i] || lastOld;
  newPrev[i] = {x:o.x, y:o.y};
}
```
Good.

Now writing draw():

```js
  function draw(now) {
    ctx.clearRect(0, 0, W, H);
    if (board.width > 1) ctx.drawImage(board, 0, 0, W, H);

    drawFood(now);
    drawSnake(now);
    drawParticles();
    drawPops();

    if (shock) {
      ctx.save();
      ctx.globalAlpha = Math.max(0, shock.life) * 0.7;
      ctx.strokeStyle = '#ff7c92';
      ctx.lineWidth = Math.max(1, cell * 0.16 * shock.life);
      ctx.beginPath();
      ctx.arc(shock.x, shock.y, shock.r, 0, Math.PI * 2);
      ctx.stroke();
      ctx.restore();
    }
    if (flash > 0) {
      ctx.fillStyle = 'rgba(255,70,100,' + (flash * 0.3).toFixed(3) + ')';
      ctx.fillRect(0, 0, W, H);
    }
  }
```

ctx.drawImage(board, 0, 0, W, H) — since ctx has the dpr transform, drawing at 0,0 with size W,H in CSS px is correct.

drawParticles: 

```js
  function drawParticles() {
    if (!particles.length) return;
    ctx.save();
    for (const p of particles) {
      const a = Math.max(0, p.life / p.max);
      ctx.globalAlpha = a;
      ctx.fillStyle = 'hsl(' + p.hue + ',100%,' + p.light + '%)';
      ctx.beginPath();
      ctx.arc(p.x, p.y, p.size * (0.4 + a * 0.6), 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.restore();
  }
```

drawPops with text.

OK, writing everything out now. Let me be careful about ordering: define all functions, then at the bottom: `resize(); reset('demo'); requestAnimationFrame(loop);` plus the ResizeObserver and event listeners.

Also the initial call to resize() needs the layout to be complete. Since the script is at the end of the body, the layout should be available. But the stage's size depends on dvh — should be fine.

Potential issue: the canvas CSS is `width:100%; height:100%` inside a stage with a fixed size, so the initial layout is fine.

Let me also add: on window resize, call resize() (in addition to ResizeObserver). Actually the ResizeObserver on the stage handles most cases; add a window resize listener for dpr changes.

Write the loop:

```js
  let lastT = performance.now();
  function loop(now) {
    const dt = Math.min(0.05, Math.max(0, (now - lastT) / 1000));
    lastT = now;
    update(dt, now);
    draw(now);
    requestAnimationFrame(loop);
  }
```

Note: the first frame's dt might be huge if `now` is a big timestamp; we clamp to 0.05.

`performance.now()` and the rAF timestamp share the same origin. Good.

Let me write out the final code carefully. Also add the `hint` styles.

One more consideration for the overlay panel's `pointer-events`. The overlay has pointer-events none by default; when shown we want clicks to restart — the document-level pointerdown handles it regardless of pointer-events. OK, keep pointer-events: none so it never blocks anything.

Also, I should make sure that pressing keys doesn't scroll: preventDefault for arrows/space. Done in onKey.

Let me write onKey fully:

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

  function onKey(e) {
    const k = e.key;
    const lk = k.length === 1 ? k.toLowerCase() : k.toLowerCase();
    const isDir = !!KEYMAP[lk];
    if (isDir || k === ' ' || k === 'Enter' || lk === 'p') e.preventDefault();
    if (e.repeat) return;   // hmm? 
    ...
  }
```
Should repeat be ignored? If you hold a key, the repeat would push the same direction repeatedly — the dedupe handles it. But holding a key and then pressing another... The queue dedupe compares against the last queued. Holding is fine. But ignoring repeats is cleaner. Actually let's not ignore repeats for direction keys — no, ignore them, it doesn't matter. Actually `e.repeat` skipping is fine; OS key repeat isn't needed since we only need transitions. Let's skip repeats.

Hmm but careful: `if (e.repeat) return;` before preventDefault... let's preventDefault first, then if repeat return.

Then:
```js
    if (mode === 'demo' || mode === 'demoDead') {
      startGame();
      if (isDir) queueDir(KEYMAP[lk]);  // optional
      return;
    }
```
Hmm, starting and immediately queueing the pressed direction — that feels responsive. But if the user presses 'W' to start, the snake immediately goes up instead of right. That's fine and responsive. But maybe surprising. I'll not queue; just start.

Actually, let's queue it — it makes the controls feel connected. Hmm. If the user presses Space to start... Space isn't a direction. OK, I'll queue direction keys on start. Actually no: I'll keep it simple and just start. Risk: none.

Let me just start.

```js
    if (mode === 'playing') {
      if (isDir) { queueDir(KEYMAP[lk]); return; }
      if (lk === 'p') { mode = 'paused'; syncUI(); return; }
    } else if (mode === 'paused') {
      if (lk === 'p' || k === ' ' || k === 'Enter') { mode = 'playing'; syncUI(); return; }
    } else if (mode === 'gameover') {
      if (k === ' ' || k === 'Enter' || lk === 'p') { startGame(); return; }
    }
```

Hmm, 'p' restarting from gameover is odd. Just Space/Enter.

Note: when resuming from pause, `acc` continues from where it was, so it resumes smoothly. Good.

queueDir:

```js
  function queueDir(d) {
    const lastD = queue.length ? queue[queue.length - 1] : dir;
    if (d.x === lastD.x && d.y === lastD.y) return;
    if (d.x === -lastD.x && d.y === -lastD.y) return;
    if (queue.length >= 3) return;
    queue.push(d);
  }
```

Note `dir` may be stale if... no, it's the direction of the last executed step. Correct.

Now `startGame`:
```js
  function startGame() {
    reset('playing');
  }
```
Since `reset` calls syncUI which handles 'playing'.

Let me now also confirm: in demo mode, `syncUI` shows the prompt. On start, it hides.

Complete. Also, `reset('demo')` at init sets scoreEl to 0.

Now, the best score: updated in step (when playing) and in die (when playing). Also reset? Never. Good.

I think we're ready. Let me also double check the AI handles the case where `snake.length - 1` is 0 (impossible, min length 4).

One more: in the AI, `dir` is the current direction; we exclude the reverse. But the snake also cannot reverse onto itself — the standard rule. Fine.

Let me write the final code.

Also, one detail: `demoDead` mode should still show the prompt (the else branch in syncUI handles it). But syncUI isn't called when entering demoDead. Let me call syncUI in die() for the demo case too — it does nothing visible since the prompt is already shown. I'll call syncUI() at the end of die() in all cases.

Writing out now.

For the head drawing, I use `dir` for the eye direction. When the snake is dead... doesn't matter.

Also, add a subtle head "bob"? No.

Final check of the CSS for the `.prompt` and `.badge`:

```css
.badge {
  position: absolute; top: 10px; left: 10px;
  padding: 4px 9px; border-radius: 7px;
  font-size: 9px; letter-spacing: .22em; font-weight: 700;
  color: #9ff5c4; background: rgba(20,60,45,.55);
  border: 1px solid rgba(110,240,180,.3);
  opacity: 0; transition: opacity .2s;
  pointer-events: none;
}
.badge.show { opacity: 1; }

.prompt {
  position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%);
  display: flex; align-items: center; gap: 8px;
  padding: 8px 15px; border-radius: 999px;
  font-size: 12.5px; letter-spacing: .03em; white-space: nowrap;
  color: #d6ffe8; background: rgba(6,14,24,.8);
  border: 1px solid rgba(110,240,180,.32);
  box-shadow: 0 0 30px rgba(40,220,140,.18), inset 0 0 18px rgba(40,220,140,.08);
  opacity: 0; transition: opacity .25s; pointer-events: none;
  animation: breathe 2.4s ease-in-out infinite;
}
.prompt.show { opacity: 1; }
.prompt .dot { width: 7px; height: 7px; border-radius: 50%; background: #6dfab0; box-shadow: 0 0 10px #6dfab0; animation: blink 1.2s ease-in-out infinite; }
@keyframes breathe { 0%,100% { transform: translateX(-50%) translateY(0);} 50% { transform: translateX(-50%) translateY(-3px);} }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.35} }
```

Careful: the `breathe` animation uses transform which overrides translateX(-50%) — I included it in the keyframes. Good.

The panel:

```css
.overlay {
  position: absolute; inset: 0; display: grid; place-items: center;
  background: radial-gradient(circle at 50% 45%, rgba(8,16,28,.55), rgba(4,8,14,.86));
  backdrop-filter: blur(2px);
  opacity: 0; transition: opacity .25s; pointer-events: none;
}
.overlay.show { opacity: 1; }
.panel { text-align: center; padding: 20px 26px; border-radius: 16px;
  background: rgba(10,18,30,.9); border: 1px solid rgba(130,240,190,.2);
  box-shadow: 0 20px 50px -20px #000, 0 0 60px -30px rgba(60,240,150,.8); }
.panel h2 { margin: 0 0 10px; font-size: 22px; letter-spacing: .2em; }
.panel h2.over { color: #ff8095; text-shadow: 0 0 22px rgba(255,80,110,.6); }
.panel h2.pause { color: #8ef3bd; text-shadow: 0 0 22px rgba(80,240,160,.5); }
.panel p { margin: 5px 0; font-size: 13px; color: #a8bdd4; letter-spacing: .04em; }
.panel p b { color: #eaf6ff; font-size: 17px; }
```

On 375px width, the panel with 26px padding and a 22px-letter-spaced title fits? "GAME OVER" at 22px with 0.2em spacing ≈ 9 chars × 26 ≈ 234 + 52 padding = 286 < 345. OK.

Now, the hint at the bottom:

```css
.hint { font-size: 11px; color: #6d8299; letter-spacing: .08em; text-align: center; }
.hint b { color: #9fb5cb; font-weight: 600; }
```

Alright. Writing the final version now.

One thing I want to verify: `ctx.lineCap`/`lineJoin` set once in drawSnake and not reset — fine since other draws don't rely on them.

Also `ctx.textAlign` set in drawPops persists — no other text drawn. Fine.

Let me assemble the whole thing.

Also — potential problem: the `while (acc >= stepTime ...)` loop with guard 5 — after death the mode changes and we exit. Good.

Another: after `reset`, `acc=0` and t=0. Good.

Final: the `update` function signature uses `now` but doesn't need it. Fine.

Writing.

Also, in drawFood I need `ctx.save()`/`restore()` around the shadow settings.

Let me write the food:

```js
  function drawFood(now) {
    if (food.x < 0) return;
    const cx = (food.x + 0.5) * cell;
    const cy = (food.y + 0.5) * cell;
    const pulse = 1 + Math.sin(now * 0.006) * 0.08;
    const r = cell * 0.3 * pulse;

    ctx.save();
    // expanding ring
    const ph = (now * 0.0014) % 1;
    ctx.globalAlpha = 0.5 * (1 - ph);
    ctx.strokeStyle = '#ff6b83';
    ctx.lineWidth = cell * 0.09;
    ctx.beginPath();
    ctx.arc(cx, cy, r + ph * cell * 0.55, 0, Math.PI * 2);
    ctx.stroke();
    ctx.restore();

    ctx.save();
    ctx.shadowColor = 'rgba(255,70,100,0.85)';
    ctx.shadowBlur = cell * 1.2;
    const g = ctx.createRadialGradient(cx - r * 0.4, cy - r * 0.45, r * 0.1, cx, cy, r * 1.1);
    g.addColorStop(0, '#ffe0d2');
    g.addColorStop(0.35, '#ff7f8f');
    g.addColorStop(1, '#c31d3e');
    ctx.fillStyle = g;
    ctx.beginPath();
    ctx.arc(cx, cy, r, 0, Math.PI * 2);
    ctx.fill();
    ctx.restore();

    // leaf
    ctx.save();
    ctx.translate(cx + r * 0.25, cy - r * 1.02);
    ctx.rotate(-0.5);
    ctx.fillStyle = '#5ee08c';
    ctx.beginPath();
    ctx.ellipse(0, 0, r * 0.52, r * 0.26, 0, 0, Math.PI * 2);
    ctx.fill();
    ctx.restore();
  }
```

The leaf position when the food is in the top row might clip. Fine.

Now particles' hue/light: `hue: 8 + Math.random()*40`, `light: 55 + Math.random()*25`.

Now the code for burstAt:

```js
  function burst(x, y) {   // grid coords
    const px = (x + 0.5) * cell, py = (y + 0.5) * cell;
    for (let i = 0; i < 20; i++) {
      const a = Math.random() * Math.PI * 2;
      const sp = (0.8 + Math.random() * 3.2) * cell * 3;
      const life = 0.35 + Math.random() * 0.45;
      particles.push({
        x: px, y: py,
        vx: Math.cos(a) * sp,
        vy: Math.sin(a) * sp - cell * 2,
        life, max: life,
        size: cell * (0.08 + Math.random() * 0.16),
        hue: 8 + Math.random() * 42,
        light: 58 + Math.random() * 24
      });
    }
  }
```
Speed scaled by cell so it looks consistent at any size. With cell ≈ 22px: sp = (0.8..4)*66 = 53..264 px/s. Good.

Death burst uses a different color. I'll add a `hueBase` param.

```js
  function burst(x, y, count, hueBase) {...}
```
For the food: hueBase 8. For death: hueBase 340 (pink/red).

OK, writing it all now for real.

Let me write the complete file.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Snake</title>
<style> ... </style>
</head>
<body>
<div class="app">
  ...
</div>
<script> ... </script>
</body>
</html>
```

I'll finalize now. Let me write out the script in full, checking each function.

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

  // ---------- constants ----------
  const COLS = 24, ROWS = 24, N = COLS * ROWS;
  const DIRS = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
  const C_HEAD = [122, 248, 174];
  const C_TAIL = [15, 108, 70];

  // ---------- dom ----------
  const canvas = document.getElementById('game');
  const ctx = canvas.getContext('2d');
  const stage = document.getElementById('stage');
  const scoreEl = document.getElementById('scoreVal');
  const bestEl = document.getElementById('bestVal');
  const overlay = document.getElementById('overlay');
  const ovTitle = document.getElementById('ovTitle');
  const ovScore = document.getElementById('ovScore');
  const ovSub = document.getElementById('ovSub');
  const promptEl = document.getElementById('prompt');
  const badgeEl = document.getElementById('badge');

  const board = document.createElement('canvas');
  const bctx = board.getContext('2d');

  let W = 320, H = 320, cell = 320 / COLS, dpr = 1;

  // ---------- state ----------
  let mode = 'demo';
  let snake = [], prev = [], dir = {x:1,y:0}, queue = [];
  let food = {x:0,y:0};
  let score = 0, best = 0;
  let stepTime = 130, acc = 0, t = 0;
  let particles = [], pops = [];
  let flash = 0, shock = null, demoDeadT = 0;
  ...
```

Alright. I'm fairly confident. Let me write it out in the final answer.

One more check on `resize()` being called from the ResizeObserver before `renderBoard` uses `cell`. Order inside resize: set cell, transform, then renderBoard. Good.

But `renderBoard` is also called directly by the observer? No — only from resize.

I need to make sure `board.width` is set correctly before the first draw. resize() → renderBoard(). Good.

For safety in draw: `if (board.width > 1) ctx.drawImage(...)`.

Let's write everything.

Also: about `dpr` and `board` — `board.width = canvas.width` is a physical size; bctx transform dpr means drawing in CSS px. Good.

Final answer below.