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
2 min 38 s
Cost
$0.033
Output tokens
23,868
Spent thinking
15,666 tokens
Speed
151.5 tok/s
Console errors
0
First token after
715 ms
First visible text after
2 min 4 s
Prompt tokens
491
Code size
18.2 KB
Finish reason
stop
Served by
Wafer
Model id
z-ai/glm-5.3
Reasoning sent
{"effort":"high"}
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 · 509 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Snake — Canvas Game</title>
<style>
  :root{
    --bg:#0a0f1e; --text:#e8edf7; --muted:#8b98b8;
    --line:rgba(255,255,255,.08); --accent:#34d399; --accent2:#fbbf24;
  }
  *{ box-sizing:border-box; }
  html,body{ height:100%; }
  body{
    margin:0; color:var(--text);
    background:
      radial-gradient(1100px 700px at 50% -10%, #1e2d50 0%, #0a0f1e 58%) fixed, #0a0f1e;
    font-family:"Segoe UI", system-ui, -apple-system, sans-serif;
    min-height:100vh; display:flex; -webkit-user-select:none; user-select:none;
    overflow-x:hidden;
  }
  main{ margin:auto; display:flex; flex-direction:column; align-items:center; gap:13px; padding:18px; width:100%; }
  header{ text-align:center; }
  h1{
    margin:0; font-weight:800; letter-spacing:.35em; text-indent:.35em;
    font-size:clamp(26px, 4vw, 40px);
    background:linear-gradient(90deg, #34d399, #a7f3d0 45%, #fbbf24);
    -webkit-background-clip:text; background-clip:text; color:transparent;
  }
  .sub{ color:var(--muted); font-size:11px; letter-spacing:.32em; text-transform:uppercase; margin-top:5px; }
  .hud{ display:flex; gap:10px; }
  .stat{
    background:rgba(17,26,46,.8); border:1px solid var(--line); border-radius:12px;
    padding:8px 18px; min-width:112px; text-align:center;
  }
  .stat .label{ font-size:10px; letter-spacing:.22em; text-transform:uppercase; color:var(--muted); }
  .stat .value{ font-size:23px; font-weight:700; font-variant-numeric:tabular-nums; font-family:ui-monospace, Menlo, Consolas, monospace; }
  .stat.score .value{ color:var(--accent); }
  .stat.best  .value{ color:var(--accent2); }
  .stat .value.pop{ animation:pop .32s ease; }
  @keyframes pop{ 40%{ transform:scale(1.35); } }
  .board-wrap{
    position:relative; border-radius:18px;
    box-shadow:0 30px 80px rgba(0,0,0,.55), 0 0 0 1px rgba(255,255,255,.06), 0 0 70px rgba(52,211,153,.08);
  }
  .board-wrap.shake{ animation:shake .42s ease; }
  @keyframes shake{
    0%,100%{transform:translate(0)} 20%{transform:translate(-7px,2px)}
    40%{transform:translate(6px,-3px)} 60%{transform:translate(-4px,1px)} 80%{transform:translate(3px,-1px)}
  }
  canvas{ display:block; width:min(560px, 92vw, 68vh); height:auto; aspect-ratio:1/1; border-radius:18px; }
  #overlay{
    position:absolute; inset:0; border-radius:18px; padding:20px;
    display:flex; flex-direction:column; align-items:center; justify-content:center; gap:9px;
    background:rgba(7,11,22,.72); backdrop-filter:blur(3px); -webkit-backdrop-filter:blur(3px);
    text-align:center; transition:opacity .35s ease, visibility .35s;
  }
  #overlay.hidden{ opacity:0; visibility:hidden; }
  #overlay h2{ margin:0; font-size:clamp(22px, 3.2vw, 33px); letter-spacing:.12em; }
  #overlay p{ margin:2px 0; color:var(--muted); font-size:14px; }
  #overlay .big{ font-size:19px; color:var(--text); font-weight:650; animation:blink 1.7s ease infinite; }
  @keyframes blink{ 50%{ opacity:.4; } }
  #overlay .go-score{
    font-size:clamp(34px, 5vw, 54px); font-weight:800; color:var(--accent);
    font-family:ui-monospace, monospace; line-height:1;
  }
  .chip{
    font-size:10.5px; letter-spacing:.25em; text-transform:uppercase; font-weight:800; color:#0a1220;
    background:linear-gradient(90deg,#34d399,#a7f3d0); padding:5px 13px; border-radius:999px;
  }
  kbd{
    background:#1c2740; border:1px solid rgba(255,255,255,.15); border-bottom-width:2px;
    border-radius:6px; padding:2px 7px; font-size:12px;
    font-family:ui-monospace, Menlo, monospace; color:#cdd7ee;
  }
  footer{ color:var(--muted); font-size:12.5px; display:flex; gap:16px; flex-wrap:wrap; justify-content:center; }
  footer kbd{ font-size:11px; padding:1px 6px; }
</style>
</head>
<body>
<main>
  <header>
    <h1>SNAKE</h1>
    <div class="sub">Vanilla JS · Canvas</div>
  </header>

  <div class="hud">
    <div class="stat score"><div class="label">Score</div><div class="value" id="score">0</div></div>
    <div class="stat best"><div class="label">Best (session)</div><div class="value" id="best">0</div></div>
  </div>

  <div class="board-wrap" id="wrap">
    <canvas id="game" width="576" height="576"></canvas>
    <div id="overlay"></div>
  </div>

  <footer>
    <span><kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd> / <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> move</span>
    <span><kbd>P</kbd> pause</span>
    <span><kbd>Space</kbd> / <kbd>Enter</kbd> restart</span>
  </footer>
</main>

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

/* ---------- setup ---------- */
const GRID = 24, CELL = 24, SIZE = GRID * CELL;
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const overlay = document.getElementById('overlay');
const wrap = document.getElementById('wrap');
const scoreEl = document.getElementById('score');
const bestEl = document.getElementById('best');

const DPR = Math.min(2, window.devicePixelRatio || 1);
canvas.width = SIZE * DPR; canvas.height = SIZE * DPR;
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);

/* pre-rendered board (checkerboard + inner frame) */
const boardCv = document.createElement('canvas');
boardCv.width = SIZE * DPR; boardCv.height = SIZE * DPR;
{
  const b = boardCv.getContext('2d');
  b.setTransform(DPR, 0, 0, DPR, 0, 0);
  b.fillStyle = '#0d1626'; b.fillRect(0, 0, SIZE, SIZE);
  b.fillStyle = 'rgba(255,255,255,.018)';
  for (let y = 0; y < GRID; y++) for (let x = 0; x < GRID; x++)
    if ((x + y) % 2 === 0) b.fillRect(x * CELL, y * CELL, CELL, CELL);
  b.strokeStyle = 'rgba(148,163,184,.16)'; b.lineWidth = 2;
  b.strokeRect(1, 1, SIZE - 2, SIZE - 2);
}

/* ---------- state ---------- */
const DIRS = { up:{x:0,y:-1}, down:{x:0,y:1}, left:{x:-1,y:0}, right:{x:1,y:0} };
const KEYMAP = {
  arrowup:'up', w:'up', arrowdown:'down', s:'down',
  arrowleft:'left', a:'left', arrowright:'right', d:'right'
};
const key = s => s.x + ',' + s.y;

let snake, prevCells, dir, dirQueue, food;
let score = 0, best = 0;
let state = 'demo';          /* demo | play | paused | over */
let demo = true;
let tickMs = 132, acc = 0, last = performance.now();
let particles = [], rings = [];
let demoRestartTimer = null;

/* ---------- game core ---------- */
function reset(){
  const c = GRID >> 1;
  snake = [{x:c, y:c}, {x:c-1, y:c}, {x:c-2, y:c}];
  prevCells = snake.map(s => ({...s}));
  dir = {x:1, y:0}; dirQueue = [];
  score = 0; tickMs = 132; acc = 0;
  particles = []; rings = [];
  spawnFood();
  updateHUD(false);
}

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

function step(){
  if (state === 'demo'){ dir = aiDir(); }
  else if (dirQueue.length){ dir = dirQueue.shift(); }

  const head = snake[0];
  const nh = {x: head.x + dir.x, y: head.y + dir.y};

  if (nh.x < 0 || nh.y < 0 || nh.x >= GRID || nh.y >= GRID) return die();

  const willGrow = food && nh.x === food.x && nh.y === food.y;
  const limit = willGrow ? snake.length : snake.length - 1;   // tail vacates unless growing
  for (let i = 0; i < limit; i++)
    if (snake[i].x === nh.x && snake[i].y === nh.y) return die();

  const old = snake;
  const next = willGrow ? [nh, ...old] : [nh, ...old.slice(0, -1)];
  prevCells = next.map((_, i) => old[Math.min(i, old.length - 1)]);
  snake = next;

  if (willGrow){
    score += 10;
    if (score > best) best = score;
    const fx = nh.x * CELL + CELL / 2, fy = nh.y * CELL + CELL / 2;
    burst(fx, fy);
    rings.push({x: fx, y: fy, age: 0});
    spawnFood();
    tickMs = Math.max(78, 132 - score * 0.5);
    updateHUD(true);
  }
}

function die(){
  state = 'over';
  acc = 0;
  prevCells = snake.map(s => ({...s}));       // freeze exactly where we are
  wrap.classList.remove('shake'); void wrap.offsetWidth; wrap.classList.add('shake');
  if (demo){
    demoRestartTimer = setTimeout(() => {
      if (state === 'over' && demo){ reset(); state = 'demo'; updateOverlay(); }
    }, 1500);
  }
  updateOverlay();
}

function start(){
  demo = false;
  if (demoRestartTimer){ clearTimeout(demoRestartTimer); demoRestartTimer = null; }
  reset();
  state = 'play';
  updateOverlay();
}
function pauseGame(){ if (state === 'play'){ state = 'paused'; updateOverlay(); } }
function resumeGame(){ if (state === 'paused'){ state = 'play'; updateOverlay(); } }

/* ---------- input (queued, never reverses) ---------- */
function pushDir(name){
  const d = DIRS[name];
  const ref = dirQueue.length ? dirQueue[dirQueue.length - 1] : dir;
  if (d.x === ref.x && d.y === ref.y) return;       // same direction
  if (d.x === -ref.x && d.y === -ref.y) return;     // reversal — never allowed
  if (dirQueue.length < 3) dirQueue.push(d);
}

window.addEventListener('keydown', e => {
  const k = e.key.toLowerCase();
  if (k.startsWith('arrow') || k === ' ') e.preventDefault();

  if (demo){ start(); if (KEYMAP[k]) pushDir(KEYMAP[k]); return; }

  if (state === 'over'){
    if (k === ' ' || k === 'enter') start();
    else if (KEYMAP[k]){ start(); pushDir(KEYMAP[k]); }
    return;
  }
  if (state === 'paused'){ if (k === 'p' || k === ' ') resumeGame(); return; }
  if (state === 'play'){
    if (KEYMAP[k]) pushDir(KEYMAP[k]);
    else if (k === 'p' || k === ' ') pauseGame();
  }
});

let swipeStart = null;
document.addEventListener('pointerdown', e => {
  swipeStart = {x: e.clientX, y: e.clientY};
  if (demo){ start(); return; }
  if (state === 'over'){ start(); return; }
  if (state === 'paused'){ resumeGame(); }
});
document.addEventListener('pointerup', e => {
  if (!swipeStart) return;
  const dx = e.clientX - swipeStart.x, dy = e.clientY - swipeStart.y; swipeStart = null;
  if (Math.hypot(dx, dy) < 24) return;
  const name = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : (dy > 0 ? 'down' : 'up');
  if (state === 'play') pushDir(name);
});

document.addEventListener('visibilitychange', () => {
  if (document.hidden && state === 'play' && !demo) pauseGame();
});

/* ---------- demo AI: BFS to food + flood-fill safety ---------- */
function floodCount(start, blocked){
  const seen = new Set([key(start)]); const q = [start]; let n = 0;
  while (q.length){
    const c = q.pop(); n++;
    for (const d of [DIRS.up, DIRS.down, DIRS.left, DIRS.right]){
      const nx = c.x + d.x, ny = c.y + d.y;
      if (nx < 0 || ny < 0 || nx >= GRID || ny >= GRID) continue;
      const k2 = nx + ',' + ny;
      if (seen.has(k2) || blocked.has(k2)) continue;
      seen.add(k2); q.push({x: nx, y: ny});
    }
  }
  return n;
}
function bfsFirstStep(target, blocked){
  const start = snake[0];
  const prev = new Map(); prev.set(key(start), null);
  const q = [start];
  while (q.length){
    const c = q.shift();
    if (c.x === target.x && c.y === target.y){
      let cur = c, par = prev.get(key(cur));
      while (par && !(par.x === start.x && par.y === start.y)){ cur = par; par = prev.get(key(cur)); }
      if (!par) return null;
      return {x: cur.x - start.x, y: cur.y - start.y};
    }
    for (const d of [DIRS.up, DIRS.down, DIRS.left, DIRS.right]){
      const nx = c.x + d.x, ny = c.y + d.y;
      if (nx < 0 || ny < 0 || nx >= GRID || ny >= GRID) continue;
      const k2 = nx + ',' + ny;
      if (prev.has(k2) || blocked.has(k2)) continue;
      prev.set(k2, c); q.push({x: nx, y: ny});
    }
  }
  return null;
}
function aiDir(){
  if (!food) return dir;
  const head = snake[0];
  const occ = new Set();
  for (let i = 0; i < snake.length - 1; i++) occ.add(key(snake[i])); // tail moves away
  const first = bfsFirstStep(food, occ);
  const options = [];
  for (const name in DIRS){
    const d = DIRS[name];
    if (d.x === -dir.x && d.y === -dir.y) continue;
    const nh = {x: head.x + d.x, y: head.y + d.y};
    if (nh.x < 0 || nh.y < 0 || nh.x >= GRID || nh.y >= GRID) continue;
    if (occ.has(key(nh))) continue;
    const occ2 = new Set(occ); occ2.add(key(nh));
    options.push({d, free: floodCount(nh, occ2), dist: Math.abs(nh.x - food.x) + Math.abs(nh.y - food.y)});
  }
  if (!options.length) return dir;
  if (first){
    const o = options.find(o => o.d.x === first.x && o.d.y === first.y);
    if (o && o.free >= snake.length + 1) return first;
  }
  options.sort((a, b) => (b.free - a.free) || (a.dist - b.dist));
  return options[0].d;
}

/* ---------- effects ---------- */
function burst(x, y){
  const cols = ['#f87171', '#fca5a5', '#fbbf24', '#fde68a', '#ffffff'];
  for (let i = 0; i < 18; i++){
    const a = Math.random() * Math.PI * 2, sp = 50 + Math.random() * 130;
    particles.push({
      x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 40,
      life: 0, max: 0.45 + Math.random() * 0.35,
      r: 1.6 + Math.random() * 2.6, col: cols[(Math.random() * cols.length) | 0]
    });
  }
}
function updateFX(dt){
  const s = dt / 1000;
  for (let i = particles.length - 1; i >= 0; i--){
    const p = particles[i];
    p.life += s;
    if (p.life >= p.max){ particles.splice(i, 1); continue; }
    p.x += p.vx * s; p.y += p.vy * s; p.vy += 200 * s;
  }
  for (let i = rings.length - 1; i >= 0; i--){
    rings[i].age += s;
    if (rings[i].age >= 0.45) rings.splice(i, 1);
  }
}

/* ---------- HUD & overlay ---------- */
function updateHUD(pop){
  scoreEl.textContent = score;
  bestEl.textContent = best;
  if (pop){ scoreEl.classList.remove('pop'); void scoreEl.offsetWidth; scoreEl.classList.add('pop'); }
}
function updateOverlay(){
  if (state === 'play'){ overlay.classList.add('hidden'); return; }
  overlay.classList.remove('hidden');
  if (demo){
    overlay.innerHTML =
      '<div class="chip">AI demo</div>' +
      '<h2>Snake</h2>' +
      '<p class="big">Click or press a key to start</p>' +
      '<p>Arrows / WASD steer &nbsp;·&nbsp; eat the red orbs</p>';
  } else if (state === 'paused'){
    overlay.innerHTML =
      '<h2>Paused</h2>' +
      '<p class="big">Press <kbd>P</kbd> to resume</p>';
  } else if (state === 'over'){
    overlay.innerHTML =
      '<h2>Game Over</h2>' +
      '<div class="go-score">' + score + '</div>' +
      '<p>Best this session: <strong>' + best + '</strong></p>' +
      '<p class="big">Press <kbd>Space</kbd> or <kbd>Enter</kbd> to restart</p>';
  }
}

/* ---------- rendering ---------- */
function render(now){
  ctx.clearRect(0, 0, SIZE, SIZE);
  ctx.drawImage(boardCv, 0, 0, SIZE, SIZE);

  /* food */
  if (food){
    const fx = food.x * CELL + CELL / 2, fy = food.y * CELL + CELL / 2;
    const r = CELL * 0.32 * (1 + 0.09 * Math.sin(now / 200));
    ctx.save();
    ctx.shadowColor = 'rgba(248,113,113,.85)'; ctx.shadowBlur = 16;
    const g = ctx.createRadialGradient(fx - r * 0.35, fy - r * 0.4, r * 0.15, fx, fy, r);
    g.addColorStop(0, '#ffb4a6'); g.addColorStop(0.5, '#f87171'); g.addColorStop(1, '#d93434');
    ctx.fillStyle = g;
    ctx.beginPath(); ctx.arc(fx, fy, r, 0, Math.PI * 2); ctx.fill();
    ctx.restore();
    ctx.fillStyle = 'rgba(255,255,255,.7)';
    ctx.beginPath(); ctx.ellipse(fx - r * 0.32, fy - r * 0.38, r * 0.2, r * 0.13, -0.6, 0, Math.PI * 2); ctx.fill();
  }

  /* snake — interpolated between ticks for smooth movement */
  const t = Math.min(1, acc / tickMs);
  const pts = snake.map((s, i) => {
    const p = prevCells[i] || s;
    return { x: (p.x + (s.x - p.x) * t) * CELL + CELL / 2,
             y: (p.y + (s.y - p.y) * t) * CELL + CELL / 2 };
  });
  const n = pts.length;
  ctx.lineCap = 'round';
  for (let pass = 0; pass < 2; pass++){
    for (let i = n - 1; i > 0; i--){
      const f = 1 - i / Math.max(1, n - 1);           // 1 near head, 0 at tail
      ctx.lineWidth = pass === 0 ? CELL * 0.80 : CELL * 0.62;
      ctx.strokeStyle = pass === 0 ? '#062015'
        : 'hsl(' + (150 - f * 16) + ' 60% ' + (33 + f * 17) + '%)';
      ctx.beginPath();
      ctx.moveTo(pts[i].x, pts[i].y); ctx.lineTo(pts[i - 1].x, pts[i - 1].y);
      ctx.stroke();
    }
  }

  /* head + eyes facing travel direction */
  const hp = pts[0];
  let dx = dir.x, dy = dir.y;
  if (n > 1){
    let vx = hp.x - pts[1].x, vy = hp.y - pts[1].y;
    const m = Math.hypot(vx, vy);
    if (m > 0.001){ dx = vx / m; dy = vy / m; }
  }
  const px = -dy, py = dx, hr = CELL * 0.42;
  ctx.save();
  ctx.shadowColor = 'rgba(52,211,153,.55)'; ctx.shadowBlur = 16;
  ctx.fillStyle = '#0a2d1c';
  ctx.beginPath(); ctx.arc(hp.x, hp.y, hr + 2.5, 0, Math.PI * 2); ctx.fill();
  ctx.restore();
  const hg = ctx.createRadialGradient(hp.x - dx * hr * 0.3, hp.y - dy * hr * 0.3, hr * 0.2, hp.x, hp.y, hr);
  hg.addColorStop(0, '#8ff0ae'); hg.addColorStop(1, '#2fbf71');
  ctx.fillStyle = hg;
  ctx.beginPath(); ctx.arc(hp.x, hp.y, hr, 0, Math.PI * 2); ctx.fill();

  for (const side of [-1, 1]){
    const ex = hp.x + dx * CELL * 0.08 + px * side * CELL * 0.17;
    const ey = hp.y + dy * CELL * 0.08 + py * side * CELL * 0.17;
    ctx.fillStyle = '#fdfdf5';
    ctx.beginPath(); ctx.arc(ex, ey, CELL * 0.125, 0, Math.PI * 2); ctx.fill();
    if (state === 'over'){                             // X-eyes on game over
      ctx.strokeStyle = '#0b1a12'; ctx.lineWidth = 1.6;
      const s = CELL * 0.055;
      ctx.beginPath();
      ctx.moveTo(ex - s, ey - s); ctx.lineTo(ex + s, ey + s);
      ctx.moveTo(ex + s, ey - s); ctx.lineTo(ex - s, ey + s);
      ctx.stroke();
    } else {
      ctx.fillStyle = '#0b1a12';
      ctx.beginPath();
      ctx.arc(ex + dx * CELL * 0.045, ey + dy * CELL * 0.045, CELL * 0.062, 0, Math.PI * 2); ctx.fill();
    }
  }

  /* rings + particles */
  for (const r of rings){
    const a = r.age / 0.45;
    if (a >= 1) continue;
    ctx.strokeStyle = 'rgba(248,113,113,' + ((1 - a) * 0.7) + ')';
    ctx.lineWidth = 2.5 * (1 - a) + 0.5;
    ctx.beginPath(); ctx.arc(r.x, r.y, 6 + a * 26, 0, Math.PI * 2); ctx.stroke();
  }
  for (const p of particles){
    const a = 1 - p.life / p.max;
    if (a <= 0) continue;
    ctx.globalAlpha = a;
    ctx.fillStyle = p.col;
    ctx.beginPath(); ctx.arc(p.x, p.y, p.r * (0.4 + 0.6 * a), 0, Math.PI * 2); ctx.fill();
  }
  ctx.globalAlpha = 1;
}

/* ---------- main loop ---------- */
function frame(now){
  const dt = Math.min(60, now - last); last = now;
  if (state === 'play' || state === 'demo'){
    acc += dt;
    let guard = 0;
    while (acc >= tickMs && guard++ < 8){
      acc -= tickMs;
      step();
      if (state === 'over'){ acc = 0; break; }
    }
  }
  updateFX(dt);
  render(now);
  requestAnimationFrame(frame);
}

/* ---------- boot ---------- */
reset();
state = 'demo';
updateOverlay();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
Reasoning trace (15,666 tokens)
Let me plan the Snake game carefully.

Requirements:
- Grid at least 20×20 cells, centered on page.
- Arrow keys + WASD.
- Quick successive key presses both honored, but no reversal. So I need an input queue (buffer of up to 2-3 direction changes) that gets consumed one per tick.
- Food +10 points, grows by 1 cell, food never spawns on snake.
- Game over on wall or self hit, overlay with score, Space/Enter restarts, P pauses.
- Smooth movement: interpolate the snake position between ticks for rendering. Also distinct head with eyes facing direction, particle burst on eating.
- Score + best score of session (just a variable, since localStorage is blocked — "best score of the session" means in-memory, perfect).

Demo mode: game must start in self-playing AI mode behind "Click or press a key to start" message. The AI should play the snake automatically until user interacts. Then when user clicks/presses key, start the real game (or take control). Let me implement: on load, demo mode = true, AI plays. Overlay shows "Click or press a key to start". On first keydown/click, hide overlay, reset game, and give control to player. Actually better: on interaction, start a fresh game for the player.

Also keyboard events only after iframe clicked — so show the message. Clicking anywhere should start.

Let me design the visuals:
- Dark theme, maybe deep slate/indigo background with subtle gradient.
- Canvas ~520px for 26×26 grid at 20px cells? Let's do grid 24×24 cells with cell size 22px → 528px canvas. Hmm, need to adapt down to 375px wide. Use CSS to scale canvas responsively: canvas width fixed logical, CSS max-width: min(90vw, ...). Use aspect ratio preserved.

Let me do: GRID = 24, CELL = 24 (logical px), canvas 576×576 logical, but rendered with devicePixelRatio for crispness? Simpler: set canvas width/height attributes to 576 (or scaled by dpr), CSS width: min(576px, calc(100vw - 32px)). Keep square via aspect-ratio CSS.

Smooth movement: keep snake as array of grid cells. On each tick (e.g., every 140ms, slightly faster over time?), move head to next cell. For rendering, interpolate between previous positions and current positions using t = elapsed/tickDuration. So store prevSnake (positions before the tick) and snake (after). Render each segment interpolated from prevSnake[i] to snake[i]. When growing, the new tail segment: prev position for the new tail is the same as old tail (it doesn't move)... Actually with growth, snake gains a segment. Let me handle: on move, newSnake = [newHead, ...snake.slice(0, len-1)] normally; when growing, keep the tail too: [newHead, ...snake]. For interpolation, each render index i maps from prev position to current position. For the tail when grown, the last segment's prev = same as current (it just stays). Simpler approach: keep for each segment the current cell and previous cell.

Implementation: snake = array of {x,y}. prevSnake = array of {x,y}, same length handling. Each tick:
- compute newHead from head + dir.
- prevSnake = snake.map(copy)... hmm, need alignment: after move, snake has new head at front. prevSnake for rendering: for segment i in new snake, its previous position is snake_old[i-... ] Hmm.

Easier: For each new snake array, prev of segment i = old snake array position that corresponds. When moving: newSnake = [newHead, ...oldSnake[0..len-2]] (drop tail) or [...oldSnake] (grow). So newSnake[i] for i>=1 equals oldSnake[i-1]. And its previous cell (where that piece was last tick) — for smooth rendering, each segment moves from its old cell to its new cell. newSnake[0] (head) moves from oldSnake[0] to newHead. newSnake[1] is at oldSnake[0], previously oldSnake[1]... wait that's the same as "segment i interpolates from oldSnake[i] to newSnake[i]"? Let's check: newSnake[i] = oldSnake[i-1] for i>=1. Segment i's previous cell is oldSnake[i], new cell is oldSnake[i-1]. Hmm no — segments are body parts; each rendered segment i should interpolate between oldSnake[i] and newSnake[i]? oldSnake[i] and newSnake[i] differ: newSnake[i]=oldSnake[i-1]. So segment i moves from oldSnake[i] to oldSnake[i-1] — i.e., each body part advances one cell along the snake. That's correct behavior visually: each part slides forward along the body path.

When growing: newSnake = [newHead, ...oldSnake] (length+1). Segment i for i>=1: new cell = oldSnake[i-1], previous cell = oldSnake[i] (exists for i < len; for i = len (last new segment, the old tail), previous = oldSnake[len-1]... wait newSnake has length len+1. newSnake[len] = oldSnake[len-1] = old tail. Its previous cell: oldSnake[len] doesn't exist. The new tail segment appears where the old tail was — it doesn't move this tick. So set its prev = its current cell. So rule: prevSnake[i] = oldSnake[min(i, len-1)]. And newSnake[i] = oldSnake[i-1] for i>=1, newHead for i=0. That works uniformly! prevSnake[i] = oldSnake[min(i, oldLen-1)], newSnake[i] = (i==0) ? newHead : oldSnake[i-1]. 

So store two arrays: prevCells and cells. Render position of segment i = lerp(prevCells[i], cells[i], t) where t in [0,1]. Since they're adjacent grid cells, lerp gives smooth slide.

Wrap-around isn't needed (walls kill), so no interpolation wrap issues. 

Rendering the snake: draw as connected path with rounded caps? A polished look: draw each interpolated segment as a rounded rect/circle chain, or draw a thick line through the interpolated points with round joins/caps. Thick polyline through centers with lineWidth = CELL*0.8, round join/cap gives smooth snake. But when turning, polyline corners look okay with round joins. But snake body should be under head. Add gradient along body: darker tail. Could draw the line with a gradient from head to tail — gradient along a path is hard; but we can approximate: draw segments individually as circles with per-segment color, plus connecting thicker line in base color. Or draw the whole polyline in a mid color, then overlay circles at each segment with shading.

Alternative simpler polish: draw polyline through interpolated centers with lineWidth ~ CELL*0.78, strokeStyle gradient vertical? Color varies along length. Could compute per-segment mini-lines and stroke each with its own color (from head hue to tail hue). Per-segment strokes with round caps produce overlapping circles-ish look — actually stroking each segment line (between segment i and i+1 interpolated positions) with round caps gives a nice smooth gradient body. Let's do that: for i from tail to head, stroke line from pos[i] to pos[i+1] with color = lerp of two greens, lineWidth decreasing slightly toward tail? Keep constant width, round caps → smooth.

Then head: circle slightly larger, with two eyes perpendicular to direction, pupils offset toward direction. Eyes facing direction: eyes positioned on head circle offset perpendicular to dir, pupils shifted forward.

Food: pulsing apple-ish circle with glow, maybe a small leaf. Simple: red/orange circle with radial gradient + subtle scale pulse + glow.

Particles: on eating, spawn ~14 particles at food position with random velocities, colors matching food; update with gravity-ish fade, drawn as small circles/squares. Also maybe a quick ring pulse.

Score HUD: HTML above canvas or drawn in canvas? Use HTML for crisp text: header with Score and Best, styled. Plus maybe speed/length. Keep in HTML outside canvas.

Grid background: subtle checkerboard (two very close dark shades), plus border glow around canvas.

Game states: 'demo' (AI), 'play', 'paused', 'over'. Overlay div covering canvas with message: demo shows "Click or press a key to start" + "AI is playing demo". Paused shows "Paused — press P". Over shows "Game Over", score, "Press Space/Enter to restart". Actually requirement: game over overlay shows score, Space/Enter restarts. Also click should probably also restart/start.

Input queue: queue of direction changes, max length 3. On keydown, map key to dir; if queue empty use current dir to validate, else last queued dir; a new dir is pushed if it's not reverse of reference and not same as reference. Each tick, shift from queue as new dir. This honors quick successive presses (e.g., up then left pressed within one tick both apply over two ticks, allowing quick cornering).

Careful: reversal check must be against the direction the snake will actually be moving when that queued item is applied, i.e., the last effective direction (last queued, or current if queue empty).

AI for demo: simple greedy with safety: each tick choose direction that moves toward food (Manhattan), preferring axis with larger delta, avoiding cells occupied by snake body (excluding tail since it moves) and staying within bounds; if no safe move toward food, pick any safe move; add some lookahead to avoid trapping itself? Keep it decent: BFS toward food avoiding snake body would be better. Simple approach: BFS on grid where blocked = snake cells (minus tail) to find shortest path to food; take first step. If no path, pick move maximizing free space (flood fill count). That's a decent AI. Snake demo can still die eventually — then in demo mode, auto-restart after showing brief overlay? On demo game over, show overlay briefly and restart demo automatically after ~1.5s. Good for continuous action in screenshot.

Also demo shouldn't require clicking to restart — auto restart in demo.

Timing: tick via accumulator in requestAnimationFrame loop. Tick duration maybe starts at 130ms, decreases as score grows (e.g., max(75, 130 - score*0.8)). Smooth interpolation needs stable t.

Particles update in rAF using real dt.

Pause: 'p' toggles when playing. Also Space during play? Requirement says Space/Enter restart after game over. During play, space could... maybe do nothing or pause? I'll make Space also toggle pause during play? Spec says P pauses; Space/Enter restart. Keep Space during play as nothing (or pause too — could conflict). I'll leave Space ignored during play to be safe, but maybe pause on blur.

Also handle visibility change → auto pause.

Restart: reset snake to length 3 in center heading right, score 0, spawn food, state 'play' (if started) or 'demo'.

Interaction: first keydown or pointerdown transitions from demo to play (fresh game). The message overlay fades out. Keys: if key pressed is arrow/WASD, start and apply that direction immediately (nice touch: pressing up starts game moving up, unless reverse). Actually just start then queue dir.

Canvas sizing: fixed logical size GRID*CELL. Let GRID=26? Requirement at least 20×20. Let's do 24×24 or 26×26. Cell 24 → 576 canvas logical. For 375px viewport: CSS width min(576px, 92vw). Fine. Use dpr scaling: canvas.width = size*dpr, ctx.scale(dpr,dpr). But keep logical coordinates in cell units. I'll set canvas width/height attributes to LOGICAL = GRID*CELL (576) and rely on CSS scaling — slightly blurry on hiDPI but fine. Better: handle dpr for crispness. Let's do dpr properly.

Layout: page with flex column center; title "SNAKE", HUD row (Score | Best), canvas wrapper with border radius + shadow, footer hint with controls. Dark background gradient with subtle radial glow. Colors: background #0b1020-ish; board #101828 / #0e1526; snake green gradient #4ade80 → #16a34a head darker/emerald; food #f87171→#ef4444 red apple with glow; accents amber for score? Use a nice palette.

Actually make snake teal/emerald: head #34d399 bright, body gradient to #0d9488. Eyes white with black pupils.

Board border: rounded rect stroke with subtle glow, inner grid checkerboard.

Let me also add slight vignette? Keep it simple but polished.

Interpolated head direction: use render direction = normalize(headPos - neckPos) at render time for eyes.

Wrap-around not used. Wall collision: newHead out of bounds → game over. But for smooth motion, at game over, maybe animate head bumping? Just stop and show overlay; snake freezes. Fine.

Food spawn: random among free cells (compute list of free cells, pick random). Guaranteed possible unless snake fills board (then win—just restart/ignore).

Score pop animation: when eating, brief scale pop on score HTML (add class).

Best score: session variable, updates on game over or live (update live when exceeding). Live update nicer: best = max(best, score).

Sound? No, keep silent (no external assets, WebAudio possible but not required; skip or add tiny beep via WebAudio oscillator? Could be nice but risky with autoplay policies — user gesture starts game so ok, but demo mode before gesture would be blocked. Skip audio or wrap in try. I'll add tiny WebAudio blip on eat only after user gesture exists. Maybe skip to reduce complexity — polish is visual. I'll add a small optional blip guarded by audioCtx state; actually keep it minimal: no audio.)

Now code structure:

```html
<!DOCTYPE html>
<html>
<head><meta charset/viewport><title>Snake</title><style>...</style></head>
<body>
<main>
  <header><h1>...</h1><div class="hud">Score/BesT</div></header>
  <div class="board-wrap">
    <canvas id="game"></canvas>
    <div id="overlay"> ... </div>
  </div>
  <footer>controls hint</footer>
</main>
<script>...</script>
</body>
</html>
```

Overlay: absolutely positioned over canvas, flex center, backdrop blur, shows different content per state. Show subtle "DEMO" badge while in demo.

Overlay content element updated via JS: title, subtitle lines.

CSS: dark bg with radial gradient, maybe animated background hue? Keep static. Fonts: system stack; maybe monospace for numbers. Title with letter-spacing.

Responsive: main max-width; canvas CSS width: min(560px, calc(100vw - 40px)); aspect-ratio 1/1; height auto.

JS details:

Constants: GRID=26? CELL? Let's use GRID=24, CELL logical 24 → 576. Canvas CSS handles scaling. dpr: canvas.width = 576*dpr; ctx.setTransform(dpr,0,0,dpr,0,0). But CSS size is min(560, ...). Logical drawing remains 576 coordinate space. Good.

Hmm, choose GRID=24 (≥20 ✓).

State vars:
- snake cells: array of {x,y}, head index 0.
- prevCells: same length.
- dir {x,y}, dirQueue [] (max 3).
- food {x,y}.
- score, best (session).
- state: 'demo' | 'play' | 'paused' | 'over'.
- tickMs = 135 base, speedup.
- particles [].
- foodPulse time.
- lastTime, acc.
- eatFlash / scorePop.

tick():
1. if dirQueue length, dir = dirQueue.shift().
2. newHead = head + dir.
3. Check wall: if out of bounds → gameOver(). Also check self: newHead in snake? Note: tail will move away unless growing; if not growing, moving into current tail cell is legal (tail vacates). But to be safe with interpolation, allow moving into tail cell when not growing. Check against snake.slice(0, len-1) if not growing... Actually classic: check newHead against body excluding last segment when not growing. When growing (just ate last tick), the tail stays, so include it. Compute willGrow flag: set when eating (we set growPending when food eaten). Hmm — growth timing: eat when newHead == food. Then this tick snake grows: newSnake keeps tail. So the tail cell remains occupied — but we already removed nothing... Let me restructure:

move():
- nextHead = head+dir.
- if wall → over.
- willGrow = (nextHead == food).
- collision check body: body cells to check = snake minus (willGrow ? none : last element). Because if not growing, tail vacates as head enters. If nextHead in that set → over.
- prevCells = snake.slice() (copy).
- snake = willGrow ? [nextHead, ...snake] : [nextHead, ...snake.slice(0,-1)].
- prevCells needs alignment to new length: prev for segment i = old snake[min(i, oldLen-1)]. Since prevCells was old array of length oldLen, new length = oldLen+1 (grow) or oldLen. For grow: new segment index oldLen... Let me just build prevCells during construction:
  - newSnake[0] prev = oldHead (old snake[0]).
  - newSnake[i] (i>=1) prev = oldSnake[i] (since newSnake[i]=oldSnake[i-1], it moved from oldSnake[i] to oldSnake[i-1]) — wait check non-grow: newSnake[i]=oldSnake[i-1], prev should be oldSnake[i]: yes as derived earlier. For i = oldLen-... in grow case, newSnake[oldLen] = oldSnake[oldLen-1] (old tail), prev = oldSnake[oldLen] doesn't exist → use oldSnake[oldLen-1] (same cell, stationary). So prevCells[i] = oldSnake[min(i, oldLen-1)] but for i=0: prev = oldSnake[0] = oldHead. That matches min formula too (min(0, oldLen-1)=0 → oldSnake[0] = old head cell). And newSnake[0] = nextHead. ✓. So uniformly: prevCells = for i in 0..newLen-1: oldSnake[min(i, oldLen-1)]; cells = newSnake.

Wait but non-grow case: newLen = oldLen. prevCells[i] = oldSnake[min(i,oldLen-1)] = oldSnake[i]. cells[i] = oldSnake[i-1] (i>=1), cells[0]=nextHead. ✓ consistent with earlier derivation. 

Simplify: prevCells[i] = oldSnake[min(i, oldLen-1)]; where oldSnake includes index i for i<=oldLen-1. Good. Implementation:

```js
const old = snake;
const next = willGrow ? [nh, ...old] : [nh, ...old.slice(0,-1)];
const pv = next.map((_, i) => old[Math.min(i, old.length-1)]);
snake = next; prevCells = pv;
```

- if willGrow: score += 10; spawnFood(); particles burst at food pos; scorePop; willGrow affects speed next tick.
- update tickMs: base 132 → min 80: tickMs = Math.max(80, 132 - score * 0.5)? score 100 → 82. Fine: Math.max(78, 132 - score*0.5).

Render(t): 
- clear, draw board bg (rounded? canvas fully board), checkerboard cells (precompute two colors), maybe subtle inner shadow.
- draw food with pulse: radius = CELL*0.32 * (1 + 0.08*sin(time*4)), glow shadowBlur.
- draw snake: compute interp positions: pos[i] = lerp(prev, cur, t). t = acc/tickMs clamped.
  - body: for i from pos.length-1 down to 1: stroke segment from pos[i] to pos[i-1] with color gradient per index. lineWidth = CELL*0.72 maybe taper slightly at tail: width = CELL*(0.62 + 0.14 * (1 - i/len)). Round caps.
  - Colors: define function bodyColor(i, len): interpolate between '#22c55e'ish? Let's use HSL: head bright emerald h=150, tail darker teal h=170, lightness 55→38. Use hsl string.
  - Actually nicer: body slightly darker than head. headColor '#4ade80'? Let me pick: head fill '#86efac' too light? Head should be distinct: brighter green with eyes. Use head '#a3e635'?? Hmm. Choose: body gradient from hsl(145, 70%, 55%) near head to hsl(160, 65%, 34%) at tail; head circle hsl(140, 75%, 62%) with dark outline. Eyes white sclera + dark pupils. That looks good on dark navy board.
  - Also draw subtle darker outline under whole snake: first stroke a slightly larger dark path (shadow) then body? Could do: stroke path polyline with lineWidth CELL*0.8 color rgba(0,0,0,0.35) offset? Skip; add shadowBlur glow around head only: emerald glow. Or draw whole snake with ctx.shadowColor green shadowBlur 12 — could look glowy nice but maybe expensive; fine.
- head circle at pos[0], radius CELL*0.42, direction d = normalize(pos[0]-pos[1]) (if length>1; else dir).
  - Eyes: two circles at head + perpendicular*0.18*CELL + forward*0.12*CELL offsets, radius CELL*0.13 white; pupils radius CELL*0.06 offset forward*CELL*0.05, color #0b1020. Also blink occasionally? skip.
- particles: draw circles fading.
- eat flash: expanding ring at food when eaten (store ring {x,y,age}).

Board drawn each frame; fine for 24×24 (576 rect fills *  per frame — 576 fillRects per frame is okay but let's pre-render board to offscreen canvas once). Yes: create offscreen boardCanvas with checkerboard + border glow drawn once; drawImage each frame. Good perf.

Overlay HTML updates: function setOverlay(state) sets innerHTML/visibility. In demo: title "SNAKE", line "Demo mode — the snake plays itself", big "Click or press any key to start". Over: "Game Over", "Score: X", "Best: Y", "Press Space or Enter to restart". Paused: "Paused", "Press P to resume". Also clicking overlay when paused resumes? Click during pause → resume maybe. Click when over → restart. Handle pointerdown on document: if demo → startGame; if over → restart; if paused → resume.

Keyboard: keydown handler:
- key = e.key.toLowerCase() or e.code.
- dirs: ArrowUp/w → {0,-1}, etc. Also prevent default for arrows/space to avoid scrolling.
- If state==='demo': startGame(); then queue pressed dir if it's a dir key.
- If state==='over': if key===' '||'enter' → restart (startGame). Also allow pressing a direction key to restart? Requirement says Space/Enter; I'll also allow direction keys to restart+move (nice, harmless). Keep spec-safe: also start on any arrow/WASD. Fine.
- If state==='play': if dir key → queue it (with reverse-check & dedupe). If 'p' → pause. Ignore space (or pause?). Let me make Space also pause/resume during play — spec doesn't forbid; but Space is specified for restart... during play it's ambiguous. I'll make Space during play do nothing? Some may test pressing space expecting pause. Hmm requirement: "Space or Enter restarts, P pauses" — refers to game over context. During play I'll have Space toggle pause too (harmless, intuitive). Actually risk: user pressing space mid-play accidentally pauses — acceptable. I'll include it.
- If state==='paused': 'p' or space → resume.

Reverse check with queue: reference dir = dirQueue.length ? dirQueue[dirQueue.length-1] : dir. If new is same as reference → ignore. If new is opposite → ignore. Else push (cap queue at 3, shift out oldest if full... just ignore if >=3).

Also e.preventDefault() for arrows/space to stop page scroll.

Demo AI: run same tick but choose dir each tick before move.
AI logic:
- Build occupancy set of snake cells (excluding tail cell since it will move — but if growing... approximate: exclude last).
- BFS from head to food over free cells (avoiding occupied). Path first step → candidate dir.
- Safety check: after hypothetical move, is the head cell adjacent to free area with escape? Simpler: after choosing candidate, verify floodFill reachable area from new head ≥ snake length (heuristic to avoid dead ends). If fails, try other safe dirs sorted by flood-fill size, pick max.
- Also add small randomness? No, deterministic good.
- Also avoid reversing: BFS naturally won't since body occupied.

BFS each tick on 24×24=576 cells: cheap.

Flood fill: BFS count reachable free cells from a cell given occupied set.

AI chooseDir():
```
const head = snake[0];
const target = food;
const blocked = new Set(snake.slice(0, -1).map(k)); // tail moves away
// BFS
queue from head, prev map; find food; if found, reconstruct first step dir.
// candidate dirs ordered by BFS distance to food (or manhattan)
For each of 4 dirs (excluding reverse of current dir & blocked cells):
  compute cell nh; if !inBounds or blocked → skip.
  score: floodCount(nh with blocked' = blocked minus tail plus new head...) — approximate with current blocked plus nh marked.
  prefer: 1) has path to food via BFS from nh? and flood large.
Simple approach: 
 best = null
 for each valid nh:
   free = floodSize(nh, blocked ∪ {nh})
   distFood = manhattan(nh, food)
   score = free*2 - distFood... choose max free primarily then min dist.
 Also prefer step along BFS path when its flood ≥ snakeLen.
```
Let me implement: 
```
function aiDir(){
  const occ = new Set(); snake.forEach((s,i)=>{ if(i < snake.length-1) occ.add(key(s)); }); // tail free
  // try BFS path
  const first = bfsFirstStep(head, food, occ);
  const options = [];
  for (const d of DIRS){
    if (d.x===-dir.x && d.y===-dir.y) continue;
    const nh = {x:head.x+d.x, y:head.y+d.y};
    if(!inB(nh) || occ.has(key(nh))) continue;
    const occ2 = new Set(occ); occ2.add(key(nh)); // tail free assumption
    const free = flood(nh, occ2);
    options.push({d, nh, free, dist: Math.abs(nh.x-food.x)+Math.abs(nh.y-food.y)});
  }
  if(!options.length) return dir; // doomed
  // if BFS step exists among options and its free >= snake.length + 2, use it
  if (first){ const o = options.find(o=>o.d.x===first.x&&o.d.y===first.y); if(o && o.free > snake.length) return first; }
  // else choose max free, tie-break min dist
  options.sort((a,b)=> b.free-a.free || a.dist-b.dist);
  return options[0].d;
}
```
In demo tick: dir = aiDir() (skip queue). Also in demo, don't speed up too much? speed based on score same as play; fine.

Demo game over: state 'over' but with demo flag; overlay shows "Click or press a key to start" still; auto-restart after 1200ms via timeout tracked. Keep demo=true until user interaction.

startGame(): demo=false; state='play'; reset(); overlay hidden (add class). reset(): snake center [ {x:c,y:c}, {x:c-1,y:c}, {x:c-2,y:c} ] heading right; prevCells = same; score=0; tickMs; food spawn; particles=[]; dirQueue=[]; dir={1,0}.

Hmm starting length 3 heading right with body to the left: cells [head(c,c), (c-1,c), (c-2,c)]. prevCells equal cells (so initial render static). dir {x:1,y:0}.

Note: initial prevCells same as cells → t interpolation yields same pos; fine.

Pause handling: when paused, freeze acc; overlay shows. On resume continue.

Also demo should not be pausable (P during demo → starts game? Requirement: pressing key starts. Any keydown in demo starts game, including P. Yes: any key in demo starts.)

Wait — careful: "Click or press a key to start". So any keydown/click in demo starts fresh player game. ✓.

Loop:
```
function frame(now){
  const dt = Math.min(50, now - last); last = now;
  if(state==='play' || state==='demo'){
    acc += dt;
    while(acc >= tickMs){ acc -= tickMs; step(); if(state==='over') { acc=0; break; } }
  }
  updateParticles(dt);
  render(now);
  requestAnimationFrame(frame);
}
```
step(): if demo → dir = aiDir(); else if queue → dir=shift. Then move logic.

t for render = clamp(acc/tickMs, 0, 1).

On game over from play: state='over'; update overlay; maybe shake canvas (CSS class). Add subtle red flash particle at head? Keep: overlay shows.

Particles:
```
function burst(x, y, colorBase){
  for(let i=0;i<16;i++){
    const a = Math.random()*Math.PI*2, sp = 40+Math.random()*90; // px per sec in cell coords? Use canvas px.
    particles.push({x, y, vx: Math.cos(a)*sp, vy: Math.sin(a)*sp - 30, life: 0, max: 0.5+Math.random()*0.4, r: 2+Math.random()*3, col: pick});
  }
}
```
Positions in canvas px (cell center). Update: x += vx*dt; vy += 160*dt (gravity); life += dt; alpha = 1 - life/max; remove when life>max. Draw with globalAlpha.

Ring pulse: rings[] {x,y,age}: draw stroke circle radius = age*80, alpha fade over 0.4s.

Score HUD: HTML spans #score, #best. On eat, update and add 'pop' class (remove after animation via animationend or timeout). CSS @keyframes pop scale.

Also show "Length" maybe. Keep Score & Best & maybe speed? Just Score/Best per spec, plus small length badge maybe. Keep clean: Score and Best.

Title header: "SNAKE" with gradient text, small subtitle "canvas · vanilla js"? Keep minimal.

Controls footer: kbd styled keys: ↑↓←→ / WASD move · P pause · Space restart.

Styling details:
- body: margin 0; background: radial-gradient(1200px 800px at 50% -10%, #1b2a4a, #0a0f1e 60%), #0a0f1e; color #e2e8f0; font-family system.
- Use `min-height: 100vh; display:grid; place-items:center;` main with padding.
- Canvas wrapper: position relative; border-radius: 16px; padding? The canvas itself has board bg; give wrapper box-shadow 0 20px 60px rgba(0,0,0,.5), and a 1px border rgba(255,255,255,.08). Canvas display block, border-radius 16px, width min(72vh?, ...). Height constraint: viewport 800 tall; header+footer ~150px; board should fit ~560px. Use CSS: width: min(560px, calc(100vw - 32px), 68vh)? For 1280×800: 68vh=544 → 544px board. Good. aspect-ratio: 1.

Actually canvas logical 576; CSS size 544 → scale 0.944, fine (we redraw at dpr so crisp).

Set canvas CSS width via `width: min(58vh, 92vw, 560px)`? Let's compute: available height 800 - header (~90) - footer (~50) - padding: ~620. 70vh = 560. So `width: min(560px, 92vw, 70vh)`. Hmm min with 70vh ensures fit vertically. Wait 70vh at 800 = 560, matches 560px. OK.

For small screens (375×667): 92vw = 345, 70vh=467 → 345. Good.

- Overlay: absolute inset 0; display flex column center; background rgba(8,12,24,0.72); backdrop-filter blur(4px); border-radius 16px; transition opacity; when hidden add `.hidden {opacity:0; pointer-events:none}` — but we need clicks on it to start; pointer-events none ok since click handler on document. Keep clickable? document pointerdown handles. Fine.

Overlay content: h2 title, p lines, kbd hints, pulsing "Click or press any key to start" with animation.

Demo badge: small chip top-left of overlay "DEMO MODE"? Put in overlay content.

- HUD: flex row gap; each stat a small card: label uppercase small, value big mono. Score card accent emerald, best amber.

Board colors: bg1 #0f1a2e? Let's do checker: #101b30 and #0d1728. Border stroke #1f2b46? Also faint grid lines? Checkerboard suffices.

Food: draw apple: circle radial gradient (#ff8069 → #e13b3b), highlight small white ellipse, tiny stem? Simple circle + glow + pulse. Color palette: use #f87171 core with shadowColor rgba(248,113,113,0.8) shadowBlur 14 pulsing.

Head glow: shadowColor rgba(52,211,153,.7), blur 18 on head circle.

Snake body colors: function seg color: t = i/(len-1): h = 150 - t*18 (150→132?), Hmm want emerald → deeper teal-green: from hsl(145 65% 52%) to hsl(165 60% 32%). Compute via simple lerp of h,l with s constant ~65%. Use `hsl(${h} ${s}% ${l}%)`.

But stroking per segment with round caps: overlapping segments with same-ish colors fine. To avoid alpha issues use opaque colors.

Order: draw from tail to head so head-side segments overlay.

Also draw a subtle outline: for each segment stroke width+3 in dark color first? That doubles strokes (len*2) fine. Could add depth: first pass all segments with color #052e16ish width CELL*0.8 (outline), second pass body colors width CELL*0.66. Gives nice cartoon outline. Let's do it — polished look.

Rounded segments at tail taper: lineWidth per segment: base = CELL*0.66; taper: for last few segments multiply by (0.7 + 0.3*frac)? Simple constant is fine; but tail should look like it ends — round cap handles.

Head: outline circle too: draw circle r=CELL*0.46 fill dark outline then r=CELL*0.40 fill headColor (#4ade80 bright). Wait body near head is hsl(145 65% 52) ≈ #3fce62. Head lighter #7ee787? Distinct head: brighter + eyes. Use head fill '#65e37f'? Let me set head color hsl(135, 70%, 62%) and body start hsl(150, 62%, 46%). Head clearly lighter/yellower.

Eyes: white circles r=CELL*0.12 at head center + dir*CELL*0.10 + perp*±CELL*0.16; pupils r=CELL*0.055 at eye + dir*CELL*0.05. Pupil color #06131f. Maybe tongue occasionally? skip.

Also when game over, maybe eyes become X? Cute: draw X eyes on over. Small touch: if state==='over', draw crossed lines instead of pupils. Nice.

Wall render: board border already dark; fine.

Extra polish: eat → score pop, ring pulse, maybe brief screen shake tiny on death (CSS class on wrapper with keyframes translate). Add 'shake' animation on death.

Now, code carefully to avoid errors.

Full 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 Game</title>
<style>
  :root{
    --bg:#0a0f1e; --panel:#111a2e; --line:rgba(255,255,255,.08);
    --text:#e8edf7; --muted:#8b98b8; --accent:#34d399; --accent2:#fbbf24;
  }
  *{box-sizing:border-box}
  html,body{height:100%}
  body{
    margin:0; background: radial-gradient(1100px 700px at 50% -10%, #1d2c4f 0%, #0a0f1e 55%) , #0a0f1e;
    color:var(--text); font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
    min-height:100vh; display:flex; align-items:center; justify-content:center; padding:18px;
    -webkit-user-select:none; user-select:none;
  }
  main{ display:flex; flex-direction:column; align-items:center; gap:14px; width:100%; }
  header{ text-align:center; }
  h1{
    margin:0; font-size: clamp(26px, 4vw, 40px); letter-spacing:.35em; font-weight:800; text-indent:.35em;
    background: linear-gradient(90deg, #34d399, #a7f3d0 45%, #fbbf24);
    -webkit-background-clip:text; background-clip:text; color:transparent;
  }
  .sub{ color:var(--muted); font-size:12px; letter-spacing:.3em; text-transform:uppercase; margin-top:4px;}
  .hud{ display:flex; gap:10px; }
  .stat{
    background:rgba(17,26,46,.75); border:1px solid var(--line); border-radius:12px;
    padding:8px 18px; min-width:110px; text-align:center;
  }
  .stat .label{ font-size:10px; letter-spacing:.22em; text-transform:uppercase; color:var(--muted); }
  .stat .value{ font-size:24px; font-weight:700; font-variant-numeric: tabular-nums; font-family:ui-monospace, Menlo, monospace; }
  .stat.score .value{ color:var(--accent); }
  .stat.best .value{ color:var(--accent2); }
  .stat .value.pop{ animation:pop .3s ease; }
  @keyframes pop{ 40%{ transform:scale(1.35);} }
  .board-wrap{
    position:relative; border-radius:18px;
    box-shadow: 0 30px 80px rgba(0,0,0,.55), 0 0 0 1px rgba(255,255,255,.06), 0 0 60px rgba(52,211,153,.07);
  }
  canvas{ display:block; width:min(560px, 92vw, 68vh); height:auto; aspect-ratio:1/1; border-radius:18px; }
```

Wait: `height:auto` with aspect-ratio 1/1 and width set → height equals width. OK.

Hmm width uses min(560px, 92vw, 68vh): 68vh might be smaller than needed; at 800 height 68vh=544. At 375 wide 92vw=345. Fine. But `height:auto` + aspect-ratio → square. Good.

```
  .board-wrap.shake{ animation:shake .4s ease; }
  @keyframes shake{ 0%,100%{transform:translate(0)} 20%{transform:translate(-7px,2px)} 40%{transform:translate(6px,-3px)} 60%{transform:translate(-4px,1px)} 80%{transform:translate(3px,-1px)} }
  #overlay{
    position:absolute; inset:0; border-radius:18px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:8px;
    background:rgba(7,11,22,.72); backdrop-filter:blur(3px); text-align:center; padding:20px;
    transition:opacity .35s ease, visibility .35s; 
  }
  #overlay.hidden{ opacity:0; visibility:hidden; }
  #overlay h2{ margin:0; font-size: clamp(22px,3.2vw,34px); letter-spacing:.12em; }
  #overlay p{ margin:2px 0; color:var(--muted); font-size:14px; }
  #overlay .big{ font-size:20px; color:var(--text); font-weight:600; animation:blink 1.6s ease infinite;}
  @keyframes blink{ 50%{opacity:.45} }
  #overlay .go-score{ font-size: clamp(30px, 5vw, 52px); font-weight:800; color:var(--accent); font-family:ui-monospace,monospace;}
  kbd{
    background:#1c2740; border:1px solid rgba(255,255,255,.14); border-bottom-width:2px; border-radius:6px;
    padding:2px 7px; font-size:12px; font-family:ui-monospace,monospace; color:#cdd7ee;
  }
  footer{ color:var(--muted); font-size:12.5px; display:flex; gap:14px; flex-wrap:wrap; justify-content:center;}
</style>
```

Body markup:

```html
<main>
  <header>
    <h1>SNAKE</h1>
    <div class="sub">Vanilla JS · Canvas</div>
  </header>
  <div class="hud">
    <div class="stat score"><div class="label">Score</div><div class="value" id="score">0</div></div>
    <div class="stat best"><div class="label">Best</div><div class="value" id="best">0</div></div>
  </div>
  <div class="board-wrap" id="wrap">
    <canvas id="game" width="576" height="576"></canvas>
    <div id="overlay"> ... filled by JS ... </div>
  </div>
  <footer>
    <span><kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd> / <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> move</span>
    <span><kbd>P</kbd> pause</span>
    <span><kbd>Space</kbd> / <kbd>Enter</kbd> restart</span>
  </footer>
</main>
```

Overlay content set by JS via innerHTML per state. Initial static content for demo:

```html
<div id="overlay">
  <div class="chip">Demo</div>
  <h2>Click or press a key to start</h2>
  <p>Arrow keys or WASD steer the snake</p>
</div>
```

I'll build overlay via function updateOverlay():
- demo: chip "AI Demo", h2 "Snake is playing itself", big "Click or press any key to start".
Wait spec: message "Click or press a key to start" — include exact-ish wording. Big line: "Click or press a key to start". Good.
- paused: h2 "Paused", p "Press P to resume".
- over: h2 "Game Over", go-score "SCORE {score}", p "Best {best}", big "Press Space or Enter to restart".
- play: overlay hidden.

JS:

```js
(() => {
"use strict";
const GRID = 24, CELL = 24, SIZE = GRID*CELL; // 576
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const overlay = document.getElementById('overlay');
const wrap = document.getElementById('wrap');
const scoreEl = document.getElementById('score');
const bestEl = document.getElementById('best');

// hi-dpi
const DPR = Math.min(2, window.devicePixelRatio || 1);
canvas.width = SIZE*DPR; canvas.height = SIZE*DPR;
ctx.setTransform(DPR,0,0,DPR,0,0);
```

Wait, CSS width set via style width min(...); canvas attribute width overridden by DPR scaling; CSS width from stylesheet uses `canvas{width:...}` so attribute changes don't affect CSS size. ✓.

Pre-render board:

```js
const board = document.createElement('canvas');
board.width = SIZE*DPR; board.height = SIZE*DPR;
{
  const b = board.getContext('2d');
  b.setTransform(DPR,0,0,DPR,0,0);
  // bg
  b.fillStyle = '#0d1626'; b.fillRect(0,0,SIZE,SIZE);
  for(let y=0;y<GRID;y++)for(let x=0;x<GRID;x++){
    if((x+y)%2===0){ b.fillStyle='rgba(255,255,255,0.017)'; b.fillRect(x*CELL,y*CELL,CELL,CELL); }
  }
  // subtle inner border
  b.strokeStyle='rgba(148,163,184,.14)'; b.lineWidth=2;
  b.strokeRect(1,1,SIZE-2,SIZE-2);
}
```

Maybe also faint dot grid? Checker enough.

State:

```js
let snake, prevCells, dir, dirQueue, food, score=0, best=0;
let state='demo';           // demo | play | paused | over
let tickMs=132, acc=0, last=performance.now();
let particles=[], rings=[];
let demoRestartTimer=null;
```

reset():

```js
function reset(){
  const c = GRID>>1;
  snake = [{x:c,y:c},{x:c-1,y:c},{x:c-2,y:c}];
  prevCells = snake.map(s=>({...s}));
  dir = {x:1,y:0}; dirQueue=[];
  score=0; tickMs=132; acc=0;
  particles=[]; rings=[];
  spawnFood();
  updateHUD(false);
}
```

spawnFood(): collect free cells:

```js
function spawnFood(){
  const occ = new Set(snake.map(k));
  const free=[];
  for(let y=0;y<GRID;y++)for(let x=0;x<GRID;x++){
    if(!occ.has(x+','+y)) free.push({x,y});
  }
  food = free.length ? free[(Math.random()*free.length)|0] : null;
}
```

key helper: `const key = s => s.x + ',' + s.y;`

step():

```js
function step(){
  if(state==='demo'){ dir = aiDir() || dir; }
  else if(dirQueue.length){ dir = dirQueue.shift(); }
  const head = snake[0];
  const nh = {x: head.x+dir.x, y: head.y+dir.y};
  // walls
  if(nh.x<0||nh.y<0||nh.x>=GRID||nh.y>=GRID){ return die(); }
  const willGrow = food && nh.x===food.x && nh.y===food.y;
  // self collision (tail vacates when not growing)
  const limit = willGrow ? snake.length : snake.length-1;
  for(let i=0;i<limit;i++){ if(snake[i].x===nh.x && snake[i].y===nh.y) return die(); }
  const old = snake;
  const next = willGrow ? [nh, ...old] : [nh, ...old.slice(0,-1)];
  prevCells = next.map((_,i)=> old[Math.min(i, old.length-1)]);
  snake = next;
  if(willGrow){
    score += 10;
    if(score>best){ best=score; }
    burst(nh.x*CELL+CELL/2, nh.y*CELL+CELL/2);
    rings.push({x:nh.x*CELL+CELL/2, y:nh.y*CELL+CELL/2, age:0});
    spawnFood();
    tickMs = Math.max(78, 132 - score*0.5);
    updateHUD(true);
  }
}
```

Wait: prevCells mapping uses old array, but new head (index0) prev = old[0] = old head ✓ (min(0, old.length-1)=0). Good.

Edge: `old[Math.min(i, old.length-1)]` — when not growing, next length = old length; i ranges 0..oldLen-1; old[i] valid ✓. When growing, next length = oldLen+1; i up to oldLen; min → old[oldLen-1] (old tail) ✓.

die():

```js
function die(){
  state='over';
  wrap.classList.remove('shake'); void wrap.offsetWidth; wrap.classList.add('shake');
  if(demo) { demoRestartTimer=setTimeout(()=>{ if(state==='over' && demo){ reset(); state='demo'; updateOverlay(); } }, 1600); }
  updateOverlay();
}
```

Hmm demo flag: keep separate `demo` boolean; state cycles demo→over(demo)→demo. Let me define: `let demo=true;` and states: 'play','paused','over' plus demo flag. Simpler: state values: 'demo','play','paused','over'. In demo, on death set state='over' but demo still true → overlay should still show start prompt + auto-restart. Overlay for over+demo: show "Click or press a key to start" plus small "restart…" note. Let me handle in updateOverlay: if state==='over' && demo → show game over briefly? The auto restart handles. Overlay during demo-over can be same as demo overlay (start prompt). Simpler: in demo mode overlay always shows start prompt; the death→reset cycle is automatic. I'll show demo overlay whenever demo===true (state 'demo' or demo-over). Actually just check `demo` boolean for overlay content.

But pause key during demo → starts game (any key). ✓.

start():

```js
function start(){
  demo=false;
  if(demoRestartTimer){clearTimeout(demoRestartTimer); demoRestartTimer=null;}
  reset();
  state='play';
  updateOverlay();
}
```

pause/resume:

```js
function pause(){ if(state==='play'){ state='paused'; updateOverlay(); } }
function resume(){ if(state==='paused'){ state='play'; last=performance.now(); acc=0? } }
```

Careful: while paused, loop doesn't accumulate acc (we check state before adding). In frame: `if(state==='play'||state==='demo'){ acc+=dt; ... }`. dt computed from rAF timestamps; fine.

Pause overlay while state paused: overlay visible.

updateOverlay():

```js
function updateOverlay(){
  if(state==='play'){ overlay.classList.add('hidden'); return; }
  overlay.classList.remove('hidden');
  if(demo){
    overlay.innerHTML = `
      <div class="chip">AI demo</div>
      <h2>Snake</h2>
      <p class="big">Click or press a key to start</p>
      <p>Arrows / WASD steer · eat the red orbs</p>`;
  } else if(state==='paused'){
    overlay.innerHTML = `
      <h2>Paused</h2>
      <p class="big">Press <kbd>P</kbd> to resume</p>`;
  } else if(state==='over'){
    overlay.innerHTML = `
      <h2>Game Over</h2>
      <div class="go-score">${score}</div>
      <p>Best this session: <strong>${best}</strong></p>
      <p class="big">Press <kbd>Space</kbd> or <kbd>Enter</kbd> to restart</p>`;
  }
}
```

Add chip CSS. Also `.go-score` etc. But overlay innerHTML with kbd elements — styled ✓.

Note: overlay covers canvas; pointerdown on document catches clicks even over overlay ✓ (overlay is child of wrap, events bubble to document).

Input:

```js
const DIRS = { up:{x:0,y:-1}, down:{x:0,y:1}, left:{x:-1,y:0}, right:{x:1,y:0} };
const KEYMAP = {
  arrowup:'up', w:'up', arrowdown:'down', s:'down',
  arrowleft:'left', a:'left', arrowright:'right', d:'right'
};

function pushDir(name){
  const d = DIRS[name];
  const ref = dirQueue.length ? dirQueue[dirQueue.length-1] : dir;
  if(d.x===ref.x && d.y===ref.y) return;      // same dir, ignore
  if(d.x===-ref.x && d.y===-ref.y) return;    // reversal, ignore
  if(dirQueue.length<3) dirQueue.push(d);
}

window.addEventListener('keydown', e=>{
  const k = e.key.toLowerCase();
  if(k.startsWith('arrow')||k===' '||k==='spacebar') e.preventDefault();
  if(state==='demo' || (state==='over' && demo)){
    start();
    if(KEYMAP[k]) pushDir(KEYMAP[k]);
    return;
  }
  if(state==='over'){ if(k===' '||k==='enter'){ start(); } else if(KEYMAP[k]){ start(); pushDir(KEYMAP[k]); } return; }
  if(state==='paused'){ if(k==='p'||k===' '){ resumeGame(); } return; }
  if(state==='play'){
    if(KEYMAP[k]){ pushDir(KEYMAP[k]); }
    else if(k==='p'){ pauseGame(); }
    else if(k===' '){ pauseGame(); }
  }
});
```

Hmm 'spacebar' legacy; `e.key` for space is ' '. Use `k===' '`. Also 'enter' key lowercased = 'enter' ✓.

Wait: state==='over' && demo — can that happen? die() sets state='over' while demo=true. In die we schedule auto-restart. But keydown in that window: demo still true → start() → good, cancels timer. So the branch `state==='demo' || (state==='over'&&demo)` → just check `demo`. Simplify: if(demo){ start(); if KEYMAP push; return; }.

pointerdown:

```js
document.addEventListener('pointerdown', ()=>{
  if(demo){ start(); return; }
  if(state==='over'){ start(); return; }
  if(state==='paused'){ resumeGame(); }
});
```

But careful: pointerdown on the page during play does nothing ✓.

Also visibilitychange → if(state==='play' && !demo) pauseGame().

resumeGame(): state='play'; updateOverlay(). Note acc continues from where it was — we stopped accumulating while paused, so resume is seamless ✓. But `last=now` handled by dt=min(...)? dt = now-last each frame always updated regardless of state, so on resume dt is one frame ✓ (we update last every frame). Yes: frame computes dt and sets last=now every frame regardless of state; acc only incremented when play/demo. 

AI:

```js
function floodCount(start, blockedSet){
  const seen = new Set([key(start)]);
  const q=[start]; let n=0;
  while(q.length){
    const c=q.pop(); n++;
    for(const d of [DIRS.up,DIRS.down,DIRS.left,DIRS.right]){
      const nx=c.x+d.x, ny=c.y+d.y;
      if(nx<0||ny<0||nx>=GRID||ny>=GRID) continue;
      const k2=nx+','+ny;
      if(seen.has(k2)||blockedSet.has(k2)) continue;
      seen.add(k2); q.push({x:nx,y:ny});
    }
  }
  return n;
}

function bfsFirstStep(target, blockedSet){
  const start = snake[0];
  const prev = new Map(); prev.set(key(start), null);
  const q=[start];
  while(q.length){
    const c=q.shift();
    if(c.x===target.x&&c.y===target.y){
      // walk back to first step
      let cur=c;
      while(prev.get(key(cur)) && prev.get(key(cur)).x!==start.x||... 
```

Simpler: store parent map; when reaching target, backtrack until parent is start → that cell is first step. Handle case target===start (won't be, food never on snake).

```js
      let cur=c;
      let par=prev.get(key(cur));
      while(par && !(par.x===start.x&&par.y===start.y)){ cur=par; par=prev.get(key(cur)); }
      if(!par) return null;
      return {x:cur.x-start.x, y:cur.y-start.y};
```

Hmm if start adjacent to target: par is start directly, loop doesn't run, cur = target cell → dir = target-start ✓.

BFS neighbors skip blockedSet cells (occupied) and bounds. Note food cell is not blocked ✓ (food not on snake).

aiDir():

```js
function aiDir(){
  const head=snake[0];
  const occ=new Set();
  for(let i=0;i<snake.length-1;i++) occ.add(key(snake[i])); // tail will move away
  const stepDir = bfsFirstStep(food, occ);
  const options=[];
  for(const name in DIRS){
    const d=DIRS[name];
    if(d.x===-dir.x&&d.y===-dir.y) continue;
    const nh={x:head.x+d.x,y:head.y+d.y};
    if(nh.x<0||nh.y<0||nh.x>=GRID||nh.y>=GRID) continue;
    if(occ.has(key(nh))) continue;
    const occ2=new Set(occ); occ2.add(key(nh));
    options.push({d,nh,free:floodCount(nh,occ2),dist:Math.abs(nh.x-food.x)+Math.abs(nh.y-food.y)});
  }
  if(!options.length) return dir; // doomed, keep going (will die)
  if(stepDir){
    const o=options.find(o=>o.d.x===stepDir.x&&o.d.y===stepDir.y);
    if(o && o.free >= snake.length+1) return stepDir;
  }
  options.sort((a,b)=> (b.free-a.free) || (a.dist-b.dist));
  return options[0].d;
}
```

Note occ excludes tail — approximation: if snake eats this tick tail stays; minor risk, acceptable for demo.

Also `food` could be null if board full — then aiDir crash on Math.abs(nh.x-food.x). Guard: if(!food) return dir. And render guard. Board full practically never; add guard anyway.

Render:

```js
function render(now){
  ctx.clearRect(0,0,SIZE,SIZE);
  ctx.drawImage(board, 0,0, SIZE,SIZE);
```

drawImage with source board canvas (SIZE*DPR px) into SIZE,DPR-scaled context: ctx is scaled by DPR, so drawImage(board,0,0,SIZE,SIZE) draws board scaled down from its native SIZE*DPR to SIZE logical → 1:1 device pixels ✓ crisp.

Food:

```js
  if(food){
    const t=now/1000;
    const fx=food.x*CELL+CELL/2, fy=food.y*CELL+CELL/2;
    const r=CELL*0.32*(1+0.09*Math.sin(t*5));
    ctx.save();
    ctx.shadowColor='rgba(248,113,113,.85)'; ctx.shadowBlur=16;
    const g=ctx.createRadialGradient(fx-r*0.35,fy-r*0.4,r*0.15,fx,fy,r);
    g.addColorStop(0,'#ffb4a6'); g.addColorStop(0.5,'#f87171'); g.addColorStop(1,'#d93434');
    ctx.fillStyle=g;
    ctx.beginPath(); ctx.arc(fx,fy,r,0,Math.PI*2); ctx.fill();
    ctx.restore();
    // little shine
    ctx.fillStyle='rgba(255,255,255,.7)';
    ctx.beginPath(); ctx.ellipse(fx-r*0.32,fy-r*0.38,r*0.2,r*0.13, -0.6, 0,Math.PI*2); ctx.fill();
  }
```

Snake:

```js
  const t = Math.min(1, acc/tickMs);
  const pts = snake.map((s,i)=>{
    const p=prevCells[i]||s;
    return { x:(p.x+(s.x-p.x)*t)*CELL+CELL/2, y:(p.y+(s.y-p.y)*t)*CELL+CELL/2 };
  });
```

Hmm prevCells[i] same length as snake ✓ (both updated in step). Initial: prevCells = copy ✓.

Body draw:

```js
  const n=pts.length;
  // outline pass
  ctx.lineCap='round'; ctx.lineJoin='round';
  for(let pass=0; pass<2; pass++){
    for(let i=n-1;i>0;i--){
      const f=1 - i/(n-1);  // 0 at tail, 1 at head
      ctx.lineWidth = pass===0 ? CELL*0.8 : CELL*0.62;
      if(pass===0) ctx.strokeStyle='#062015';
      else{
        const h=146+f*10, l=34+f*16;
        ctx.strokeStyle=`hsl(${h} 62% ${l}%)`;
      }
      ctx.beginPath(); ctx.moveTo(pts[i].x,pts[i].y); ctx.lineTo(pts[i-1].x,pts[i-1].y); ctx.stroke();
    }
  }
```

Hmm outline pass draws full path including around head-to-neck. Then body colors. Then head circle drawn on top with its own outline. The head outline circle r ~ CELL*0.4+outline.

Taper: lineWidth could vary per segment but constant fine. Actually let me taper tail slightly: width *= (pass===0? (0.75+0.25*(1-i/(n-1))) : ...). Keep constant for simplicity — round caps unify.

Wait issue: segments between pts[i] and pts[i-1] — for i=n-1 (tail) connects tail to next. When snake length 3, two strokes. ✓. But at t interpolation, pts are mid-slide positions, adjacent cells offset by dir — smooth ✓.

Colors: at head-side segment (i=1): f = 1-1/(n-1) ≈ 1 → h≈156, l≈50. Tail: h=146, l=34. Slight variation; maybe more contrast: h from 150 (tail) to 128? Let me do h = 150 - f*14 (150→136 toward head), l = 32 + f*18 (32→50). Slightly yellow-green head-side. Eh, either fine.

Head:

```js
  const hp = pts[0], np = pts[1]||{x:hp.x-dir.x,y:hp.y-dir.y};
  let dx=hp.x-np.x, dy=hp.y-np.y;
  const m=Math.hypot(dx,dy)||1; dx/=m; dy/=m;
  if(n===1){dx=dir.x;dy=dir.y;}
  const px=-dy, py=dx; // perpendicular
  const hr=CELL*0.42;
  // glow
  ctx.save();
  ctx.shadowColor='rgba(52,211,153,.6)'; ctx.shadowBlur=18;
  ctx.fillStyle='#0a2d1c';
  ctx.beginPath(); ctx.arc(hp.x,hp.y,hr+2.5,0,Math.PI*2); ctx.fill();
  ctx.restore();
  const hg=ctx.createRadialGradient(hp.x-dx*hr*0.3,hp.y-dy*hr*0.3,hr*0.2,hp.x,hp.y,hr);
  hg.addColorStop(0,'#8ff0ae'); hg.addColorStop(1,'#2fbf71');
  ctx.fillStyle=hg;
  ctx.beginPath(); ctx.arc(hp.x,hp.y,hr,0,Math.PI*2); ctx.fill();
```

Eyes:

```js
  const ex=px, ey=py;
  for(const side of [-1,1]){
    const cxA=hp.x+dx*CELL*0.08+ex*side*CELL*0.17;
    const cyA=hp.y+dy*CELL*0.08+ey*side*CELL*0.17;
    ctx.fillStyle='#fdfdf5';
    ctx.beginPath(); ctx.arc(cxA,cyA,CELL*0.125,0,Math.PI*2); ctx.fill();
    if(state==='over'){
      // X eyes
      ctx.strokeStyle='#0b1a12'; ctx.lineWidth=1.6;
      const s=CELL*0.06;
      ctx.beginPath();
      ctx.moveTo(cxA-s,cyA-s); ctx.lineTo(cxA+s,cyA+s);
      ctx.moveTo(cxA+s,cyA-s); ctx.lineTo(cxA-s,cyA+s);
      ctx.stroke();
    } else {
      ctx.fillStyle='#0b1a12';
      ctx.beginPath(); ctx.arc(cxA+dx*CELL*0.045, cyA+dy*CELL*0.045, CELL*0.062,0,Math.PI*2); ctx.fill();
    }
  }
```

Rings & particles:

```js
  // rings
  for(const r of rings){
    const a=r.age/0.45;
    if(a>=1) continue;
    ctx.strokeStyle=`rgba(248,113,113,${(1-a)*0.7})`;
    ctx.lineWidth=2.5*(1-a)+0.5;
    ctx.beginPath(); ctx.arc(r.x,r.y, 6+a*26, 0, Math.PI*2); ctx.stroke();
  }
  // particles
  for(const p of particles){
    const a=1-p.life/p.max;
    if(a<=0) continue;
    ctx.globalAlpha=a;
    ctx.fillStyle=p.col;
    ctx.beginPath(); ctx.arc(p.x,p.y,p.r*(0.4+0.6*a),0,Math.PI*2); ctx.fill();
  }
  ctx.globalAlpha=1;
```

updateFX(dt):

```js
function updateFX(dt){
  const s=dt/1000;
  for(let i=particles.length-1;i>=0;i--){
    const p=particles[i];
    p.life+=s;
    if(p.life>=p.max){ particles.splice(i,1); continue; }
    p.x+=p.vx*s; p.y+=p.vy*s; p.vy+=200*s;
  }
  for(let i=rings.length-1;i>=0;i--){
    rings[i].age+=s;
    if(rings[i].age>=0.45) rings.splice(i,1);
  }
}
```

burst(x,y):

```js
function burst(x,y){
  const cols=['#f87171','#fca5a5','#fbbf24','#fde68a','#ffffff'];
  for(let i=0;i<18;i++){
    const a=Math.random()*Math.PI*2, sp=50+Math.random()*130;
    particles.push({x,y,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp-40,life:0,max:0.45+Math.random()*0.35,r:1.6+Math.random()*2.6,col:cols[(Math.random()*cols.length)|0]});
  }
}
```

HUD:

```js
function updateHUD(pop){
  scoreEl.textContent=score;
  bestEl.textContent=best;
  if(pop){ scoreEl.classList.remove('pop'); void scoreEl.offsetWidth; scoreEl.classList.add('pop'); }
}
```

frame:

```js
function frame(now){
  const dt=Math.min(60, now-last); last=now;
  if(state==='play'||state==='demo'){
    acc+=dt;
    let guard=0;
    while(acc>=tickMs && guard<8){
      acc-=tickMs; guard++;
      step();
      if(state==='over'){ acc=0; break; }
    }
  }
  updateFX(dt);
  render(now);
  requestAnimationFrame(frame);
}
```

Note: while paused/over, render still draws current interpolated positions with t=acc/tickMs — acc frozen ✓.

Init: reset(); state='demo'; demo=true; updateOverlay(); requestAnimationFrame(frame).

Also on demo death, state='over', demo stays true → overlay shows demo start prompt ✓ and auto restart. In die(): schedule setTimeout to reset+state='demo'. But state 'over' blocks stepping; after timer state='demo' resumes ✓.

Edge: start() when demo over pending timer → cleared ✓.

One more: after user starts (start()), demo=false forever. Restart after game over: start() again (reset). ✓.

Best score session: best updated on eat. ✓ Display best in HUD.

Check "Quick successive key presses must both be honored": queue of 3, applied one per tick ✓. E.g., moving right, press up then left quickly → tick1 up, tick2 left ✓ (left not reverse of up ✓).

Also prevent reversing: ref = last queued or current dir ✓.

Now overlay chip CSS:

```
.chip{ font-size:10.5px; letter-spacing:.25em; text-transform:uppercase; color:#0a1220; background:linear-gradient(90deg,#34d399,#a7f3d0); padding:4px 12px; border-radius:999px; font-weight:800; }
```

Also `.big` uses blink animation.

kbd inside overlay p — styled globally ✓.

Potential issue: overlay innerHTML replaced each updateOverlay; fine.

Game over overlay shows score ✓ and best ✓.

Pause during demo? keydown: `if(demo){start();...}` — pressing P in demo starts game (any key) ✓ per requirement "Click or press a key to start".

Space during play → pauseGame. Hmm but requirement says Space restarts (at game over). Fine.

Enter during play → nothing ✓.

Also handle keydown repeat (holding key): pushDir dedupes same dir ✓.

Canvas focus: not needed; window keydown listener. iframe receives keyboard after click ✓.

Now check canvas CSS: `width:min(560px, 92vw, 68vh)` — 68vh of 800 = 544px < 560 → board 544px. Plus header ~? Let's compute total height: main gap 14px; header (h1 ~40 + sub ~18) ~58; hud ~55; canvas 544; footer ~20; gaps 14*3=42; padding 36 → ~755 < 800 ✓. 

Small screens 375×667: canvas 345; total ~ 345+58+55+20+42+36 ≈ 556 < 667 ✓. Footer wraps maybe two rows, still fine. Also body uses flex center; if content taller than viewport, body min-height 100vh with align-items center — could overflow top on very short screens; use `align:safe`? Use margin auto trick: body{display:flex} main{margin:auto} to allow scroll if needed. Set body overflow-x hidden? Avoid horizontal scrollbar: content width = canvas 345 + padding ✓ no horizontal scroll.

Actually with `justify-content:center; align-items:center` and content taller than viewport, it overflows both top and bottom equally → top clipped unreachable. Use `margin:auto` on main within flex body to fix. I'll do body{display:flex} main{margin:auto; ...}.

Font h1 letter-spacing .35em text-indent to center compensate ✓.

Now, double-check prevCells indexing after die: not updated, snake unchanged → render static with t=acc/tickMs ~1? acc reset to 0 → t=0 → positions = prevCells... wait at death, prevCells still from previous step, snake current; t=0 renders snake at prevCells positions (one cell back). Slight visual: snake head appears not to enter wall — actually fine/better. Hmm but t=0 render shows snake entirely at previous tick positions — the head is one cell before collision. That's okay visually (bump). Actually maybe better freeze at t=1 (current cells). At death set prevCells = snake.map(copy) so it renders exactly at final cells (head touching wall cell? head = nh? No — on death we never moved; snake unchanged; head is at last valid cell). Render t whatever → same. Let's in die(): prevCells = snake.map(s=>({...s})); acc=0. ✓ head stays at last valid cell, adjacent to wall. Good.

Also on death from self-collision same.

One more polish: draw snake shadow subtle: skip.

Check step() when food null (board full): willGrow false; fine. aiDir guard: if(!food) return {x:dir.x,y:dir.y}. Add.

Also ensure `demo` defined before use in keydown.

Let me also add "length" indicator? skip.

Test mentally the BFS first-step backtrack: prev map stores parent key→parent cell. Start key maps null. When c reaches target, backtrack:

```js
let cur = c;
let par = prev.get(key(cur));
while(par && !(par.x===start.x && par.y===start.y)){
  cur = par; par = prev.get(key(cur));
}
if(!par) return null;
return {x: cur.x-start.x, y: cur.y-start.y};
```

Case target adjacent to start: cur=target, par=start → while condition false (par is start) → return target-start ✓.
Case start===target: not applicable.
Case path length 2: start→A→target. cur=target,par=A → not start → cur=A, par=start → exit → return A-start ✓.

prev.get returns parent cell object (we store cell objects). We set prev.set(key(neighbor), current). ✓.

BFS blocked check: neighbor blocked if blockedSet has key. Also skip if already visited (prev.has). ✓.

Note: start cell is head; occupied set includes head (snake[0] included since i<len-1 includes 0). BFS starts at head; visited includes head ✓; neighbors of head can't be head itself. ✓.

floodCount uses pop (stack DFS) fine.

Performance: aiDir runs BFS+floods per tick (~576 ops) trivial.

Demo AI quality: decent, will survive long-ish, occasionally die → auto restart. 

Now, one subtlety: In demo mode tick speed also increases with score (tickMs update happens in step regardless of demo) — that's fine, shows dynamic demo.

HUD update in demo also shows score — fine.

Now write overlay initial HTML (static) — will be replaced by updateOverlay() on init. Init call updateOverlay() anyway.

Also requirement "grid of at least 20×20 cells, centered on the page" ✓ 24×24 centered.

Let me also double check `e.key.toLowerCase()` for 'W' → 'w' ✓.

preventDefault: for arrows and space to avoid scrolling: check `['arrowup','arrowdown','arrowleft','arrowright',' '].includes(k)` → preventDefault. Also maybe prevent for all handled keys. Fine.

Edge: user presses arrow during 'over' (not demo) → we call start() then pushDir → game starts moving in that direction ✓ nice.

Now also `start()` on Enter ✓.

Compose final CSS with responsive tweak: hud stat min-width 110px might overflow at 375? Two stats side by side 2*110+gap=230 < 345 ✓.

Footer text wraps ✓.

Title uses clamp ✓.

Let me now write out the complete file carefully.

One more check on render pts when snake just reset: prevCells==cells → pts static ✓.

Check ring/particle draw before/after snake: particles over snake — draw after snake ✓ (they're drawn after in render order I planned: board, food, snake, rings, particles ✓).

Also `ctx.lineJoin` irrelevant for separate segments.

Potential blip: When snake length grows to large, stroke count fine.

Also make sure no console errors: all elements defined; script at end of body; use IIFE.

`void scoreEl.offsetWidth` fine.

Let me also add subtle CSS for `.go-score` etc. And overlay pointer-events: default auto; overlay has pointerdown → bubbles to document ✓ (document listener). But overlay is above canvas — clicks anywhere in wrap bubble ✓.

Also add `touch-action: none`? Not needed; no touch controls required (could add swipe — nice-to-have; skip to limit risk? Add simple swipe: pointerdown/up delta → direction. Hmm, requirement mentions keyboard. I'll add basic swipe support quickly — low risk:

```js
let swipeStart=null;
document.addEventListener('pointerdown',e=>{ swipeStart={x:e.clientX,y:e.clientY}; ...existing logic });
document.addEventListener('pointerup',e=>{
  if(!swipeStart) return;
  const dx=e.clientX-swipeStart.x, dy=e.clientY-swipeStart.y; swipeStart=null;
  if(Math.hypot(dx,dy)<24) return;
  const name = Math.abs(dx)>Math.abs(dy) ? (dx>0?'right':'left') : (dy>0?'down':'up');
  if(state==='play') pushDir(name);
});
```

Conflict: pointerdown also starts game on demo — then pointerup swipe could immediately set direction — harmless. OK include.

But careful: pointerdown handler already handles start; the swipeStart set before/after irrelevant. Fine.

Also prevent double-start: pointerdown on overlay triggers start; then keydown later fine.

Write final code now. Also verify no `localStorage` usage ✓ (session best in memory).

Double-check the `min(560px, 92vw, 68vh)` — CSS min() with mixed units ✓.

Also `canvas{border-radius:18px}` matches wrapper shadow ✓ (wrapper has box-shadow ring). wrapper border-radius also 18 for shadow shape ✓.

Final check on hsl syntax `hsl(146 62% 34%)` modern Chrome ✓.

Also ensure `overlay` z-index above canvas: it's after canvas in DOM, absolute → on top ✓.

Also initial overlay content: set by updateOverlay on init (demo=true) ✓.

Edge: In start(), we call reset() which calls updateHUD(false) sets score 0 best retains ✓.

Now the step loop `while(acc>=tickMs)` — tickMs can change inside step (after eat) ✓ fine.

I think it's solid. Write it out.

Also remember: at game over in demo, overlay shows demo prompt ("Click or press a key to start") — but state is 'over'; updateOverlay checks `demo` first ✓.

Wait in die(): updateOverlay() called — demo branch → shows start prompt ✓ (even though also auto-restarting behind — fine).

Let me write the final HTML.

Also add `<noscript>`? Not needed.

Length check ok. Write it.

One last detail: `render(now)` uses `now` param from rAF; `acc/tickMs` t. During 'over' state acc=0 → t=0 → but prevCells==cells → static ✓.

Also `last` init before first frame ✓.

Colors for body: h = 150 - f*16 → head-side h=134, tail 150. l = 33 + f*17 → head-side 50, tail 33. s=60.

Head fill gradient '#8ff0ae'→'#2fbf71' distinct lighter ✓.

OK final answer.