Models 44

GLM 5.3

by Z.aiReasoningClean
Open in new window

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

How it went

Total time
4 min 9 s
Cost
$0.149
Output tokens
49,573
Spent thinking
40,122 tokens
Speed
199.8 tok/s
Console errors
0
First token after
830 ms
First visible text after
3 min 49 s
Prompt tokens
491
Code size
27.6 KB
Finish reason
stop
Served by
CoreWeave
Model id
z-ai/glm-5.3
Reasoning sent
{"effort":"max"}
Max output
943,718 tokens
Generated
Nov 12, 2026

What it was asked

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

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

Code run-1.html · 658 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Snake · Canvas Edition</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><rect width='24' height='24' rx='6' fill='%239ef25c'/><circle cx='9' cy='10' r='3' fill='%23fff'/><circle cx='15' cy='10' r='3' fill='%23fff'/></svg>">
<style>
  :root{
    --bg:#070b14;
    --line:rgba(130,170,255,.14);
    --text:#e8eefc;
    --muted:#8b98b8;
    --lime:#9ef25c;
    --amber:#ffc95c;
    --coral:#ff7a70;
    --mono:ui-monospace,'SF Mono','Cascadia Mono',Menlo,Consolas,monospace;
  }
  *{box-sizing:border-box;margin:0;padding:0}
  html,body{height:100%}
  body{
    background:
      radial-gradient(1100px 640px at 50% -12%,#13203c 0%,rgba(19,32,60,0) 58%),
      radial-gradient(820px 520px at 88% 112%,rgba(26,74,58,.38) 0%,rgba(0,0,0,0) 55%),
      var(--bg);
    color:var(--text);
    font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;
    height:100vh;height:100dvh;
    display:flex;align-items:center;justify-content:center;
    overflow:hidden;padding:14px;
    user-select:none;-webkit-user-select:none;
    touch-action:manipulation;
  }
  .stage{display:flex;flex-direction:column;gap:clamp(10px,1.9vh,18px);width:600px}

  /* ---------- header ---------- */
  header{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}
  .brand{display:flex;align-items:center;gap:12px}
  .mark{width:36px;height:36px;flex:0 0 auto;border-radius:12px;position:relative;
    background:linear-gradient(150deg,#d6ff9a,#43b56e);
    box-shadow:0 8px 22px rgba(110,210,120,.30),inset 0 1px 0 rgba(255,255,255,.45)}
  .mark::before,.mark::after{content:'';position:absolute;top:10px;width:7px;height:7px;border-radius:50%;
    background:radial-gradient(circle at 62% 62%,#fff 0 54%,#14243c 62% 100%)}
  .mark::before{left:8px}.mark::after{left:19px}
  .titles h1{font-size:clamp(19px,3vw,25px);font-weight:800;letter-spacing:.32em;color:#f2f6ff;line-height:1}
  .tag{display:block;margin-top:4px;font-size:9.5px;font-weight:600;letter-spacing:.34em;text-transform:uppercase;color:var(--muted)}
  .scores{display:flex;gap:8px;margin-left:auto}
  .pill{display:flex;align-items:baseline;justify-content:space-between;gap:10px;min-width:104px;
    padding:8px 14px;border-radius:12px;background:rgba(255,255,255,.045);border:1px solid var(--line)}
  .pill .lbl{font-size:9.5px;font-weight:700;letter-spacing:.22em;text-transform:uppercase;color:var(--muted)}
  .pill .val{display:inline-block;font-family:var(--mono);font-size:19px;font-weight:700;color:var(--lime);font-variant-numeric:tabular-nums}
  .pill.best .val{color:var(--amber)}
  .val.pop{animation:pop .38s cubic-bezier(.2,.9,.3,1.45)}
  @keyframes pop{35%{transform:scale(1.5) rotate(-4deg);color:#fff}}

  /* ---------- board ---------- */
  .board-wrap{position:relative;border-radius:16px;overflow:hidden;
    box-shadow:0 0 0 1px rgba(130,170,255,.13),0 30px 80px rgba(0,0,0,.55),0 0 90px rgba(110,210,120,.07)}
  body.demo-mode .board-wrap{cursor:pointer}
  canvas{display:block}

  /* ---------- overlays ---------- */
  .overlay{position:absolute;inset:0;z-index:5;display:flex;align-items:center;justify-content:center;
    opacity:0;visibility:hidden;transition:opacity .28s ease,visibility .28s}
  .overlay.show{opacity:1;visibility:visible}
  .overlay.demo{pointer-events:none;justify-content:flex-end;padding-bottom:6.5%;
    background:radial-gradient(130% 95% at 50% 38%,rgba(5,9,18,0) 34%,rgba(5,9,18,.62) 100%)}
  .demo-chip{position:absolute;top:12px;left:12px;display:flex;align-items:center;gap:8px;
    padding:6px 12px;border-radius:999px;font-size:10.5px;font-weight:800;letter-spacing:.24em;color:#c9edb0;
    background:rgba(9,15,28,.62);border:1px solid rgba(158,242,92,.28);backdrop-filter:blur(5px)}
  .demo-chip .dot{width:7px;height:7px;border-radius:50%;background:var(--lime);box-shadow:0 0 8px var(--lime);animation:blink 1.15s infinite}
  @keyframes blink{50%{opacity:.2}}
  .prompt{text-align:center;padding:13px 24px;border-radius:16px;max-width:min(92%,360px);
    background:rgba(8,13,26,.74);border:1px solid rgba(130,170,255,.17);
    box-shadow:0 14px 44px rgba(0,0,0,.5);backdrop-filter:blur(6px);
    animation:bob 2.6s ease-in-out infinite}
  @keyframes bob{50%{transform:translateY(-5px)}}
  .prompt-line{font-size:clamp(14px,2.3vw,18px);font-weight:700;color:#f0f5ff}
  .prompt-sub{margin-top:5px;font-size:11.5px;color:var(--muted)}
  .overlay.veil{background:rgba(4,8,16,.58);backdrop-filter:blur(3px)}
  .panel{padding:24px 38px 26px;border-radius:20px;text-align:center;
    background:rgba(11,17,32,.88);border:1px solid rgba(130,170,255,.18);
    box-shadow:0 26px 70px rgba(0,0,0,.6);
    transform:translateY(12px) scale(.94);transition:transform .32s cubic-bezier(.2,.9,.3,1.35)}
  .overlay.show .panel{transform:none}
  .ov-big{font-size:clamp(21px,3.6vw,27px);font-weight:800;letter-spacing:.3em;margin-right:-.3em}
  .ov-big.lime{color:var(--lime);text-shadow:0 0 26px rgba(158,242,92,.35)}
  .ov-big.coral{color:var(--coral);text-shadow:0 0 26px rgba(255,122,112,.35)}
  .ov-stats{display:flex;justify-content:center;margin:16px 0 4px}
  .stat{padding:0 24px}
  .stat+.stat{border-left:1px solid rgba(130,170,255,.16)}
  .stat span{display:block;font-size:9.5px;font-weight:700;letter-spacing:.26em;text-transform:uppercase;color:var(--muted);margin-bottom:3px}
  .stat b{font-family:var(--mono);font-size:28px;font-weight:700}
  .lime{color:var(--lime)}.amber{color:var(--amber)}
  .newbest{display:none;margin:8px 0 2px;padding:5px 13px;border-radius:999px;font-size:10.5px;font-weight:800;letter-spacing:.2em;
    color:#0b1526;background:linear-gradient(90deg,#d6ff9a,#9ef25c);box-shadow:0 4px 18px rgba(158,242,92,.4);
    animation:tag .9s ease-in-out infinite}
  .newbest.on{display:inline-block}
  @keyframes tag{50%{transform:scale(1.07)}}
  .ov-sub{margin-top:10px;font-size:12px;color:var(--muted)}
  #btnRestart{margin-top:14px;font:inherit;font-size:13px;font-weight:800;letter-spacing:.06em;color:#0b1526;
    padding:10px 24px;border:0;border-radius:999px;cursor:pointer;
    background:linear-gradient(180deg,#d6ff9a,#8fe455);box-shadow:0 8px 24px rgba(158,242,92,.28);
    transition:transform .14s ease,box-shadow .14s ease}
  #btnRestart:hover{transform:translateY(-1px);box-shadow:0 10px 28px rgba(158,242,92,.4)}
  #btnRestart:active{transform:translateY(1px)}

  /* ---------- footer ---------- */
  footer{display:flex;justify-content:center;align-items:center;gap:16px;flex-wrap:wrap;font-size:11.5px;color:var(--muted)}
  .hint{display:flex;align-items:center;gap:5px}
  .hint em{font-style:normal;margin:0 2px;color:#5b6884}
  kbd{font-family:var(--mono);font-size:10px;font-weight:600;color:#dbe4f5;min-width:21px;text-align:center;
    display:inline-block;padding:2px 6px;border-radius:6px;background:rgba(255,255,255,.06);
    border:1px solid rgba(130,170,255,.22);border-bottom:2px solid rgba(130,170,255,.28)}

  @media (max-width:480px){
    .tag{display:none}
    .mark{width:30px;height:30px;border-radius:10px}
    .mark::before,.mark::after{top:8px;width:6px;height:6px}
    .mark::after{left:16px}
    .pill{min-width:0;padding:6px 11px}
    .pill .val{font-size:16px}
    footer{gap:10px;font-size:10.5px}
    .stat{padding:0 16px}
    .stat b{font-size:23px}
    .panel{padding:20px 22px 22px}
  }
</style>
</head>
<body class="demo-mode">
<main class="stage" id="stage">
  <header>
    <div class="brand">
      <span class="mark" aria-hidden="true"></span>
      <div class="titles">
        <h1>SNAKE</h1>
        <span class="tag">canvas edition</span>
      </div>
    </div>
    <div class="scores">
      <div class="pill"><span class="lbl">Score</span><span class="val" id="scoreVal">0</span></div>
      <div class="pill best"><span class="lbl">Best</span><span class="val" id="bestVal">0</span></div>
    </div>
  </header>

  <div class="board-wrap">
    <canvas id="game"></canvas>

    <div class="overlay demo show" id="ovDemo">
      <div class="demo-chip"><span class="dot"></span>AI&nbsp;DEMO</div>
      <div class="prompt">
        <div class="prompt-line">Click or press a key to start</div>
        <div class="prompt-sub">Arrows / WASD to steer &middot; P pauses &middot; Space restarts</div>
      </div>
    </div>

    <div class="overlay veil" id="ovPause">
      <div class="panel">
        <div class="ov-big lime">PAUSED</div>
        <div class="ov-sub">press <kbd>P</kbd> to resume</div>
      </div>
    </div>

    <div class="overlay veil" id="ovOver">
      <div class="panel">
        <div class="ov-big coral">GAME OVER</div>
        <div class="ov-stats">
          <div class="stat"><span>Score</span><b class="lime" id="ovScoreNum">0</b></div>
          <div class="stat"><span>Best</span><b class="amber" id="ovBestNum">0</b></div>
        </div>
        <div class="newbest" id="ovNewBest">NEW BEST!</div>
        <div class="ov-sub">press <kbd>Space</kbd> or <kbd>Enter</kbd> to restart</div>
        <button id="btnRestart" type="button">Play again</button>
      </div>
    </div>
  </div>

  <footer>
    <span class="hint"><kbd>&uarr;</kbd><kbd>&larr;</kbd><kbd>&darr;</kbd><kbd>&rarr;</kbd><em>/</em><kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> move</span>
    <span class="hint"><kbd>P</kbd> pause</span>
    <span class="hint"><kbd>Space</kbd> restart</span>
  </footer>
</main>

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

/* ============================ constants ============================ */
const COLS = 24, ROWS = 24;
const BASE_TICK = 132, MIN_TICK = 82, TICK_STEP = 1.7;
const DIRS = { up:{x:0,y:-1}, down:{x:0,y:1}, left:{x:-1,y:0}, right:{x:1,y:0} };

/* ============================ dom refs ============================ */
const canvas   = document.getElementById('game');
const ctx      = canvas.getContext('2d');
const stage    = document.getElementById('stage');
const headerEl = document.querySelector('header');
const footerEl = document.querySelector('footer');
const scoreVal = document.getElementById('scoreVal');
const bestVal  = document.getElementById('bestVal');
const ovDemo     = document.getElementById('ovDemo');
const ovPause    = document.getElementById('ovPause');
const ovOver     = document.getElementById('ovOver');
const ovScoreNum = document.getElementById('ovScoreNum');
const ovBestNum  = document.getElementById('ovBestNum');
const ovNewBest  = document.getElementById('ovNewBest');
const btnRestart = document.getElementById('btnRestart');

/* ============================ state ============================ */
let cell = 22, dpr = 1, board = null;
let mode = 'demo';                 // 'demo' (AI plays) | 'play'
let state = 'running';             // 'running' | 'paused' | 'dying' | 'over'
let snake = [], prevSnake = [], dir = DIRS.right, queue = [];
let food = null, foodBorn = 0;
let score = 0, best = 0, newBest = false;
let tickMs = BASE_TICK, acc = 0, lastT = 0, now = 0, dieAt = 0, shake = 0, headPulse = 0;
let particles = [];

/* ============================ layout ============================ */
function resize(pass = 0) {
  dpr = Math.min(window.devicePixelRatio || 1, 2);
  const headH = headerEl.offsetHeight || 64;
  const footH = footerEl.offsetHeight || 34;
  const availW = Math.min(window.innerWidth - 36, 760);
  const availH = window.innerHeight - headH - footH - 52;
  cell = Math.max(9, Math.min(34, Math.floor(Math.min(availW, availH) / COLS)));
  const px = cell * COLS;
  canvas.style.width = canvas.style.height = px + 'px';
  canvas.width = canvas.height = Math.round(px * dpr);
  stage.style.width = px + 'px';
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  buildBoard(px);
  if (pass < 3 && (headerEl.offsetHeight !== headH || footerEl.offsetHeight !== footH)) resize(pass + 1);
}

function buildBoard(px) {
  board = document.createElement('canvas');
  board.width = board.height = Math.max(2, Math.round(px * dpr));
  const b = board.getContext('2d');
  b.setTransform(dpr, 0, 0, dpr, 0, 0);
  const g = b.createLinearGradient(0, 0, px, px);
  g.addColorStop(0, '#111d34'); g.addColorStop(1, '#0a1220');
  b.fillStyle = g; b.fillRect(0, 0, px, px);
  b.fillStyle = 'rgba(150,185,255,0.03)';
  for (let y = 0; y < ROWS; y++)
    for (let x = 0; x < COLS; x++)
      if (((x + y) & 1) === 0) b.fillRect(x * cell, y * cell, cell, cell);
  const v = b.createRadialGradient(px / 2, px / 2, px * 0.22, px / 2, px / 2, px * 0.74);
  v.addColorStop(0, 'rgba(120,190,255,0.05)');
  v.addColorStop(1, 'rgba(0,0,0,0.24)');
  b.fillStyle = v; b.fillRect(0, 0, px, px);
}

/* ============================ game core ============================ */
function resetGame() {
  const cx = COLS >> 1, cy = ROWS >> 1;
  snake = [];
  for (let i = 0; i < 4; i++) snake.push({ x: cx - i, y: cy });
  prevSnake = snake.map(s => ({ x: s.x, y: s.y }));
  dir = DIRS.right; queue = [];
  score = 0; newBest = false;
  tickMs = BASE_TICK; acc = 0;
  particles = []; shake = 0; headPulse = 0;
  state = 'running';
  spawnFood();
  updateScoreUI();
}

function spawnFood() {
  const occ = new Set(snake.map(s => s.x + '_' + s.y));
  const free = [];
  for (let y = 0; y < ROWS; y++)
    for (let x = 0; x < COLS; x++)
      if (!occ.has(x + '_' + y)) free.push({ x, y });
  food = free.length ? free[(Math.random() * free.length) | 0] : null;
  foodBorn = now;
}

/* queued input: rapid presses are all honored, reversals are impossible */
function enqueueDir(d) {
  const last = queue.length ? queue[queue.length - 1] : dir;
  if ((d.x === last.x && d.y === last.y) || (d.x === -last.x && d.y === -last.y)) return;
  if (queue.length < 3) queue.push(d);
}

function step() {
  if (mode === 'demo') dir = aiDir();
  else if (queue.length) dir = queue.shift();

  const h = snake[0], nx = h.x + dir.x, ny = h.y + dir.y;
  if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) return die();

  const eating = food && nx === food.x && ny === food.y;
  const len = eating ? snake.length : snake.length - 1;   // tail vacates unless growing
  for (let i = 0; i < len; i++) { const s = snake[i]; if (s.x === nx && s.y === ny) return die(); }

  prevSnake = snake.map(s => ({ x: s.x, y: s.y }));
  snake.unshift({ x: nx, y: ny });

  if (eating) {
    score += 10;
    if (mode === 'play' && score > best) { best = score; newBest = true; }
    updateScoreUI(true);
    headPulse = 1;
    burstAt(food);
    tickMs = Math.max(MIN_TICK, BASE_TICK - (snake.length - 4) * TICK_STEP);
    spawnFood();
  } else {
    snake.pop();
  }
}

function die() {
  state = 'dying'; dieAt = now; shake = 1;
}

function finishDeath() {
  if (mode === 'demo') { resetGame(); return; }
  state = 'over';
  ovScoreNum.textContent = score;
  ovBestNum.textContent = best;
  ovNewBest.classList.toggle('on', newBest && score > 0);
  show(ovOver, true);
}

/* ============================ ui helpers ============================ */
function show(el, on) { el.classList.toggle('show', on); }

function updateScoreUI(pop) {
  scoreVal.textContent = score;
  bestVal.textContent = best;
  if (pop) { scoreVal.classList.remove('pop'); void scoreVal.offsetWidth; scoreVal.classList.add('pop'); }
}

function startPlay(firstDir) {
  if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
  mode = 'play';
  document.body.classList.remove('demo-mode');
  show(ovDemo, false); show(ovOver, false); show(ovPause, false);
  resetGame();
  if (firstDir) enqueueDir(firstDir);
}

function togglePause() {
  if (state === 'running') { state = 'paused'; show(ovPause, true); }
  else if (state === 'paused') { state = 'running'; show(ovPause, false); acc = 0; }
}

/* ============================ demo AI ============================ */
function aiDir() {
  const h = snake[0];
  const cand = [DIRS.up, DIRS.down, DIRS.left, DIRS.right]
    .filter(d => !(d.x === -dir.x && d.y === -dir.y));
  const blocked = new Set(snake.slice(0, snake.length - 1).map(k => k.x + '_' + k.y));
  let bestD = null, bestS = -1e9;
  for (const d of cand) {
    const nx = h.x + d.x, ny = h.y + d.y;
    if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
    if (blocked.has(nx + '_' + ny)) continue;
    const space = flood(nx, ny, blocked);
    const dist = food ? Math.abs(nx - food.x) + Math.abs(ny - food.y) : 0;
    let s = -dist * 2 + Math.min(space, snake.length + 6) * 0.6;
    if (space < snake.length) s -= 600;     // avoid cramped dead-ends
    if (d === dir) s += 0.5;                // slight preference for going straight
    if (s > bestS) { bestS = s; bestD = d; }
  }
  return bestD || dir;
}

function flood(sx, sy, blocked) {
  const seen = new Uint8Array(COLS * ROWS);
  const st = [sy * COLS + sx]; seen[st[0]] = 1;
  let n = 0; const cap = snake.length + 6;
  while (st.length) {
    const i = st.pop(); n++;
    if (n >= cap) return n;
    const x = i % COLS, y = (i - x) / COLS;
    if (x > 0        && !seen[i - 1]    && !blocked.has((x - 1) + '_' + y)) { seen[i - 1] = 1;    st.push(i - 1); }
    if (x < COLS - 1 && !seen[i + 1]    && !blocked.has((x + 1) + '_' + y)) { seen[i + 1] = 1;    st.push(i + 1); }
    if (y > 0        && !seen[i - COLS] && !blocked.has(x + '_' + (y - 1))) { seen[i - COLS] = 1; st.push(i - COLS); }
    if (y < ROWS - 1 && !seen[i + COLS] && !blocked.has(x + '_' + (y + 1))) { seen[i + COLS] = 1; st.push(i + COLS); }
  }
  return n;
}

/* ============================ particles ============================ */
function burstAt(f) {
  const px = (f.x + .5) * cell, py = (f.y + .5) * cell;
  const colors = ['#ffd23f', '#ff9a3d', '#ff5b66', '#fff2b0', '#9ef25c'];
  for (let i = 0; i < 18; i++) {
    const a = Math.random() * Math.PI * 2, sp = cell * (1.6 + Math.random() * 4.4);
    particles.push({ t: 'dot', x: px, y: py, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - cell * 1.4,
      r: cell * (.05 + Math.random() * .09), life: 1, decay: 1 / (.4 + Math.random() * .4),
      col: colors[(Math.random() * colors.length) | 0] });
  }
  particles.push({ t: 'ring', x: px, y: py, r: cell * .25, life: 1, decay: 1 / .4 });
  particles.push({ t: 'txt', x: px, y: py - cell * .25, vy: -cell * 1.5, life: 1, decay: 1 / .75, str: '+10' });
}

function updateParticles(dt) {
  for (let i = particles.length - 1; i >= 0; i--) {
    const p = particles[i];
    p.life -= p.decay * dt;
    if (p.life <= 0) { particles.splice(i, 1); continue; }
    if (p.t === 'dot') {
      p.x += p.vx * dt; p.y += p.vy * dt;
      p.vx *= Math.max(0, 1 - 3 * dt);
      p.vy = p.vy * Math.max(0, 1 - 1.4 * dt) + cell * 10 * dt;
    } else if (p.t === 'ring') { p.r += cell * 3.4 * dt; }
    else { p.y += p.vy * dt; }
  }
}

/* ============================ drawing ============================ */
function bodyColor(k, dead) {
  if (dead) return `hsl(${10 + 8 * k},68%,${56 - 24 * k}%)`;
  return `hsl(${95 + 70 * k},${72 - 18 * k}%,${60 - 24 * k}%)`;
}

function render() {
  const px = cell * COLS;
  ctx.save();
  ctx.clearRect(0, 0, px, px);
  const pad = shake > 0 ? 8 : 0;
  if (shake > 0) {
    const s = shake * shake * cell * .5;
    ctx.translate((Math.random() * 2 - 1) * s, (Math.random() * 2 - 1) * s);
  }
  if (board) ctx.drawImage(board, -pad, -pad, px + pad * 2, px + pad * 2);
  if (food) drawFood();
  drawSnake();
  drawParticles();
  ctx.restore();
}

function drawFood() {
  const born = Math.min(1, (now - foodBorn) / 300);
  const back = 1 + 2.70158 * Math.pow(born - 1, 3) + 1.70158 * Math.pow(born - 1, 2);
  const scale = Math.max(0, born < 1 ? back : 1) * (1 + 0.06 * Math.sin(now / 170));
  const r = cell * .34 * scale;
  if (r <= 0.2) return;
  const px = (food.x + .5) * cell, py = (food.y + .5) * cell;

  let g = ctx.createRadialGradient(px, py, 0, px, py, r * 2.6);
  g.addColorStop(0, 'rgba(255,150,60,.30)'); g.addColorStop(1, 'rgba(255,150,60,0)');
  ctx.fillStyle = g; ctx.beginPath(); ctx.arc(px, py, r * 2.6, 0, 7); ctx.fill();

  g = ctx.createRadialGradient(px - r * .35, py - r * .4, r * .1, px, py, r * 1.02);
  g.addColorStop(0, '#fff6c8'); g.addColorStop(.35, '#ffc860');
  g.addColorStop(.8, '#ff7a3c'); g.addColorStop(1, '#f4502e');
  ctx.fillStyle = g; ctx.beginPath(); ctx.arc(px, py, r, 0, 7); ctx.fill();

  ctx.strokeStyle = '#7a4a22'; ctx.lineWidth = Math.max(1.2, cell * .07); ctx.lineCap = 'round';
  ctx.beginPath(); ctx.moveTo(px, py - r * .75);
  ctx.quadraticCurveTo(px + r * .15, py - r * 1.15, px + r * .1, py - r * 1.3); ctx.stroke();
  ctx.save();
  ctx.translate(px + r * .35, py - r * 1.12); ctx.rotate(-.55);
  ctx.fillStyle = '#6cc96a';
  ctx.beginPath(); ctx.ellipse(0, 0, r * .5, r * .22, 0, 0, 7); ctx.fill();
  ctx.restore();
}

function drawSnake() {
  const n = snake.length;
  const it = state === 'running' ? Math.min(1, acc / tickMs) : 1;
  const pts = new Array(n);
  for (let i = 0; i < n; i++) {
    const c = snake[i], p = prevSnake[Math.min(i, prevSnake.length - 1)] || c;
    pts[i] = { x: (p.x + (c.x - p.x) * it + .5) * cell, y: (p.y + (c.y - p.y) * it + .5) * cell };
  }
  const dead = (state === 'dying' || state === 'over');
  const dT = now - dieAt;

  /* body — tapering gradient strokes, tail first */
  ctx.lineCap = 'round'; ctx.lineJoin = 'round';
  for (let i = n - 1; i > 0; i--) {
    const k = i / (n - 1);
    let col = bodyColor(k, dead);
    if (dead && dT < 140) col = '#ffd9d2';
    ctx.strokeStyle = col;
    ctx.lineWidth = Math.max(3, cell * (.80 - .30 * k));
    ctx.beginPath(); ctx.moveTo(pts[i].x, pts[i].y); ctx.lineTo(pts[i - 1].x, pts[i - 1].y); ctx.stroke();
  }

  /* glossy spine */
  if (n > 2) {
    ctx.strokeStyle = dead ? 'rgba(255,210,200,.14)' : 'rgba(255,255,255,.10)';
    ctx.lineWidth = Math.max(1.5, cell * .13);
    ctx.beginPath(); ctx.moveTo(pts[1].x, pts[1].y);
    for (let i = 2; i < n; i++) ctx.lineTo(pts[i].x, pts[i].y);
    ctx.stroke();
  }

  /* head geometry */
  const hd = pts[0];
  let hx, hy;
  if (n > 1) { hx = hd.x - pts[1].x; hy = hd.y - pts[1].y; }
  else { hx = dir.x; hy = dir.y; }
  const hl = Math.hypot(hx, hy);
  if (hl > 1e-4) { hx /= hl; hy /= hl; } else { hx = dir.x; hy = dir.y; }
  const ox = -hy, oy = hx;                     /* perpendicular */
  const hr = cell * .46 * (1 + .22 * headPulse);

  /* flicking tongue */
  if (!dead) {
    const ph = now % 2600;
    if (ph < 300) {
      const e = Math.sin(ph / 300 * Math.PI);
      const bx = hd.x + hx * hr * .7, by = hd.y + hy * hr * .7;
      const len = hr * 1.05 * e;
      const tx = bx + hx * len, ty = by + hy * len;
      ctx.strokeStyle = '#ff4d5e'; ctx.lineWidth = Math.max(1.4, cell * .09);
      ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(tx, ty);
      for (const s of [-1, 1]) {
        let fx = hx + ox * .75 * s, fy = hy + oy * .75 * s;
        const fl = Math.hypot(fx, fy); fx /= fl; fy /= fl;
        const L = hr * .42 * e;
        ctx.moveTo(tx, ty); ctx.lineTo(tx + fx * L, ty + fy * L);
      }
      ctx.stroke();
    }
  }

  /* head */
  let g = ctx.createRadialGradient(hd.x - hx * hr * .4, hd.y - hy * hr * .4, hr * .12, hd.x, hd.y, hr * 1.06);
  if (dead) { g.addColorStop(0, '#ffc3ba'); g.addColorStop(.45, '#f0655a'); g.addColorStop(1, '#7e2f2a'); }
  else { g.addColorStop(0, '#d9ff9b'); g.addColorStop(.45, '#9ef25c'); g.addColorStop(1, '#3fae62'); }
  ctx.save();
  if (!dead) { ctx.shadowColor = 'rgba(158,242,92,.45)'; ctx.shadowBlur = cell * .7; }
  ctx.fillStyle = g; ctx.beginPath(); ctx.arc(hd.x, hd.y, hr, 0, 7); ctx.fill();
  ctx.restore();
  ctx.strokeStyle = 'rgba(8,26,18,.35)'; ctx.lineWidth = 1;
  ctx.beginPath(); ctx.arc(hd.x, hd.y, hr - .5, 0, 7); ctx.stroke();

  /* eyes facing the direction of travel */
  for (const s of [-1, 1]) {
    const ex = hd.x + hx * hr * .30 + ox * hr * .46 * s;
    const ey = hd.y + hy * hr * .30 + oy * hr * .46 * s;
    const er = hr * .33;
    ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(ex, ey, er, 0, 7); ctx.fill();
    if (dead) {
      ctx.strokeStyle = '#232f47'; ctx.lineWidth = Math.max(1.3, er * .38); ctx.lineCap = 'round';
      const xr = er * .5;
      ctx.beginPath();
      ctx.moveTo(ex - xr, ey - xr); ctx.lineTo(ex + xr, ey + xr);
      ctx.moveTo(ex + xr, ey - xr); ctx.lineTo(ex - xr, ey + xr);
      ctx.stroke();
    } else {
      ctx.fillStyle = '#14243c';
      ctx.beginPath(); ctx.arc(ex + hx * er * .34, ey + hy * er * .34, er * .52, 0, 7); ctx.fill();
      ctx.fillStyle = 'rgba(255,255,255,.95)';
      ctx.beginPath(); ctx.arc(ex - er * .22, ey - er * .28, er * .16, 0, 7); ctx.fill();
    }
  }
}

function drawParticles() {
  for (const p of particles) {
    const a = Math.max(0, Math.min(1, p.life));
    if (p.t === 'dot') {
      ctx.globalAlpha = a;
      ctx.fillStyle = p.col;
      ctx.beginPath(); ctx.arc(p.x, p.y, Math.max(.4, p.r * (.35 + .65 * a)), 0, 7); ctx.fill();
    } else if (p.t === 'ring') {
      ctx.globalAlpha = a * .55;
      ctx.strokeStyle = '#ffd23f'; ctx.lineWidth = Math.max(1, cell * .09 * a);
      ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, 7); ctx.stroke();
    } else {
      ctx.globalAlpha = Math.min(1, a * 1.3);
      ctx.font = '800 ' + Math.max(10, cell * .62) + 'px ui-monospace,Menlo,Consolas,monospace';
      ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.lineJoin = 'round';
      ctx.lineWidth = Math.max(2, cell * .14);
      ctx.strokeStyle = 'rgba(7,12,24,.85)';
      ctx.strokeText(p.str, p.x, p.y);
      ctx.fillStyle = '#dcffa0';
      ctx.fillText(p.str, p.x, p.y);
    }
  }
  ctx.globalAlpha = 1;
}

/* ============================ main loop ============================ */
function frame(t) {
  now = t;
  const dt = Math.min((t - lastT) / 1000, .1);
  lastT = t;
  if (state === 'running') {
    acc += dt * 1000;
    let guard = 0;
    while (acc >= tickMs && state === 'running' && guard++ < 6) { step(); acc -= tickMs; }
  } else if (state === 'dying' && now - dieAt > 800) {
    finishDeath();
  }
  if (state !== 'paused') {
    updateParticles(dt);
    shake = Math.max(0, shake - dt * 1.8);
    headPulse = Math.max(0, headPulse - dt * 3.2);
  }
  render();
  requestAnimationFrame(frame);
}

/* ============================ input ============================ */
const KEYMAP = {
  ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right',
  w: 'up', a: 'left', s: 'down', d: 'right', W: 'up', A: 'left', S: 'down', D: 'right'
};

window.addEventListener('keydown', e => {
  const k = e.key;
  if (k === ' ' || k.startsWith('Arrow')) e.preventDefault();
  if (mode === 'demo') { startPlay(KEYMAP[k] ? DIRS[KEYMAP[k]] : null); return; }
  if (state === 'over') { if (k === ' ' || k === 'Enter') startPlay(null); return; }
  if (k === 'p' || k === 'P') { togglePause(); return; }
  if (state === 'running' && KEYMAP[k]) enqueueDir(DIRS[KEYMAP[k]]);
});

document.addEventListener('pointerdown', e => {
  if (e.pointerType === 'touch') return;
  if (mode === 'demo') startPlay(null);
  else if (state === 'over') startPlay(null);
  else if (state === 'paused') togglePause();
});

/* swipe / tap support for touch screens */
let tstart = null;
document.addEventListener('touchstart', e => {
  const t = e.changedTouches[0]; tstart = { x: t.clientX, y: t.clientY };
}, { passive: true });
document.addEventListener('touchend', e => {
  if (!tstart) return;
  const t = e.changedTouches[0];
  const dx = t.clientX - tstart.x, dy = t.clientY - tstart.y; tstart = null;
  if (Math.hypot(dx, dy) < 20) {
    if (mode === 'demo') startPlay(null);
    else if (state === 'over') startPlay(null);
    else if (state === 'paused') togglePause();
    return;
  }
  const d = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? DIRS.right : DIRS.left) : (dy > 0 ? DIRS.down : DIRS.up);
  if (mode === 'demo') startPlay(d);
  else if (state === 'running') enqueueDir(d);
}, { passive: true });

btnRestart.addEventListener('click', () => { if (state === 'over') startPlay(null); });
window.addEventListener('blur', () => { if (mode === 'play' && state === 'running') togglePause(); });
window.addEventListener('resize', () => resize());

/* ============================ boot ============================ */
resize();
resetGame();
requestAnimationFrame(t => { now = t; lastT = t; requestAnimationFrame(frame); });

})();
</script>
</body>
</html>
Reasoning trace (40,122 tokens)
Let me build a polished Snake game. Key requirements:

1. Grid ≥20×20, centered on page
2. Arrow keys + WASD, input queue for quick successive presses, no reversing
3. Food = +10 points, grow by 1, food never spawns on snake
4. Game over overlay with score, Space/Enter restarts, P pauses
5. Smooth movement (interpolated), distinct head with eyes facing direction, particle burst on eat
6. Score + best score (session — use in-memory variable, no localStorage)

Also: self-playing demo mode behind "Click or press a key to start" message, since it's a game. The game must auto-play with a simple AI until the user interacts.

Let me plan the architecture:

**Layout**: Full-page dark background, centered game area. Canvas with the grid. Header above with title, score, best. Canvas centered. Responsive sizing — fit within 1280×800 and down to 375px. I'll compute canvas size based on viewport.

**Grid**: Let's do 24×24 cells (at least 20×20). Canvas sized responsively.

**Rendering with smooth movement**: I'll use a tick-based logic (snake moves cell-to-cell at fixed intervals, e.g., every 110ms) but render with interpolation between previous and current positions for smoothness. Each segment interpolates from its previous cell to its current cell.

Approach: keep snake as array of {x,y} cells. On each tick, compute new positions. For rendering, keep `prevSnake` array and interpolate with t = timeSinceTick / tickInterval.

For the snake body rendering: draw as a smooth path. One nice technique: draw the snake as a thick rounded polyline through interpolated segment centers, then draw the head with eyes. That gives a cohesive smooth look.

Interpolation detail: when snake grows, prevSnake is shorter. Handle by clamping: for segment i, prev position = prevSnake[i] if exists else prevSnake[prevSnake.length-1] (tail stays). Actually when growing, the new tail segment appears at the old tail's position — so prev for the new last segment is same as current (it doesn't move). Let me think: standard snake: on move, unshift new head, pop tail (unless growing). So snake.length increases by 1 when growing. prevSnake had length N, new snake length N+1. New snake[i] corresponds to... new snake[0] is new head cell; new snake[i] for i≥1 equals old snake[i-1]. So prev of new segment i is old snake[i-1]... wait no.

Let me think again. Old snake: [h, b1, b2, ..., tail] positions p0..p(N-1). After move (no growth): new snake = [newHead, p0, p1, ..., p(N-2)]. So new snake[i] = p(i-1) for i≥1, and new[0] = newHead. The segment i moved from p(i)... hmm, for interpolation we want: each rendered segment moves from its old position to new position. Segment identity: new snake segment at index i came from old position: for i=0 (head), old position = p0 (head moves from p0 to newHead). For i≥1, new snake[i] = old snake[i-1], so that "piece of body" at cell new[i] was previously at... The body cells shift: cell content moves forward. Actually simpler: interpolate each index i from prevSnake[i] to snake[i], where prevSnake is the old snake array. Old snake[i] = p(i), new snake[i] = p(i-1) for i≥1 — that means each segment index moves one cell along the body toward the head. That's exactly correct for smooth animation: each body segment slides forward along the path. 

When growing: new snake = [newHead, p0, ..., p(N-1)] length N+1. new[i] = old[i-1] for i≥1. Interpolate index i from old[i] to new[i]: for i < N, old[i] exists, moves forward one. For i = N (new tail), old[N] doesn't exist; use old[N-1] = new[N], so it stays put. Good — clamp: prev = prevSnake[Math.min(i, prevSnake.length-1)].

But careful: when interpolating positions between two cells that are adjacent, that's fine. However, the path drawn through interpolated points: head is moving from old head to new head, and each body point moves along. The polyline through interpolated centers will be smooth. At turns, the interpolated points form a nice rounded corner automatically since points slide along the perpendicular... Actually with linear interpolation of each vertex, at a turn the polyline will cut the corner slightly — that's fine and looks smooth/organic.

Edge case: game over on wall hit — the head would interpolate into the wall. I'll handle death by not interpolating the head past the boundary; on death, freeze at final position (t=1 of last valid state, or just don't advance). Simpler: on tick, check collision; if dead, set state to GAME_OVER, don't update positions (keep last). Then render at t=1 (clamped). Maybe add a little death animation — snake flashes red / shakes.

**Input queue**: Queue of direction changes, max size ~3. On each tick, shift from queue: accept if not opposite of current direction and not same as current. This honors quick successive presses (e.g., up then left quickly → both applied on consecutive ticks... hmm, "quick successive key presses must both be honored"). With a queue, pressing Up then Left rapidly within one tick: at next tick, Up applied; following tick, Left applied. Both honored. Also within same tick, pressing Left then Up: both queued, applied on consecutive ticks. Good.

Actually some implementations apply up to one direction per tick; queue makes both eventually honored. That satisfies the requirement.

Prevent 180° reversal: check against the direction actually in effect (last applied or the last queued direction to prevent queuing Up then Down when moving Right... Let me be careful: if moving Right, press Up then Down quickly: queue = [Up, Down]. Tick 1 applies Up. Tick 2 would apply Down — but snake is now moving Up, so Down is reversal → reject. Good, check against current direction at application time. But also if queue has [Up] and current is Right, then user presses Down: relative to current Right, Down is fine; but after Up is applied, Down would be reversal. Checking at application time handles it: when Down comes up for application, current dir is Up → reject Down. Fine — it just gets skipped. Alternatively check against last queued to be more permissive... Standard approach: when enqueueing, compare against the last direction in queue (or current dir if empty); reject if same or opposite. That prevents filling the queue with junk. Then at application it's guaranteed valid. Let me do that: effectiveDir = queue.length ? queue[queue.length-1] : currentDir. If newDir is not opposite and not same as effectiveDir, push.

**Self-play demo AI**: Simple AI that plays automatically before the user interacts. Requirements: "Games must start in a self-playing demo mode (a simple AI plays) behind a 'Click or press a key to start' message". So on load, the game runs with an AI controlling the snake, with an overlay message "Click or press a key to start". When user clicks or presses a key, the real game starts (reset, user control).

Simple AI: greedy move toward food avoiding immediate death. Algorithm: at each tick, consider candidate directions (not reverse): filter those that don't hit wall/body next cell; among safe ones, pick the one minimizing Manhattan distance to food; if none safe, pick any non-reverse. Add slight lookahead or just greedy — greedy can trap itself but that's OK for a demo; if AI dies, just restart automatically in demo mode. Maybe make the AI a bit smarter with a simple "check flood fill space" heuristic to survive longer. A simple safety check: after moving, count reachable free cells via flood fill; prefer moves with reachable space ≥ snake length. That's cheap enough on a 24×24 grid. Let me implement a decent AI:

```
function aiChooseDir() {
  const head = snake[0];
  const candidates = dirs.filter(d => !(d.x===-dir.x && d.y===-dir.y) || snake.length===1);
  score each candidate:
    next = head + d
    if collides with wall or body (excluding tail cell that will move? approximate: exclude current tail) → invalid
    flood fill from next over free cells (treat body as blocked, except tail) → space
    if space < snake.length → heavily penalize but keep as fallback
    score = -manhattan(next, food) + (space big ? bonus : -1000)
  pick best valid; fallback to any valid; if none, keep dir (dies).
}
```

That's fine. The demo AI will look decent.

In demo mode, the snake plays at a moderate speed. Also show "DEMO" indicator maybe. When user interacts (any key or click), switch to playing mode: reset game, wait... Actually just start fresh with snake at center moving right, overlay disappears. Show "Click or press a key to start" overlay in demo mode. On interaction, remove overlay and restart the game with user control.

Hmm, but the requirement also says: "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." Good, we have that.

**Pause**: P toggles pause. Show pause overlay. During pause, ignore movement ticks. Space/Enter restarts when game over. Also maybe Space/Enter starts from demo? Let's make any key/click start the game from demo. During play, Space could also pause? Keep it simple: Space/Enter restart after game over; P pauses during play. Maybe also allow clicking to restart on game over. I'll add: on game over overlay, "Press Space / Enter to play again" — clicking also restarts.

**Best score**: session best — in-memory variable (localStorage banned). Show "BEST" in header. Best persists across restarts within the session, including demo AI scores? Hmm — if demo AI plays and scores, does that count toward best? I'd say the best should reflect the player's best... but the demo AI scores count as session best? It might be weird if the AI gets a high score that the player can't beat showing as best. I think best should only track the user's runs. I'll track best only in playing mode (not demo). Actually, it could be fun to show it either way. Let me only update best when in playing mode (mode === 'play'). That's cleaner.

**Visual polish**:
- Dark background with subtle vignette / gradient.
- Board: slightly lighter panel with faint grid checkerboard pattern (like subtle alternating cells), rounded corners, maybe a subtle inner border.
- Snake: gradient body (e.g., from bright green head to darker teal tail), drawn as thick rounded polyline with slight width taper toward tail. Head: rounded, with two eyes positioned based on direction, with pupils. Maybe a tongue flick occasionally — cute touch.
- Food: pulsing circle with glow, maybe an apple-like: red/orange orb with radial gradient and subtle shine, plus a tiny leaf? Keep simple: pulsing glowing orb with radial gradient.
- Particles: burst of small circles in food color when eaten, with velocity, gravity-lite, fading. Also a quick "+10" floating text.
- Score pop animation in header when eating.
- Death: brief screen shake + snake turns red, then overlay.
- Overlay: glassmorphism panel with score, best, "new best" badge if applicable, restart instructions.
- Smooth movement via interpolation as described.

Canvas rendering with devicePixelRatio for crispness.

**Responsive**: canvas sized via JS: cell size computed from available space. Let's compute: board area = min(available width, available height) constraints. With 24 cols/rows: cell = floor(min(maxW, maxH)/24). Canvas width = cell*24. Recompute on resize. At 375px wide, cell ~ (375-32)/24 ≈ 14px → board 336px. Fine. At 1280×800: available height maybe 800 - header(≈90) - footer(≈40) - margins ≈ 640 → cell 26 → board 624px. Good.

Let me use CSS layout: body flex column center; header with title + score pills; canvas wrapper. Compute size in JS on resize.

**Timing**: use requestAnimationFrame loop. Logic tick accumulator: `acc += dt; while (acc >= tickMs) { step(); acc -= tickMs; }`. t = acc / tickMs for interpolation. tickMs starts at ~130ms, speeds up slightly as score grows (min ~80ms). Hmm, speed increases add polish; keep modest.

Actually careful: when paused or game over, don't accumulate.

Let me now think about drawing the snake nicely.

Positions: for each segment index i, interpolated position:
```
const prev = prevSnake[Math.min(i, prevSnake.length - 1)];
const cur = snake[i];
const x = lerp(prev.x, cur.x, t), y = lerp(prev.y, cur.y, t);
```
Wait — that's wrong! Interpolating snake[i] from prevSnake[i] to snake[i]: prevSnake[i] is old position of index i, snake[i] is new position of index i. As computed above, new snake[i] = old snake[i-1] (body shifts forward). So index i moves from old[i] to old[i-1]... For the head, from old[0] to new head cell. Hmm wait, but that means the head interpolates from old head position to new head position — correct. Body index 1 interpolates from old[1] to old[0] — i.e., it slides forward along the body path. Correct. So the rendered polyline at t: point i = lerp(prevSnake[i], snake[i], t). Yes that's what I wrote. 

But one subtlety: at a turn, consecutive interpolated points might cross? E.g., body at cells forming an L. Points: index i moves from A to A', index i+1 moves from B to B'. All points move along the body path forward, so the polyline remains "along the path" — it just shortens corners smoothly. Should look good.

Corner cutting more: with linear interpolation of vertices, the polyline between two interpolated points is straight, and corners get cut diagonally. With thick stroke and round joins, looks smooth and organic. Good.

Drawing: 
- Build path through interpolated points (in pixel coords, center of each cell).
- Stroke with lineWidth = cell * 0.78, lineCap/Join round, strokeStyle gradient? A linear gradient along the whole snake bounding box is meh. Alternative: draw body in segments with per-segment color, or draw the whole path with a base color, then overlay head glow. Simpler polished approach: stroke path multiple times: outer stroke (darker, slightly wider) then inner stroke (brighter). Then head circle with radial gradient + eyes.

Per-segment gradient: could stroke each segment pair with color interpolated by index → but with round caps overlapping, colors blend fine (later segments drawn over earlier... need to draw tail-first to head-last so head area is on top). Draw from tail to head: for i from len-2 down to 0: stroke segment from p[i+1] to p[i] with color based on i. Round caps make joints smooth. Colors: hue from ~160 (head) to ~190 (tail) with brightness falloff. This gives a nice gradient body. Then draw head as a rounded shape: circle at p[0] slightly larger, radial gradient, plus eyes.

Actually to avoid seams with per-segment strokes, drawing order tail→head with round caps: each stroke covers the joint. Since color changes gradually, overlaps are barely visible. Good.

Also add a subtle scale pattern? Maybe a lighter stroke line down the center? Keep it clean: body with slight width taper — lineWidth decreasing toward tail from 0.8*cell to 0.55*cell. With per-segment strokes, width can vary per segment. 

Eyes: head direction vector d (normalized, based on current direction). Perpendicular vector pp. Eye positions: head center + d*0.15*cell ± pp*0.22*cell. Eye radius ~0.14*cell, white with dark pupil offset toward direction. Also small highlight. Cute.

Tongue: occasionally (every ~2s, for 300ms), draw a small forked red tongue ahead of head. Nice detail — flicks in/out. I'll animate with a phase.

Food: pulsing radius: r = cell*0.32 * (1 + 0.08*sin(time*4)). Radial gradient: bright center #ffdd66 → #ff8844 → transparent glow. Add a subtle rotating shine? Also a soft outer glow using shadowBlur. Plus tiny sparkle particles around food occasionally? Keep it: glow + pulse.

Particles on eat: 14–20 particles from food position, random velocity outward, colors from a warm palette, shrink + fade over ~600ms, slight gravity. Also "+10" floating text rising and fading.

Also small ripple ring expanding at eat position.

Death animation: on death, set state 'dying', run for ~700ms: shake canvas (translate random small offsets decaying) and flash snake red, then show game over overlay (fade in). Nice polish.

Screen shake: apply ctx.translate(shakeX, shakeY) where shake decays.

**Grid background**: subtle checkerboard: for each cell, if (x+y)%2, fill with rgba(255,255,255,0.015) over base board color. Plus outer rounded border stroke. Maybe faint vignette radial gradient on the board. Also cell coordinates? Keep clean.

**Header**: Title "SNAKE" with a small logo (maybe a tiny snake glyph drawn in CSS or a unicode? no emoji... "no emojis in UI" is a general guideline I hold; use text/CSS shapes). I'll design: left: title with small colored square accent; right: two pills: SCORE and BEST with values. Pills styled with borders and mono-ish numbers. Use a Google font? External fonts not allowed except... "No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task." Task doesn't allow any CDN. So use system font stack. I'll use a font stack like `'Segoe UI', system-ui...` and for numbers maybe `ui-monospace, 'SF Mono', Menlo, Consolas, monospace`. Fine.

Actually, could I import Google Fonts? Not allowed per rules (no external files/fonts unless CDN explicitly allowed by the task — task says single HTML file vanilla JS; no CDN mentioned). So system fonts only.

**Overlays** (HTML over canvas, absolutely positioned within a board wrapper):
- Demo overlay: centered message "Click or press any key to start" with subtle pulsing, plus "DEMO" badge and maybe "AI is playing" hint. Semi-transparent dark backdrop so the game is visible behind. Keep backdrop light (rgba(0,0,0,0.35)) so demo is visible.
- Pause overlay: "PAUSED — press P to resume".
- Game over overlay: "GAME OVER", score big, best, "new best!" tag if beat, "Press Space or Enter to restart" + button "Play again" clickable.

Overlays with fade transitions (CSS opacity/transform).

Footer hint: key hints: "←↑↓→ / WASD move · P pause · Space restart". Rendered as styled kbd elements. Nice touch: kbd pills.

**Score pop**: on score change, animate the score value scale via CSS class re-trigger.

**Structure**:

```html
<body>
  <main class="stage">
    <header> title + scoreboards </header>
    <div class="board-wrap">
      <canvas id="game"></canvas>
      <div class="overlay" id="overlay"> ... dynamic content ... </div>
    </div>
    <footer> controls hint </footer>
  </main>
</body>
```

Overlay content I'll manage via JS building innerHTML or having three overlay divs toggled. Three separate overlay divs with classes for show/hide is cleaner for transitions. Actually one overlay container with different panels; I'll have separate divs: #demoOverlay, #pauseOverlay, #overOverlay.

**Game states**: mode: 'demo' | 'play'. state: 'running' | 'paused' | 'dying' | 'over'. In demo mode, states: running (AI plays), over → auto-restart after ~1.5s.

**Key handling**:
```
window.addEventListener('keydown', e => {
  const k = e.key;
  if (mode==='demo') { startPlay(); /* also, if the key is a direction, queue it */ }
  ...
  map arrows/wasd to dir; prevent default for arrows/space to avoid scrolling.
  if state==='running': enqueue dir
  if k==='p' or 'P': toggle pause (only in play mode)
  if state==='over' && (k===' '||k==='Enter'): restart (play mode)
});
```
Also click: if mode==='demo' → startPlay; if over → restart; if running... clicking board maybe pause? Could be annoying; click only handles demo/start and restart.

Edge: In demo mode when user presses an arrow key, we start the game AND should apply that direction. Nice touch: startPlay(initialDir). The fresh snake moves right; if user pressed left, it'd be reversal — ignore. I'll pass the pressed dir into the queue after restart.

**startPlay()**: mode='play'; resetGame(); hide overlays; state='running'.

**resetGame()**: snake initial: center, length 4, horizontal facing right: cells [(cx+1? ) ] Let's place head at (cx+2, cy) body extending left: snake = [{x:cx+2,y:cy},{x:cx+1,y:cy},{x:cx,y:cy},{x:cx-1,y:cy}] wait head first: head at (cx+2,cy)? Center at col 11/12 for 24 grid; place head at (cx, cy) with body to the left: snake = [{cx,cy},{cx-1,cy},{cx-2,cy},{cx-3,cy}]. dir = {1,0}. Spawn food. score=0. tickMs=135. prevSnake = copy. particles cleared. acc=0.

Wait — also demo restart uses same resetGame but mode stays demo.

**Food spawn**: random cell not on snake. With snake large, use rejection sampling with fallback scan over all free cells. Free cells list approach: gather all free cells, pick random. That's robust.

**Tick step()**:
```
if (mode==='demo') dir = aiDir() (computed each tick)
else { if queue.length: dir = queue.shift() }
newHead = head + dir
if out of bounds or hits body (excluding tail if not growing... careful: tail cell will be vacated unless growing) → die()
```
Standard: compute if eating (newHead == food). Body collision check: snake cells except the last one (tail) — because tail moves away — unless the snake will grow this tick (then tail stays). If eating: tail stays → check against all body. Simpler: check collision against snake.slice(0, eating? snake.length : snake.length-1)... Actually if not eating, tail vacates, so hitting current tail cell is OK. If eating, tail remains, all cells blocked.

Implementation:
```
const eating = food && newHead.x===food.x && newHead.y===food.y;
const bodyToCheck = eating ? snake : snake.slice(0, snake.length-1);
if (out of bounds || bodyToCheck.some(s=>s.x===nx&&s.y===ny)) { die(); return; }
prevSnake = snake.map(s=>({...s})); // snapshot
snake.unshift({x:nx,y:ny});
if (eating) { score+=10; ...; spawnFood(); particles; speedup; }
else snake.pop();
```
Wait — prevSnake snapshot must be taken before modifying. And when eating, snake grows: new length = prev+1, interpolation clamps new tail to stay. Good.

Hmm, one more subtlety with interpolation on eating: new snake[i] = oldSnake[i-1] for i in 1..N (N = old length), and new[0]=newHead. prev index clamp: for i=N, prevSnake[N-1] which equals snake[N] — stays. For i in 1..N-1: prev = prevSnake[i], cur = snake[i] = prevSnake[i-1] — slides forward. 

Also note: interpolation across a turn where a segment's prev and cur are not adjacent? They're always adjacent cells (consecutive body cells), so fine. Except the clamp case where prev==cur (stationary tail when growing) — fine.

**die()**: state='dying'; deathTime=now; play shake; after ~900ms → state='over'; update best (if mode==='play' and score>best); show overlay. In demo mode: after 1.2s auto resetGame() and continue demo.

Actually best update: should best update live during play (so header shows best = max(best, score) immediately)? Common: best updates when game ends. But live-updating looks nice. Requirement: "Display the current score and the best score of the session." I'll update best immediately when score exceeds it (and mark newBest flag for overlay). Simplest: when score changes, if score > best → best = score, newBest = true. Show "NEW BEST" badge on game over if flag.

**Speed**: tickMs = max(80, 135 - snake.length * 1.2)? Let's do base 135, each food reduces 1.6ms, min 85. Fine.

**Demo AI details**:

```
function aiDir() {
  const head = snake[0];
  const options = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}]
    .filter(d => !(d.x===-dir.x && d.y===-dir.y));
  const scored = [];
  for (const d of options) {
    const nx=head.x+d.x, ny=head.y+d.y;
    if (nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
    // body blocked: cells that will be occupied. Approximate: all body except tail (tail moves) 
    const blocked = new Set(snake.slice(0, snake.length-1).map(k=>k.x+','+k.y));
    if (blocked.has(nx+','+ny)) continue;
    // flood fill free space from (nx,ny)
    const space = floodCount(nx, ny, blocked);
    const dist = Math.abs(nx-food.x)+Math.abs(ny-food.y);
    const spaceScore = space >= snake.length ? 0 : -(snake.length - space)*100;
    scored.push({d, score: -dist + spaceScore + (space)*0.01});
  }
  if (!scored.length) return dir; // doomed
  scored.sort((a,b)=>b.score-a.score);
  return scored[0].d;
}
```
floodCount: BFS over grid treating `blocked` set as walls, start (nx,ny), count reachable. Cap count at snake.length (early exit) for perf. 24×24=576 cells, 4 flood fills per tick each ≤576 — trivial.

Also add tiny randomness to avoid oscillation? Greedy + space check is decent. Add: prefer continuing straight slightly (small bonus for d===current dir) to reduce jitter: +0.1. Hmm, prefer direction toward food primarily. OK.

To make the demo look good immediately (screenshot at 3s): snake starts center, food somewhere, AI moving. Good.

**Demo overlay design**: top center small badge "DEMO — AI playing", and center bottom message "Click or press any key to start" with pulsing animation. Keep the backdrop minimal so the game is clearly visible. Maybe a slim gradient at bottom. I'll do: centered card with glass effect: "SNAKE" big? The header already has the title. Overlay: a pill at bottom: "Click or press any key to start" + small "watching the AI" hint. Also top-left badge "DEMO".

Hmm, actually classic: big centered prompt. But it would cover the snake. Bottom-centered pill is better — snake visible. Plus dim the board slightly? Slight dim ok.

**Canvas sizing**:
```
function resize() {
  const wrap = boardWrap; compute available: 
  const maxW = Math.min(window.innerWidth - 32, 720);
  const maxH = window.innerHeight - headerH - footerH - margins (~ 170);
  cell = Math.max(10, Math.floor(Math.min(maxW, maxH) / COLS));
  ... set canvas style width/height = cell*COLS, and backing store *dpr.
}
```
Better: measure actual available using getBoundingClientRect of the stage minus header/footer. Simpler: use CSS to layout and compute: avail W = min(innerWidth - 24, 760); avail H = innerHeight - (header+footer heights measured via offsetHeight) - 48. Then cell = floor(min(availW, availH)/COLS); clamp cell between 9 and 30. Canvas CSS size = cell*COLS px. Backing = *dpr, ctx.setTransform(dpr,0,0,dpr,0,0).

To make first paint immediate, run resize() before starting loop.

I'll set the board wrapper to shrink-wrap the canvas; overlays absolutely positioned with same size, overflow hidden, border-radius.

**Rounded corners on canvas**: wrap in div with border-radius + overflow hidden; canvas inside. Board drawn with background fill anyway.

Board visual: I'll draw inside canvas: fill rounded rect? Since wrapper clips, just fill full canvas with board color, plus subtle checker + border via CSS on wrapper (border: 1px solid rgba(...); box-shadow for depth). Also outer glow shadow.

Background page: deep gradient: radial-gradient at top, colors like #0b1220 → #050810, plus subtle noise? Can't use images; could do repeating-linear-gradient subtle. Keep gradients.

Color scheme: Board: #0f1a2b-ish with checker rgba(255,255,255,0.02). Snake: green gradient #4ade80 → #16a34a? Or more vivid: head #a3ff5e / lime. Let's do: head color hsl(95, 80%, 65%) → tail hsl(160, 60%, 40%)? A blend from lime to teal looks modern. Food: warm amber/coral (#ffb347 / #ff6b6b glow) contrasts green nicely. Accent for UI: same lime.

Let me define palette:
- bg page: #070b14, gradient to #0b1120
- board: #0d1526, checker rgba(148, 197, 255, 0.03)
- border: rgba(120,160,255,0.12)
- snake head: #b6f36a? hmm. Let's use: head #9ef25c (lime), body to #2fbf87 (teal-green), tail darker #1f8f6f. I'll compute per-segment via lerp in HSL: h 90→160, s 75%→55%, l 62%→38%.
- eyes white, pupils #0a0f1a.
- food: core #ffd23f, mid #ff8a3d, edge #ff5252, glow rgba(255,138,61,0.35).
- Text: #e8eefc, muted #8b98b8.
- Accent (title, score numbers): #9ef25c.

**+10 floating text**: canvas-drawn: particles array includes type 'text'.

Let me also add subtle food spawn animation: scale from 0 with ease-out over 250ms (store foodSpawnTime).

**Snake head details**: Draw head as: circle radius cell*0.42 at interpolated head pos, with radial gradient (lighter top-left). Then snout? Eyes: two white circles r=cell*0.13 at positions offset perpendicular ±cell*0.18 and forward cell*0.12; pupils r=cell*0.065 offset toward dir by cell*0.03. Plus tiny white glints. Direction vector for eyes: use interpolated heading — direction from neck point to head point (normalized), which rotates smoothly around corners. 

heading = normalize(p0 - p1). If zero (start), use dir.

**Tongue**: every so often: phase = (time % 2400); if phase < 260: t = sin(phase/260*PI); tongue length = cell*0.5*t; draw red line from head front (head + heading*r) forward, forked at end: two short lines. Cute. Draw before head? Draw after head but starting at head edge. Draw tongue first then head over its base? Tongue comes out of mouth — draw tongue then head circle would hide base. Draw head, then tongue from head edge outward. Fine.

**Shake on death**: shakeT decays; offset = (rand-0.5)*shakeAmp*shakeT.

**Also small squash on eat**: head scale pop when eating — headPulse timer: head radius *1.15 decaying. Nice.

**Performance**: trivial.

**Code organization** within one script: constants, state, DOM refs, resize, reset, input, AI, step, particles, render (board, food, snake, particles, floats), overlay management, main loop.

Let me also handle: prevent arrow/space default scrolling: e.preventDefault() for relevant keys.

Visibility change: pause when tab hidden? rAF stops anyway; dt clamps: clamp dt to 100ms to avoid jumps. Good.

**Now overlay HTML**:

```html
<div class="board-wrap" id="boardWrap">
  <canvas id="game"></canvas>
  <div class="scanlines"></div>? maybe skip
  <div class="overlay demo" id="ovDemo">
    <div class="demo-badge">DEMO · AI PLAYING</div>
    <div class="start-prompt">
      <div class="prompt-title">Click or press a key to start</div>
      <div class="prompt-sub">arrows / WASD to steer</div>
    </div>
  </div>
  <div class="overlay pause" id="ovPause">
    <div class="panel"><div class="ov-big">PAUSED</div><div class="ov-sub">press <kbd>P</kbd> to resume</div></div>
  </div>
  <div class="overlay over" id="ovOver">
    <div class="panel">
      <div class="ov-big">GAME OVER</div>
      <div class="ov-score">SCORE <b id="ovScoreNum">0</b></div>
      <div class="ov-best">BEST <b id="ovBestNum">0</b></div>
      <div class="newbest" id="ovNewBest">NEW BEST!</div>
      <div class="ov-sub">press <kbd>Space</kbd> or <kbd>Enter</kbd> to restart</div>
      <button id="btnRestart">Play again</button>
    </div>
  </div>
</div>
```

Overlays use pointer-events: none except buttons? Click anywhere to start/restart: attach click handler on boardWrap and also document? Careful: clicking button triggers restart; clicking overlay in demo starts. I'll put click on document body: if demo → start; if over → restart. Also keydown as described. But avoid starting when clicking... it's fine.

Overlays show/hide with class toggles: `.overlay{opacity:0;visibility:hidden;transition:...} .overlay.show{...}`.

Pause: pressing P during play toggles. Also pressing P during demo? Ignore or start? Any key in demo starts game. So 'p' would start the game too — fine.

Also while 'dying', ignore direction input; queue cleared on reset.

Score displays: header pills with ids scoreNum, bestNum. Pop animation: on update add class 'pop', remove after animation (use animationend or re-trigger by void offsetWidth).

Footer: `<footer class="hints">` with kbd pills: "← ↑ → ↓" "W A S D" "move", "P pause", "Space restart". Hide some on small screens.

Let me think about the header layout: 
```
<header>
  <div class="brand">
    <span class="brand-mark"></span>  <!-- small canvas-free CSS snake dot? -->
    <h1>SNAKE</h1>
  </div>
  <div class="scores">
    <div class="pill"><span class="label">SCORE</span><span class="value" id="scoreNum">0</span></div>
    <div class="pill best"><span class="label">BEST</span><span class="value" id="bestNum">0</span></div>
  </div>
</header>
```
brand-mark: a small CSS element: maybe a rounded pill with two eye dots? Cute: 22×22 rounded square gradient green with two white dots. Or simpler: a small "S" glyph in a rounded lime square. I'll do a tiny CSS snake: a green rounded rectangle with eyes via pseudo-elements. Let's do: `.brand-mark` = 26×26 border-radius 9px, background lime-green gradient, with two dark eye dots (::before/::after positioned). Actually eyes white on green. Nice mini-head. 

Title font: letter-spacing wide, font-weight 800. Maybe stacked: "SNAKE" with a thin sub "— the classic, polished"? Keep minimal: subtitle small muted "classic arcade · canvas edition"? Something tasteful: "SNAKE" + tagline "vanilla js · canvas". Eh — I'll add small muted tag under/next: "· pixel-perfect classic". Keep it subtle. Maybe just the mark + SNAKE + a small muted dot + "24 × 24". Let me add grid size badge — nice: "24×24 GRID". Meh. I'll include a small tagline "the classic, remastered". Fine.

**Now, the "at least 20×20"** — I'll go 24×24 (COLS=ROWS=24).

**Demo mode specifics**: demo starts on load. AI picks direction each tick. Overlay visible with prompt. When AI dies in demo: brief "over" flash? Just auto-restart demo after ~1s with a fade? Keep simple: on death in demo, state 'dying' → after 900ms, resetGame(keep demo) and continue. Maybe show a tiny "AI crashed — respawning" toast? Skip; keep it smooth.

Also demo speed: slightly slower (tickMs 120) for watchability. OK same as normal start.

**Best score during demo**: not recorded (as decided).

Wait, but screenshot at 3s: demo mode must be running with overlay message "Click or press a key to start" — good.

**Handling rapid key presses**: queue max 3. Enqueue rule: last = queue.length? queue[queue.length-1] : dir. If newDir same as last or opposite of last → ignore. Else push (if queue.length<3).

Hmm, subtle: also ignore if same as current dir when queue empty (already heading that way) — covered by "same as last".

**Restart with Space during play?** Requirement says Space restarts on game over. During running, Space does nothing (or could pause? avoid). Keep nothing.

**Also click-to-pause?** No.

Now write the render functions carefully.

```
function render(t) {
  ctx.save();
  ctx.clearRect? We'll fill board bg fully each frame.
  // shake
  if (shake>0) ctx.translate(sx, sy);
  drawBoard();
  drawFood(time);
  drawParticlesUnder? no, particles above snake? Particles above everything except overlays. Draw order: board, food, snake, particles, floats.
  drawSnake(t);
  drawParticles();
  ctx.restore();
}
```

drawBoard: fill #0d1526; checker: loop cells fill alternate with very low alpha; plus subtle inner glow: radial gradient centered, from rgba(120,180,255,0.05) center to transparent — gives depth. And a faint grid line? Checker is enough. Maybe also 1px border inside via strokeRect. Since wrapper has CSS border, fine.

Perf: checker loop 576 rects per frame — fine. Could pre-render board to offscreen canvas once per resize — do that for cleanliness: create offscreen canvas boardCanvas on resize, draw pattern there, drawImage each frame. Yes, nicer.

drawFood:
```
const px = (food.x+0.5)*cell, py=(food.y+0.5)*cell;
const pulse = 1 + 0.07*Math.sin(now/180);
const spawnT = clamp((now-foodBorn)/260,0,1); ease out back?
const r = cell*0.34 * pulse * spawnEase;
glow: ctx.shadowColor + shadowBlur = cell*0.9; draw radial gradient circle:
grad: 0: '#ffe9a3', 0.35: '#ffb340', 0.75:'#ff7043', 1:'rgba(255,90,60,0)'
draw arc r*1.6? Actually glow ring: draw circle radius r with gradient stops, plus shadow. Let me: 
- outer soft halo: radialGradient(px,py,0,px,py,r*2.2): rgba(255,150,60,0.28)→0; fill circle r*2.2.
- core: radialGradient offset toward top-left: (px-r*0.25,py-r*0.3) white-ish → amber → red edge; fill circle r.
- tiny leaf/stem: small green rounded rect above? Cute apple: stem: dark line up 0.15*cell, leaf: small ellipse green rotated. Let me add stem+leaf — makes it read as food. Draw stem: stroke line from top of orb up; leaf: ellipse rotated -30°.
```
Looks good.

drawSnake(t):
```
pts = snake.map((seg,i)=>{
  const p = prevSnake[min(i, prevSnake.length-1)];
  return { x: (lerp(p.x,seg.x,t)+0.5)*cell, y: (lerp(p.y,seg.y,t)+0.5)*cell };
});
// body: draw from tail to neck
for (let i=pts.length-1;i>0;i--){
  const k = i/(pts.length-1); // 1 at tail
  color = bodyColor(k) // k=0 head-side
  width = cell*(0.82 - 0.28*k) maybe with min
  strokeStyle=color; lineWidth=width; lineCap='round';
  ctx.beginPath(); moveTo(pts[i]); lineTo(pts[i-1]); stroke();
}
```
bodyColor(k): lerp hue 95→165, sat 70→55, light 60→36. Also during dying, blend toward red: colorMix with #ff5b4d based on deathFlash.

Actually per-segment stroke with round caps: overlapping caps of adjacent segments with slightly different colors — the later-drawn (closer to head) covers. The color difference between adjacent segments is small, so no visible artifact. Also width tapering with round caps: fine.

Add subtle top highlight: after body, stroke a thinner line (width*0.35) along the same path with rgba(255,255,255,0.14)? Offset? A center highlight stroke along whole path: draw path through all pts, stroke with rgba(255,255,255,0.10), lineWidth cell*0.16, round caps. Might look like a glossy spine. Could look nice; slight risk of looking odd at the head. I'll clip it to body via drawing under the head circle... Let me try: draw spine from pts[1] onward (skip head area), alpha 0.12. OK.

Head:
```
const hd = pts[0], nk = pts[1];
let hx = hd.x-nk.x, hy=hd.y-nk.y; len=hypot; if len>0.001 normalize else use dir
const hr = cell*0.46 * headPulse;
// tongue (draw before head so base hidden? tongue comes out front; drawing before head means base covered by head — good)
drawTongue(hd, heading) 
// head circle
radial gradient centered slightly back: 
g = ctx.createRadialGradient(hd.x - heading.x*hr*0.2 - hr*0.3? ...)
simpler: gradient center at hd + heading*(-0.15hr) with radius hr*1.4: stops: 0:'#d6ff8a'? Let me: 0:'#c8ff7a',0.5:'#8ef05a', 1:'#3fae62'
fill circle hr.
// rim
stroke circle with rgba(255,255,255,0.25) lineWidth 1.5? maybe subtle darker outline: rgba(20,40,25,0.35).
// eyes
perp = {x:-heading.y, y:heading.x}
for s of [-1,1]:
  ex = hd.x + heading.x*hr*0.28 + perp.x*hr*0.45*s
  ey = ...
  white circle r=hr*0.34
  pupil: at ex+heading.x*hr*0.12, r=hr*0.17, color #12213a? dark #0c1626
  glint: tiny white dot r=hr*0.07 offset up-left.
```
During dying: eyes become X? Cute: draw crosses instead of pupils when dead. Let's do: if state dying/over: draw X eyes (two lines). Fun polish. 

drawTongue: 
```
const period=2600; ph = now % period; if(ph<300 && state==='running'){ const e=Math.sin(ph/300*Math.PI); const len=hr*1.1*e; base = hd + heading*hr*0.9? Start at head edge: hd+heading*(hr*0.8). tip = base+heading*len. draw line base→tip (red #ff4d5e, width cell*0.08, round cap), then fork: two lines from tip to tip+heading*0.25len rotated ±25°... compute fork dirs: rotate heading ±0.45 rad.
}
```

Particles:
```
spawnBurst(x,y): for 16: particles.push({x,y,vx:cos*sp,vy:sin*sp,life:1,decay:rand(1.2..2)/s? ,r:rand(1.5..3)*cell/24? size relative: cell*rand(0.06..0.14), color: pick from ['#ffd23f','#ff8a3d','#ff5b66','#9ef25c'?] warm palette mostly.})
update: x+=vx*dt; vy += gravity? slight: vy += 300*dt? scale with cell. drag: vx*=pow(0.0.., dt). Use simple: p.vx *= (1-2.5*dt); p.vy = p.vy*(1-2.5*dt)+? gravity g=cell*8*dt? Let me define in px/sec: speed = cell*(2..6). gravity = cell*10 /s². life -= dt/0.7.
render: alpha=life, radius shrink r*life... circles.
```
Also ring: {type:'ring', r growing cell*0.4→cell*1.4, alpha fading} stroke circle.
Float text: {type:'text', str:'+10', y drifting up, alpha}.

**+10 text**: font bold `${cell*0.5}px` fill lime, stroke dark for contrast? fill '#eaffb0' with shadow. Rising cell*0.8 over 0.7s, fade.

Now overlays in DOM, with `position:absolute; inset:0; display:flex; align-items:center; justify-content:center;`.

Game over panel design: glass card: background rgba(10,16,30,0.78), backdrop-filter blur(8px), border 1px rgba(255,255,255,0.08), radius 18px, padding 28px 40px, text-center, box-shadow big. Big title "GAME OVER" letterspaced, red-ish? Title in gradient text? Use coral #ff6b6b for game over. Score big number in lime. New best badge: pill with lime bg dark text, subtle pulse animation.

Pause overlay: similar glass card: "PAUSED".

Demo overlay: no full card; bottom prompt: position absolute bottom 8%; centered; pill: dark bg blur border, text "Click or press a key to start" with animated pulsing ring/dot. Plus top-left "DEMO" chip. Add subtle animated bounce on prompt (translateY keyframes) or a pulsing halo. Also dim board slightly with radial vignette? The overlay div could have background: radial-gradient(ellipse at center, transparent 40%, rgba(4,8,16,0.55)) — dims edges, keeps center visible. Good.

Demo chip top-left: "AI DEMO" with a small pulsing green dot.

Keyboard focus: iframe needs click to receive keys — that's why prompt says click or press a key. 

Also make the whole document cursor: pointer in demo? Add `body.demo-mode` class → cursor pointer on canvas wrap. Fine minor.

**Edge: window blur during play** → auto-pause? Nice: window.addEventListener('blur', pause if running && mode play). Good touch, prevents unfair deaths. But in sandbox iframe blur might fire on load? Only if focus lost; on load focus may not be in iframe anyway. Auto-pause only when state==='running' && mode==='play'. When user hasn't started, mode is demo — no effect. OK.

**Resize** handling: recompute cell; re-render board offscreen; keep game state (cell-based so fine).

Also handle very small heights: cell min 10 → board 240px + header; fits 375×667 phones landscape? Width 375: maxW = 375-24=351 → cell=floor(351/24)=14 → 336 board. Height fine.

Hmm, for narrow phones, maybe use COLS=24 fixed. 336 board OK.

**Score pills responsive**: header wraps? Use flex wrap; on small screens, header row compresses: title smaller. Ensure no horizontal scrollbar: body overflow hidden? Better `overflow-x:hidden` hmm; design to fit. I'll set body margin 0, stage max-width, and header pills compact.

**Fonts**: 
- Title: `font-family: 'Trebuchet MS', 'Segoe UI', system-ui, sans-serif`? Use system stack: `-apple-system, 'Segoe UI', Roboto, ...`. For a game, a chunky feel: letter-spacing + weight 800 works with system-ui bold. Numbers: `ui-monospace, 'Cascadia Mono', 'SF Mono', Menlo, Consolas, monospace` with font-variant-numeric: tabular-nums.

Now, let me also consider the "grid at least 20×20 cells, centered on the page" — canvas centered via flex. ✓.

Let me now write the actual code.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Snake — Canvas Edition</title>
<style>
  :root{
    --bg:#070b14; --panel:#0d1526; --line:rgba(130,170,255,.14);
    --text:#e8eefc; --muted:#8b98b8; --lime:#9ef25c; --lime-deep:#5ec26a;
    --coral:#ff6b6b; --amber:#ffb340;
  }
  *{box-sizing:border-box;margin:0;padding:0}
  html,body{height:100%}
  body{
    background:
      radial-gradient(1200px 700px at 50% -10%, #101b33 0%, rgba(16,27,51,0) 60%),
      radial-gradient(900px 500px at 85% 110%, rgba(20,60,50,.25) 0%, rgba(0,0,0,0) 60%),
      var(--bg);
    color:var(--text);
    font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;
    min-height:100vh; min-height:100dvh;
    display:flex; align-items:center; justify-content:center;
    overflow:hidden;
    user-select:none; -webkit-user-select:none;
  }
  ...
```

Wait overflow hidden on body might clip on short screens... Use `overflow:hidden` to guarantee no scrollbar; content sized to fit via JS. If viewport super short, board shrinks (min cell 9). OK.

Stage layout: 
```
.stage{display:flex;flex-direction:column;align-items:center;gap:clamp(10px,2vh,18px);padding:16px;}
header{display:flex;align-items:flex-end;justify-content:space-between;width:var(--bw); gap...}
```
Hmm, header width should match board width. Since board width is set by JS, set a CSS variable `--bw` on resize: `document.documentElement.style.setProperty('--bw', boardPx+'px')` and header uses width:var(--bw) with min-width fallback. Or simpler: put header inside a wrapper whose width shrinks to canvas: make `.stage` width fit-content? `.stage{width:min-content}`? then header width:100% of stage = canvas width. Let me do: `.stage{display:flex;flex-direction:column;align-items:stretch;width:min-content}` — header spans canvas width. But canvas width set inline by JS. And stage centered by body flex. 

Careful: `width:min-content` with canvas inside works since canvas has explicit px size. Header `width:100%`? With align-items:stretch, header stretches to stage width. Yes.

Footer hints also stretch.

Header content:
```
<header>
  <div class="brand">
    <span class="mark" aria-hidden="true"></span>
    <div class="titles">
      <h1>SNAKE</h1>
      <span class="tag">the classic · remastered</span>
    </div>
  </div>
  <div class="scores">
    <div class="pill"><span class="lbl">Score</span><span class="val" id="scoreVal">0</span></div>
    <div class="pill best"><span class="lbl">Best</span><span class="val" id="bestVal">0</span></div>
  </div>
</header>
```

mark CSS: 30×30, radius 10, background: linear-gradient(145deg,#c8ff7a,#3fae62); position relative; eyes: ::before,::after white circles 6px at 30% top, left 25%/60%. Add box-shadow glow lime.

h1: font-size clamp(20px, 3vw, 28px); letter-spacing .35em (with padding-left to offset? minor). font-weight 800. Color: #f2f6ff. tag: 10px uppercase letterspace muted.

Pills: background rgba(255,255,255,.04); border 1px solid var(--line); border-radius 999px; padding 6px 14px; display flex; gap 10px; align-items baseline. lbl: 10px uppercase tracking muted. val: monospace 18px bold color lime; best val maybe amber. Pop animation: keyframes scorePop {0{scale:1}40{scale:1.35}100{scale:1}} applied via class.

Board wrap:
```
.board-wrap{position:relative;border-radius:14px;box-shadow:0 20px 60px rgba(0,0,0,.55), 0 0 0 1px rgba(130,170,255,.10), 0 0 80px rgba(90,200,110,.07); overflow:hidden;}
canvas{display:block;border-radius:14px? no need since clipped}
```

Overlays CSS as planned.

Footer:
```
<footer class="hints">
  <span class="hint"><kbd>↑</kbd><kbd>←</kbd><kbd>↓</kbd><kbd>→</kbd><i>/</i><kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> move</span>
  <span class="hint"><kbd>P</kbd> pause</span>
  <span class="hint"><kbd>Space</kbd> restart</span>
</footer>
```
kbd style: mini pills: background rgba(255,255,255,.06); border 1px solid var(--line); border-bottom-width 2px; radius 6px; padding 2px 7px; font 11px mono; color #cdd8f0.

On small screens hide footer maybe: media query <480px: .hints{display:none}? Keep but wrap. Actually with 375 width, kbd row might overflow: allow flex-wrap + smaller. I'll allow wrap and reduce.

Now JS. Let me write it fully and carefully.

```js
'use strict';
// ---------- constants ----------
const COLS = 24, ROWS = 24;
const BASE_TICK = 132, MIN_TICK = 82, TICK_STEP = 1.7;
const DIRS = { up:{x:0,y:-1}, down:{x:0,y:1}, left:{x:-1,y:0}, right:{x:1,y:0} };

// ---------- dom ----------
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const wrap = document.getElementById('boardWrap');
const scoreVal = ..., bestVal = ...;
const ovDemo, ovPause, ovOver, ovScoreNum, ovBestNum, ovNewBest, btnRestart;

// ---------- state ----------
let cell = 22, dpr = 1;
let mode = 'demo';           // 'demo' | 'play'
let state = 'running';       // 'running' | 'paused' | 'dying' | 'over'
let snake = [], prevSnake = [], dir = DIRS.right, queue = [];
let food = null, foodBorn = 0;
let score = 0, best = 0, newBest = false;
let tickMs = BASE_TICK, acc = 0, lastTime = 0;
let particles = [];
let shake = 0, dieAt = 0, headPulse = 0;
let now = 0; // ms timestamp updated each frame
let boardCanvas = null;
```

resize:
```js
function resize(){
  dpr = Math.min(window.devicePixelRatio || 1, 2);
  const stage = document.querySelector('.stage');
  const headH = document.querySelector('header').offsetHeight;
  const footH = document.querySelector('footer').offsetHeight;
  const availW = Math.min(window.innerWidth - 28, 780);
  const availH = window.innerHeight - headH - footH - 58;
  cell = Math.max(9, Math.floor(Math.min(availW, availH) / COLS));
  const px = cell * COLS;
  canvas.style.width = px + 'px';
  canvas.style.height = px * ROWS / COLS + 'px'; // square: px
  canvas.width = Math.round(px * dpr);
  canvas.height = Math.round(px * dpr);
  ctx.setTransform(dpr,0,0,dpr,0,0);
  buildBoard();
}
```
Since square, height = px.

Wait: availH might be negative on tiny windows → clamp handles min 9 → 216px board; might overflow viewport but acceptable.

But header/footer offsetHeight depends on width... circular but fine (header width follows stage width which follows canvas). Initial: canvas width 0 → header narrow? Header content width: brand + pills, ~ 300px min. Let me give stage a min-width via CSS: `.stage{min-width:min(92vw, 560px)}`? Hmm, simpler: don't tie header width to canvas exactly; give header `width:100%` inside stage where stage width = canvas width, but measure with current layout: offsetHeight doesn't depend much on width unless wrapping. Minor. Also call resize twice? Just call once on load and on window resize; heights stable enough.

Actually let me simplify: fixed budget: availH = innerHeight - 150 (header ~64 + footer ~34 + gaps ~40 + padding 32 ≈ 170). Use innerHeight - 165, min 240. And measure precisely is nicer but fixed budget is robust. I'll measure header/footer with fallback 70/40:

```js
const headH = headerEl.offsetHeight || 70;
const footH = footerEl.offsetHeight || 36;
const availH = window.innerHeight - headH - footH - 56;
```

buildBoard offscreen:
```js
function buildBoard(){
  boardCanvas = document.createElement('canvas');
  const px = cell*COLS;
  boardCanvas.width = Math.round(px*dpr); boardCanvas.height = Math.round(px*dpr);
  const b = boardCanvas.getContext('2d');
  b.setTransform(dpr,0,0,dpr,0,0);
  b.fillStyle = '#0d1526'; // maybe gradient
  const g = b.createLinearGradient(0,0,px,px);
  g.addColorStop(0,'#101b31'); g.addColorStop(1,'#0b1322');
  b.fillStyle=g; b.fillRect(0,0,px,px);
  // checker
  b.fillStyle='rgba(148,180,255,0.028)';
  for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++) if((x+y)%2===0) b.fillRect(x*cell,y*cell,cell,cell);
  // vignette
  const v = b.createRadialGradient(px/2,px/2,px*0.25,px/2,px/2,px*0.75);
  v.addColorStop(0,'rgba(120,190,255,0.05)'); v.addColorStop(1,'rgba(0,0,0,0.22)');
  b.fillStyle=v; b.fillRect(0,0,px,px);
}
```
Then render: ctx.drawImage(boardCanvas, 0,0, px, px) — since boardCanvas backing is px*dpr and ctx transform is dpr-scaled, drawImage(boardCanvas,0,0,px,px) draws it at correct size. ✓.

resetGame:
```js
function resetGame(){
  const cx = Math.floor(COLS/2), cy = Math.floor(ROWS/2);
  snake = [];
  for(let i=0;i<4;i++) snake.push({x:cx-i, y:cy});
  prevSnake = snake.map(s=>({...s}));
  dir = DIRS.right; queue = [];
  score = 0; newBest = false;
  tickMs = BASE_TICK; acc = 0;
  particles = []; shake = 0; headPulse = 0;
  state = 'running';
  spawnFood();
  updateScoreUI();
}
```

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

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

step:
```js
function step(){
  if(mode==='demo'){ dir = aiDir(); }
  else if(queue.length){ dir = queue.shift(); }
  const head = snake[0];
  const nx = head.x+dir.x, ny = head.y+dir.y;
  if(nx<0||ny<0||nx>=COLS||ny>=ROWS){ die(); return; }
  const eating = food && nx===food.x && ny===food.y;
  const bodyLen = eating ? snake.length : snake.length-1;
  for(let i=0;i<bodyLen;i++){ const s=snake[i]; if(s.x===nx&&s.y===ny){ die(); return; } }
  prevSnake = snake.map(s=>({x:s.x,y:s.y}));
  snake.unshift({x:nx,y:ny});
  if(eating){
    score += 10;
    if(mode==='play' && score>best){ best=score; newBest=true; updateScoreUI(); }
    updateScoreUI(true);
    headPulse = 1;
    burstAt(food);
    tickMs = Math.max(MIN_TICK, BASE_TICK - (snake.length-4)*TICK_STEP);
    spawnFood();
  } else {
    snake.pop();
  }
}
```

Wait order: spawnFood uses snake incl new head — after unshift, correct (food not on snake). ✓. Note: eating branch: we didn't pop → grew by 1. ✓.

die():
```js
function die(){
  state='dying'; dieAt=now; shake=1;
  if(mode==='play' && score>best){ best=score; newBest=true; } // final safety
  updateScoreUI();
}
```
Then in loop: if state==='dying' && now-dieAt>750 → finishDeath():
```js
function finishDeath(){
  if(mode==='demo'){ resetGame(); showOverlay(demoOnly); }
  else { state='over'; showOverOverlay(); }
}
```
In demo: immediately reset to running. Also re-show demo overlay (it should remain visible anyway during whole demo; I'll keep ovDemo shown during demo always, independent).

Overlays management: 
```js
function setOverlay(el, show){ el.classList.toggle('show', show); }
```
Demo overlay shown while mode==='demo'. On startPlay: hide demo overlay. On death in play → show ovOver with values. On restart → hide ovOver.

startPlay:
```js
function startPlay(firstDir){
  mode='play';
  document.body.classList.remove('demo-mode');
  setOverlay(ovDemo,false); setOverlay(ovOver,false); setOverlay(ovPause,false);
  resetGame();
  if(firstDir) enqueueDir(firstDir);
}
```

Input:
```js
const KEYMAP = {ArrowUp:'up',ArrowDown:'down',ArrowLeft:'left',ArrowRight:'right',w:'up',a:'left',s:'down',d:'right',W:'up',A:'left',S:'down',D:'right'};

window.addEventListener('keydown', e=>{
  const key = e.key;
  if(key===' '||key.startsWith('Arrow')) e.preventDefault();
  if(mode==='demo'){
    const d = KEYMAP[key];
    startPlay(d ? DIRS[d] : null);
    return;
  }
  if(state==='over'){
    if(key===' '||key==='Enter'){ startPlay(null); }  // restart
    return;
  }
  if(key==='p'||key==='P'){
    if(state==='running'){ state='paused'; setOverlay(ovPause,true); }
    else if(state==='paused'){ state='paused'→'running'; setOverlay(ovPause,false); lastTime=now; acc=0; }
    return;
  }
  if(state==='running'){
    const d = KEYMAP[key];
    if(d) enqueueDir(DIRS[d]);
  }
});
```
Note: when unpausing, reset acc to 0 and lastTime handled in loop via dt clamp; acc=0 avoids instant tick.

Click:
```js
document.addEventListener('pointerdown', e=>{
  if(mode==='demo'){ startPlay(null); return; }
  if(state==='over'){ startPlay(null); }
});
```
btnRestart also triggers (bubbles → same handler). Ensure no double-start: pointerdown on button → state==='over' → startPlay. Fine. But careful: clicking during play does nothing. And clicking pause overlay? Could resume on click? Add: if paused and click → resume. Sure, nice: `else if(state==='paused'){ resume }`.

Hmm — pointerdown anywhere on document including header/footer — fine.

One more: in demo mode, first keydown might be 'p' — starts game (fine). Also Enter in demo — starts.

AI:
```js
function aiDir(){
  const head = snake[0];
  const options = Object.values(DIRS).filter(d=>!(d.x===-dir.x&&d.y===-dir.y));
  const blocked = new Set(snake.slice(0, snake.length-1).map(k=>k.x+'_'+k.y)); // tail moves away (approx: not eating)
  // but if next cell is food, tail stays — minor; ignore or handle: if candidate is food cell we could re-block tail. Skip.
  let bestD=null, bestScore=-Infinity;
  for(const d of options){
    const nx=head.x+d.x, ny=head.y+d.y;
    if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
    if(blocked.has(nx+'_'+ny)) continue;
    const space = floodCount(nx,ny,blocked);
    const dist = Math.abs(nx-food.x)+Math.abs(ny-food.y);
    let s = -dist*2 + Math.min(space, snake.length+4)*0.6;
    if(space < snake.length) s -= 500;             // cramped
    if(d===dir) s += 0.4;                          // slight straight preference... but object identity: DIRS.up etc — d===dir works since dir is one of DIRS values. ✓
    if(s>bestScore){bestScore=s;bestD=d;}
  }
  return bestD || dir;
}

function floodCount(sx,sy,blocked){
  const seen = new Uint8Array(COLS*ROWS);
  const q=[sy*COLS+sx]; seen[q[0]]=1;
  let count=0, cap=snake.length+6;
  while(q.length){
    const idx=q.pop(); count++;
    if(count>=cap) return count;
    const x=idx%COLS, y=(idx/COLS)|0;
    // neighbors
    if(x>0 && !seen[idx-1] && !blocked.has((x-1)+'_'+y)){seen[idx-1]=1;q.push(idx-1);}
    ... etc
  }
  return count;
}
```
blocked is Set of strings 'x_y'. Fine. Early exit cap prevents useless work.

Hmm, aiDir uses `food` — food always exists. ✓

One more AI consideration: when the candidate cell equals the tail's current cell (which will vacate) it's allowed — blocked excludes last segment. But if that move eats food adjacent... edge cases negligible.

Particles:
```js
function burstAt(f){
  const px=(f.x+0.5)*cell, py=(f.y+0.5)*cell;
  const colors=['#ffd23f','#ff9a3d','#ff5b66','#fff2b0'];
  for(let i=0;i<18;i++){
    const a=Math.random()*Math.PI*2, sp=cell*(1.5+Math.random()*4.5);
    particles.push({type:'dot',x:px,y:py,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp - cell*1.2,
      r:cell*(0.06+Math.random()*0.10), life:1, decay:1/(0.45+Math.random()*0.4), color:colors[(Math.random()*colors.length)|0]});
  }
  particles.push({type:'ring',x:px,y:py,r:cell*0.3,life:1,decay:1/0.45});
  particles.push({type:'text',x:px,y:py-cell*0.3,vy:-cell*1.6,life:1,decay:1/0.8,str:'+10'});
}
function updateParticles(dt){
  for(let i=particles.length-1;i>=0;i--){
    const p=particles[i];
    p.life-=p.decay*dt;
    if(p.life<=0){particles.splice(i,1);continue;}
    if(p.type==='dot'){
      p.x+=p.vx*dt; p.y+=p.vy*dt;
      p.vx*=(1-3.2*dt); p.vy=p.vy*(1-1.6*dt)+cell*9*dt;
    } else if(p.type==='ring'){ p.r+=cell*3.2*dt; }
    else if(p.type==='text'){ p.y+=p.vy*dt; }
  }
}
function drawParticles(){
  for(const p of particles){
    ctx.save();
    if(p.type==='dot'){
      ctx.globalAlpha=Math.max(0,p.life);
      ctx.fillStyle=p.color;
      ctx.beginPath();ctx.arc(p.x,p.y,p.r*(0.4+0.6*p.life),0,7);ctx.fill();
    } else if(p.type==='ring'){
      ctx.globalAlpha=Math.max(0,p.life)*0.5;
      ctx.strokeStyle='#ffd23f'; ctx.lineWidth=Math.max(1,cell*0.08*p.life);
      ctx.beginPath();ctx.arc(p.x,p.y,p.r,0,7);ctx.stroke();
    } else {
      ctx.globalAlpha=Math.max(0,Math.min(1,p.life*1.4));
      ctx.font=`800 ${cell*0.6}px ui-monospace, Menlo, monospace`;
      ctx.textAlign='center';
      ctx.lineWidth=cell*0.12; ctx.strokeStyle='rgba(8,14,26,0.8)'; ctx.lineJoin='round';
      ctx.strokeText(p.str,p.x,p.y); 
      ctx.fillStyle='#d9ff9e';
      ctx.fillText(p.str,p.x,p.y);
    }
    ctx.restore();
  }
}
```
Note `arc(...,0,7)` — 7 > 2π ✓ (lazy full circle).

Drag formula `p.vx*=(1-3.2*dt)` — with dt~0.016 → 0.95 factor per frame; fine as dt small. dt clamped ≤ 0.05? Loop clamp: dt=min((t-last)/1000, 0.05)... careful with pause resumption. I'll clamp dt to 0.1 max and set lastTime=now each frame regardless.

Main loop:
```js
function frame(t){
  now = t;
  let dt = Math.min((t-lastTime)/1000, 0.1);
  lastTime = t;
  if(state==='running'){
    acc += dt*1000;
    let guard=0;
    while(acc>=tickMs && state==='running' && guard++<5){ step(); acc-=tickMs; }
  } else if(state==='dying'){
    if(now-dieAt>780) finishDeath();
  }
  if(state!=='paused'){ updateParticles(dt); shake=Math.max(0,shake-dt*2.2); headPulse=Math.max(0,headPulse-dt*3.5); }
  render();
  requestAnimationFrame(frame);
}
```
Hmm if paused, particles freeze — good. But `now` continues for food pulse animation... during pause, food still pulses (render uses now) — acceptable, looks alive. OK.

Also during 'dying'/'over', particles continue — nice.

interpolation t for render:
```js
const it = state==='running' ? Math.min(1, acc/tickMs) : 1;
```
During dying: freeze at t=1 (snake at last full position — since positions only advance in step, snake array is the last valid state; prevSnake same tick... After a step completes, snake is the new state; acc/tickMs interpolates within the next tick. On die(), we return before updating snake — so snake is still fully at previous completed positions with prevSnake from last step; t should be 1 to show final resting. But visually, snake was mid-interpolation when die happened... Actually die happens at tick boundary: acc≈tickMs, it≈1, snake at completed position. Setting t=1 shows final position — matches. Good.

render():
```js
function render(){
  const px=cell*COLS;
  ctx.save();
  ctx.clearRect(0,0,px,px);
  if(boardCanvas) ctx.drawImage(boardCanvas,0,0,px,px);
  if(shake>0){
    const s=shake*shake*cell*0.45;
    ctx.translate((Math.random()*2-1)*s,(Math.random()*2-1)*s);
  }
  if(food) drawFood();
  drawSnake();
  drawParticles();
  ctx.restore();
}
```

drawFood:
```js
function drawFood(){
  const born = Math.min(1,(now-foodBorn)/280);
  const pop = 1 - Math.pow(1-born,3); // easeOutCubic — wait easeOutBack nicer: 
  const pulse = 1+0.06*Math.sin(now/170);
  const r = cell*0.34*pulse*(born<1? (0.2+0.8*pop) : 1);
```
Hmm easeOutBack overshoot: `const back = 1 + 1.7*Math.pow(born-1,3) + 0.7*... ` let me just do simple: scale = easeOutBack(born) where easeOutBack(t)=1+2.70158*Math.pow(t-1,3)+1.70158*Math.pow(t-1,2). Multiply r by that; clamp min 0.
```
  const px=(food.x+0.5)*cell, py=(food.y+0.5)*cell;
  // halo
  let g=ctx.createRadialGradient(px,py,0,px,py,r*2.6);
  g.addColorStop(0,'rgba(255,150,60,0.30)');g.addColorStop(1,'rgba(255,150,60,0)');
  ctx.fillStyle=g; ctx.beginPath();ctx.arc(px,py,r*2.6,0,7);ctx.fill();
  // orb
  g=ctx.createRadialGradient(px-r*0.35,py-r*0.4,r*0.1,px,py,r);
  g.addColorStop(0,'#fff6c8');g.addColorStop(0.35,'#ffc860');g.addColorStop(0.8,'#ff7a3c');g.addColorStop(1,'#f4502e');
  ctx.fillStyle=g;ctx.beginPath();ctx.arc(px,py,r,0,7);ctx.fill();
  // stem
  ctx.strokeStyle='#7a4a22';ctx.lineWidth=Math.max(1.2,cell*0.07);ctx.lineCap='round';
  ctx.beginPath();ctx.moveTo(px,py-r*0.8);ctx.quadraticCurveTo(px+r*0.1,py-r*1.25,px+r*0.05,py-r*1.35);ctx.stroke();
  // leaf
  ctx.fillStyle='#63c96a';
  ctx.save();ctx.translate(px+r*0.15,py-r*1.15);ctx.rotate(-0.6);
  ctx.beginPath();ctx.ellipse(0,0,r*0.42,r*0.2,0,0,7);ctx.fill();ctx.restore();
}
```
Careful: stem coordinates when food spawns near top row: py - r*1.35 could go slightly negative (above canvas) — clipped by canvas, fine.

drawSnake:
```js
function drawSnake(){
  const it = state==='running' ? Math.min(1, acc/tickMs) : 1;
  const n = snake.length;
  const pts = new Array(n);
  for(let i=0;i<n;i++){
    const c=snake[i];
    const p=prevSnake[Math.min(i,prevSnake.length-1)]||c;
    pts[i]={x:(p.x+(c.x-p.x)*it+0.5)*cell, y:(p.y+(c.y-p.y)*it+0.5)*cell};
  }
  const dead = (state==='dying'||state==='over');
  const flash = dead? Math.max(0, Math.sin((now-dieAt)/90)) : 0; // blink
  // body strokes tail→head
  for(let i=n-1;i>0;i--){
    const k=i/Math.max(1,n-1);
    let col=bodyColor(k);
    if(dead) col=flash>0.5? '#ff6a5e' : col; // blink red
```
Hmm blink via sin threshold gives strobing red — maybe blend: mix color toward red by 0.65 constant + slight blink. Let me do: if dead, col = k<0.5? '#e35b4f' : '#8f3f38' with occasional flash. Simple: define deadBody colors gradient red: head-side '#f0655a' tail '#7e3a33', plus alpha flicker: ctx.globalAlpha = 0.75+0.25*sin(now/60). OK.

```js
    ctx.strokeStyle=col;
    ctx.lineWidth=Math.max(3, cell*(0.80-0.30*k));
    ctx.lineCap='round'; ctx.lineJoin='round';
    ctx.beginPath();ctx.moveTo(pts[i].x,pts[i].y);ctx.lineTo(pts[i-1].x,pts[i-1].y);ctx.stroke();
  }
  // glossy spine
  if(n>2){
    ctx.strokeStyle='rgba(255,255,255,0.10)';
    ctx.lineWidth=Math.max(1.5,cell*0.14);
    ctx.lineCap='round';
    ctx.beginPath();ctx.moveTo(pts[1].x,pts[1].y);
    for(let i=2;i<n;i++)ctx.lineTo(pts[i].x,pts[i].y);
    ctx.stroke();
  }
```
Hmm wait — spine along interpolated pts from neck to tail. But careful: interpolated pts when a corner cuts, spine follows same path — fine.

Hmm, but there's a subtlety with the spine during movement: points shift; the polyline stays within body. OK.

```js
  // head
  const hd=pts[0];
  let hx,hy;
  if(n>1){hx=hd.x-pts[1].x;hy=hd.y-pts[1].y;}
  else {hx=dir.x;hy=dir.y;}
  const hl=Math.hypot(hx,hy);
  if(hl<0.0001){hx=dir.x;hy=dir.y;} else {hx/=hl;hy/=hl;}
  const px=-hy, py=hx; // perpendicular
  const hr=cell*0.46*(1+0.22*headPulse);
  // tongue
  if(!dead){
    const ph=now%2600;
    if(ph<300){
      const e=Math.sin(ph/300*Math.PI);
      const bx=hd.x+hx*hr*0.75, by=hd.y+hy*hr*0.75;
      const len=hr*0.9*e;
      const tx=bx+hx*len, ty=by+hy*len;
      ctx.strokeStyle='#ff4d5e';ctx.lineWidth=Math.max(1.5,cell*0.09);ctx.lineCap='round';
      ctx.beginPath();ctx.moveTo(bx,by);ctx.lineTo(tx,ty);
      const fl=hr*0.34*e;
      ctx.lineTo(tx+ (hx*0.5+px*0.5)*fl*1.4? ...);
```
Fork: two lines from tip: dir rotated ±0.5rad: 
```
      for(const s of [-1,1]){
        const fx=hx*Math.cos(0.5)+ (s>0? -hy*Math.sin(0.5) : hy*... 
```
Simpler: fork endpoints = tip + normalize(hx±px*0.6, hy±py*0.6)*fl. Let me: for s in [-1,1]: fdx = hx + px*0.7*s; fdy = hy + py*0.7*s; normalize; end = tip + f*fl. Draw lineTo from tip. Combine into path: moveTo(bx,by) lineTo(tx,ty); then moveTo(tx,ty) lineTo(ex1,ey1); moveTo(tx,ty) lineTo(ex2,ey2); stroke once.
```js
    }
  }
  // head shape
  let g=ctx.createRadialGradient(hd.x-hx*hr*0.3-px*hr*0.15? 
```
Simplify: gradient centered slightly back from face:
```js
  g=ctx.createRadialGradient(hd.x-hx*hr*0.35,hd.y-hy*hr*0.35,hr*0.15,hd.x,hd.y,hr*1.05);
```
Wait createRadialGradient(x0,y0,r0,x1,y1,r1): inner circle at back-center small radius, outer at head center radius hr*1.05. stops: 0 dead? For alive: 0:'#d3ff8f',0.45:'#9ef25c',1:'#48b06a'. Dead: 0:'#ffb0a5',0.45:'#f0655a',1:'#8a332c'.
```js
  ctx.fillStyle=g;ctx.beginPath();ctx.arc(hd.x,hd.y,hr,0,7);ctx.fill();
  // subtle rim
  ctx.strokeStyle='rgba(10,30,20,0.35)';ctx.lineWidth=1;ctx.beginPath();ctx.arc(hd.x,hd.y,hr-0.5,0,7);ctx.stroke();
```
Hmm rim full circle dark subtle — ok maybe skip or keep alpha low. Keep.

Eyes:
```js
  for(const s of [-1,1]){
    const ex=hd.x+hx*hr*0.30+px*hr*0.46*s;
    const ey=hd.y+hy*hr*0.30+py*hr*0.46*s;
    const er=hr*0.34;
    ctx.fillStyle='#ffffff';ctx.beginPath();ctx.arc(ex,ey,er,0,7);ctx.fill();
    if(dead){
      // X eyes
      ctx.strokeStyle='#1a2333';ctx.lineWidth=Math.max(1.4,er*0.4);ctx.lineCap='round';
      const xr=er*0.55;
      ctx.beginPath();
      ctx.moveTo(ex-xr,ey-xr);ctx.lineTo(ex+xr,ey+xr);
      ctx.moveTo(ex+xr,ey-xr);ctx.lineTo(ex-xr,ey+xr);
      ctx.stroke();
    } else {
      const pr=er*0.52;
      ctx.fillStyle='#14243c';ctx.beginPath();ctx.arc(ex+hx*er*0.35,ey+hy*er*0.35,pr,0,7);ctx.fill();
      ctx.fillStyle='rgba(255,255,255,0.9)';ctx.beginPath();ctx.arc(ex+hx*er*0.15-er*0.25? 
```
Glint: at pupil upper-left: gx = ex - er*0.22, gy = ey - er*0.28, r=er*0.16.
```js
    }
  }
```

bodyColor(k): 
```js
function bodyColor(k){
  const h=95+70*k, s=72-18*k, l=60-24*k;
  return `hsl(${h} ${s}% ${l}%)`;
}
```
hsl space-separated supported in modern browsers (canvas fillStyle accepts CSS color; `hsl(120 50% 50%)` valid CSS Color 4 — Chrome supports). Safer with commas: `hsl(120, 50%, 50%)`. Use commas for safety.

k=0 (near head): hsl(95,72%,60%) lime-ish; k=1 (tail): hsl(165,54%,36%) teal-dark. 

But careful: per-segment i from n-1 down: k = i/(n-1); segment between pts[i] and pts[i-1]: use k for that segment (i closer to tail → larger k). For i=n-1 (tail-most) k=1. Good.

**Demo overlay & start prompt**: ensure visible on first paint — yes since demo mode active and CSS shows `.overlay.demo.show`.

Wait — one more requirement check: "Games must start in a self-playing demo mode (a simple AI plays) behind a 'Click or press a key to start' message". ✓.

**updateScoreUI**:
```js
function updateScoreUI(pop){
  scoreVal.textContent=score;
  bestVal.textContent=best;
  if(pop){ scoreVal.classList.remove('pop'); void scoreVal.offsetWidth; scoreVal.classList.add('pop'); }
}
```
Also ovScoreNum etc set when showing over overlay.

**showOverOverlay**:
```js
function showOverOverlay(){
  ovScoreNum.textContent=score;
  ovBestNum.textContent=best;
  ovNewBest.style.display = newBest? '' : 'none';
  setOverlay(ovOver,true);
}
```

**Init**:
```js
document.body.classList.add('demo-mode');
resize();
resetGame();
requestAnimationFrame(t=>{lastTime=t;now=t;requestAnimationFrame(frame)});
window.addEventListener('resize', resize);
```
Careful: resetGame uses `now` for foodBorn — set now=0 initial fine.

Also on resize during play, cell changes → positions scale; fine.

Also `window.addEventListener('blur', ...)` auto-pause in play mode.

Let me now also double check the interpolation for the very first frames: prevSnake=snake copy → static until first tick. acc accumulates → after tickMs, step runs. ✓ Demo AI immediately steers.

**A critical check on step() when dying at wall**: die() called; snake unchanged; state dying; render t=1: pts all at final positions; head at last cell before wall. Then shake + red flash + X eyes. 

Wait — when dying, interpolation t: state!=='running' → it=1. But acc/tickMs could be ~1 anyway. ✓.

But hmm: at the moment of death there's a visual "stop mid-motion" since the head was about to move. That's the natural expectation (bonks the wall). Good. Could add a small head bump toward wall? Skip.

**Self-collision check when growing**: bodyToCheck = full snake when eating. But note the head can't be at newHead... trivially true. ✓

**Queue reversal guard across application**: e.g., moving right, quickly press up, left: queue=[up,left]. Tick1: dir=up (check: not opposite of right ✓). Tick2: dir=left (not opposite of up ✓). Snake turns up then left — both honored. ✓

Pressing up, down quickly while moving right: enqueue up ✓ (last=right, up not same/opposite); enqueue down: last=up → down is opposite of up → rejected. Good — prevents the classic reverse bug.

**Space to restart also via click** ✓.

**Now the pause during demo?** P in demo starts game (any key). ✓ requirement only says P pauses (during play). ✓

**Prevent scroll**: body overflow hidden + preventDefault on arrows/space. ✓

**Accessibility-ish**: add aria-hidden to decorative stuff; not critical.

Let me write overlay CSS:

```css
.overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
  opacity:0;visibility:hidden;transition:opacity .3s ease, visibility .3s;z-index:5;}
.overlay.show{opacity:1;visibility:visible}
.overlay.demo{background:radial-gradient(120% 90% at 50% 40%, rgba(5,9,18,0) 30%, rgba(5,9,18,.55) 100%);
  pointer-events:none; flex-direction:column;}
```
Demo overlay content:
```html
<div class="overlay demo" id="ovDemo">
  <div class="demo-chip"><span class="dot"></span>AI DEMO</div>
  <div class="prompt">
    <div class="prompt-line">Click or press a key to start</div>
    <div class="prompt-sub">Arrows / WASD to steer · P to pause</div>
  </div>
</div>
```
Position: chip top-left absolute; prompt at bottom: use flex column with justify-content:flex-end + padding-bottom 9%; chip absolute top 14px left 14px.

```css
.overlay.demo{justify-content:flex-end;padding-bottom:7%;}
.demo-chip{position:absolute;top:14px;left:14px;display:flex;align-items:center;gap:8px;
  font-size:11px;letter-spacing:.22em;font-weight:700;color:#bfe3a0;
  background:rgba(10,18,32,.6);border:1px solid rgba(158,242,92,.25);border-radius:999px;padding:6px 12px;
  backdrop-filter:blur(4px);}
.demo-chip .dot{width:7px;height:7px;border-radius:50%;background:#9ef25c;animation:blink 1.1s infinite}
@keyframes blink{50%{opacity:.25}}
.prompt{text-align:center;background:rgba(8,13,26,.72);border:1px solid rgba(130,170,255,.16);
  border-radius:16px;padding:14px 26px;backdrop-filter:blur(6px);
  box-shadow:0 12px 40px rgba(0,0,0,.45);
  animation:bob 2.4s ease-in-out infinite; pointer-events:none;}
@keyframes bob{50%{transform:translateY(-5px)}}
.prompt-line{font-size:clamp(15px,2.2vw,19px);font-weight:700;letter-spacing:.02em;color:#eef4ff}
.prompt-sub{margin-top:5px;font-size:12px;color:var(--muted)}
```

Pause & over overlays:
```css
.overlay.veil{background:rgba(5,9,18,.55);backdrop-filter:blur(3px);}
.panel{background:rgba(11,17,32,.85);border:1px solid rgba(130,170,255,.18);border-radius:20px;
  padding:26px 40px;text-align:center;box-shadow:0 24px 70px rgba(0,0,0,.6);
  transform:translateY(10px) scale(.96);transition:transform .3s cubic-bezier(.2,.9,.3,1.3);}
.overlay.show .panel{transform:none;}
.ov-big{font-size:clamp(22px,4vw,30px);font-weight:800;letter-spacing:.28em; }
#ovOver .ov-big{color:#ff7a70;}
#ovPause .ov-big{color:#9ef25c;}
```
For game over, panel content:
```
<div class="ov-big">GAME OVER</div>
<div class="ov-stats">
  <div class="stat"><span>SCORE</span><b id="ovScoreNum">0</b></div>
  <div class="stat"><span>BEST</span><b id="ovBestNum">0</b></div>
</div>
<div class="newbest" id="ovNewBest">★ NEW BEST</div>  -- star char? not emoji; '★' is a text symbol... to be safe use "NEW BEST!" plain or a small SVG. Use plain text with a CSS sparkle? Plain "NEW BEST!" fine.
<div class="ov-sub">press <kbd>Space</kbd> / <kbd>Enter</kbd> to restart</div>
```
Stats: two blocks side by side, divider.

```css
.ov-stats{display:flex;gap:0;margin:18px 0 6px;}
.stat{padding:0 26px;}
.stat+.stat{border-left:1px solid rgba(130,170,255,.15);}
.stat span{display:block;font-size:10px;letter-spacing:.2em;color:var(--muted);}
.stat b{font-size:30px;font-family:mono;color:#eaffb0... score lime; best amber}
.newbest{display:inline-block;margin-top:8px;font-size:11px;font-weight:800;letter-spacing:.18em;color:#0c1626;background:linear-gradient(90deg,#c8ff7a,#9ef25c);padding:5px 12px;border-radius:999px;animation:pulseTag 1.2s infinite}
@keyframes pulseTag{50%{transform:scale(1.06)}}
.ov-sub{margin-top:12px;font-size:12.5px;color:var(--muted)}
```

Pause panel:
```
<div class="ov-big">PAUSED</div>
<div class="ov-sub">press <kbd>P</kbd> to resume</div>
```

kbd style defined once globally.

Button "Play again": optional since text says keys; add a real button for mouse users:
```css
#btnRestart{margin-top:14px;font:inherit;font-weight:700;font-size:13px;color:#0c1626;
 background:linear-gradient(180deg,#c8ff7a,#8fe455);border:none;border-radius:999px;padding:10px 22px;cursor:pointer;
 box-shadow:0 6px 20px rgba(158,242,92,.25);transition:transform .15s;}
#btnRestart:hover{transform:translateY(-1px)}
#btnRestart:active{transform:translateY(1px)}
```

Header CSS:
```css
header{display:flex;align-items:center;justify-content:space-between;gap:12px;}
.brand{display:flex;align-items:center;gap:12px;}
.mark{width:34px;height:34px;border-radius:11px;background:linear-gradient(145deg,#c8ff7a,#3fae62);
 position:relative;box-shadow:0 6px 18px rgba(110,210,110,.28), inset 0 1px 0 rgba(255,255,255,.35);}
.mark::before,.mark::after{content:'';position:absolute;top:9px;width:6px;height:6px;border-radius:50%;
 background:#fff;box-shadow:inset -1px -1px 0 rgba(0,0,0,.15);}
.mark::before{left:7px}.mark::after{left:17px}
/* pupils */
```
Eyes with pupils: use box-shadow to add pupil? Two pseudo elements each white circle, with pseudo-pseudo impossible. Could add radial-gradient background on pseudos: `background:radial-gradient(circle at 60% 60%, #fff 40%, #fff 55%, #16324a 58%)`. Let's do: `background:radial-gradient(circle at 62% 62%, #fff 0 55%, #14243c 60% 100%)`. Gives white eye with dark pupil offset — cute.

h1: `font-size:clamp(20px,3.2vw,26px);font-weight:800;letter-spacing:.34em;margin-right:-.34em;color:#f0f5ff;`
Hmm, add a color span: "SNAKE" with the last letter accent? Keep plain, maybe `S N A K E` with lime "S"? Overkill. Add text-shadow subtle. tag: `display:block;font-size:10px;letter-spacing:.3em;text-transform:uppercase;color:var(--muted);margin-top:2px`.

scores pills:
```css
.scores{display:flex;gap:10px}
.pill{display:flex;align-items:baseline;gap:10px;background:rgba(255,255,255,.045);
 border:1px solid var(--line);border-radius:12px;padding:8px 16px;min-width:110px;justify-content:space-between}
.pill .lbl{font-size:10px;letter-spacing:.22em;color:var(--muted);text-transform:uppercase}
.pill .val{font-family:var(--mono);font-size:20px;font-weight:700;color:var(--lime);font-variant-numeric:tabular-nums}
.pill.best .val{color:var(--amber)}
.val.pop{animation:pop .35s cubic-bezier(.2,.9,.3,1.4)}
@keyframes pop{40%{transform:scale(1.45);color:#fff}}
```
`.val` is span — needs display:inline-block for transform. Add `display:inline-block`.

Footer hints:
```css
footer{display:flex;gap:18px;justify-content:center;flex-wrap:wrap;font-size:11.5px;color:var(--muted)}
.hint{display:flex;align-items:center;gap:6px}
kbd{font-family:var(--mono);font-size:10.5px;color:#dbe4f5;background:rgba(255,255,255,.06);
 border:1px solid rgba(130,170,255,.2);border-bottom:2px solid rgba(130,170,255,.25);
 border-radius:6px;padding:2px 7px;min-width:22px;text-align:center;display:inline-block}
```

Stage:
```css
.stage{display:flex;flex-direction:column;gap:clamp(10px,1.8vh,18px);padding:14px;align-items:center}
```
But header should span board width. If stage align-items:center, header width = its content width, not board width. To match board width: wrap header+footer in a container with width set to canvas width. Easiest: set `#hud{width: <canvasWidth>px}` from JS. Let me do: JS sets `stage.style.width = px+'px'` where px = canvas css width. Then header/footer width:100%. stage has `align-items:stretch` default? flex-direction column default align-items:stretch — children stretch to width. So: `.stage{display:flex;flex-direction:column;...}` and set style.width. Canvas inside will be exactly px anyway. And body centers stage. But before JS sets width, stage auto width = content. Fine, JS runs immediately.

Actually careful: stage width = canvas width (px). Canvas is child; header/footer stretch. 

Media query small: reduce paddings, hide tagline, shrink pills.

Also `body.demo-mode` → maybe show cursor pointer over board: `body.demo-mode .board-wrap{cursor:pointer}`.

**Potential issue**: `pointerdown` on document fires for any click including on the restart button — restart handled. Also selecting the button with keyboard Enter... Enter keydown when over → startPlay. Fine, might double fire (keydown + click) — both call startPlay; second call: mode already 'play', state 'running'... A pointerdown 'click' from keyboard-activated button would fire after keydown → resetGame again. Minor; guard: in startPlay, if mode==='play' && state==='running' && just started <100ms, ignore. Simpler: button pointerdown default? Let me set button onclick → restart only if state==='over'. And document pointerdown handler: same condition. Double invocation only if both trigger — keydown Enter triggers startPlay (state over→running) then click event on button (from Enter activation) → handler checks state==='over' → false → skip. Since keydown fires before click. ✓ Similarly Space keydown — Space activates focused button on keyup? Button activation via Space happens on keyup, and our keydown handler already restarted; then keyup click → state check prevents. ✓

Also after restart, blur the button to avoid Space re-triggering it: `btnRestart.blur()` inside startPlay, or use pointerdown only. I'll call `document.activeElement?.blur?.()` in startPlay. Fine — hmm, but if user wants to restart with Space while button focused after a mouse click... after clicking button we restart anyway. OK blur it.

**One more consideration**: The task says "Arrow keys and WASD control the snake. Quick successive key presses must both be honored" — my queue of 3 handles that. ✓

"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." ✓

Grid ≥20×20 centered ✓ (24×24).

Now — "no horizontal scrollbar" ✓ overflow hidden. "Something meaningful visible on first paint" ✓ demo running.

Let me also double-check `floodCount` neighbor indexing: idx = y*COLS+x. Left: idx-1 valid if x>0. Right: idx+1 if x<COLS-1. Up: idx-COLS if y>0. Down: idx+COLS if y<ROWS-1. blocked keys: `${x}_${y}`.

blocked set built from snake.slice(0, len-1) — but during flood fill, we also should treat the *current head* cell as blocked? The head is part of snake (index 0), included. Start cell (nx,ny) is the candidate itself, not blocked (checked before). ✓

AI quality check: greedy with flood-space penalty. When snake is short, fine. When long, space check keeps it alive mostly. Occasionally dies — fine for demo, auto-restarts.

One subtle thing: `if(space < snake.length) s -= 500;` — space capped at snake.length+6, so comparison uses capped value... cap for early-exit returns count possibly less than actual. If count hits cap, count = cap ≥ snake.length+6 > snake.length → no penalty. Good — early exit returns cap which is enough.

Edge: space could be huge but the -dist*2 dominates: e.g., dist 0 (adjacent to food) vs safe move dist 1: prefers food unless food move has space<snake.length (penalty 500). Good.

Also AI shouldn't be too perfect — fine either way.

**Demo restart timing**: finishDeath in demo: `resetGame()` — but keep demo overlay shown (it is, since we only hide on startPlay). Also maybe brief pause: set state='running' immediately. OK. Maybe add small delay: state='running' after 400ms? Implement: in demo finish: `resetGame();` — instant. It's fine; add a 500ms dead-snake red flash first — the dying state already lasts 780ms. Good.

**Score during dying: updateScoreUI** already done.

**best in demo**: not updated (guarded by mode==='play'). Header BEST shows 0 during demo until user plays. That's correct semantically ("best of the session" = player's best). Hmm, but reviewer might expect best to track demo too? I'll keep player-only; label "BEST".

Actually wait — should I reconsider? If the AI demo scores 120 and header shows BEST 0 while demo score shows 120 — that's consistent with "your best". I think fine and arguably better.

Now, food spawn "never on snake": ✓ free-cell selection.

**Snake speed initial**: 132ms/tick ≈ 7.5 cells/s. Smooth interpolation makes it look good.

**headPulse** set to 1 on eat; decays 3.5/s → ~285ms. hr scale 1+0.22*headPulse → subtle pop. ✓

Let me also add a subtle trail glow behind head? Skip — enough polish.

**Text rendering font**: `800 ${size}px` — canvas font shorthand: `ctx.font = '800 14px Menlo, monospace'` valid. Include family. ✓

**Now assemble everything.** Also `<meta name="viewport">` for mobile. Also touch support? Not required; but maybe swipe? Not required — skip (keyboard game). But on mobile the demo plays itself and tap starts... then user can't steer. Hmm, requirement mentions keyboard. Could add on-screen swipe for mobile as bonus — adds complexity. Given viewport testing is 1280×800 and 375px (screenshots), mobile control isn't judged. But a tap-started game on mobile would be unplayable... I could add simple touch swipe controls quickly: listen touchstart/touchend, compute delta, enqueue dir. That's ~15 lines. Also tap to start. Let me add swipe — cheap insurance.

```js
let touchStart=null;
document.addEventListener('touchstart',e=>{const t=e.touches[0];touchStart={x:t.clientX,y:t.clientY};},{passive:true});
document.addEventListener('touchend',e=>{
  if(!touchStart)return;
  const t=e.changedTouches[0];
  const dx=t.clientX-touchStart.x, dy=t.clientY-touchStart.y;
  touchStart=null;
  if(Math.hypot(dx,dy)<18){ // tap
    if(mode==='demo') startPlay(null); else if(state==='over') startPlay(null); else if(state==='paused') togglePause();
    return;
  }
  const d = Math.abs(dx)>Math.abs(dy) ? (dx>0?DIRS.right:DIRS.left) : (dy>0?DIRS.down:DIRS.up);
  if(mode==='demo') startPlay(d); else if(state==='running') enqueueDir(d);
},{passive:true});
```
Also prevent default scrolling: body overflow hidden + `touch-action:none` on board wrap? touch-action:none on body to prevent pull-refresh interference. Add `touch-action:manipulation` maybe; use none on canvas wrap. Fine.

But careful: pointerdown handler also fires for touches → startPlay twice? pointerdown on demo → startPlay(null) then touchend tap → mode already 'play', state running → nothing (tap branch: mode!=='demo', state==='running' → nothing). OK harmless. But swipe on touch would ALSO trigger pointerdown → startPlay(null) at touchstart... then swipe direction queued at touchend — but startPlay reset the game with dir right; enqueueDir(d) applies — user swipes up → queue up — works. Actually order: pointerdown (start) → touchend (enqueue). Fine. But to reduce weirdness, make pointerdown handler ignore if event.pointerType==='touch'? Then touch logic solely in touch handlers. Yes cleaner:

```js
document.addEventListener('pointerdown', e=>{
  if(e.pointerType==='touch') return;
  ...
});
```

**Also keydown repeat**: holding a key fires repeats — enqueueDir ignores same dir. Fine.

**Now think about whether the demo overlay pointer-events:none** — clicks pass through to document handler anyway since it's on document. ✓ (pointer-events none avoids intercepting; document still gets event.)

For over/pause overlays, panel buttons need pointer events — overlays default pointer-events auto. But overlay covers board — pointerdown still bubbles to document handler → restart on any click when over — desired. ✓ For pause overlay, click → resume (add branch). But the panel content — click anywhere resumes. OK.

**HUD during demo**: shows AI score live. ✓ nice.

**Prevent context menu on long press?** skip.

**Now write final CSS with responsiveness**:
```css
@media (max-width:520px){
  .tag{display:none}
  .pill{min-width:0;padding:6px 12px}
  .pill .val{font-size:17px}
  h1{letter-spacing:.2em}
  footer{gap:10px}
  .hint.opt{display:none}  // hide P/Space hints? keep all, wrap
}
```
I'll mark move hint with arrows... all fit at 375 with wrap: "↑←↓→ / WASD move" "P pause" "Space restart" — should fit in 336px width with wrapping. Set footer font 10.5px on small.

Header at 375: brand (mark 34 + SNAKE ~100) + two pills (~100 each) = ~340 — tight but fits at 336? Pills min-width 0, padding smaller. Might wrap awkwardly — allow header flex-wrap? If wraps, height grows, resize accounts (measured). Set `.scores{flex-wrap:nowrap}` and header `flex-wrap:wrap` so pills drop below brand on very narrow. resize() measures header height after change... but resize is called on window resize only, not when layout reflows due to width change — window resize triggers both. Order: resize() reads header offsetHeight which reflects current (old) canvas width state... circular dependency: header height could depend on width which depends on header height. In practice with flex-wrap, height changes only at breakpoint; I call resize() → compute with current header height → set canvas → header may wrap → next resize event? No event. To be safe, after setting sizes, if header height changed, call resize once more (converges in 1-2 iterations):

```js
function resize(){
  ...compute...
  apply();
  const nh = headerEl.offsetHeight;
  if(nh !== headH && iterations<2) resize(); // recompute
}
```
Simpler: measure header height with a probe: temporarily? Meh. Alternative: make header height constant regardless of wrap by... it can't be. I'll do the iterate-twice approach with a guard.

Actually simplest robust: compute availH conservatively: header height measured, then after applying sizes, call buildBoard etc; then schedule a second resize pass via rAF once. Slight flicker ok? Overkill... Let me just do recursive guard:

```js
function resize(pass=0){
  const headH = headerEl.offsetHeight || 64;
  const footH = footerEl.offsetHeight || 34;
  const availW = Math.min(window.innerWidth - 26, 780);
  const availH = window.innerHeight - headH - footH - 54;
  cell = Math.max(9, Math.floor(Math.min(availW, availH)/COLS));
  const px = cell*COLS;
  if(canvas.style.width !== px+'px'){ /* nothing */ }
  canvas.style.width = canvas.style.height = px+'px';
  canvas.width = canvas.height = Math.round(px*dpr);
  stage.style.width = px+'px';
  ctx.setTransform(dpr,0,0,dpr,0,0);
  buildBoard(px);
  if(pass<2 && headerEl.offsetHeight !== headH) resize(pass+1);
}
```
dpr computed inside. Good.

Note: `stage.style.width = px+'px'` — but stage has padding 14px; width incl padding? style.width sets content-box width... with box-sizing:border-box (set globally *), width includes padding. So stage total = px → inner content = px - 28 → header narrower than canvas by 28px. Hmm. Instead set stage width to px + padding? Or set padding on stage's parent (body) and no padding on stage; give stage gap only. Then stage width = canvas width exactly, header stretches exactly. Body has padding via its flex centering; add body padding 16px. Let me: `.stage{display:flex;flex-direction:column;gap:...;}` no padding; `body{padding:16px}`. stage.style.width=px. ✓

**Let me reconsider availW**: window.innerWidth - body padding (32) minus small margin = -40? Use `window.innerWidth - 36`. At 375 → 339 → cell 14 → 336. ✓ At 1280×800: availW=780→cell 32 capped? 780/24=32.5→32 → 768 board. availH = 800 - header(~56) - footer(~30) - 54 = 660 → cell 27 → board 648. Header width 648. Good size. Cap cell at 30? 27 < 30, fine. Let me also cap cell ≤ 34.

**Colors check contrast**: board #0d1526 vs page #070b14 — subtle distinction with border+shadow. ✓

Now let me also double check canvas ctx.setTransform with dpr and using `ctx.clearRect(0,0,px,px)` — with transform, clears css-pixel area = full canvas. ✓ Since we also drawImage board over everything, clear optional. Keep.

drawImage(boardCanvas, 0,0,px,px): source canvas is px*dpr pixels; dest rect px×px under dpr transform → exact. ✓

**Shake**: translate before drawing food/snake but after board? Board shaking too looks better (whole board shakes). Currently board drawn before translate — move translate before board drawImage: then edges reveal background... with shake small (≤ cell*0.45*shake² ≤ ~5px) edges show clear canvas → page bg shows through — acceptable-ish, but better: scale board slightly? Just draw board after translate too and accept tiny edge reveal (looks like impact). Actually reveal shows `clearRect` → transparent → canvas element background (page bg) — a 3px dark edge flash, barely noticeable. Alternatively clip. I'll translate everything including board, and also draw board 6px overscan? Simplest: draw board image at (0,0) with size px+12 offset -6: drawImage(boardCanvas, -6,-6, px+12, px+12) — slightly scaled, invisible. Do that when shaking... always: drawImage(boardCanvas, -pad, -pad, px+pad*2, px+pad*2) with pad = shake>0 ? 8 : 0. Slight scale when shaking — fine.

Actually simpler: build board canvas 8px larger? Nah, dynamic pad approach fine.

**Edge: ctx.arc with r possibly 0** (food spawn ease with min 0) — arc r=0 fine (no draw). Also negative? back-ease overshoot positive; clamp r≥0. ✓

Let me now write the complete file. I'll be careful with details.

Additional polish idea: subtle glow around snake head (shadowBlur) — skip for perf simplicity? shadowBlur per segment is costly; only head maybe. Add: ctx.shadowColor='rgba(158,242,92,.5)';shadowBlur=cell*0.6 for head fill then reset. Cheap (one shape). OK.

Also food glow shadow: I used manual radial halo — good, no shadowBlur needed.

Let me also consider the snake's body at length 1? Never (starts 4). n≥4 always. ✓

Also initial snake placement cx=12: cells x=12,11,10,9 col; y=12. Head at (12,12) moving right. Room to right: cols 13..23 = 11 cells before wall. Demo AI steers immediately. ✓

**Now finalize the "eyes facing direction"**: uses heading from neck→head interpolated — eyes rotate smoothly through turns. During pure vertical/horizontal moves it's exact. ✓

**Write out the full HTML now.**

Also add small favicon? Can't use external images; data URI favicon is allowed (inline). Add a tiny SVG data-uri favicon lime square — nice touch, one line:
```html
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><rect width='24' height='24' rx='6' fill='%239ef25c'/><circle cx='9' cy='10' r='3' fill='%23fff'/><circle cx='15' cy='10' r='3' fill='%23fff'/></svg>">
```
OK.

Double-check no banned APIs: no localStorage ✓, no alert ✓.

Let me write final code carefully, watching for syntax errors.

Details for `drawSnake` dead flash: I'll compute `deadT = now - dieAt`; blink = deadT<600 && ((deadT/110)|0)%2===0 → alternate. And body colors red-toned with alpha pulses. Implementation:

```js
const dead = state==='dying' || state==='over';
...
for body:
  let col;
  if(dead){
    const k2=k;
    col = `hsl(${8+6*k2}, 70%, ${58-26*k2}%)`;  // red gradient head→tail
  } else col = bodyColor(k);
```
plus a white flash at death moment: first 120ms draw body brighter: mix? Add: if(dead && deadT<130) col='#ffd9d4'. Simple.

Head colors dead: gradient stops red as described.

Also eyes X when dead. ✓

Tongue only when running. ✓

One more check on `drawSnake` spine: draws after body before head. ok.

**Score pill pop**: transform on inline-block val.

**Now the tick + acc while tab inactive**: rAF paused; dt clamped 0.1s on return — game effectively pauses. Fine.

**Pause overlay + demo overlay conflict**: none.

**When user starts play from demo while AI mid-run**: resetGame — snake resets to center. Slight visual jump — acceptable and standard.

Let me write everything now. I'll write clean, commented code.

One more thought on the requirement "smooth movement": interpolation ✓. Also maybe ease? Linear is right for constant speed.

Also, hmm: interpolation across multiple queued turns within one tick — only one dir change applied per tick, so path stays cell-adjacent. ✓

Potential visual artifact: when the snake is very long and the tail region is straight, per-segment strokes with round caps overlap same-colored segments — invisible. ✓

Let me now also double check enqueue when queue has [up] and current dir right, user presses right: last=up; right vs up: not same, not opposite → pushed → queue [up,right]: after up applied, right applied next tick. Fine (intended behavior — pressing up then right quickly both honored).

But note: pressing right while moving right with empty queue → last=dir=right → same → ignored ✓.

**AI dir object identity**: `if(d===dir) s+=0.4` — dir is mutated? dir = aiDir() returns one of DIRS values; identity holds since we always assign from DIRS. ✓ (Never create new dir objects elsewhere — enqueueDir pushes DIRS values ✓.)

**One risk**: `startPlay(firstDir)` with firstDir=DIRS.left (user pressed left while demo) → resetGame sets dir right; enqueueDir(left): last=right, left opposite → rejected. Fine.

Also in startPlay, `state` set by resetGame to 'running'. ✓

**updateScoreUI initial call** sets 0/0.

**showOverOverlay** — also should re-show after death in play only. In demo death → no over overlay, straight reset.

Also during demo 'dying', the demo overlay (start prompt) remains visible — fine, shows the crash drama behind.

**Keyboard 'Enter' during running**: nothing. ✓

**Space during running**: nothing (and prevented default to avoid page scroll — body overflow hidden anyway).

Alright — also add `e.preventDefault()` for WASD? Not needed.

Edge: some browsers `e.key` ' ' — yes space is ' '. ✓

**Compose final answer.** Let me write the code.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Snake · Canvas Edition</title>
<link rel="icon" href="data:image/svg+xml,...">
<style> ... </style>
</head>
<body class="demo-mode">
<main class="stage" id="stage">
  <header id="hud-top">
    <div class="brand">
      <span class="mark" aria-hidden="true"></span>
      <div class="titles">
        <h1>SNAKE</h1>
        <span class="tag">canvas edition</span>
      </div>
    </div>
    <div class="scores">
      <div class="pill"><span class="lbl">Score</span><span class="val" id="scoreVal">0</span></div>
      <div class="pill best"><span class="lbl">Best</span><span class="val" id="bestVal">0</span></div>
    </div>
  </header>

  <div class="board-wrap" id="boardWrap">
    <canvas id="game"></canvas>

    <div class="overlay demo show" id="ovDemo">
      <div class="demo-chip"><span class="dot"></span>AI&nbsp;DEMO</div>
      <div class="prompt">
        <div class="prompt-line">Click or press a key to start</div>
        <div class="prompt-sub">Arrows / WASD to steer · P pauses</div>
      </div>
    </div>

    <div class="overlay veil" id="ovPause">
      <div class="panel">
        <div class="ov-big lime">PAUSED</div>
        <div class="ov-sub">press <kbd>P</kbd> to resume</div>
      </div>
    </div>

    <div class="overlay veil" id="ovOver">
      <div class="panel">
        <div class="ov-big coral">GAME OVER</div>
        <div class="ov-stats">
          <div class="stat"><span>Score</span><b class="lime" id="ovScoreNum">0</b></div>
          <div class="stat"><span>Best</span><b class="amber" id="ovBestNum">0</b></div>
        </div>
        <div class="newbest" id="ovNewBest">NEW BEST!</div>
        <div class="ov-sub">press <kbd>Space</kbd> or <kbd>Enter</kbd> to restart</div>
        <button id="btnRestart" type="button">Play again</button>
      </div>
    </div>
  </div>

  <footer id="hud-bottom">
    <span class="hint"><kbd>↑</kbd><kbd>←</kbd><kbd>↓</kbd><kbd>→</kbd><em>/</em><kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> move</span>
    <span class="hint"><kbd>P</kbd> pause</span>
    <span class="hint"><kbd>Space</kbd> restart</span>
  </footer>
</main>
<script> ... </script>
</body>
</html>
```

Wait: demo overlay has class "show" initially — but if JS starts in demo (it does), keep. Fine even before JS.

Hmm, `.overlay.demo` pointer-events none — set. Also `.overlay.veil` needs pointer-events auto default. But the veil covering board intercepts clicks — document pointerdown still fires (bubbles). ✓

Now, one concern: overlays inside `.board-wrap` which has `overflow:hidden;border-radius` — demo overlay prompt near bottom edge: padding-bottom 7% keeps inside. ✓

CSS var --mono: `ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace`.

Let me write final CSS fully:

```css
:root{
  --bg:#070b14;
  --line:rgba(130,170,255,.14);
  --text:#e8eefc;
  --muted:#8b98b8;
  --lime:#9ef25c;
  --amber:#ffc95c;
  --coral:#ff7a70;
  --mono:ui-monospace,'SF Mono','Cascadia Mono',Menlo,Consolas,monospace;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
  background:
    radial-gradient(1100px 640px at 50% -12%,#13203c 0%,rgba(19,32,60,0) 58%),
    radial-gradient(820px 520px at 88% 112%,rgba(26,74,58,.38) 0%,rgba(0,0,0,0) 55%),
    var(--bg);
  color:var(--text);
  font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;
  height:100vh;height:100dvh;
  display:flex;align-items:center;justify-content:center;
  overflow:hidden;
  padding:14px;
  user-select:none;-webkit-user-select:none;
  touch-action:manipulation;
}
.stage{display:flex;flex-direction:column;gap:clamp(10px,1.9vh,18px);width:600px}
header{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}
.brand{display:flex;align-items:center;gap:12px;margin-right:auto}
```
Hmm flex-wrap on header: if pills wrap to next line they'd be left-aligned under brand — center them: header{...} with wrapped row: use justify-content:space-between + .brand margin-right auto... simpler: `@media (max-width:430px){ header{...} }` whatever — allow wrap; wrapped pills align start; add `.scores{margin-left:auto}` so on wrap they go right. Actually with flex-wrap, second line starts at left unless margin-left:auto. Set `.scores{margin-left:auto}` and brand no auto. Then normal case: brand left, scores pushed right ✓; wrap case: scores on own row aligned right ✓.

```css
.mark{width:36px;height:36px;flex:0 0 auto;border-radius:12px;
  background:linear-gradient(150deg,#d6ff9a,#43b56e);
  box-shadow:0 8px 22px rgba(110,210,120,.30), inset 0 1px 0 rgba(255,255,255,.45);
  position:relative}
.mark::before,.mark::after{content:'';position:absolute;top:10px;width:7px;height:7px;border-radius:50%;
  background:radial-gradient(circle at 62% 62%,#fff 0 54%,#14243c 62% 100%)}
.mark::before{left:8px}
.mark::after{left:19px}
.titles h1{font-size:clamp(19px,3vw,25px);font-weight:800;letter-spacing:.32em;color:#f2f6ff;line-height:1}
.tag{display:block;margin-top:4px;font-size:9.5px;font-weight:600;letter-spacing:.34em;text-transform:uppercase;color:var(--muted)}
.scores{display:flex;gap:8px;margin-left:auto}
.pill{display:flex;align-items:baseline;justify-content:space-between;gap:10px;min-width:104px;
  padding:8px 14px;border-radius:12px;background:rgba(255,255,255,.045);border:1px solid var(--line)}
.pill .lbl{font-size:9.5px;font-weight:700;letter-spacing:.22em;text-transform:uppercase;color:var(--muted)}
.pill .val{display:inline-block;font-family:var(--mono);font-size:19px;font-weight:700;color:var(--lime);font-variant-numeric:tabular-nums}
.pill.best .val{color:var(--amber)}
.val.pop{animation:pop .38s cubic-bezier(.2,.9,.3,1.45)}
@keyframes pop{35%{transform:scale(1.5) rotate(-4deg);color:#fff}}
```

Board & overlays:

```css
.board-wrap{position:relative;border-radius:16px;overflow:hidden;
  box-shadow:0 0 0 1px rgba(130,170,255,.13), 0 30px 80px rgba(0,0,0,.55), 0 0 90px rgba(110,210,120,.07)}
body.demo-mode .board-wrap{cursor:pointer}
canvas{display:block}
.overlay{position:absolute;inset:0;z-index:5;display:flex;align-items:center;justify-content:center;
  opacity:0;visibility:hidden;transition:opacity .28s ease,visibility .28s}
.overlay.show{opacity:1;visibility:visible}
.overlay.demo{pointer-events:none;justify-content:flex-end;padding-bottom:6.5%;
  background:radial-gradient(130% 95% at 50% 38%,rgba(5,9,18,0) 34%,rgba(5,9,18,.62) 100%)}
.demo-chip{position:absolute;top:12px;left:12px;display:flex;align-items:center;gap:8px;
  padding:6px 12px;border-radius:999px;font-size:10.5px;font-weight:800;letter-spacing:.24em;color:#c9edb0;
  background:rgba(9,15,28,.62);border:1px solid rgba(158,242,92,.28);backdrop-filter:blur(5px)}
.demo-chip .dot{width:7px;height:7px;border-radius:50%;background:var(--lime);box-shadow:0 0 8px var(--lime);animation:blink 1.15s infinite}
@keyframes blink{50%{opacity:.2}}
.prompt{text-align:center;padding:13px 24px;border-radius:16px;
  background:rgba(8,13,26,.74);border:1px solid rgba(130,170,255,.17);
  box-shadow:0 14px 44px rgba(0,0,0,.5);backdrop-filter:blur(6px);
  animation:bob 2.6s ease-in-out infinite}
@keyframes bob{50%{transform:translateY(-5px)}}
.prompt-line{font-size:clamp(15px,2.4vw,18px);font-weight:750? 
```
font-weight 750 unusual — use 700.
```css
.prompt-line{font-size:clamp(15px,2.3vw,18px);font-weight:700;color:#f0f5ff}
.prompt-sub{margin-top:5px;font-size:11.5px;color:var(--muted)}
.overlay.veil{background:rgba(4,8,16,.58);backdrop-filter:blur(3px)}
.panel{padding:24px 38px 26px;border-radius:20px;text-align:center;
  background:rgba(11,17,32,.88);border:1px solid rgba(130,170,255,.18);
  box-shadow:0 26px 70px rgba(0,0,0,.6);
  transform:translateY(12px) scale(.94);transition:transform .32s cubic-bezier(.2,.9,.3,1.35)}
.overlay.show .panel{transform:none}
.ov-big{font-size:clamp(21px,3.6vw,27px);font-weight:800;letter-spacing:.3em;margin-right:-.3em}
.ov-big.lime{color:var(--lime);text-shadow:0 0 26px rgba(158,242,92,.35)}
.ov-big.coral{color:var(--coral);text-shadow:0 0 26px rgba(255,122,112,.35)}
.ov-stats{display:flex;justify-content:center;margin:16px 0 4px}
.stat{padding:0 24px}
.stat+.stat{border-left:1px solid rgba(130,170,255,.16)}
.stat span{display:block;font-size:9.5px;font-weight:700;letter-spacing:.26em;text-transform:uppercase;color:var(--muted);margin-bottom:3px}
.stat b{font-family:var(--mono);font-size:28px;font-weight:700}
.stat b.lime{color:var(--lime)}
.stat b.amber{color:var(--amber)}
.newbest{display:none;margin:8px 0 2px;padding:5px 13px;border-radius:999px;font-size:10.5px;font-weight:800;letter-spacing:.2em;
  color:#0b1526;background:linear-gradient(90deg,#d6ff9a,#9ef25c);box-shadow:0 4px 18px rgba(158,242,92,.4);
  animation:tag .9s ease-in-out infinite}
.newbest.on{display:inline-block}
@keyframes tag{50%{transform:scale(1.07)}}
.ov-sub{margin-top:10px;font-size:12px;color:var(--muted)}
#btnRestart{margin-top:14px;font:inherit;font-size:13px;font-weight:800;letter-spacing:.06em;color:#0b1526;
  padding:10px 24px;border:0;border-radius:999px;cursor:pointer;
  background:linear-gradient(180deg,#d6ff9a,#8fe455);box-shadow:0 8px 24px rgba(158,242,92,.28);
  transition:transform .14s ease,box-shadow .14s ease}
#btnRestart:hover{transform:translateY(-1px);box-shadow:0 10px 28px rgba(158,242,92,.4)}
#btnRestart:active{transform:translateY(1px)}
footer{display:flex;justify-content:center;align-items:center;gap:16px;flex-wrap:wrap;font-size:11.5px;color:var(--muted)}
.hint{display:flex;align-items:center;gap:5px}
.hint em{font-style:normal;margin:0 2px;color:#5b6884}
kbd{font-family:var(--mono);font-size:10px;font-weight:600;color:#dbe4f5;min-width:21px;text-align:center;
  display:inline-block;padding:2px 6px;border-radius:6px;background:rgba(255,255,255,.06);
  border:1px solid rgba(130,170,255,.22);border-bottom:2px solid rgba(130,170,255,.28)}
@media (max-width:480px){
  .tag{display:none}
  .mark{width:30px;height:30px;border-radius:10px}
  .mark::before,.mark::after{top:8px;width:6px;height:6px}
  .mark::after{left:16px}
  .pill{min-width:0;padding:6px 11px}
  .pill .val{font-size:16px}
  footer{gap:10px;font-size:10.5px}
  .stat{padding:0 16px}
  .stat b{font-size:23px}
  .panel{padding:20px 22px 22px}
}
```

Now JS. Writing it in full:

```js
(() => {
'use strict';

/* ---------- setup ---------- */
const COLS=24, ROWS=24;
const BASE_TICK=132, MIN_TICK=82, TICK_STEP=1.7;
const DIRS={up:{x:0,y:-1},down:{x:0,y:1},left:{x:-1,y:0},right:{x:1,y:0}};

const canvas=document.getElementById('game');
const ctx=canvas.getContext('2d');
const stage=document.getElementById('stage');
const headerEl=document.querySelector('header');
const footerEl=document.querySelector('footer');
const scoreVal=document.getElementById('scoreVal');
const bestVal=document.getElementById('bestVal');
const ovDemo=document.getElementById('ovDemo');
const ovPause=document.getElementById('ovPause');
const ovOver=document.getElementById('ovOver');
const ovScoreNum=document.getElementById('ovScoreNum');
const ovBestNum=document.getElementById('ovBestNum');
const ovNewBest=document.getElementById('ovNewBest');
const btnRestart=document.getElementById('btnRestart');

/* ---------- state ---------- */
let cell=22, dpr=1, board=null;
let mode='demo', state='running';
let snake=[], prevSnake=[], dir=DIRS.right, queue=[];
let food=null, foodBorn=0;
let score=0, best=0, newBest=false;
let tickMs=BASE_TICK, acc=0, lastT=0, now=0, dieAt=0, shake=0, headPulse=0;
let particles=[];

/* ---------- layout ---------- */
function resize(pass=0){
  dpr=Math.min(window.devicePixelRatio||1,2);
  const headH=headerEl.offsetHeight||64, footH=footerEl.offsetHeight||34;
  const availW=Math.min(window.innerWidth-36,760);
  const availH=window.innerHeight-headH-footH-52;
  cell=Math.max(9,Math.min(34,Math.floor(Math.min(availW,availH)/COLS)));
  const px=cell*COLS;
  canvas.style.width=canvas.style.height=px+'px';
  canvas.width=canvas.height=Math.round(px*dpr);
  stage.style.width=px+'px';
  ctx.setTransform(dpr,0,0,dpr,0,0);
  buildBoard(px);
  if(pass<3 && headerEl.offsetHeight!==headH) resize(pass+1);
}
```
Wait, availH could be tiny/negative on small screens: Math.max(9,...) ensures ≥9 → 216px board might exceed availH → page overflow hidden → clipped footer? At 375×667: headH~46, footH~30, availH=667-46-30-52=539 → cell=min(339,539)/24=14 ✓. Fine.

Note innerHeight vs 100dvh — fine.

buildBoard:
```js
function buildBoard(px){
  board=document.createElement('canvas');
  board.width=board.height=Math.max(2,Math.round(px*dpr));
  const b=board.getContext('2d');
  b.setTransform(dpr,0,0,dpr,0,0);
  const g=b.createLinearGradient(0,0,px,px);
  g.addColorStop(0,'#111d34');g.addColorStop(1,'#0a1220');
  b.fillStyle=g;b.fillRect(0,0,px,px);
  b.fillStyle='rgba(150,185,255,0.03)';
  const c=cell;
  for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++)
    if(((x+y)&1)===0)b.fillRect(x*c,y*c,c,c);
  const v=b.createRadialGradient(px/2,px/2,px*0.22,px/2,px/2,px*0.74);
  v.addColorStop(0,'rgba(120,190,255,0.05)');
  v.addColorStop(1,'rgba(0,0,0,0.24)');
  b.fillStyle=v;b.fillRect(0,0,px,px);
}
```

Game core:
```js
function resetGame(){
  const cx=COLS>>1, cy=ROWS>>1;
  snake=[];for(let i=0;i<4;i++)snake.push({x:cx-i,y:cy});
  prevSnake=snake.map(s=>({x:s.x,y:s.y}));
  dir=DIRS.right;queue=[];
  score=0;newBest=false;tickMs=BASE_TICK;acc=0;
  particles=[];shake=0;headPulse=0;
  state='running';
  spawnFood();
  updateScoreUI();
}
function spawnFood(){
  const occ=new Set(snake.map(s=>s.x+'_'+s.y));
  const free=[];
  for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++){if(!occ.has(x+'_'+y))free.push({x,y});}
  food=free.length?free[(Math.random()*free.length)|0]:null;
  foodBorn=now;
}
function enqueueDir(d){
  const last=queue.length?queue[queue.length-1]:dir;
  if((d.x===last.x&&d.y===last.y)||(d.x===-last.x&&d.y===-last.y))return;
  if(queue.length<3)queue.push(d);
}
function step(){
  if(mode==='demo')dir=aiDir();
  else if(queue.length)dir=queue.shift();
  const h=snake[0], nx=h.x+dir.x, ny=h.y+dir.y;
  if(nx<0||ny<0||nx>=COLS||ny>=ROWS)return die();
  const eating=food&&nx===food.x&&ny===food.y;
  const len=eating?snake.length:snake.length-1;
  for(let i=0;i<len;i++){const s=snake[i];if(s.x===nx&&s.y===ny)return die();}
  prevSnake=snake.map(s=>({x:s.x,y:s.y}));
  snake.unshift({x:nx,y:ny});
  if(eating){
    score+=10;
    if(mode==='play'){ if(score>best){best=score;newBest=true;} updateScoreUI(true);}
    else updateScoreUI(true);
    headPulse=1;
    burstAt(food);
    tickMs=Math.max(MIN_TICK,BASE_TICK-(snake.length-4)*TICK_STEP);
    spawnFood();
  }else snake.pop();
}
```
Wait — in demo, updateScoreUI(true) pops the animation too, fine, but best unchanged. Also for play I call updateScoreUI(true) inside if — covers both; simplify:

```js
    score+=10;
    if(mode==='play'&&score>best){best=score;newBest=true;}
    updateScoreUI(true);
```

die / finish:
```js
function die(){
  state='dying';dieAt=now;shake=1;
}
function finishDeath(){
  if(mode==='demo'){resetGame();}
  else{
    state='over';
    ovScoreNum.textContent=score;
    ovBestNum.textContent=best;
    ovNewBest.classList.toggle('on',newBest&&score>0);
    show(ovOver,true);
  }
}
```

UI helpers:
```js
function show(el,on){el.classList.toggle('show',on);}
function updateScoreUI(pop){
  scoreVal.textContent=score;
  bestVal.textContent=best;
  if(pop){scoreVal.classList.remove('pop');void scoreVal.offsetWidth;scoreVal.classList.add('pop');}
}
```

Mode switching:
```js
function startPlay(firstDir){
  if(document.activeElement&&document.activeElement.blur)document.activeElement.blur();
  mode='play';
  document.body.classList.remove('demo-mode');
  show(ovDemo,false);show(ovOver,false);show(ovPause,false);
  resetGame();
  if(firstDir)enqueueDir(firstDir);
}
```

Pause:
```js
function togglePause(){
  if(state==='running'){state='paused';show(ovPause,true);}
  else if(state==='paused'){state='running';show(ovPause,false);acc=0;}
}
```

AI:
```js
function aiDir(){
  const h=snake[0];
  const cand=[DIRS.up,DIRS.down,DIRS.left,DIRS.right].filter(d=>!(d.x===-dir.x&&d.y===-dir.y));
  const blocked=new Set(snake.slice(0,snake.length-1).map(k=>k.x+'_'+k.y));
  let bestD=null,bestS=-1e9;
  for(const d of cand){
    const nx=h.x+d.x,ny=h.y+d.y;
    if(nx<0||ny<0||nx>=COLS||ny>=ROWS)continue;
    if(blocked.has(nx+'_'+ny))continue;
    const space=flood(nx,ny,blocked);
    const dist=food?Math.abs(nx-food.x)+Math.abs(ny-food.y):0;
    let s=-dist*2+Math.min(space,snake.length+6)*0.6;
    if(space<snake.length)s-=600;
    if(d===dir)s+=0.5;
    if(s>bestS){bestS=s;bestD=d;}
  }
  return bestD||dir;
}
function flood(sx,sy,blocked){
  const seen=new Uint8Array(COLS*ROWS);
  const st=[sy*COLS+sx];seen[st[0]]=1;
  let n=0;const cap=snake.length+6;
  while(st.length){
    const i=st.pop();n++;
    if(n>=cap)return n;
    const x=i%COLS,y=(i-x)/COLS;
    if(x>0&&!seen[i-1]&&!blocked.has((x-1)+'_'+y)){seen[i-1]=1;st.push(i-1);}
    if(x<COLS-1&&!seen[i+1]&&!blocked.has((x+1)+'_'+y)){seen[i+1]=1;st.push(i+1);}
    if(y>0&&!seen[i-COLS]&&!blocked.has(x+'_'+(y-1))){seen[i-COLS]=1;st.push(i-COLS);}
    if(y<ROWS-1&&!seen[i+COLS]&&!blocked.has(x+'_'+(y+1))){seen[i+COLS]=1;st.push(i+COLS);}
  }
  return n;
}
```

Particles:
```js
function burstAt(f){
  const px=(f.x+.5)*cell, py=(f.y+.5)*cell;
  const colors=['#ffd23f','#ff9a3d','#ff5b66','#fff2b0','#9ef25c'];
  for(let i=0;i<18;i++){
    const a=Math.random()*Math.PI*2, sp=cell*(1.6+Math.random()*4.4);
    particles.push({t:'dot',x:px,y:py,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp-cell*1.4,
      r:cell*(.05+Math.random()*.09),life:1,decay:1/(.4+Math.random()*.4),
      col:colors[(Math.random()*colors.length)|0]});
  }
  particles.push({t:'ring',x:px,y:py,r:cell*.25,life:1,decay:1/.4});
  particles.push({t:'txt',x:px,y:py-cell*.25,vy:-cell*1.5,life:1,decay:1/.75,str:'+10'});
}
function updateParticles(dt){
  for(let i=particles.length-1;i>=0;i--){
    const p=particles[i];
    p.life-=p.decay*dt;
    if(p.life<=0){particles.splice(i,1);continue;}
    if(p.t==='dot'){p.x+=p.vx*dt;p.y+=p.vy*dt;p.vx*=Math.max(0,1-3*dt);p.vy=p.vy*Math.max(0,1-1.4*dt)+cell*10*dt;}
    else if(p.t==='ring'){p.r+=cell*3.4*dt;}
    else{p.y+=p.vy*dt;}
  }
}
```

Draw:
```js
function bodyColor(k,dead){
  if(dead)return `hsl(${10+8*k},68%,${56-24*k}%)`;
  return `hsl(${95+70*k},${72-18*k}%,${60-24*k}%)`;
}
function render(){
  const px=cell*COLS;
  ctx.save();
  ctx.clearRect(0,0,px,px);
  const pad=shake>0?8:0;
  if(shake>0){
    const s=shake*shake*cell*.5;
    ctx.translate((Math.random()*2-1)*s,(Math.random()*2-1)*s);
  }
  if(board)ctx.drawImage(board,-pad,-pad,px+pad*2,px+pad*2);
  if(food)drawFood();
  drawSnake();
  drawParticles();
  ctx.restore();
}
```
Hmm pad when not shaking 0 → draw exact. ✓

drawFood:
```js
function drawFood(){
  const born=Math.min(1,(now-foodBorn)/300);
  const back=1+2.70158*Math.pow(born-1,3)+1.70158*Math.pow(born-1,2); // easeOutBack
  const scale=Math.max(0,born<1?back:1)*(1+0.06*Math.sin(now/170));
  const r=cell*.34*scale;
  if(r<=0)return;
  const px=(food.x+.5)*cell, py=(food.y+.5)*cell;
  let g=ctx.createRadialGradient(px,py,0,px,py,r*2.6);
  g.addColorStop(0,'rgba(255,150,60,.30)');g.addColorStop(1,'rgba(255,150,60,0)');
  ctx.fillStyle=g;ctx.beginPath();ctx.arc(px,py,r*2.6,0,7);ctx.fill();
  g=ctx.createRadialGradient(px-r*.35,py-r*.4,r*.1,px,py,r*1.02);
  g.addColorStop(0,'#fff6c8');g.addColorStop(.35,'#ffc860');g.addColorStop(.8,'#ff7a3c');g.addColorStop(1,'#f4502e');
  ctx.fillStyle=g;ctx.beginPath();ctx.arc(px,py,r,0,7);ctx.fill();
  ctx.strokeStyle='#7a4a22';ctx.lineWidth=Math.max(1.2,cell*.07);ctx.lineCap='round';
  ctx.beginPath();ctx.moveTo(px,py-r*.75);ctx.quadraticCurveTo(px+r*.15,py-r*1.15,px+r*.1,py-r*1.3);ctx.stroke();
  ctx.save();
  ctx.translate(px+r*.35,py-r*1.12);ctx.rotate(-.55);
  ctx.fillStyle='#6cc96a';
  ctx.beginPath();ctx.ellipse(0,0,r*.5,r*.22,0,0,7);ctx.fill();
  ctx.restore();
}
```
easeOutBack at born=0: 1+2.70158*(-1)+1.70158*1 = 1-2.70158+1.70158=0 ✓; born=1 → 1 ✓; overshoot ~1.1 mid. Negative values? easeOutBack dips? At small t: derivative positive; value stays ≥0? easeOutBack formula: 1 + c3*(t-1)^3 + c1*(t-1)^2 where c1=1.70158, c3=c1+1=2.70158. At t=0.1: (t-1)=-0.9: c3*(-0.729)= -1.9695; c1*0.81=1.3783; 1-1.9695+1.3783=0.4088 >0 ✓. At t=0.05: -0.857*2.70158? (t-1)^3=-0.857375*2.70158=-2.316; (t-1)^2=0.9025*1.70158=1.5352; 1-2.316+1.535=0.219 ✓. Monotonic-ish, min 0 at 0. Fine, and Math.max(0,...) guards.

drawSnake:
```js
function drawSnake(){
  const n=snake.length;
  const it=state==='running'?Math.min(1,acc/tickMs):1;
  const pts=new Array(n);
  for(let i=0;i<n;i++){
    const c=snake[i], p=prevSnake[Math.min(i,prevSnake.length-1)]||c;
    pts[i]={x:(p.x+(c.x-p.x)*it+.5)*cell,y:(p.y+(c.y-p.y)*it+.5)*cell};
  }
  const dead=(state==='dying'||state==='over');
  const dT=now-dieAt;
  // body
  ctx.lineCap='round';ctx.lineJoin='round';
  for(let i=n-1;i>0;i--){
    const k=i/(n-1);
    let col=bodyColor(k,dead);
    if(dead&&dT<140)col='#ffd9d2';
    ctx.strokeStyle=col;
    ctx.lineWidth=Math.max(3,cell*(.80-.30*k));
    ctx.beginPath();ctx.moveTo(pts[i].x,pts[i].y);ctx.lineTo(pts[i-1].x,pts[i-1].y);ctx.stroke();
  }
  // spine highlight
  if(n>2){
    ctx.strokeStyle=dead?'rgba(255,210,200,.14)':'rgba(255,255,255,.10)';
    ctx.lineWidth=Math.max(1.5,cell*.13);
    ctx.beginPath();ctx.moveTo(pts[1].x,pts[1].y);
    for(let i=2;i<n;i++)ctx.lineTo(pts[i].x,pts[i].y);
    ctx.stroke();
  }
  // head geometry
  const hd=pts[0];
  let hx,hy;
  if(n>1){hx=hd.x-pts[1].x;hy=hd.y-pts[1].y;}
  else{hx=dir.x;hy=dir.y;}
  const hl=Math.hypot(hx,hy);
  if(hl>1e-4){hx/=hl;hy/=hl;}else{hx=dir.x;hy=dir.y;}
  const ox=-hy,oy=hx;
  const hr=cell*.46*(1+.22*headPulse);
  // tongue
  if(!dead){
    const ph=now%2600;
    if(ph<300){
      const e=Math.sin(ph/300*Math.PI);
      const bx=hd.x+hx*hr*.7,by=hd.y+hy*hr*.7;
      const len=hr*1.05*e;
      const tx=bx+hx*len,ty=by+hy*len;
      ctx.strokeStyle='#ff4d5e';ctx.lineWidth=Math.max(1.4,cell*.09);
      ctx.beginPath();ctx.moveTo(bx,by);ctx.lineTo(tx,ty);
      for(const s of[-1,1]){
        let fx=hx+ox*.75*s,fy=hy+oy*.75*s;
        const fl=Math.hypot(fx,fy);fx/=fl;fy/=fl;
        const L=hr*.42*e;
        ctx.moveTo(tx,ty);ctx.lineTo(tx+fx*L,ty+fy*L);
      }
      ctx.stroke();
    }
  }
  // head
  let g=ctx.createRadialGradient(hd.x-hx*hr*.4,hd.y-hy*hr*.4,hr*.12,hd.x,hd.y,hr*1.06);
  if(dead){g.addColorStop(0,'#ffc3ba');g.addColorStop(.45,'#f0655a');g.addColorStop(1,'#7e2f2a');}
  else{g.addColorStop(0,'#d9ff9b');g.addColorStop(.45,'#9ef25c');g.addColorStop(1,'#3fae62');}
  ctx.save();
  if(!dead){ctx.shadowColor='rgba(158,242,92,.45)';ctx.shadowBlur=cell*.7;}
  ctx.fillStyle=g;ctx.beginPath();ctx.arc(hd.x,hd.y,hr,0,7);ctx.fill();
  ctx.restore();
  ctx.strokeStyle='rgba(8,26,18,.35)';ctx.lineWidth=1;
  ctx.beginPath();ctx.arc(hd.x,hd.y,hr-.5,0,7);ctx.stroke();
  // eyes
  for(const s of[-1,1]){
    const ex=hd.x+hx*hr*.30+ox*hr*.46*s;
    const ey=hd.y+hy*hr*.30+oy*hr*.46*s;
    const er=hr*.33;
    ctx.fillStyle='#fff';ctx.beginPath();ctx.arc(ex,ey,er,0,7);ctx.fill();
    if(dead){
      ctx.strokeStyle='#232f47';ctx.lineWidth=Math.max(1.3,er*.38);ctx.lineCap='round';
      const xr=er*.5;
      ctx.beginPath();
      ctx.moveTo(ex-xr,ey-xr);ctx.lineTo(ex+xr,ey+xr);
      ctx.moveTo(ex+xr,ey-xr);ctx.lineTo(ex-xr,ey+xr);
      ctx.stroke();
    }else{
      ctx.fillStyle='#14243c';
      ctx.beginPath();ctx.arc(ex+hx*er*.34,ey+hy*er*.34,er*.52,0,7);ctx.fill();
      ctx.fillStyle='rgba(255,255,255,.95)';
      ctx.beginPath();ctx.arc(ex-er*.22,ey-er*.28,er*.16,0,7);ctx.fill();
    }
  }
}
```
Wait, glint should be relative to pupil position — fine as static offset.

Note: shadowBlur for head each frame — one arc, cheap. ✓

drawParticles:
```js
function drawParticles(){
  for(const p of particles){
    const a=Math.max(0,Math.min(1,p.life));
    if(p.t==='dot'){
      ctx.globalAlpha=a;
      ctx.fillStyle=p.col;
      ctx.beginPath();ctx.arc(p.x,p.y,Math.max(.4,p.r*(.35+.65*a)),0,7);ctx.fill();
    }else if(p.t==='ring'){
      ctx.globalAlpha=a*.55;
      ctx.strokeStyle='#ffd23f';
      ctx.lineWidth=Math.max(1,cell*.09*a);
      ctx.beginPath();ctx.arc(p.x,p.y,p.r,0,7);ctx.stroke();
    }else{
      ctx.globalAlpha=Math.min(1,a*1.3);
      ctx.font=`800 ${Math.max(10,cell*.62)}px ${'ui-monospace,Menlo,Consolas,monospace'}`;
      ctx.textAlign='center';ctx.textBaseline='middle';
      ctx.lineJoin='round';
      ctx.lineWidth=Math.max(2,cell*.14);
      ctx.strokeStyle='rgba(7,12,24,.85)';
      ctx.strokeText(p.str,p.x,p.y);
      ctx.fillStyle='#dcffa0';
      ctx.fillText(p.str,p.x,p.y);
    }
  }
  ctx.globalAlpha=1;
}
```
Font shorthand with template literal: `ctx.font = '800 14px ui-monospace,Menlo,Consolas,monospace'` — valid CSS font shorthand requires family; "ui-monospace" is a valid generic-ish family keyword in CSS Fonts 4, Chrome supports in canvas? Should be fine; fallbacks anyway.

Note: font shorthand: `800 14px ui-monospace, Menlo, monospace` — font-style weight size family. ✓

Main loop:
```js
function frame(t){
  now=t;
  const dt=Math.min((t-lastT)/1000,.1);
  lastT=t;
  if(state==='running'){
    acc+=dt*1000;
    let guard=0;
    while(acc>=tickMs&&state==='running'&&guard++<6){step();acc-=tickMs;}
  }else if(state==='dying'&&now-dieAt>800){finishDeath();}
  if(state!=='paused'){
    updateParticles(dt);
    shake=Math.max(0,shake-dt*1.8);
    headPulse=Math.max(0,headPulse-dt*3.2);
  }
  render();
  requestAnimationFrame(frame);
}
```
Note after finishDeath in demo, state='running', acc=0 — but while loop already exited... acc reset by resetGame ✓.

Edge: when state changes to 'dying' inside while loop, loop exits via condition ✓.

Input:
```js
const KEYMAP={ArrowUp:'up',ArrowDown:'down',ArrowLeft:'left',ArrowRight:'right',
  w:'up',a:'left',s:'down',d:'right',W:'up',A:'left',S:'down',D:'right'};

window.addEventListener('keydown',e=>{
  const k=e.key;
  if(k===' '||k==='Spacebar'||k.startsWith('Arrow'))e.preventDefault();
  if(mode==='demo'){
    startPlay(KEYMAP[k]?DIRS[KEYMAP[k]]:null);
    return;
  }
  if(state==='over'){
    if(k===' '||k==='Enter')startPlay(null);
    return;
  }
  if(k==='p'||k==='P'){togglePause();return;}
  if(state==='running'&&KEYMAP[k])enqueueDir(DIRS[KEYMAP[k]]);
},{passive:false});
```
`k==='Spacebar'` old IE, harmless.

Pointer:
```js
document.addEventListener('pointerdown',e=>{
  if(e.pointerType==='touch')return;
  if(mode==='demo')startPlay(null);
  else if(state==='over')startPlay(null);
  else if(state==='paused')togglePause();
});
```
Touch:
```js
let tstart=null;
document.addEventListener('touchstart',e=>{
  const t=e.changedTouches[0];tstart={x:t.clientX,y:t.clientY};
},{passive:true});
document.addEventListener('touchend',e=>{
  if(!tstart)return;
  const t=e.changedTouches[0];
  const dx=t.clientX-tstart.x,dy=t.clientY-tstart.y;tstart=null;
  const dist=Math.hypot(dx,dy);
  if(dist<20){
    if(mode==='demo')startPlay(null);
    else if(state==='over')startPlay(null);
    else if(state==='paused')togglePause();
    return;
  }
  const d=Math.abs(dx)>Math.abs(dy)?(dx>0?DIRS.right:DIRS.left):(dy>0?DIRS.down:DIRS.up);
  if(mode==='demo')startPlay(d);
  else if(state==='running')enqueueDir(d);
},{passive:true});
```

btnRestart:
```js
btnRestart.addEventListener('click',()=>{if(state==='over')startPlay(null);});
```
(document pointerdown already handles; belt & suspenders. But pointerdown fires before click → state already 'running' → click handler no-op ✓.)

Blur auto-pause:
```js
window.addEventListener('blur',()=>{
  if(mode==='play'&&state==='running')togglePause();
});
```

Init:
```js
resize();
window.addEventListener('resize',()=>resize());
resetGame();
requestAnimationFrame(t=>{now=t;lastT=t;requestAnimationFrame(frame);});
```
resetGame before first frame; spawnFood uses now=0 → foodBorn=0; first frame now=~16ms → born small → food pops in ✓.

Also demo overlay: has 'show' class in HTML ✓; body has demo-mode class ✓.

One issue: `stage.style.width` set in resize → but stage initially 600px CSS default. resize called immediately. ✓

Also: header measured before canvas sized — header width = stage width (old 600) → heights stable regardless of width unless wrap. Fine with recursion guard.

**Check `.overlay.demo` visibility even before 'show'?** It has show in markup ✓.

**Check ovDemo stays during demo death/reset** — yes never hidden until startPlay.

**Potential bug**: In step(), demo mode: `dir=aiDir()` — aiDir may return `dir` when trapped (bestD null) → keeps going → dies ✓.

**aiDir food null check** — food always non-null (board not full). ✓

**Interp when queue applies new dir at tick**: prevSnake snapshot before unshift; head interp from old head cell to new head cell in new dir — corners handled by neck→head heading. ✓

**Multiple ticks per frame (guard 6)**: if tab throttled, dt clamp 0.1 → acc ≤ 100ms → at most 1 tick typically. Fine.

**Now check**: `while(acc>=tickMs&&state==='running'&&guard++<6){step();acc-=tickMs;}` — if step causes die → state 'dying' → exit ✓.

**updateScoreUI in demo pops animation** each food — fine, shows liveliness.

**Best display in over overlay**: shows session best ✓.

Hmm, one more: after finishing death in play mode, `state='over'`, render continues (frame keeps rAF), particles decay, snake red with X eyes frozen. ✓ Nice.

**Restart via Space when overlay visible** ✓.

**What if user presses P during 'dying'?** togglePause only handles running/paused → ignored ✓.

**What if user presses direction during 'dying'?** state!=='running' → ignored ✓.

**During 'over', pressing arrow** — state==='over' branch returns unless space/enter. ✓ Good (prevents accidental instant restart).

**Canvas rounding**: cell integer ✓.

Now double-check CSS `.stage{width:600px}` default overridden by JS inline. Also stage shouldn't exceed viewport: JS ensures. But before JS runs... script at end of body, runs immediately — fine.

**Footer `flex-wrap`** and stage width = board width (≥216px). At 375: stage 336; footer content might exceed 336 → wraps to 2 lines → footerEl.offsetHeight changes → resize recursion handles ✓ (initial resize measures footer before stage width set... then recursion pass adjusts ✓).

Let me trace resize recursion: pass=0: headH/footH measured with stage width 600 (initial CSS) — say footer single line 20px. Set stage width 336 → footer wraps to 2 lines 40px → headerEl/footerEl offsetHeight changed → call resize(1) → recompute availH with footH=40 → maybe cell smaller → stage width smaller... converges. Guard pass<3. But note: I compare only headerEl? Compare both: `if(pass<3&&(headerEl.offsetHeight!==headH||footerEl.offsetHeight!==footH))resize(pass+1);` ✓

Hmm but there's a subtlety: resize reads offsetHeight immediately after style change — layout is synchronous on offsetHeight read ✓.

**One more possible issue**: `overflow:hidden` on body with stage taller than viewport (tiny screens) → top/bottom clipped. min cell 9 → board 216 + header ~50 + footer ~40 + gaps ~ 340 total. Viewport 320? Rare. Accept.

Let me reconsider cell min: `Math.max(9, ...)` at innerWidth 320 → availW=284 → cell 11 (floor(284/24)=11) → board 264. Header wraps. OK.

**Aesthetic check of colors**: snake lime→teal on navy board; food orange-red with warm glow; particles warm + a couple lime; UI lime/amber accents on dark navy. Cohesive. ✓

Also let me add slight glow to score pill value? Not needed.

**Title tagline**: "canvas · vanilla js edition" — I'll use "vanilla js · 24×24 grid"? Keep "canvas edition". Fine: `canvas edition`.

Wait, also maybe display grid size somewhere subtle — skip.

Now, "quick successive key presses must both be honored" — with tick 132ms, pressing two turns within 132ms: both queued & applied on consecutive ticks ✓. 

Potential subtle bug in enqueueDir: queue=[A]; user presses B (= opposite of A but valid vs current). E.g., moving right, press up (queue [up]), press down quickly: last=up → down rejected ✓ (would be reversal after up). Correct behavior.

Press up then left: [up,left] ✓ both applied.

**Score font tabular** ✓.

**Now check the "over" overlay stat markup**: `<div class="stat"><span>Score</span><b class="lime">` — .stat b.lime color defined; but ov-big also uses .lime class → CSS `.ov-big.lime` and `.stat b.lime` distinct selectors. Careful `.lime{color:var(--lime)}` generic simpler: define `.lime{color:var(--lime)!important}`? Just define utility: `.lime{color:var(--lime)}.amber{color:var(--amber)}` — .ov-big.lime then inherits color var — but I wrote `.ov-big.lime{color:var(--lime);text-shadow...}` — keep both; utility classes cover stat b. Simplify: define `.lime{color:var(--lime)}` and `.amber{color:var(--amber)}`, plus `.ov-big.lime{text-shadow:...}`, `.ov-big.coral{color:var(--coral);text-shadow}`. Then `.ov-big.coral` sets color; `.coral` utility not defined separately — fine.

Let me finalize CSS selectors accordingly.

**Prompt-sub content**: "Arrows / WASD to steer · P pauses · Space restarts" — good.

Also demo chip text "AI DEMO". 

**Final check of the head rim stroke** — dark outline circle; on second thought a light rim looks better: 'rgba(230,255,200,.25)'? The head gradient is bright; a subtle darker edge grounds it. Keep dark at low alpha.

**Eyes size**: hr*0.33 with hr=0.46*cell → eye ≈ 0.15*cell ~ 3.3px at cell 22 — small but visible. Pupil 0.52*er. Glint tiny. At cell 27 (desktop) eye ~4.5px. OK.

Eye positions: forward 0.30hr, side ±0.46hr — inside head radius 1hr ✓.

**Tongue base at 0.7hr from center** — starts inside head, drawn before? No — I draw tongue AFTER head fill? Order in code: tongue drawn BEFORE head circle? Let me re-check my drawSnake order: body → spine → head geometry → tongue (drawn) → head gradient fill → eyes. I placed tongue block before head fill ✓ (tongue base hidden under head). In my code above: "// tongue" then "// head". ✓ Yes I wrote tongue before head. Good.

But wait — tongue drawn before head means head circle (with shadowBlur) covers tongue base — tongue emerges from edge ✓.

**Shadow on head fill**: glow around head — but shadow also offset by translate shake — fine.

Also while dead, no shadow (guard `if(!dead)`) ✓.

**One more piece**: `ctx.globalAlpha` reset after particles ✓; also after head save/restore ✓.

**textAlign/textBaseline** set in particles — affects subsequent text? Only text drawing is particles. Reset anyway not needed.

**Check `ctx.arc(...,0,7)`** — 7 rad > 2π ✓.

**Check for any leftover `hsl()` with wrong syntax**: `hsl(${95+70*k},${72-18*k}%,${60-24*k}%)` → e.g. `hsl(95,72%,60%)` ✓. k up to 1: hsl(165,54%,36%) ✓. Dead: `hsl(${10+8*k},68%,${56-24*k}%)` ✓.

**Now the score pill pop animation transform on `.val` inline-block** ✓.

**Overlays `backdrop-filter`** — Chrome ✓.

**demo overlay `pointer-events:none`** — clicks fall through to document handler ✓.

**veil overlays** — pointer events auto; document pointerdown receives bubbled event ✓.

**Possible double startPlay from pointerdown + keydown?** Different events, user does one. ✓

**Space key preventDefault also prevents button activation scroll** ✓.

**Font of ov stats** mono ✓.

I think we're set. Let me also add a tiny detail: show "DEMO" score relation? skip.

Let me write final file completely now, and mentally compile once more for syntax.

Also: `const stage=document.getElementById('stage')` — id="stage" on main ✓.

Careful: `header` variable name conflicts with nothing. Use headerEl.

Also `now` initialized 0; updateScoreUI fine.

Also I refer to `state` in drawSnake via closure ✓.

One more consideration: initial demo snake could immediately run into AI weirdness at start (head at 12,12 moving right, food random). AI computes dir each tick — first tick dir may change to up etc. It uses dir (right) for reverse filtering. ✓

Alright — also confirm `startPlay` when keydown in demo with KEYMAP keys passes DIRS ✓.

Also ensure that when starting via click while demo's snake mid-interpolation: resetGame sets prevSnake=snake copy → static then ticks ✓.

Also the "dying" red flash in demo: resetGame clears particles/shake ✓.

Edge: `finishDeath` demo → resetGame() — demo overlay still 'show' ✓, body still demo-mode class ✓.

**Write the final HTML.** Also remember: NO text outside the code block.

Let me count roughly the structure to ensure nothing missing, then produce.

One last thing — the requirement "Grid of at least 20×20 cells, centered on the page": 24×24 ✓ centered ✓.

Also double-check that at 1280×800 the layout isn't cramped: availH = 800 - header(~54) - footer(~26) - 52 = 668 → cell=min(760,668)/24=27 → 648px board; stage width 648; header wraps? brand ~200 + pills ~230 = 430 < 648 ✓ single line.

Body padding 14px — availW uses innerWidth-36 ✓.

Also `100dvh` with fallback 100vh ✓.

Ah — one more consideration: `body{height:100vh}` + `display:flex` + `overflow:hidden` — fine.

Also `main.stage` margin: none.

Potential CSS specificity: `.overlay.show .panel{transform:none}` overrides `.panel{transform:...}` ✓.

Demo overlay is `.overlay.demo.show` — its flex settings apply always.

Also: the `.overlay.demo` has `justify-content:flex-end` overriding `.overlay`'s center — plus align-items center default ✓.

Write it.

Actually, let me reconsider `resize()` measuring `headerEl.offsetHeight` — header has flex-wrap; its height includes wrapped rows ✓.

And guard: also compare footer. ✓

Also one thing: `canvas.style.width=canvas.style.height=px+'px'` — string assignment chain works (both get same string) ✓.

Final code assembly. I'll also add a `hidden` handling for `ovNewBest` via class 'on' with display none default ✓.

Double check kbd inside ov-sub ✓.

Also — should clicking anywhere during 'running' (play) do nothing? Yes currently. Good.

Also the pause overlay sub says press P to resume; click also resumes (pointer handler). ✓

I'm fairly confident. Write final.

One more re-read of requirement: "with an overlay showing the score" ✓ (game over overlay shows score + best).

"Display the current score and the best score of the session" — header pills always visible ✓.

Also — hmm, "best score of the session": since localStorage banned, in-memory session best is exactly right.

Let me make sure there are no console errors: all getElementById ids exist. IDs: game, stage, scoreVal, bestVal, ovDemo, ovPause, ovOver, ovScoreNum, ovBestNum, ovNewBest, btnRestart, boardWrap (not used in JS? I use canvas/stage; boardWrap unused in JS — fine, keep id anyway or remove; keep).

querySelector('header') → first header ✓. querySelector('footer') ✓.

In resize I use `headerEl`/`footerEl` defined before resize called ✓ (script bottom, DOM ready).

`requestAnimationFrame(t=>{now=t;lastT=t;requestAnimationFrame(frame);})` — first frame dt=0 ✓.

Everything good. Also ensure `e.startsWith` — `k.startsWith('Arrow')` where k string ✓ (e.key always string).

Now writing final output. I'll keep code tidy with section comments.

Small extra flourish I might add: when eating, a brief board flash? skip.

Also I realize the demo chip and prompt might overlap the board edges at small sizes — prompt padding-bottom 6.5% scales ✓.

Also on very small canvas (216px), prompt width might exceed board (prompt ~260px) → clipped by overflow hidden! `.prompt` white-space could overflow. Fix: `max-width:92%` on prompt and prompt-line font clamps to 15px min... 15px + padding 24 → min width ~ 220px < 216? "Click or press a key to start" at 15px ≈ 215px wide — borderline at 216 board. Add `padding:12px 16px` on small via media query and allow wrap: `.prompt-line{white-space:normal}`. And `.prompt{max-width:min(92%,340px)}`. It'll wrap to 2 lines at tiny sizes — acceptable. Actually 216px board only occurs below ~260px viewport width — very rare. Still, add max-width safety.

Similarly demo-chip tiny — fine.

OK, produce final answer now.

Let me also double check the forked tongue path: I do beginPath, moveTo(bx,by), lineTo(tx,ty), then loop moveTo/lineTo forks, single stroke ✓.

And `const ox=-hy,oy=hx;` — perpendicular ✓.

In dead state, tongue skipped, eyes X ✓.

`bodyColor(k,dead)` ✓.

Also note: during 'over' state (play mode), dead=true keeps red snake — overlay on top. ✓

Also during 'dying', interpolation it=1: `state==='running'?...:1` ✓.

Hmm — subtle: during 'dying', acc might be mid-tick (~0.5) but we force it=1 — visual jump from half-move to full-rest. But as analyzed, die happens right at tick boundary when acc≈tickMs (it≈1) — jump negligible. Actually acc>=tickMs triggered step → die; acc-=tickMs happens after step in while body — but we `return die()` inside step... the while body: `{step();acc-=tickMs;}` — step returns after die (returns undefined), then acc-=tickMs executes → acc small → it would be ~0 → big jump backward! Wait: `while(acc>=tickMs&&state==='running'&&...){step();acc-=tickMs;}` — step() sets state='dying' — but the current iteration still completes: acc-=tickMs. Then condition re-checked → exits. acc now ≈ 0..small. But render uses it = state==='running' ? ... : 1 → forced 1 regardless of acc ✓. Good, the forced it=1 in dying/over saves us. ✓

And when snake is running normally, it = acc/tickMs ∈ [0,1) ✓.

Now — one more nuance: at the exact frame where step() runs (e.g., eat), acc resets; particles spawn; interpolation restarts from new prev — smooth ✓.

Deliver final code.

Also let me add `aria` labels? Skip mostly; add role none. Fine.

Also one more small thing: `document.title` set in head ✓.

Final check for forbidden: no external requests (favicon is data URI — allowed? "No external files, images, fonts or network requests" — data URI is inline, not a request. Safe.)

Write out.