Models 44

GLM 5.3 Flash

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
6 min 45 s
Cost
$0.023
Output tokens
60,048
Spent thinking
51,264 tokens
Speed
148.5 tok/s
Console errors
0
First token after
838 ms
First visible text after
6 min 13 s
Prompt tokens
491
Code size
25.2 KB
Finish reason
stop
Served by
DigitalOcean
Model id
z-ai/glm-5.3-flash
Reasoning sent
{"effort":"max"}
Max output
64,000 tokens
Generated
Nov 10, 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 · 677 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#0a0e13">
<title>Snake · Vanilla Canvas</title>
<style>
  :root{
    --bg:#0a0e13;
    --panel:#10161f;
    --line:#223041;
    --text:#e7ecf3;
    --muted:#64748b;
    --accent:#34d399;
    --accent-soft:#6ee7b7;
    --danger:#f87171;
    --mono:ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
  }
  *{box-sizing:border-box;margin:0;padding:0}
  body{
    min-height:100svh;
    display:flex;
    background-color:var(--bg);
    background-image:radial-gradient(1100px 620px at 50% -8%, #152230 0%, rgba(10,14,19,0) 68%);
    background-repeat:no-repeat;
    color:var(--text);
    font-family:var(--mono);
    padding:18px 16px;
    -webkit-font-smoothing:antialiased;
  }
  .frame{
    margin:auto;
    width:min(572px, 94vw);
    width:clamp(300px, min(94vw, 100svh - 240px), 572px);
    width:clamp(300px, min(94vw, 100dvh - 240px), 572px);
    display:flex;flex-direction:column;gap:13px;
  }
  .top{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}
  .brand{display:flex;align-items:center;gap:10px}
  .brand svg{display:block;flex:none}
  .brand h1{font-size:19px;font-weight:800;letter-spacing:.32em;color:#f1f5f9}
  .hud{display:flex;align-items:center;gap:8px}
  .chip{display:flex;flex-direction:column;align-items:center;gap:3px;min-width:78px;padding:7px 13px;background:var(--panel);border:1px solid var(--line);border-radius:10px}
  .chip span{font-size:9px;letter-spacing:.24em;color:var(--muted)}
  .chip b{font-size:18px;font-weight:700;line-height:1;color:var(--text);font-variant-numeric:tabular-nums}
  .chip.score b{color:var(--accent-soft)}
  .btn{font-family:var(--mono);font-size:10px;letter-spacing:.18em;color:var(--accent-soft);background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:10px 12px;cursor:pointer;transition:color .15s,border-color .15s}
  .btn:hover{border-color:#33475e}
  .btn.off{color:var(--muted)}
  .stage{position:relative;width:100%;aspect-ratio:1;border-radius:16px;overflow:hidden;background:#0d1319;touch-action:none;box-shadow:0 26px 70px -22px rgba(0,0,0,.7),0 0 0 1px var(--line)}
  .stage canvas{display:block;width:100%;height:100%}
  @keyframes shake{15%{transform:translate(-5px,2px)}35%{transform:translate(5px,-3px)}55%{transform:translate(-4px,2px)}75%{transform:translate(3px,-1px)}92%{transform:translate(-2px,1px)}}
  .stage.shake{animation:shake .42s linear}
  .overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;text-align:center;padding:22px;background:rgba(7,11,16,.42);transition:opacity .2s ease}
  .overlay.dim{background:rgba(7,11,16,.74);backdrop-filter:blur(4px)}
  .overlay.is-hidden{opacity:0;pointer-events:none}
  .card{display:flex;flex-direction:column;align-items:center;gap:12px;max-width:100%}
  .panel{background:rgba(12,18,26,.93);border:1px solid var(--line);border-radius:18px;padding:26px 40px;box-shadow:0 24px 60px rgba(0,0,0,.5)}
  .attract{background:rgba(10,15,21,.74);border:1px solid rgba(148,163,184,.16);border-radius:18px;padding:24px 32px;box-shadow:0 18px 50px rgba(0,0,0,.45)}
  .logo-big{display:flex;align-items:center;gap:12px}
  .logo-big span{font-size:32px;font-weight:800;letter-spacing:.3em;margin-right:-.3em;color:#f1f5f9}
  .pill{display:flex;align-items:center;gap:8px;font-size:10px;letter-spacing:.22em;color:var(--accent-soft);border:1px solid rgba(110,231,183,.35);background:rgba(52,211,153,.08);padding:6px 13px;border-radius:999px}
  .pulse{width:8px;height:8px;border-radius:50%;background:var(--accent);animation:pulse 1.15s ease-in-out infinite}
  @keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.3;transform:scale(.65)}}
  .cta{font-size:12.5px;color:#cbd8e6}
  .sub{font-size:10.5px;color:var(--muted)}
  .overlay h2{font-size:17px;letter-spacing:.3em;margin-right:-.3em;text-transform:uppercase;color:var(--danger)}
  .overlay h2.calm{color:#e7ecf3}
  .overlay h2.win{color:var(--accent-soft)}
  .newbest{font-size:10px;font-weight:700;letter-spacing:.26em;color:#052e21;background:var(--accent);padding:5px 12px;border-radius:999px}
  .final{display:flex;flex-direction:column;gap:5px}
  .final span{font-size:9.5px;letter-spacing:.26em;color:var(--muted)}
  .final b{font-size:42px;font-weight:800;line-height:1;color:#f1f5f9;font-variant-numeric:tabular-nums}
  .final.small b{font-size:17px;color:#cbd8e6}
  kbd{display:inline-block;padding:2px 7px;border:1px solid #33465c;border-bottom-width:2px;border-radius:6px;background:#151f2a;color:#e2e8f0;font-family:var(--mono);font-size:.92em}
  .hints{display:flex;gap:16px;justify-content:center;flex-wrap:wrap;color:#5b6b7d;font-size:11.5px}
  .hints span{display:inline-flex;align-items:center;gap:6px}
  @media (max-width:430px){
    .brand h1{font-size:16px;letter-spacing:.24em}
    .chip{min-width:0;padding:6px 10px}
    .chip b{font-size:15px}
    .panel{padding:20px 22px}
    .final b{font-size:34px}
    .hints{gap:10px;font-size:10.5px}
  }
</style>
</head>
<body>
<main class="frame">
  <header class="top">
    <div class="brand">
      <svg width="26" height="26" viewBox="0 0 24 24" fill="none" aria-hidden="true">
        <circle cx="5.4" cy="7.2" r="2.1" fill="#f87171"/>
        <path d="M6.6 5.9c.9-.9 2-.9 2.6-.4" stroke="#4ade80" stroke-width="1.4" stroke-linecap="round"/>
        <path d="M3.5 17h7a4.5 4.5 0 0 0 0-9h2.6" stroke="#34d399" stroke-width="3.2" stroke-linecap="round"/>
        <circle cx="14.6" cy="8" r="2.9" fill="#86efac"/>
        <circle cx="15.6" cy="7.2" r="0.8" fill="#0a0e13"/>
      </svg>
      <h1>SNAKE</h1>
    </div>
    <div class="hud">
      <div class="chip score"><span>SCORE</span><b id="score">0</b></div>
      <div class="chip"><span>BEST</span><b id="best">0</b></div>
      <button id="sfxBtn" class="btn" type="button">SFX ON</button>
    </div>
  </header>

  <div class="stage" id="stage">
    <canvas id="game" width="572" height="572" aria-label="Snake board"></canvas>
    <div class="overlay" id="overlay"></div>
  </div>

  <footer class="hints">
    <span><kbd>←↑↓→</kbd> / <kbd>WASD</kbd> steer</span>
    <span><kbd>P</kbd> pause</span>
    <span><kbd>Space</kbd>/<kbd>Enter</kbd> restart</span>
  </footer>
</main>

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

/* ================= constants ================= */
const COLS = 22, ROWS = 22, CELL = 26, BOARD = COLS * CELL, TAU = Math.PI * 2;
const START_TICK = 140, MIN_TICK = 82, TICK_STEP = 3;
const MONOF = 'Menlo, Consolas, monospace';
const MARK = '<svg width="34" height="34" viewBox="0 0 24 24" fill="none" aria-hidden="true">'
  + '<circle cx="5.4" cy="7.2" r="2.1" fill="#f87171"/>'
  + '<path d="M6.6 5.9c.9-.9 2-.9 2.6-.4" stroke="#4ade80" stroke-width="1.4" stroke-linecap="round"/>'
  + '<path d="M3.5 17h7a4.5 4.5 0 0 0 0-9h2.6" stroke="#34d399" stroke-width="3.2" stroke-linecap="round"/>'
  + '<circle cx="14.6" cy="8" r="2.9" fill="#86efac"/>'
  + '<circle cx="15.6" cy="7.2" r="0.8" fill="#0a0e13"/>'
  + '</svg>';

const DIR4 = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}]; // R L D U
const DX = [1,-1,0,0], DY = [0,0,1,-1];
const DIRKEYS = {
  ArrowUp:DIR4[3], ArrowDown:DIR4[2], ArrowLeft:DIR4[1], ArrowRight:DIR4[0],
  w:DIR4[3], s:DIR4[2], a:DIR4[1], d:DIR4[0]
};

/* ================= dom ================= */
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const stage = document.getElementById('stage');
const overlay = document.getElementById('overlay');
const scoreEl = document.getElementById('score');
const bestEl = document.getElementById('best');
const sfxBtn = document.getElementById('sfxBtn');

/* ================= state ================= */
let viewScale = 1;
let state = 'demo', isDemo = true;          // states: demo | playing | paused | over
let snake = [], prevSnake = [], dir = DIR4[0], queue = [];
let food = null, score = 0, best = 0, prevBest = 0, newBest = false, isWin = false;
let tickMs = START_TICK, acc = 0, lastT = 0, deathAt = 0;
let particles = [], popups = [];
let overlayKey = 'init', shakeTO = 0, swipe = null;

/* ================= audio (tiny synth) ================= */
let ac = null, sfxOn = true;
function ensureAudio(){
  if(!ac){ try{ ac = new (window.AudioContext || window.webkitAudioContext)(); }catch(err){ ac = null; } }
  if(ac && ac.state === 'suspended') ac.resume().catch(()=>{});
}
function blip(f0, f1, dur, type, vol){
  if(!sfxOn || !ac) return;
  try{
    const t0 = ac.currentTime;
    const o = ac.createOscillator(), g = ac.createGain();
    o.type = type;
    o.frequency.setValueAtTime(f0, t0);
    o.frequency.exponentialRampToValueAtTime(Math.max(1, f1), t0 + dur);
    g.gain.setValueAtTime(vol, t0);
    g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
    o.connect(g); g.connect(ac.destination);
    o.start(t0); o.stop(t0 + dur + 0.03);
  }catch(err){}
}
const sfxEat = () => blip(620, 990, 0.09, 'square', 0.10);
const sfxDie = () => blip(210, 52, 0.5, 'sawtooth', 0.16);
const sfxGo  = () => blip(430, 780, 0.14, 'triangle', 0.09);

/* ================= sizing ================= */
function fit(){
  const r = canvas.getBoundingClientRect();
  if(r.width < 2) return;
  const dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 2.5));
  const s = Math.round(r.width * dpr);
  if(canvas.width !== s){ canvas.width = s; canvas.height = s; }
  viewScale = s / BOARD;
}
if('ResizeObserver' in window) new ResizeObserver(fit).observe(canvas);
window.addEventListener('resize', fit);

/* ================= core ================= */
const ckey = (x, y) => y * COLS + x;

function updateHUD(){ scoreEl.textContent = score; bestEl.textContent = best; }

function spawnFood(){
  const occ = new Uint8Array(COLS * ROWS);
  for(const s of snake) occ[ckey(s.x, s.y)] = 1;
  const free = [];
  for(let i = 0; i < occ.length; i++) if(!occ[i]) free.push(i);
  if(!free.length){ food = null; return true; }        // board full -> perfect run
  const i = free[(Math.random() * free.length) | 0];
  food = { x: i % COLS, y: Math.floor(i / COLS) };
  return false;
}

function reset(mode){
  isDemo = mode === 'demo';
  const cx = COLS >> 1, cy = ROWS >> 1;
  snake = [{x:cx,y:cy},{x:cx-1,y:cy},{x:cx-2,y:cy}];
  prevSnake = snake.map(s => ({x:s.x, y:s.y}));
  dir = DIR4[0]; queue = [];
  score = 0; tickMs = START_TICK; acc = 0;
  particles.length = 0; popups.length = 0;
  newBest = false; isWin = false;
  if(!isDemo) prevBest = best;
  spawnFood();
  state = isDemo ? 'demo' : 'playing';
  updateHUD();
}

function burst(cx, cy, tint, count){
  const px = (cx + 0.5) * CELL, py = (cy + 0.5) * CELL;
  const colors = [tint, '#fca5a5', '#fde68a', '#86efac', '#ffffff'];
  for(let i = 0; i < count; i++){
    const a = Math.random() * TAU, sp = 55 + Math.random() * 135, L = 0.4 + Math.random() * 0.4;
    particles.push({ x:px, y:py, vx:Math.cos(a)*sp, vy:Math.sin(a)*sp - 46,
                     life:L, ttl:L, size:1.6 + Math.random()*2.4,
                     color:colors[(Math.random()*colors.length)|0] });
  }
  if(particles.length > 260) particles.splice(0, particles.length - 260);
}

function die(win){
  isWin = !!win;
  newBest = !isDemo && score > prevBest && score > 0;
  deathAt = performance.now();
  state = 'over';
  burst(snake[0].x, snake[0].y, isWin ? '#86efac' : '#f87171', isWin ? 26 : 20);
  if(!isDemo){
    stage.classList.remove('shake'); void stage.offsetWidth; stage.classList.add('shake');
    clearTimeout(shakeTO); shakeTO = setTimeout(() => stage.classList.remove('shake'), 480);
    isWin ? sfxGo() : sfxDie();
  }
}

function onEat(){
  const fx = food.x, fy = food.y;
  score += 10;
  if(!isDemo) best = Math.max(best, score);
  burst(fx, fy, '#f87171', 16);
  popups.push({ x:fx, y:fy, age:0 });
  tickMs = Math.max(MIN_TICK, tickMs - TICK_STEP);
  sfxEat();
  food = null;                       // respawns at the start of the next step
  updateHUD();
}

function doStep(){
  if(!food && spawnFood()){ die(true); return; }
  let d = null;
  if(isDemo){
    d = aiDir();
  }else{
    while(queue.length){
      const q = queue.shift();
      if(q.x === -dir.x && q.y === -dir.y) continue;   // never reverse
      if(q.x === dir.x && q.y === dir.y) continue;
      d = q; break;
    }
    if(!d) d = dir;
  }
  dir = d;
  const h = snake[0];
  const nx = h.x + d.x, ny = h.y + d.y;
  if(nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS){ die(false); return; }   // wall
  for(let i = 0; i < snake.length - 1; i++){                                 // self
    if(snake[i].x === nx && snake[i].y === ny){ die(false); return; }
  }
  prevSnake = snake.map(s => ({x:s.x, y:s.y}));
  const eat = food && nx === food.x && ny === food.y;
  snake.unshift({x:nx, y:ny});
  if(eat) onEat(); else snake.pop();
}

/* ================= autopilot (demo mode) ================= */
function aiDir(){
  const head = snake[0], tail = snake[snake.length - 1];
  const occ = new Uint8Array(COLS * ROWS);
  for(let i = 0; i < snake.length - 1; i++) occ[ckey(snake[i].x, snake[i].y)] = 1;
  const headI = ckey(head.x, head.y), tailI = ckey(tail.x, tail.y);
  const foodI = food ? ckey(food.x, food.y) : -1;

  const bfsStep = (targetI) => {
    if(targetI < 0 || targetI === headI) return null;
    const par = new Int32Array(COLS * ROWS).fill(-1);
    const stepDir = new Int8Array(COLS * ROWS).fill(-1);
    const seen = new Uint8Array(COLS * ROWS);
    seen[headI] = 1;
    const q = [headI]; let qi = 0;
    while(qi < q.length){
      const c = q[qi++], cx = c % COLS, cy = (c / COLS) | 0;
      for(let dd = 0; dd < 4; dd++){
        const nx2 = cx + DX[dd], ny2 = cy + DY[dd];
        if(nx2 < 0 || ny2 < 0 || nx2 >= COLS || ny2 >= ROWS) continue;
        const n = ny2 * COLS + nx2;
        if(seen[n]) continue;
        if(occ[n] && n !== targetI) continue;
        seen[n] = 1; par[n] = c; stepDir[n] = dd;
        if(n === targetI){
          let cur = n;
          while(par[cur] !== headI) cur = par[cur];
          return DIR4[stepDir[cur]];
        }
        q.push(n);
      }
    }
    return null;
  };

  const floodFrom = (startI, occArr) => {
    const seen = new Uint8Array(COLS * ROWS);
    seen[startI] = 1;
    const st = [startI]; let n = 0;
    while(st.length){
      const c = st.pop(); n++;
      const cx = c % COLS, cy = (c / COLS) | 0;
      for(let dd = 0; dd < 4; dd++){
        const nx2 = cx + DX[dd], ny2 = cy + DY[dd];
        if(nx2 < 0 || ny2 < 0 || nx2 >= COLS || ny2 >= ROWS) continue;
        const ni = ny2 * COLS + nx2;
        if(seen[ni] || occArr[ni]) continue;
        seen[ni] = 1; st.push(ni);
      }
    }
    return n;
  };

  const opts = [];
  for(let i = 0; i < 4; i++){
    const d = DIR4[i];
    if(d.x === -dir.x && d.y === -dir.y) continue;
    const nx = head.x + d.x, ny = head.y + d.y;
    if(nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
    const ni = ckey(nx, ny);
    if(occ[ni]) continue;
    const eating = (ni === foodI);
    const occ2 = occ.slice();
    occ2[ni] = 1;
    occ2[tailI] = eating ? 1 : 0;
    opts.push({ d, area:floodFrom(ni, occ2),
                dist: food ? Math.abs(food.x - nx) + Math.abs(food.y - ny) : 0,
                r: Math.random() });
  }
  if(!opts.length) return dir;

  const need = snake.length + 1;
  const toFood = bfsStep(foodI);
  if(toFood){
    const o = opts.find(o => o.d.x === toFood.x && o.d.y === toFood.y);
    if(o && o.area >= need) return o.d;
  }else{
    const toTail = bfsStep(tailI);       // survival: chase own tail
    if(toTail){
      const o = opts.find(o => o.d.x === toTail.x && o.d.y === toTail.y);
      if(o && o.area >= need) return o.d;
    }
  }
  opts.sort((a, b) => b.area - a.area || a.dist - b.dist || a.r - b.r);
  return opts[0].d;
}

/* ================= effects ================= */
function updateFx(dt){
  for(const p of particles){
    p.life -= dt;
    p.vy += 330 * dt;
    p.vx *= Math.max(0, 1 - 1.6 * dt);
    p.x += p.vx * dt; p.y += p.vy * dt;
  }
  particles = particles.filter(p => p.life > 0);
  for(const q of popups) q.age += dt;
  popups = popups.filter(q => q.age < 0.9);
}

/* ================= rendering ================= */
function drawFood(t){
  const fx = (food.x + 0.5) * CELL, fy = (food.y + 0.5) * CELL;
  const pulse = 1 + 0.07 * Math.sin(t / 240);
  const halo = ctx.createRadialGradient(fx, fy, CELL * 0.1, fx, fy, CELL * pulse);
  halo.addColorStop(0, 'rgba(248,113,113,0.30)');
  halo.addColorStop(1, 'rgba(248,113,113,0)');
  ctx.fillStyle = halo;
  ctx.beginPath(); ctx.arc(fx, fy, CELL * pulse, 0, TAU); ctx.fill();

  const r = CELL * 0.33 * pulse;
  const g = ctx.createRadialGradient(fx - r*0.35, fy - r*0.42, r*0.12, fx, fy, r*1.05);
  g.addColorStop(0, '#fda4af');
  g.addColorStop(0.55, '#f43f5e');
  g.addColorStop(1, '#be123c');
  ctx.fillStyle = g;
  ctx.beginPath(); ctx.arc(fx, fy, r, 0, TAU); ctx.fill();

  ctx.strokeStyle = '#8b5e34'; ctx.lineWidth = 2; ctx.lineCap = 'round';
  ctx.beginPath(); ctx.moveTo(fx + 0.5, fy - r + 1.5); ctx.lineTo(fx + 2.6, fy - r - 3.6); ctx.stroke();

  ctx.fillStyle = '#4ade80';
  ctx.beginPath(); ctx.ellipse(fx + 6.2, fy - r - 2.6, 4.2, 2.1, -0.55, 0, TAU); ctx.fill();

  ctx.fillStyle = 'rgba(255,255,255,0.75)';
  ctx.beginPath(); ctx.ellipse(fx - r*0.34, fy - r*0.36, r*0.22, r*0.13, -0.6, 0, TAU); ctx.fill();
}

function drawSnake(){
  const anim = (state === 'playing' || state === 'demo');
  const alpha = anim ? Math.min(1, acc / tickMs) : 1;
  const pts = snake.map((s, i) => {
    const p = prevSnake[i] || s;
    return { x:(p.x + (s.x - p.x) * alpha + 0.5) * CELL,
             y:(p.y + (s.y - p.y) * alpha + 0.5) * CELL };
  });
  const dead = (state === 'over' && !isWin);

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

  // dark rim
  ctx.strokeStyle = 'rgba(4,28,20,0.92)';
  ctx.lineWidth = CELL * 0.84;
  ctx.beginPath();
  ctx.moveTo(pts[0].x, pts[0].y);
  for(let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
  ctx.stroke();

  // gradient body
  const last = pts[pts.length - 1];
  const g = ctx.createLinearGradient(pts[0].x, pts[0].y, last.x, last.y);
  g.addColorStop(0, '#8df0ac');
  g.addColorStop(0.45, '#34d399');
  g.addColorStop(1, '#0f766e');
  ctx.strokeStyle = g;
  ctx.lineWidth = CELL * 0.66;
  ctx.beginPath();
  ctx.moveTo(pts[0].x, pts[0].y);
  for(let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
  ctx.stroke();

  // head
  const h = pts[0];
  ctx.fillStyle = '#a7f3d0';
  ctx.strokeStyle = 'rgba(4,28,20,0.92)';
  ctx.lineWidth = 3;
  ctx.beginPath(); ctx.arc(h.x, h.y, CELL * 0.46, 0, TAU); ctx.fill(); ctx.stroke();

  // eyes facing travel direction
  const fx = dir.x, fy = dir.y, pxv = -dir.y, pyv = dir.x;
  for(const sgn of [-1, 1]){
    const ex = h.x + fx * CELL * 0.15 + pxv * sgn * CELL * 0.19;
    const ey = h.y + fy * CELL * 0.15 + pyv * sgn * CELL * 0.19;
    if(dead){
      ctx.strokeStyle = '#0b1220'; ctx.lineWidth = 2.2;
      const r = CELL * 0.09;
      ctx.beginPath();
      ctx.moveTo(ex - r, ey - r); ctx.lineTo(ex + r, ey + r);
      ctx.moveTo(ex + r, ey - r); ctx.lineTo(ex - r, ey + r);
      ctx.stroke();
    }else{
      ctx.fillStyle = '#ffffff';
      ctx.beginPath(); ctx.arc(ex, ey, CELL * 0.125, 0, TAU); ctx.fill();
      ctx.fillStyle = '#0b1220';
      ctx.beginPath(); ctx.arc(ex + fx * CELL * 0.05, ey + fy * CELL * 0.05, CELL * 0.062, 0, TAU); ctx.fill();
    }
  }
}

function drawParticles(){
  for(const p of particles){
    const a = Math.max(0, p.life / p.ttl);
    ctx.globalAlpha = a;
    ctx.fillStyle = p.color;
    ctx.beginPath(); ctx.arc(p.x, p.y, p.size * (0.35 + 0.65 * a), 0, TAU); ctx.fill();
  }
  ctx.globalAlpha = 1;
}

function drawPopups(){
  if(!popups.length) return;
  ctx.font = '700 17px ' + MONOF;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  for(const q of popups){
    const k = q.age / 0.9;
    const x = (q.x + 0.5) * CELL, y = (q.y + 0.5) * CELL - 9 - k * 26;
    ctx.globalAlpha = 1 - k * k;
    ctx.lineWidth = 4; ctx.strokeStyle = 'rgba(8,12,16,0.65)';
    ctx.strokeText('+10', x, y);
    ctx.fillStyle = '#fde68a';
    ctx.fillText('+10', x, y);
  }
  ctx.globalAlpha = 1;
}

function render(t){
  if(!(viewScale > 0)) return;
  ctx.setTransform(viewScale, 0, 0, viewScale, 0, 0);

  ctx.fillStyle = '#0d1319';
  ctx.fillRect(0, 0, BOARD, BOARD);
  ctx.fillStyle = 'rgba(148,163,184,0.045)';
  for(let y = 0; y < ROWS; y++)
    for(let x = (y & 1); x < COLS; x += 2) ctx.fillRect(x * CELL, y * CELL, CELL, CELL);

  const vg = ctx.createRadialGradient(BOARD/2, BOARD/2, BOARD*0.34, BOARD/2, BOARD/2, BOARD*0.74);
  vg.addColorStop(0, 'rgba(0,0,0,0)');
  vg.addColorStop(1, 'rgba(0,0,0,0.28)');
  ctx.fillStyle = vg; ctx.fillRect(0, 0, BOARD, BOARD);

  ctx.strokeStyle = 'rgba(148,163,184,0.09)';
  ctx.lineWidth = 2;
  ctx.strokeRect(1, 1, BOARD - 2, BOARD - 2);

  if(food) drawFood(t);
  drawSnake();
  drawParticles();
  drawPopups();

  if(state === 'over'){
    const el = (performance.now() - deathAt) / 450;
    if(el < 1){
      ctx.fillStyle = 'rgba(239,68,68,' + (0.3 * (1 - el)).toFixed(3) + ')';
      ctx.fillRect(0, 0, BOARD, BOARD);
    }
  }
}

/* ================= overlay ================= */
function refreshOverlay(){
  const overReady = state === 'over' && !isDemo && (performance.now() - deathAt > 450);
  let key;
  if(state === 'demo') key = 'demo';
  else if(state === 'paused') key = 'paused';
  else if(overReady) key = 'over' + (newBest ? 'b' : '') + (isWin ? 'w' : '');
  else key = 'none';
  if(key === overlayKey) return;
  overlayKey = key;

  let html = '';
  if(key === 'demo'){
    html = '<div class="card attract">'
      + '<div class="logo-big">' + MARK + '<span>SNAKE</span></div>'
      + '<div class="pill"><i class="pulse"></i><span>AUTOPILOT DEMO</span></div>'
      + '<p class="cta">Click or press any key to play</p>'
      + '<p class="sub">Arrows / WASD to steer · eat apples · don\u2019t crash</p>'
      + '</div>';
  }else if(key === 'paused'){
    html = '<div class="card panel">'
      + '<h2 class="calm">Paused</h2>'
      + '<p class="cta">Press <kbd>P</kbd> or click to resume</p>'
      + '</div>';
  }else if(key.indexOf('over') === 0){
    html = '<div class="card panel">'
      + '<h2 class="' + (isWin ? 'win' : '') + '">' + (isWin ? 'Perfect run' : 'Game over') + '</h2>'
      + (newBest ? '<div class="newbest">NEW BEST</div>' : '')
      + '<div class="final"><span>SCORE</span><b>' + score + '</b></div>'
      + '<div class="final small"><span>BEST</span><b>' + best + '</b></div>'
      + '<p class="cta">Press <kbd>Space</kbd> or <kbd>Enter</kbd> to restart</p>'
      + '</div>';
  }
  overlay.innerHTML = html;
  overlay.classList.toggle('is-hidden', key === 'none');
  overlay.classList.toggle('dim', key === 'paused' || key.indexOf('over') === 0);
}

/* ================= input ================= */
function pushDir(d){
  const lastQ = queue.length ? queue[queue.length - 1] : dir;
  if(d.x === lastQ.x && d.y === lastQ.y) return;
  if(d.x === -lastQ.x && d.y === -lastQ.y) return;   // no reversing
  if(queue.length < 3) queue.push(d);
}

function startPlayer(d){
  reset('player');
  if(d) pushDir(d);
  sfxGo();
}

window.addEventListener('keydown', e => {
  const raw = e.key;
  const k = raw.length === 1 ? raw.toLowerCase() : raw;
  const d = DIRKEYS[k];
  if(d || raw === ' ' || raw === 'Enter' || k === 'p') e.preventDefault();
  ensureAudio();
  if(d){
    if(state === 'demo') startPlayer(d);
    else if(state === 'over' && isDemo) startPlayer(d);
    else if(state === 'playing' || state === 'paused') pushDir(d);
  }else if(raw === ' ' || raw === 'Enter'){
    if(state === 'demo' || state === 'over') startPlayer(null);
  }else if(k === 'p'){
    if(state === 'playing'){ state = 'paused'; acc = 0; }
    else if(state === 'paused'){ state = 'playing'; }
  }
});

document.addEventListener('pointerdown', e => {
  swipe = { x: e.clientX, y: e.clientY };
  if(e.target && e.target.closest && e.target.closest('#sfxBtn')) return;
  ensureAudio();
  if(state === 'demo' || state === 'over') startPlayer(null);
  else if(state === 'paused') state = 'playing';
});
document.addEventListener('pointerup', e => {
  if(!swipe) return;
  const dx = e.clientX - swipe.x, dy = e.clientY - swipe.y;
  swipe = null;
  if(state !== 'playing') return;
  if(Math.max(Math.abs(dx), Math.abs(dy)) < 24) return;
  pushDir(Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? DIR4[0] : DIR4[1])
                                      : (dy > 0 ? DIR4[2] : DIR4[3]));
});
document.addEventListener('pointercancel', () => { swipe = null; });

sfxBtn.addEventListener('click', e => {
  e.stopPropagation();
  sfxOn = !sfxOn;
  sfxBtn.textContent = 'SFX ' + (sfxOn ? 'ON' : 'OFF');
  sfxBtn.classList.toggle('off', !sfxOn);
  ensureAudio();
  if(sfxOn) blip(520, 700, 0.08, 'triangle', 0.08);
});

document.addEventListener('visibilitychange', () => {
  if(document.hidden && state === 'playing'){ state = 'paused'; acc = 0; }
});
window.addEventListener('blur', () => {
  if(state === 'playing'){ state = 'paused'; acc = 0; }
});

/* ================= main loop ================= */
function frame(t){
  requestAnimationFrame(frame);
  let dt = (t - lastT) / 1000; lastT = t;
  if(dt < 0) dt = 0;
  if(dt > 0.25) dt = 0.25;

  if(state === 'playing' || state === 'demo'){
    acc += dt * 1000;
    let guard = 0;
    while(acc >= tickMs && guard++ < 6){
      acc -= tickMs;
      doStep();
      if(state !== 'playing' && state !== 'demo'){ acc = 0; break; }
    }
  }
  if(state === 'over' && isDemo && t - deathAt > 1000) reset('demo');

  updateFx(dt);
  refreshOverlay();
  render(t);
}

reset('demo');
fit();
updateHUD();
lastT = performance.now();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
Reasoning trace (51,264 tokens)
Let me plan the Snake game carefully.

Requirements:
- Grid ≥ 20×20, centered on page. I'll do 21×21 or 24×24 grid. Let's do 21×21 (odd so there's a center cell). Actually let's do 20×20... hmm, I want a nice size. Let's do 22×22 or 21×21. Canvas cell size maybe 26px → 21×26 = 546px. Let's compute for 1280×800 viewport: canvas ~560px works. I'll use cell size 26, grid 21×21 → 546×546 canvas. Plus padding maybe.

- Arrow keys and WASD control. Quick successive key presses both honored: maintain an input queue of directions (max 2-3), each tick pop from queue, check not reverse relative to current direction. Also prevent reversing.

- Food: +10 points, grow by 1, spawn not on snake (pick random from free cells).

- Game over on wall or self hit, overlay with score; Space or Enter restarts; P pauses.

- Visual polish: smooth movement (interpolate between grid steps), distinct head with eyes facing direction, particle burst on eating.

- Score + best score of session (in-memory variable; can't use localStorage — blocked). So best score just for the session, kept in JS variable.

- Self-playing demo mode behind "Click or press a key to start" message. The game must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" overlay. So on load, run an AI that plays snake (autonomous, avoiding walls/self, seeking food). Overlay shows "Click or press a key to start". When user clicks or presses key, reset game and switch to player control. Also demo mode should show "DEMO" label maybe.

Important: keyboard events only received after user clicks iframe. So the overlay should respond to click too. Also "press any key to start" — but Space/Enter restart etc. Let me design:

- State machine: 'demo' → on click/keydown → 'playing' (fresh game). During 'playing', P pauses, game over → overlay "Game Over — score" with "Press Space or Enter to restart" → Space/Enter → 'playing' fresh. Also maybe click restarts after game over. Add hint.

Wait: if any key starts from demo, that's fine. But during game over, only Space/Enter restart? Requirement says Space or Enter restarts. I can also allow click to restart for convenience (mouse users). I'll allow click on canvas/game-over overlay to restart too — harmless extra. Actually careful: in demo mode any key or click starts. In game over, Space/Enter/click restarts.

AI for demo: simple greedy pathing: BFS to food avoiding snake body, taking first step; if no path, move to any safe cell (prefer moves maximizing free space via flood fill). Simple approach: compute BFS from head to food treating snake body as obstacles (excluding tail cell which will move... keep simple: treat all body except tail as blocked). If path found, take first step. Else, pick safe neighbor maximizing flood-fill area. This gives decent demo play. To avoid AI dying quickly, also add lookahead: among safe moves, prefer the one with largest reachable area, tie-break by distance to food. Let me implement:

AI step:
1. BFS from head to food over free cells (grid minus snake body except tail, considering tail moves). Simple: blocked = snake cells except last (tail). If food reachable, take first step of shortest path — but also check that after moving, we don't immediately die next turn? BFS path already avoids body; but body changes. Good enough for demo. Additionally verify chosen next cell is safe (not wall/body). If BFS fails, fallback.
2. Fallback: among the 3 non-reverse directions (and any safe ones), evaluate: simulate move, flood fill reachable area from new head; pick move with max area; tie-break smaller Manhattan distance to food. If none safe, just keep direction (dies).

Also AI speed maybe slightly slower or same. Keep same tick rate so demo looks lively.

Smooth movement: use fixed timestep for logic (e.g., tick interval based on speed, ~130ms), and interpolate render positions between previous and current grid positions using accumulator alpha. Snake segments stored as array of {x,y}; keep prevSegments for interpolation. Draw each segment as rounded rect interpolated between prev and current positions. Head drawn with eyes facing direction.

Rendering: nice polished look. Dark background, subtle grid, snake with gradient of greens? Avoid decorative gradients as "cheap" — but functional gradients for snake body shading are fine game art. I think a tasteful approach: dark slate background, checkerboard subtle cells, snake body in a nice green/teal with rounded segments, head slightly larger with eyes and maybe tongue. Food as a red/apple-like circle with shine. Particle burst on eating: small colored particles with velocity, fading.

Score display: HUD above/beside canvas: "SCORE 0" and "BEST 0". Style with a nice font — no external fonts allowed? "No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task." The task doesn't explicitly allow any CDN. So use system font stack — but avoid Inter/Roboto/Arial/system-ui as primary identity font... Hmm, that's from my design taste, but the constraint here says no external fonts. I can use a monospace stack like "ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace" which gives an arcade-ish feel and isn't the overused sans list. Monospace fits a grid game nicely. I'll go with a mono stack, letter-spaced uppercase labels. Good.

Page layout: centered canvas with header (title "SNAKE"), HUD chips for score/best, canvas framed with subtle border/shadow, footer hint of controls. Background: deep dark (#0d1117-ish or a dark teal). Add subtle vignette? Keep tasteful.

Canvas sizing/responsive: canvas fixed logical size 546×546; scale down via CSS max-width: min(92vw, 546px), height auto. Use devicePixelRatio for crispness: set canvas width = 546*dpr, style width 546px (with CSS scaling). Handle dpr in code.

Let me define constants:
- COLS = 21, ROWS = 21, CELL = 26 → 546×546.
- Actually make it slightly bigger: CELL=27 → 567. Fine. I'll use 24×24? More cells nicer. Let's do 22×22 with CELL 26 → 572px. Hmm 1280 wide, 800 tall: canvas 572 + header ~90 + footer ~40 → ~700. Fits. I'll go COLS=ROWS=22, CELL=26, canvas 572×572. Or 24 grid with cell 24 → 576. Either fine. Let's do 24×24 cells of 24px = 576px. Requirement: at least 20×20 — 24×24 satisfies. But cell 24 with smooth interpolation fine. Hmm, I'll go 22×22, CELL=26 (572). Fine.

Actually, let me reconsider visual: rounded board with inner padding? Keep board flush grid with 1px grid lines subtle.

Game logic details:

State:
- snake: array of cells, head at index 0. e.g., start at center with length 3, direction right.
- dir: current direction {x,y}; queue of pending directions (max 3).
- food: {x,y}.
- score, best (session).
- state: 'demo' | 'playing' | 'paused' | 'over'.
- tickInterval: base 140ms; maybe speed up slightly as score grows (min 80ms). Speed increase adds polish: every 50 points, -8ms. Careful: demo too.

Tick:
- pop next valid dir from queue (skip reverses relative to current dir).
- compute new head = head + dir.
- if out of bounds or collides with body (excluding tail if not growing) → game over.
  - Standard: moving into tail cell is OK if tail will move (not growing). Simplify: collision check against body cells excluding the last one, unless growing this tick. Growth: when eating, snake grows by one (tail doesn't move that tick). Implementation: if newHead == food → grow: unshift head, don't pop tail; spawn food; score += 10; particles; else unshift head, pop tail.
  - Collision check: body includes all current segments except tail IF not eating; if newHead is food, tail won't move, so include tail. Simpler robust check: check newHead against snake[0..len-2] (excluding tail) always, plus if newHead is food, also check tail? If newHead is on food and also on tail cell... impossible since food never spawns on snake. So checking against snake minus tail is fine, but the case where head moves into the cell the tail is vacating: allowed — good.
  - Wait: but if snake length 1? Start length 3, fine.
- On eat: score += 10; spawn food at random free cell; particles burst at food pixel pos; maybe speed up.
- On game over: state 'over'; if score > best, best = score; overlay shows. Also in demo mode, game over → auto-restart demo after short delay (1.2s) so the demo keeps playing. Nice touch: demo resets itself.

Input queue honoring quick presses: on keydown, map to dir; push to queue if queue length < 3; validation of reverse happens at consumption time relative to current dir (so e.g., moving right, quickly press Up then Left: queue [Up, Left]; next tick applies Up; following tick applies Left — both honored, and Left isn't reverse of Up, so fine). Also filter immediate reverse relative to last queued dir when pushing? Better to validate at pop time against current direction; if popped dir is reverse of current dir, skip it and try next in queue. That handles press-then-quickly-press-back? If moving right and user presses Left then Up quickly (queue [Left, Up]): tick1 pops Left — reverse of right → skip; pops Up → apply. Hmm but the user's intent with Left then Up quickly... The classic issue: pressing Left then Up within one tick — with naive "last key wins" the Up would be applied while moving right → Up is fine (perpendicular). But if applied Left directly → death by reversal. My approach honors both: Left skipped (illegal now), Up applied. Then next tick Left? No—Left was skipped/removed. Hmm, should skipped dirs be removed or kept for next tick? If kept, next tick current dir is Up, Left is perpendicular to Up → would apply Left. That could be weird but actually matches "both honored". Standard approach: keep the queue, at each tick shift until finding a dir that's not reverse of current dir (and not same as current? same is harmless—just skip duplicates). Let me do: while queue not empty: d = shift; if d is reverse of current dir or same as current dir → discard; else apply and break. That way quick presses both honored when legal.

Also ignore keydown repeat (e.repeat) to avoid queue flooding.

P pauses: toggles pause when in 'playing'. Show "PAUSED" overlay. In demo mode, P should probably not matter; ignore.

Space/Enter: in 'over' → restart to playing. In 'demo' → start playing. In 'playing' → maybe ignore (or also nothing). Also prevent page scroll: e.preventDefault() for arrows/space.

Click: canvas or overlay click → if demo: start playing; if over: restart; if paused: unpause? Could be nice. Also maybe clicking during play does nothing.

Rendering loop: requestAnimationFrame; accumulate dt; while acc >= tickMs → step. alpha = acc/tickMs for interpolation.

Interpolation rendering: store prevSnake (positions before last step) and snake (after). For each segment i, lerp between prevSnake[i] (or if growing, new tail segment: prev = same as current, appears in place) and snake[i]. When snake grows, prevSnake shorter; for i >= prevSnake.length use snake[i] as prev (tail pops in place). Actually when growing, the new segment is at head (unshift) — indices shift. Let me think: prev array length L, after step length L+1 (grow) or L (normal). Map: segment i in new array corresponds to segment i-? Hmm. Simplest approach: each segment's rendered position = lerp(prevPosOfSameIndex, currentPos). For normal move: new snake = [newHead, ...old[0..L-2]]. prev for index 0 is old[0] (old head) → head slides from old head cell to new head cell. prev for index i (i≥1) is old[i]? new[i] = old[i-1]. So segment at new index i was at old index i-1... For smooth motion, each drawn segment should slide from old[i] to new[i] = old[i-1]. So prev for new index i should be old[i]? Let's check: new[1] = old[0]. The segment now at index1 was previously the head at old[0]. Visually, body segments are indistinguishable dots along the path; what matters is that the whole chain slides forward. If I lerp new[i] from old[i] → new[i]: old[0]→old[0]?? Hmm.

Let me think again: positions p_i(t) = lerp(old[i], new[i], t). old = [A,B,C] (head A). new (no grow) = [N,A,B]. lerp: head goes A→N ✓. index1: B→A ✓ (slides from B to A, following). index2: C→B ✓. So prev for new index i is old[i], and since arrays are same length for no-grow, just use prevSnake[i]. For grow: old=[A,B,C], new=[N,A,B,C] (length 4). prev for new[0]=A→N? old[0]=A ✓. new[1]=A, old[1]=B → B→A ✓. new[2]=B, old[2]=C → C→B ✓. new[3]=C, old[3] undefined → use new[3]=C → stays at C ✓ (new tail appears stationary). 

So render: for each i, prev = prevSnake[i] ?? snake[i]; pos = lerp(prev, snake[i], alpha). 

Head eyes: direction = current dir (or interpolated movement direction). Eyes: two small circles offset perpendicular to direction, positioned toward front of head. Maybe white eyes with dark pupils, pupils shifted toward direction. Cute. Also maybe a slightly different head color.

Snake drawing style: draw each segment as rounded rect with size slightly less than cell (e.g., 22/26), color gradient along body from head color to tail color (e.g., #4ade80 → #166534, or a teal #2dd4bf → #0f766e). Use HSL interpolation: hue fixed, lightness varies. Draw with rounded corners; to make it look continuous, overlap segments slightly or draw connecting rect between consecutive segments. Simpler polished approach: draw path as thick rounded polyline through interpolated centers with lineCap/lineJoin round, stroke width ~ CELL*0.78, in body color; then overlay head circle. That gives a smooth continuous snake — nicer! But gradient along length is harder with a single stroke. Could stroke in segments with varying color — drawing per-segment short lines with round caps, colors varying. Overlapping round caps create smooth chain. Let me do that: for i from len-1 down to 1: draw line from center(i) to center(i-1) with color at t=i/(len-1). Round caps make joins smooth. Then head: circle slightly bigger with eyes.

Edge case: single segment (length1) — start length 3, fine. Also when snake wraps? No wrap — walls kill.

Also draw subtle pattern: rounded square under each body segment? The polyline approach with uniform stroke looks clean. Add slight darker outline? Maybe draw the polyline twice: wider dark stroke (outline) then inner colored stroke. That gives crisp cartoon look. Yes: outer stroke width CELL*0.86 in darker shade, inner CELL*0.68 in body gradient... hmm two passes with per-segment colors: pass1 all outline color, pass2 gradient. Fine.

Food: draw as an apple: red circle with small highlight + green leaf + tiny stem. Cute and doable with canvas primitives. Add gentle pulse (scale oscillation via sin(time)). Also a soft radial glow behind? Keep subtle: faint halo using radial gradient — acceptable as game art (not UI decoration). Fine.

Particles: on eat, spawn ~14 particles at food center with random velocity, gravity slight, fade out over ~500ms, colors: reds/greens/whites. Rendered each frame; also maybe a score popup "+10" floating up. Nice polish: floating "+10" text at eat position, rising and fading. I'll add both.

Background grid: checkerboard two very-dark shades + maybe subtle dot at intersections. Keep subtle: fill board with #10151c and alternate cells #111823 barely different. Draw thin border inside canvas.

Overlay: HTML div positioned over canvas (absolute) for messages: "SNAKE" title, "Click or press any key to start", in demo show "DEMO — AI playing". For game over: "GAME OVER", score, best, "Press Space or Enter to restart". Paused: "PAUSED — P to resume". Use HTML overlay for crisp text. Style: translucent dark backdrop with blur, centered text.

HUD: above canvas: left "SCORE 000", right "BEST 000". Maybe centered title. Let me design header row: "SNAKE" wordmark left, chips right. Under canvas: controls hint: "← ↑ ↓ → / WASD move · P pause · Space restart". Keep tidy.

Colors: page bg #0b0f14; panel/canvas frame #131a22 with border #1f2937. Accent: green #34d399 / emerald. Food red #f87171 / #ef4444. Text #e5e7eb, muted #94a3b8.

Fonts: mono stack. Title letter-spacing wide.

Speed: base tick 150ms? Classic snake ~ 8 cells/sec feels ok. Let's start 140ms, decrease by 4ms per food to min 90. Demo same.

Grid 22×22 = 484 cells. Snake start at (11,11) heading right with body [(11,11),(10,11),(9,11)].

Wall behavior: game over on wall — yes.

Best score: session variable, update on game over (or live). Update live: best = max(best, score) shown live. On restart keep best.

Demo AI details:

function aiChooseDir():
 - head = snake[0]; food pos.
 - Options: 4 dirs; exclude reverse of current dir.
 - Build blocked set: snake cells except tail (tail moves unless eating — if next move eats, tail stays; minor, ignore).
 - BFS from head to food over grid avoiding blocked (head is start). If reachable, get first step along path; verify that step is not into blocked cell... it's from BFS so it's free. But also should avoid choosing a step that leads into a dead-end trap — BFS to food generally fine for demo; add safety: after picking candidate step, simulate and flood-fill count reachable cells from new head; if count < snake length, maybe fallback to safer move. To keep it simple but competent:
   1. Compute for each candidate dir (non-reverse, in-bounds, not blocked): score = (reachable area via flood fill from resulting position) primarily; among those with max area ≥ snake.length, prefer smallest BFS-distance to food; if BFS path exists from current head, prefer the first step of that path when its area is safe.
   
   Simpler robust: 
   - safety(dir): simulate head move; flood fill free cells from new head (blocked = body except tail); area count.
   - If BFS path to food exists: candidate = first step. If safety(candidate) >= snake.length (or >= some threshold like snake length), choose it. Else fall through.
   - Fallback: choose safe dir with max area; tie-break min Manhattan distance to food.
   - If no safe dir: choose any non-reverse in-bounds dir (will die) — or keep current dir.

   Also, tail-chasing: when no path to food, following own tail is a known survival tactic: BFS to tail cell. I can add: if no path to food, try path to tail (tail treated as free), choose first step if safe. Then fallback flood fill. This makes demo survive long, look smart.

Implementation of BFS: grid 22×22=484 cells, trivial cost.

Flood fill count: also trivial.

Demo tick uses same step() but dir chosen by AI (push into queue directly or set dir). I'll have step(useDir) where dir comes from queue for player, or aiChooseDir for demo.

Demo auto-restart: on game over in demo, wait 1.2s then reset and continue demo. Also show small "DEMO" badge on overlay during demo (overlay semi-transparent so gameplay visible). Overlay in demo: pointer-events... clicking anywhere starts game. The overlay div covers canvas; click handler on overlay too.

"Click or press a key to start": clicking the page anywhere (document) starts. Keydown anywhere starts. Good.

Note: keydown for starting — any key including arrows; that keydown should also maybe register direction? If user presses ArrowUp to start, don't immediately move up? Start fresh game heading right; the pressed key could be queued as first input — nice touch. I'll apply: if key is a direction key, queue it.

Prevent arrow/space scrolling: preventDefault on those keys.

Resize/DPR: set canvas.width = SIZE*dpr etc. once (and on dpr change maybe). CSS: width: min(92vw, 572px)? Also consider height: min(calc(100vh - 160px), ...). Use CSS: .board { width: min(92vw, 572px, calc(100vh - 210px)); aspect-ratio: 1; }. Hmm height fit: 800 viewport: header ~70, footer ~40, margins → canvas ≤ ~640. 572 fits. For small heights, clamp via calc. Use aspect-ratio 1 and max-width/max-height. Simplest:

canvas { width: min(572px, 92vw, calc(100dvh - 190px)); height: auto; aspect-ratio: 1/1; display:block; }

Actually aspect-ratio with width set → height auto equals width. Good.

For crisp rendering with dpr: I'll set canvas backing store to 572*dpr and scale ctx by dpr. But CSS scales canvas down responsively; backing store fixed at 572*dpr — downscaling by CSS may blur slightly; better: size backing store to displayed size * dpr via getBoundingClientRect on resize. Let's implement: function fit() { const rect = canvas.getBoundingClientRect(); canvas.width = rect.width*dpr; canvas.height = rect.height*dpr; scale = rect.width / BOARD_PX; } then in draw use ctx.setTransform(dpr*scale...). Simpler: compute cell size from actual size: cell = rect.width / COLS. Draw everything in "board pixels" with transform scale s = rect.width*dpr / (COLS*CELL_BASE)? Let me just define BOARD = COLS (unit) and use ctx scaled so 1 unit = 1 cell: ctx.setTransform(rect.width*dpr/COLS, 0,0, rect.height*dpr/ROWS, 0,0). Then draw in cell units: positions in cells, sizes in cells (e.g., stroke width 0.78). Line widths in cell units fine. Text "+10" font size in cell units (e.g., 0.8). That's elegant. But devicePixelRatio rounding: fine.

However overlay text as HTML avoids canvas text mostly. The "+10" popup in canvas: font: `bold ${0.75}px ...` — canvas font sizes below 1px with transform scale? Canvas font size with transform: font is specified in current units? No — font size is in CSS pixels affected by transform? Actually text rendering under transform scales with the transform. Setting font '0.8px ...' then scaled by transform ~25 → renders at ~20px. But tiny font sizes (<1px) may have issues in some browsers? Chrome handles transformed small font sizes fine (it rasterizes after transform). Hmm, there've been historical issues with very small font sizes being rasterized at tiny size then scaled (blurry). Safer: keep transform as scale-to-device-pixels, and multiply all coordinates by cellPx. I'll keep classic approach: work in "board pixels" where CELL=26 fixed, and set transform scale = displayedPx*dpr/BOARD_PX. So ctx.setTransform(s,0,0,s,0,0) with s = cssSize*dpr/572. Then font '20px monospace' works normally. Good — do that.

So: BOARD_PX = COLS*CELL = 22*26 = 572.

fit(): rect = canvas.getBoundingClientRect(); dpr = devicePixelRatio; canvas.width = round(rect.width*dpr); canvas.height = round(rect.height*dpr); viewScale = canvas.width / BOARD_PX.

In draw: ctx.setTransform(viewScale,0,0,viewScale,0,0). All drawing in board coords 0..572.

Observe resize via ResizeObserver on canvas or window resize → refit.

Now details of drawing:

drawBoard():
- bg fill: roundRect full board with bg color #0f141b; then checker cells: for (x+y)%2 fill rgba(255,255,255,0.015). Maybe also inner border stroke #223.
- Subtle grid lines? Checker enough.

drawFood(t): pulse = 1 + 0.08*sin(t/300). Draw shadow ellipse? Keep: halo radial gradient rgba(red,0.25)→transparent radius ~0.9 cell * pulse. Apple body: circle r=0.34*CELL * pulse, color #ef4444 with darker bottom? Add radial gradient inside for volume (#fca5a5 highlight top-left → #dc2626). Stem: short brown line; leaf: green ellipse rotated. Cute.

drawSnake(alpha):
- compute interpolated centers.
- pass outline: strokeStyle dark (e.g., '#064e3b' or rgba(0,0,0,0.35)) lineWidth CELL*0.86, lineJoin/lineCap round, path through all centers → stroke.
- pass body: per-segment strokes with gradient colors: for i in [len-1 .. 1]: strokeStyle = colorAt(i/(len-1)); beginPath moveTo(c[i]) lineTo(c[i-1]); stroke with width CELL*0.66, round caps. Hmm many strokes with round caps overlapping — colors will overlap at caps; drawing from tail to head so head-side drawn later on top — fine.
- Actually simpler: build one path, stroke once with a solid color, then overlay lighter stripe? Gradient along path: create linear gradient from head center to tail center and stroke whole path with it. That's smooth and cheap: grad = ctx.createLinearGradient(headX, headY, tailX, tailY); stops: 0 → #5eead4? Let's choose palette: head #86efac-ish bright green; mid #34d399; tail #0d9488 (teal-green). Outline pass color #022c22 or 'rgba(6,42,30,0.9)'.

Wait, linear gradient from head to tail changes as snake moves — fine, looks organic.

- Head: draw circle radius CELL*0.42 at head center with color lighter (#a7f3d0?) Actually distinct head: slightly larger rounded square or circle in brighter tone + eyes. Let's draw head as rounded square (rr = CELL*0.44) rotated? Circle simpler: circle r=0.46*CELL fill #6ee7b7, stroke outline dark. Eyes: perpendicular offset ±0.16*CELL, forward offset 0.12*CELL from center; white circles r=0.11*CELL; pupils black r=0.055*CELL offset toward dir by 0.03. Add tiny tongue? maybe skip; add subtle: when about to eat? skip.

Direction for eyes: use current dir; during interpolation the head is moving along dir so eyes face dir. On start dir right.

Also draw head slightly ahead? no.

Particles: array {x,y,vx,vy,life,ttl,color,size}. Update in render loop with dt. Draw as small circles or rounded squares. Colors: mix of '#f87171','#fca5a5','#fef3c7','#86efac'. Burst 16 particles, speeds 40–140 px/s random angle; gravity 300 px/s²; life 0.5–0.8s; alpha = life/ttl; size 2–4.

Score popup: {x,y,age}: text "+10" rising 26px over 0.8s fading. Color #fde68a. Font bold 18px mono. Draw with globalAlpha.

HUD updates: scoreEl.textContent etc. only when changed.

Overlay handling: one overlay div, content swapped by state:
- demo: big "SNAKE" title, "Demo — the snake plays itself", "Click or press any key to play". Backdrop lighter (more transparent) so demo visible: rgba(5,8,12,0.55) + blur(2px). Maybe place demo text at bottom so gameplay visible: overlay with flex column, title top? Let's structure: overlay covers board; in demo mode show a compact centered card: "DEMO" chip pulsing + "Click or press any key to start". Make card small at center-bottom? Center is fine but blocks view of snake middle. Put demo notice as a bottom bar within overlay (position absolute bottom). And a big title "SNAKE" at top. That looks like an attract screen — nice.

- playing: overlay hidden (display:none).
- paused: centered card "PAUSED", "Press P to resume".
- over: centered card "GAME OVER", final score big, best, "Space or Enter to restart · Click to restart". Maybe also show length eaten apples count? Score enough. Add subtle "New best!" tag when beat session best. Track: on game over if score>previousBest → show "NEW BEST". Note best updates live... Store bestAtRunStart? Simpler: at game over, if score > best then best=score and flag newBest (compare before update). Also live HUD best shows max. For the flag: keep best before run: at start of run set runBestRef = best; at over, newBest = score > runBestRef && score>0? Eh, simpler: keep best updated only at game over? But requirement "display best score of session" — live update is nicer. I'll update live and for the overlay flag, track flag when score surpasses previous best at eat time? Just compute at game over: if score === best && score > 0 → likely new best (could tie old best without beating... then not new). Track prevBest at run start (when starting a run: prevBest = best). At over: newBest = score > prevBest. Clean.

Keyboard mapping:
- ArrowUp/W → {0,-1}; etc.
- keydown: if key in map: preventDefault; handle per state: demo → startGame(then queue dir); playing → pushDir; paused? allow queue? While paused, ignore or queue; I'll queue so resume is smooth — but pause overlay says P to resume; direction keys while paused: queue them (harmless).
- ' ' or 'Enter': demo→start; over→start; playing→ nothing? Maybe space also pause? No—P pauses. Keep space as restart-only per spec. preventDefault space to avoid scroll.
- 'p'/'P': playing→paused; paused→playing; demo: ignore (or also start? no).

Also visibilitychange: auto-pause when hidden? Nice touch, prevent runaway. Add: document.addEventListener('visibilitychange', if playing → pause). Also clamp dt (max 100ms) to avoid tunneling after tab switch — with fixed-step accumulator clamped.

Tunneling: fixed step, one step per tick — snake moves 1 cell per tick, no tunneling issue. If dt huge, clamp acc to avoid multiple catch-up steps (or allow max 3). I'll clamp dt to 0.25s and loop steps while acc>=tick (max e.g. 5 iterations).

Death animation: small polish — on game over, flash head red? Shake board? Simple: add CSS shake class on canvas for 300ms + overlay appears after ~400ms delay so player sees the crash. Let's do: on death, state 'over', deathTime; overlay shown after 500ms via setTimeout? Manage with timestamp in render loop: overlay visible when now - deathTime > 450. Also draw dead snake tinted? Keep simple: brief red flash overlay via canvas fill fading. I'll implement deathFlash alpha decaying. Plus board shake: translate canvas via CSS class 'shake' with keyframes; remove after animation. Add class then setTimeout remove 400ms.

Sound: not required; skip (autoplay policies anyway). Could add tiny WebAudio blip on eat after user gesture — since game starts after click/keydown (user gesture), audio is allowed. Nice polish: eat blip + game-over thud, with mute toggle? Keep minimal: a small mute button maybe overkill. I'll add subtle sounds with WebAudio, initialized on first gesture, and a mute toggle button in header (icon drawn with text "SOUND ON/OFF"? no emoji icons... use simple text "SFX: ON"). Hmm my taste says icons via icon library, but no external libs allowed here; text label fine. Small button "SFX ON" toggling. It's extra code but adds polish. Risk: complexity/bugs. I'll include a tiny synth: eat = short square blip 880→1320Hz 0.08s; die = descending saw 200→60 0.4s; start = soft. Keep volume low 0.15. Add try/catch guard. I'll include it — modest code.

Actually let me keep scope controlled but polished. Sound is a good differentiator. Include minimal SFX with a toggle. Button styling small in header.

Layout structure:

<body>
  <main class="wrap">
    <header class="top">
      <h1>SNAKE</h1>  (with a small snake glyph? draw a tiny inline SVG icon — allowed since inline SVG, not external) — yes small inline svg snake/food icon next to title. Keep minimal: a 18px inline SVG of a rounded snake shape. Or skip icon; typographic logo with accent dot: "SNAKE" with the A replaced? Keep simple: "SNAKE" + small green square logo made of CSS. I'll do inline SVG: a little snake curve. Let me craft: <svg viewBox="0 0 24 24"><path d="M4 15c0-3 2-5 5-5h6c1.7 0 3-1.3 3-3" ...>. Eh, I'll design: path M3 16 h8 a4 4 0 0 0 0-8 h-4 a3 3 0 1 1 3-3? Overcomplicating. Simple snake glyph: rounded zigzag path with round caps + eye dot. I'll write something plausible: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M4 17h9a4 4 0 0 0 0-8H8"/><circle cx="19.5" cy="9" r="0.5" fill="currentColor"/></svg> Hmm the head: add filled circle at path end. Let me do path "M4 17h8a4 4 0 0 0 0-8H7" then head dot at (19,9)? Path ends at x=7 going left... Let me define: start tail (4,17), horizontal to (12,17), arc up to (12,9) via a4 4 0 0 0? Direction semantics fiddly. Alternative simpler glyph: three rounded squares + eye — like a mini snake of 3 cells: <svg viewBox="0 0 24 24"><rect x="3" y="10" width="8" height="8" rx="2.5"/><rect x="11" y="6"...>. Honestly a simple "food + snake" mark: I'll use an apple dot and a curved stroke. Let me just write:

<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
  <path d="M3.5 16.5h8a4.5 4.5 0 0 0 0-9H8" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round"/>
  <circle cx="17.5" cy="7.5" r="2.6" fill="currentColor"/>
  <circle cx="18.4" cy="6.9" r="0.7" fill="#0b0f14"/>
</svg>

Check geometry: path starts (3.5,16.5) → h8 → (11.5,16.5); arc a4.5 4.5 0 0 0 0,-9: from (11.5,16.5) to (11.5,7.5), sweep 0 (counterclockwise) → bulges left? For upward arc, sweep 0 curves to the left of direction (direction up → left is -x) → bulge left toward x=7. Then H8 → to (8,7.5) moving left — that goes backwards over the bulge. Hmm that'd overlap. Let me make it: tail left, curve up and to the right ending with head at right: path "M3.5 17 H11 a5 5 0 0 1 5-5 h2"? Start (3.5,17) → (11,17); arc a5 5 0 0 1 5,-5: sweep 1 (clockwise) from (11,17) to (16,12): clockwise going up → bulges right ✓. Then h2 → (18,12). Head circle at (18,12) r2.6 fill; eye at (18.8,11.4) r0.7 dark. And an apple: small red circle at (5,8) r2 + leaf. Cute enough. Good.

Header layout: left: logo+title+subtitle? Right: score chips + SFX button. Chips: label small muted + value bold tabular. 

Footer: control hints with <kbd> styled keys. kbd style: border, bg subtle, radius, mono.

Responsive: at 375px: header stacks? Keep row wrap; canvas min(92vw,...) fits. HUD chips wrap below title maybe. Use flex-wrap. Fine.

Now, code structure:

const COLS=22, ROWS=22, CELL=26, W=COLS*CELL, H=ROWS*CELL.

let state='demo', snake=[], prevSnake=[], dir={x:1,y:0}, queue=[], food, score=0, best=0, prevBest=0, tickMs=140, acc=0, lastT=0, particles=[], popups=[], deathAt=0, started=false...

function reset(demoMode){ center start; dir right; queue=[]; score=0; tickMs=START_TICK; spawnFood(); prevSnake = copy; state = demoMode?'demo':'playing'; overlay update; }

spawnFood(): free cells = all cells minus snake; pick random. If none (win!) → victory state? 484 cells unlikely; handle: if no free cell → treat as win: state 'over' with message "You win!". Minor; include guard.

step():
- determine move dir: if state==='demo': d = aiDir(); else: pop queue valid; if none d=dir. Actually apply: set dir=d.
- newHead = head+dir.
- wall check → die().
- self check: for i in 0..len-2 (exclude tail) if equal → die. But wait: also if eating, tail stays; check includes? As argued food can't be on tail so fine.

Hmm wait, one subtlety: exclude tail only — but if length 2? still fine (exclude last). If snake length 1? not possible.

Actually careful: excluding tail is correct only when tail moves (not eating). If eating this tick, tail stays, so moving into tail cell would collide. But newHead==food && food on tail impossible. ✓.

- prevSnake = snake.map(clone) BEFORE moving.
- snake.unshift(newHead); if eat: score+=10; effects; spawnFood(); speed up: tickMs = max(80, tickMs-2.5)? per apple -3ms from 140 → after 20 apples = 80. ok. else snake.pop().
- update HUD.

die(): state='over'; if(state was demo) schedule demo restart; sounds; shake; deathAt=performance.now(); newBest calc; best update (live already? update best at eat time live: best=max(best,score)). I'll update best live on eat. prevBest set at run start.

Wait demo game over: keep state 'over' but overlay in demo style? Show quick "crash" then auto-restart demo after 1s. Overlay logic: if state 'over': if demoMode flag → after 900ms auto reset(demo). Implement via timestamp check in update loop: if state==='over' && demoMode && now-deathAt>1000 → reset(true).

Keep variable mode='demo'|'player' separate from state? Let me use: runMode: 'demo' | 'player'; state: 'ready'(demo idle?) Hmm. Simplify: state ∈ {'demo','playing','paused','over'} plus flag isDemoRun = (state==='demo') or (state==='over' && lastRunWasDemo). Use lastRunWasDemo boolean set at reset.

Input handlers:

document.addEventListener('keydown', e=>{
  const k = e.key;
  const dirK = DIRS[k]; // map includes Arrow keys & wasd (lowercase & uppercase)
  if(dirK || k===' ' || k==='Enter' || k==='p'||k==='P'|| k==='Escape') e.preventDefault()? Careful preventing all — fine for these keys.
  ensureAudio();
  if(dirK){ if(state==='demo'|| (state==='over'&&lastRunWasDemo? just start)) start player run then pushDir(dirK); else if(state==='playing'||state==='paused') pushDir(dirK); else if(state==='over') — pressing arrow on game over: ignore (require space/enter/click to avoid accidental skip). Hmm, accidental restart by mashing arrows is annoying; require Space/Enter/click. OK.
  }
  else if(k===' '||k==='Enter'){ if(state==='demo'||state==='over') startPlayer(); }
  else if(k==='p'||k==='P'){ if(state==='playing') pause(); else if(state==='paused') resume(); }
});

pushDir(d): last = queue.length? queue[queue.length-1] : dir; if(d.x===last.x&&d.y===last.y) return; if(d.x===-last.x&&d.y===-last.y) return; // pre-filter reverses against last queued/current → this implements "can never reverse" while honoring quick presses. Wait pre-filtering against last queued: moving right, press Up (queued), then Left quickly: last queued = Up, Left not reverse → queued. Next tick: pop Up (valid vs dir right ✓). Tick after: pop Left (valid vs Up ✓). Both honored ✓. Moving right, press Left then Up quickly: Left is reverse of current → filtered out immediately; Up queued. Good — Left rejected (illegal), Up honored. This matches spec: "can never reverse onto itself" while both honored when legal. Also cap queue length 3: if(queue.length<3) push.

But there's the subtle pop-time check too: dir applied must not reverse current dir. With pre-filter vs last queued (which chains from current dir), reverse can't enter queue. Because chain: first queued compared to current dir; subsequent compared to previous queued. So any applied dir is non-reverse of the dir before it, and the first is non-reverse of current. ✓ No pop-time check needed, but I'll keep a safety check in step anyway.

Click handling: canvas + overlay click → if demo: startPlayer(); if over: startPlayer(); if paused: resume(); if playing: nothing. Also 'pointerdown' better for responsiveness. Use click on a wrapper .stage that contains canvas+overlay.

Also mouse users: maybe allow swipe? Not required; skip.

AI implementation:

function aiDir(){
  const head=snake[0];
  const blocked = new Set(snake.slice(0,-1).map(k)); // key x+','+y ; exclude tail
  const occ = key set of all snake (for flood after move we simulate)
  
  candidates = DIR4 filter not reverse of dir.
  
  score each candidate:
    nh = head+d; if out of bounds or blocked.has(nh) → invalid (dead) → skip (but keep as last-resort list).
    simulate: body after move (if nh is food, tail stays else tail pops): newBody = [nh, ...snake.slice(0, eating? snake.length-? ...)]. Compute blockedAfter = set of newBody minus its tail (tail will move next) — for flood fill area we care about future occupancy approximated. area = floodCount(nh, blockedAfter).
    distToFood = manhattan(nh, food).
    pathFirst = bfs first step toward food from head (with current blocked). Precompute once: pathStep = bfsStep(head, food, blocked) → direction or null.
  
  choose: among valid candidates, prefer those with area >= snake.length (safe-ish); among safe: if candidate equals pathStep dir and its area safe → pick it. Else pick max area, tie: min dist, tie random.
  If none valid: return current dir (die).
}

Hmm pathStep is a direction; candidate match by dx,dy.

BFS: from head, neighbors 4, avoid blocked; parent map; return first step direction toward food if reachable. Standard.

floodCount(start, blockedSet): BFS/DFS count reachable cells (excluding blocked), cap at maybe 200 for perf (484 max fine anyway).

Perf: per tick, BFS 484 + flood up to 4×484 — trivial.

Edge: when no path to food and snake big, tail-chase: bfsStep(head, tailCell, blockedIncludingAllBut?) — target tail must be reachable: treat tail as free (it's excluded from blocked already). bfsStep(head, snake[len-1], blocked). If step found and safe area ok → follow. Add before fallback.

This yields decent survival. Good enough.

Now overlay HTML:

<div class="stage">
  <canvas id="game"></canvas>
  <div class="overlay" id="overlay">
     dynamic content
  </div>
</div>

Overlay content variants (build via JS innerHTML or preset elements toggled). I'll build with JS templates for flexibility:

demo: 
 <div class="attract">
   <div class="demo-tag"><span class="dot"></span>DEMO — AUTOPILOT</div>
   <h2>Snake</h2>? Actually big title maybe in header already. The attract card: "Click or press any key to start". Put title big in overlay: "SNAKE" big with accent. Then hint. Position: centered card.
 </div>

Let me design overlay demo: centered column: big "SNAKE" (letterspaced, with green head glyph), under: pill "AUTOPILOT DEMO", under: "Click or press any key to play". Backdrop rgba(7,10,15,.62) + backdrop-filter blur(3px). But then autopilot hidden behind blur... blur only slight and translucent — visible enough. Alternatively place card top and keep bottom clear... The snake roams everywhere anyway. Slight blur ok; or no blur, just tint. I'll use tint rgba(6,10,14,0.55) no blur for demo (gameplay visible), blur+dark for game over/paused.

Game over card: "GAME OVER" heading (red-ish), score number huge, "BEST 120" small, NEW BEST badge conditional, hint "Space / Enter — play again". 

Paused: "PAUSED" + "Press P to resume".

Also small "SCORE" inside over card.

HUD: 
<header>
 <div class="brand">svg + SNAKE + tagline "classic, polished"? maybe subtle subtitle. 
 <div class="hud">
   <div class="chip"><span>SCORE</span><b id="score">0</b></div>
   <div class="chip"><span>BEST</span><b id="best">0</b></div>
   <button id="sfx">SFX ON</button>
 </div>
</header>

Footer hints: <kbd>←↑↓→</kbd>/<kbd>WASD</kbd> move · <kbd>P</kbd> pause · <kbd>Space</kbd> restart. Use arrows as text characters (not emoji — arrows are fine typographic chars).

Styling details:
- body: bg with subtle radial vignette: background: radial-gradient(1200px 800px at 50% -10%, #101821, #0a0e13). Hmm gradient background — is that "decorative gradient to fake design"? A subtle dark vignette is fine and tasteful, not a "gradient banner". I'll keep it very subtle.
- .wrap: min-height:100svh; display:flex; column; align-items:center; justify-content:center; gap.
- .stage: position:relative; border-radius:14px; overflow:hidden; box-shadow: 0 20px 60px rgba(0,0,0,.5), inset? plus border 1px #263140. Also outline ring accent? subtle.
- canvas display block.
- Score numbers: font-variant-numeric: tabular-nums.
- Chip: bg #121a23 border #223041 radius 10px padding 8px 14px; label 10px letterspacing muted; value 18px.

Shake animation keyframes: translate ±4px.

Pulse dot for demo tag.

Focus: after click, keyboard goes to iframe once clicked — fine.

Also handle the case: user clicks during 'playing' — nothing (maybe resume if paused). 

visibilitychange → if playing: pause.

Prevent context menu? not needed.

Let me also add: score popup and particles arrays capped.

Draw order: board → food → snake → particles → popups → death flash.

Death flash: red overlay alpha = max(0, 1-(now-deathAt)/400)*0.35 fill over board.

Also when 'over', stop stepping; snake drawn static (alpha=1). Keep rendering loop running always for particles/food pulse.

Demo start on load: reset(true) → state 'demo', AI plays immediately. Good for the 3s screenshot: game in action + overlay "click or press any key" visible. 

One more requirement: "Display the current score and the best score of the session." ✓ HUD.

"Game over when snake hits wall or itself, with overlay showing the score" ✓.

"P pauses" ✓ (during play).

Now write the code carefully.

Constants:

const COLS=22, ROWS=22, CELL=26, W=COLS*CELL, H=ROWS*CELL;
const START_TICK=140, MIN_TICK=82, TICK_STEP=3;

DIRS map:
const KEY_DIRS = {ArrowUp:U, ArrowDown:D, ArrowLeft:L, ArrowRight:R, w:U, W:U, s:D, S:D, a:L, A:L, d:R, D:R};

U={x:0,y:-1} etc. Store as objects; compare by x,y.

Snake state:

let snake, prevSnake, dir, queue, food, score, best=0, prevBest=0, tickMs, growPending? Not needed—growth handled by not popping.

step(moveDir):
 // moveDir guaranteed non-reverse.
 const head=snake[0];
 const nx=head.x+moveDir.x, ny=head.y+moveDir.y;
 dir=moveDir;
 if(nx<0||ny<0||nx>=COLS||ny>=ROWS) return die();
 // self collision (exclude tail)
 for(let i=0;i<snake.length-1;i++){ if(snake[i].x===nx&&snake[i].y===ny) return die(); }
 prevSnake = snake.map(p=>({x:p.x,y:p.y}));
 const eating = (food && nx===food.x && ny===food.y);
 snake.unshift({x:nx,y:ny});
 if(eating){ onEat(); } else snake.pop();

Wait ordering: prevSnake should snapshot before modification ✓ (done before unshift).

onEat(): score+=10; best=Math.max(best,score); updateHUD(); burst(food); popup; sfx eat; tickMs=Math.max(MIN_TICK, tickMs-TICK_STEP); spawnFood();

Hmm prevSnake snapshot before move; when eating, snake grows; render mapping as designed ✓.

die(): 
 sfx die; shake; deathAt=now; 
 newBest = score>prevBest && score>0;
 state='over'; lastWasDemo = (was demo);
 update overlay after delay handled in loop; set overlayTimer.

Actually overlay show: in render loop, if state==='over' and (now-deathAt>500) → ensure overlay shows game-over content; else keep hidden (canvas shows crash). For demo: after 1100ms → reset(true).

Also during 'over' before overlay shows, keys: space restart allowed immediately? Fine either way; allow.

reset(mode): 
 snake = [{x:cx,y:cy},{x:cx-1,y:cy},{x:cx-2,y:cy}] with cx=Math.floor(COLS/2)=11, cy=11.
 dir={x:1,y:0}; queue=[]; score=0; tickMs=START_TICK; particles=[]; popups=[]; prevBest=best; prevSnake=snake.map(clone); spawnFood(); state = mode==='demo'?'demo':'playing'; isDemo = mode==='demo'; overlayRefresh(); updateHUD();

Wait: in demo mode we shouldn't reset prevBest... prevBest only matters for player's newBest flag. In reset('player') set prevBest=best. In demo reset, prevBest untouched but best updates during demo eats?? Demo eating increments score/best — that would pollute the player's session best! Hmm. "Best score of the session" — should reflect player's plays. If AI demo sets best 300, then player sees BEST 300 — weird. Solution: in demo mode, don't touch best/HUD score? Show HUD score updating during demo? It's fun to see demo score, but best should be player-only. Decide: during demo, HUD shows the demo's live score too (lively), but best only updates from player runs. On starting player run, score resets to 0. And prevBest=best at player run start; demo runs never write best. But HUD BEST during demo shows player's best so far (0 initially) ✓.

So onEat: score+=10; if(!isDemo) best=Math.max(best,score); updateHUD.

updateHUD: scoreEl.textContent=score; bestEl.textContent=best.

Also on demo eat: effects still fire (particles) ✓ nice.

Tick speed in demo: same ramp; on player start reset to START_TICK ✓.

Loop:

function frame(t){
 requestAnimationFrame(frame);
 const dt = Math.min(0.05, (t-lastT)/1000); lastT=t; // clamp 50ms? That limits catch-up; with rAF paused in background, on return dt clamped → fine.
 if(state==='playing'||state==='demo'){
   acc += dt*1000;
   let guard=0;
   while(acc>=tickMs && guard++<5){ acc-=tickMs; doStep(); if(state!=='playing'&&state!=='demo') {acc=0;break;} }
 } else acc=0;
 // demo auto-restart
 if(state==='over' && isDemo && t-deathAt>1000) reset('demo');
 updateFx(dt);
 render(t);
 overlayRefresh (only on state change — do event-driven, but the delayed game-over overlay needs timing → handle: overlayHasFlag; I'll refresh overlay each frame cheaply by comparing a cached key string; build content only when key changes. Key = state + (overDelayPassed?'2':'') + newBest. Simple.)
}

doStep(): 
 let d;
 if(isDemo && state==='demo'){ d = aiDir(); dir=d; }
 else {
   while(queue.length){ const q=queue.shift(); if(!(q.x===-dir.x&&q.y===-dir.y) && !(q.x===dir.x&&q.y===dir.y)){ d=q; break; } }
   if(!d) d=dir;
   dir=d;
 }
 step(d) — wait step sets dir again; fine, pass d and step uses it. But in demo, aiDir might return reverse? aiDir filters reverse ✓.

Hmm in step I set dir=moveDir before collision check — if die, dir irrelevant.

Render function:

function render(t){
 ctx.setTransform(viewScale,0,0,viewScale,0,0);
 // bg
 ctx.fillStyle='#0e141c'; ctx.fillRect(0,0,W,H);
 checker: for cells (x+y)%2===0 fill rgba(255,255,255,0.018)? On 22×22 that's 242 rects per frame — fine, but cache to offscreen canvas for perf elegance. Precompute pattern once into offscreen canvas at BOARD size, then drawImage each frame. Do that: build bgCanvas once (at 572×572 logical; but viewScale changes... offscreen at fixed 572, drawn scaled — fine, slight blur when scaled up? viewScale = css*dpr/572; if css=572,dpr=2 → scale 2 → upscaling 572 offscreen by 2 → checker edges blurry-ish but they're soft fills anyway; fine. Or build offscreen at 572*2. I'll build at 572 with dpr min 2 factor: size = 572*Math.max(1,Math.min(dpr,2)). Simplicity: just draw rects each frame; 242 fillRects is nothing. Keep direct drawing.

 drawFood(t) unless state==='over'? Food still draw (harmless).
 drawSnake(alpha) where alpha = (state playing/demo)? acc/tickMs clamped 0..1 : 1.
 particles, popups.
 death flash.
}

Snake color gradient: create per-frame linear gradient head→tail. headC = interp head pos, tailC = interp last pos.

Colors:
outline: '#0b3b2e'? Let's pick: body outline 'rgba(4,26,20,0.85)' hmm want crisp dark rim: '#052e21'.
gradient stops: 0:'#7dF2b1'? Let me choose pleasing: 
 head #a3f7bf? Let's do: 0: '#8cf5b0'? I'll go: g.addColorStop(0,'#9ff5b8')? Hmm too pastel. Classic vibrant: head '#7CFC9A'... Let me settle: 
 stop0 '#86efac' (green-300), stop .5 '#34d399' (emerald-400), stop1 '#0d9488' (teal-600). Outline '#064e3b' (emerald-900). Head fill '#bbf7d0'? Head slightly brighter: '#a7f3d0'. Eyes white #fff, pupil #0f172a. Good on dark bg.

Food: body '#f87171' base with radial highlight '#fecaca'→'#ef4444'→'#dc2626'? Keep: radial gradient centered upper-left: '#fda4af'→'#e11d48'? Rose. Leaf '#4ade80', stem '#92400e' brown, highlight white ellipse alpha .7.

Board bg '#0d1319', checker alt 'rgba(148,163,184,0.045)'? subtle bluish. Border inner stroke '#1e2a36' 2px. Also outer canvas CSS border handles frame.

Popup color '#fde68a'.

Death flash 'rgba(239,68,68,α)'.

Particles colors: ['#f87171','#fca5a5','#fde68a','#86efac','#fff'] weighted reds.

Now the AI code concretely:

const DIR4=[{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
key(x,y)=x+y*COLS? use y*COLS+x as int. Blocked as Uint8Array(COLS*ROWS) rebuilt per ai call — cheap.

function aiDir(){
 const head=snake[0];
 const occ = new Uint8Array(COLS*ROWS);
 for(let i=0;i<snake.length-1;i++) occ[snake[i].y*COLS+snake[i].x]=1; // exclude tail
 const tail=snake[snake.length-1];
 
 const bfsFirst=(target)=>{
   // BFS from head to target cell; returns first-step dir or null
   const prevDir = new Int8Array(COLS*ROWS).fill(-1); // store direction index taken to reach
   const from = new Int32Array(COLS*ROWS).fill(-1);
   const q=[head.y*COLS+head.x]; const seen=new Uint8Array(COLS*ROWS); seen[q[0]]=1;
   const ti=target.y*COLS+target.x;
   if(ti===q[0]) return null;
   let qi=0;
   while(qi<q.length){
     const c=q[qi++]; const cx=c%COLS, cy=(c/COLS)|0;
     for(let d=0;d<4;d++){
       const nx2=cx+DIR4[d].x, ny2=cy+DIR4[d].y;
       if(nx2<0||ny2<0||nx2>=COLS||ny2>=ROWS) continue;
       const n=ny2*COLS+nx2;
       if(seen[n]) continue;
       const isTarget = n===ti;
       if(occ[n] && !isTarget) continue; // target may be tail (free)
       seen[n]=1; from[n]=c; prevDir[n]=d;
       if(isTarget){ // walk back to find first step
         let cur=n;
         while(from[cur]!==q[0]... hmm need first step: walk back until parent is head.
         let cur=n; while(from[cur]!==headIndex) cur=from[cur]; firstCell=cur; d0=prevDir[cur]; return DIR4[d0];
       }
       q.push(n);
     }
   }
   return null;
 };

Hmm walking back: from[cur] chain until node whose parent is head index. headIndex = head.y*COLS+head.x. while(from[cur]!==headIndex) cur=from[cur]; then dirOf step = prevDir[cur]. But if target adjacent to head: from[target]=headIndex → loop doesn't run, cur=target, prevDir[target] = step dir ✓.

Note: BFS treats occ excluding tail; when following path multiple steps, body shifts — approximation fine.

floodCount(fromCell, occArr): 
 const seen=new Uint8Array(COLS*ROWS); stack=[fromCell]; count=0;
 while stack: pop c; for neighbors valid & !occ & !seen → seen, push, count++. Return count.

Candidate evaluation:
 let best=null;
 const pathToFood = bfsFirst(food); // dir or null
 const options=[];
 for(const d of DIR4){
   if(d.x===-dir.x&&d.y===-dir.y) continue;
   const nx=head.x+d.x, ny=head.y+d.y;
   if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
   const ni=ny*COLS+nx;
   if(occ[ni]) continue;
   // simulate occupancy after this move:
   const eating = food.x===nx&&food.y===ny;
   const occ2 = occ.slice(); occ2[ni]=1; // new head occupied
   if(!eating){ const t=tail.y*COLS+tail.x; occ2[t]=0; } // tail vacates
   // area from new head: neighbors... flood from ni but ni itself occupied by head; flood from ni counting cells reachable excluding occ2 but including? Count free cells reachable from new head position: treat ni as start (not blocked).
   const area = floodFrom(ni, occ2);
   options.push({d, area, dist: Math.abs(food.x-nx)+Math.abs(food.y-ny)});
 }
 if(!options.length) return dir;
 // prefer path step if it's among options with decent area
 if(pathToFood){
   const opt = options.find(o=>o.d.x===pathToFood.x&&o.d.y===pathToFood.y);
   if(opt && opt.area >= Math.min(snake.length+2, ...)) hmm threshold: require opt.area >= snake.length? If path exists to food, area likely ≥ path length. Use: if(opt && opt.area >= snake.length) return opt.d. But early game area huge. Edge: opt.area counts free cells reachable; must be ≥ remaining snake length to be safe-ish. ok.
 }
 // fallback: max area, then min dist
 options.sort((a,b)=> b.area-a.area || a.dist-b.dist);
 return options[0].d;

Also tail-chase when no food path: 
 if(!pathToFood){ const chase=bfsFirst(tail); if(chase){ const opt=options.find(match chase); if(opt && opt.area>=? maybe just take if area>=snake.length... when no food path we're in survival; following tail with any area≥? Take chase step if opt.area >= 2? I'd take chase step if its area >= snake.length too, else fall to max-area. Hmm if no food path, max-area fallback already decent. Tail-chase specifically keeps snake compact. I'll include: if(!pathToFood && chase){opt match; if(opt) return opt.d;} before generic fallback but after checking opt.area>0. Risk of loops? Tail chase is known-safe-ish. With flood check we might skip chase when area small and pick max area anyway — fine.

Simplify final logic:
 1. options valid list.
 2. if pathToFood && matching option && option.area >= snake.length → take it.
 3. if !pathToFood && chaseDir && matching option && option.area >= snake.length → take it.
 4. else take max area (tie min dist).
 5. none → dir.

Also randomness: add tiny random tie-break so demo looks less robotic: sort stable with random jitter? tie-break by dist then random. Add o.rnd=Math.random() and sort by area desc, dist asc, rnd. ok.

Potential AI flaw: BFS path to food exists but taking first step each tick recomputes — greedy BFS fine.

Performance: bfsFirst twice + 4 floods each ≤ 484 cells, per tick (≤ 12/s) — trivial.

Food spawn: 
function spawnFood(){
 const free=[];
 const occ=new Uint8Array(COLS*ROWS); snake.forEach(s=>occ[s.y*COLS+s.x]=1);
 for(let i=0;i<COLS*ROWS;i++) if(!occ[i]) free.push(i);
 if(!free.length){ food=null; win(); return; }
 const i=free[(Math.random()*free.length)|0];
 food={x:i%COLS, y:(i/COLS)|0};
}

win(): rare; treat as over with message "PERFECT!". I'll set state 'over', newBest logic, overlay text variant win. Minor code.

Popups & particles update in updateFx(dt):
 particles: p.vy+=G*dt; p.x+=vx*dt; ... p.life-=dt; filter life>0.
 popups: age+=dt; filter age<0.9.

Draw particles: for each: ctx.globalAlpha=life/ttl; fillStyle color; circle size*(life/ttl). Reset alpha.

Popups: y offset = -age*30; alpha=1-age/0.9; font `bold ${17}px` mono; textAlign center; strokeText dark then fillText? Just fill with shadow? fillText '#fde68a' with slight dark stroke for readability: ctx.lineWidth=3 strokeStyle rgba(0,0,0,.5) strokeText then fillText.

Now overlay refresh implementation:

const overlay = document.getElementById('overlay');
let overlayKey='';
function refreshOverlay(){
 let key, html;
 if(state==='demo'){
   key='demo';
   html = `
    <div class="card attract">
      <div class="logo-big">${SNAKE_SVG}<span>SNAKE</span></div>
      <div class="pill"><span class="pulse"></span>AUTOPILOT DEMO</div>
      <p class="cta">Click or press any key to play</p>
      <p class="sub">Autopilot is playing — watch it chase the apple.</p>
    </div>`;
 } else if(state==='paused'){
   key='paused';
   html = `<div class="card"><h2>Paused</h2><p class="cta">Press <kbd>P</kbd> to resume</p></div>`;
 } else if(state==='over' && overOverlayVisible){
   key='over'+newBest+(isWin?1:0);
   html = `<div class="card">
     <h2 class="${isWin?'win':''}">${isWin?'Perfect!':'Game Over'}</h2>
     ${newBest?'<div class="newbest">NEW BEST</div>':''}
     <div class="final"><span>SCORE</span><b>${score}</b></div>
     <div class="final sub2"><span>BEST</span><b>${best}</b></div>
     <p class="cta">Press <kbd>Space</kbd> or <kbd>Enter</kbd> to restart</p>
   </div>`;
 } else { key='hidden'; html=''; }
 if(key!==overlayKey){ overlayKey=key; overlay.innerHTML=html; overlay.classList.toggle('hidden', key==='hidden'); overlay.classList.toggle('dim', state==='over'||state==='paused'); }
}

overOverlayVisible = state==='over' && (performance.now()-deathAt>450). Compute each frame before refresh. Demo over: keep overlay hidden during the 1s crash pause? Show subtle? Keep hidden then auto-restart; fine.

Hmm 'over' + isDemo: after 1000ms auto-reset; overlay stays hidden ✓ (since overOverlayVisible only after 450ms → it would show game-over card briefly in demo at 450–1000ms — unwanted). Condition: show game-over overlay only if !isDemo. For demo over: show nothing (or tiny "crashed — restarting"?). Show nothing.

Wait but newBest flag: computed at die() time: newBest = !isDemo && score>prevBest && score>0.

Audio:

let audioCtx=null, sfxOn=true;
function ensureAudio(){ if(!audioCtx){ try{ audioCtx = new (window.AudioContext||window.webkitAudioContext)(); }catch(e){} } if(audioCtx&&audioCtx.state==='suspended') audioCtx.resume(); }
function blip(freq0,freq1,dur,type,vol){ if(!sfxOn||!audioCtx) return; const t0=audioCtx.currentTime; const o=audioCtx.createOscillator(); const g=audioCtx.createGain(); o.type=type; o.frequency.setValueAtTime(freq0,t0); o.frequency.exponentialRampToValueAtTime(freq1,t0+dur); g.gain.setValueAtTime(vol,t0); g.gain.exponentialRampToValueAtTime(0.0001,t0+dur); o.connect(g).connect(audioCtx.destination); o.start(t0); o.stop(t0+dur+0.02); }
eat: blip(520,880,0.09,'square',0.12)? maybe two-tone. die: blip(220,55,0.45,'sawtooth',0.18). start: blip(440,660,0.12,'triangle',0.1). pause click: blip(300,300,0.06,'sine',0.08).

SFX button toggles sfxOn, text update.

ensureAudio called on keydown/pointerdown (user gestures). Demo mode has no gesture until interaction — so demo is silent; fine (autoplay anyway blocked). After user starts, sounds work.

Edge: eat sound each apple ok.

Now canvas fit: 

function fit(){
 const rect=canvas.getBoundingClientRect();
 const dpr=Math.min(window.devicePixelRatio||1, 2.5);
 const w=Math.max(1, Math.round(rect.width*dpr));
 canvas.width=w; canvas.height=w; // square
 viewScale=w/BOARD; // BOARD=W=H=572
}
new ResizeObserver(fit).observe(canvas); plus initial call after layout. Also window 'resize' fallback.

Careful: canvas CSS width set via CSS; height auto via aspect-ratio. rect.height should equal width. Use rect.width for both.

viewScale used in render setTransform. If canvas.width is 0 (hidden?) guard.

Also overlay is sibling absolutely positioned over canvas — .stage{position:relative; width:min(...)}; canvas{width:100%; display:block; aspect-ratio:1}. .stage gets the size; canvas 100%. Overlay inset 0.

Sizing CSS: .stage{ width:min(572px, 92vw, calc(100dvh - 230px)); } At 800px height: 800-230=570 → ~570. Header ~64 + footer ~36 + gaps 16*2 + page padding. Let me compute: wrap padding 20 top/bottom; header 56; gap 14; stage 570; gap 12; footer 30 → total ≈ 20+56+14+570+12+30+20=722 <800 ✓. On shorter screens calc shrinks. Min floor: max(280px, ...)? width:min(572px,92vw,calc(100dvh - 230px)) could go tiny on landscape phones; add max(300px,...)? CSS min/max combo: width:clamp(300px, min(92vw, 100dvh - 230px), 572px)? clamp(min, val, max) with val = min(92vw, calc(100dvh - 230px)). Write: width: clamp(300px, min(92vw, 100dvh - 230px), 572px); — calc inside min allowed. Use dvh with fallback vh: provide both lines (vh then dvh).

100dvh support modern Chrome ✓.

Fonts: 
--mono: ui-monospace, "SF Mono", "Casse"? Write: font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace.

Title style: font-weight 800; letter-spacing .35em (with padding-left to compensate); color #e5e7eb; accent: color the "A"? Or put small underline bar. Keep: "SNAKE" white + trailing green block cursor? Subtitle small muted "VANILLA CANVAS EDITION"? Cute one-liner. Keep minimal.

Footer hints with kbd: styled.

Let me also add subtle "board frame" glow? Avoid glow. Simple crisp border.

Edge: prevent double-start from keydown auto-repeat when starting: e.repeat check for start keys? If user holds space at game over → restart then space repeats do nothing in playing ✓ fine.

Also when starting from demo with a direction key, we start player run and push that dir — but pushDir compares with dir=right; pressing Left at start would be filtered (reverse) ✓.

Also initial direction right; food spawn random; make sure food not spawning on snake ✓.

One more: "Quick successive key presses must both be honored" — my queue does this ✓. Test mentally: moving Right; press Up, quickly press Left (before tick): queue=[Up,Left]? pushDir(Up): last=dir=Right; Up ok → queue [Up]. pushDir(Left): last=Up; Left vs Up: not reverse(Down), not same → queue [Up,Left]. Tick1: pop Up → dir=Up ✓. Tick2: pop Left → dir=Left ✓. Both honored ✓.

Press Up then Down quickly while moving Right: Down vs last=Up → reverse → filtered. So only Up applies. Correct behavior (Down after Up within one tick would kill).

Now the snake "eyes facing direction": use dir at render time (current). During interpolation head slides toward dir ✓. Also draw pupils offset toward dir; add small tongue flick occasionally? skip.

Head distinctness: bigger rounded head + brighter fill + eyes ✓. Maybe also draw a subtle darker "snout"? Keep circle+eyes.

Let me also consider drawing body stroke joins: using single polyline path through centers with lineJoin round — corners look rounded ✓. Draw outline pass then gradient pass. The gradient from head to tail: createLinearGradient(headX,headY,tailX,tailY) — if head==tail (impossible len≥3). ok.

Also draw a subtle spine highlight? skip.

Body width: CELL*0.72 inner, outline CELL*0.72+? Outline pass width CELL*0.86. Head circle r CELL*0.46 vs body half-width 0.43 → head slightly bigger ✓. Outline circle stroke: draw head with fill + stroke lineWidth 3 outline color? Simpler: draw head as circle with fill '#a7f3d0' and stroke '#064e3b' lw 3. Hmm head inside outline pass? The outline pass polyline ends at head center; head circle drawn after with its own stroke — consistent.

Rounded head direction: eyes positioned: ex = headX + dir.x*0.16*CELL ± perp*0.17*CELL... let me define forward f=(dirx,diry), perp p=(-diry,dirx). eye centers: head + f*0.14*CELL ± p*0.18*CELL. Eye radius 0.13*CELL white; pupil radius 0.065*CELL at eye + f*0.045*CELL, color '#0f172a'. 

Add slight "blink"? skip.

Checker + border: also draw faint outer padding? Canvas exactly board.

Score popup position: at food cell center, but popup should render above snake — draw after snake ✓.

Now HUD update: also maybe display LENGTH? Extra chip "LEN 3"? Could be nice but optional; skip to keep clean. Actually a small third chip "LENGTH" adds info polish. Eh — keep two chips + sfx button.

Title area maybe include a tiny "session best" note. Fine.

Accessibility: aria-labels; not critical.

Let me now write final code. Careful with details:

- e.preventDefault for game keys to stop scrolling (space, arrows). Include 'KeyW' etc via e.key. Use e.key values: 'ArrowUp'..., 'w','W','a','A','s','S','d','D',' ','Enter','p','P'. preventDefault for arrows+space always (even in demo) to avoid scroll — page probably doesn't scroll anyway (100svh fixed). Still fine.

- keydown listener on window.

- Click: stage.addEventListener('pointerdown', ...) — handle states; also ensureAudio().

- Buttons: sfx button pointerdown stopPropagation so it doesn't trigger stage start? sfx button is in header, outside stage ✓. But clicking sfx shouldn't start game — it's outside stage; keydown still could... fine.

Let me handle: clicking anywhere on document (not just stage) starts? Spec: "Click or press a key to start" — clicking anywhere is friendlier. document.addEventListener('pointerdown', handler) with same logic; but sfx button click would then also start the game — acceptable? Clicking SFX toggles sound AND starts game — slightly odd but harmless. Better: restrict start-click to stage + overlay, plus header? I'll bind to document but ignore if event target is the sfx button (closest('#sfx')). Good.

- Pause on blur: window 'blur' → if playing pause. Also visibilitychange.

Now overlay 'dim' class: for over/paused stronger backdrop.

Overlay CSS:
.overlay{position:absolute; inset:0; display:flex; align-items:center; justify-content:center; padding:20px; background:rgba(6,10,15,.45); transition: opacity .18s ease; }
.overlay.hidden{opacity:0; pointer-events:none;}
.overlay.dim{background:rgba(6,10,15,.68); backdrop-filter: blur(3px);}
.card{ text-align:center; display:flex; flex-direction:column; align-items:center; gap:10px; }
Note overlay must not block pointer events for stage click when hidden ✓ pointer-events none when hidden. When visible, clicks land on overlay → stage handler via bubbling ✓ (overlay inside stage).

Demo overlay content shouldn't intercept?? It's inside stage; stage pointerdown fires ✓.

Card styles: maybe translucent panel bg for over/paused: background:rgba(13,20,28,.85); border:1px solid #223041; border-radius:16px; padding:22px 34px; box-shadow. For demo attract: transparent (no card bg) so board visible: .attract{background:transparent;border:none;box-shadow:none}.

Game-over card shows score big: .final b{font-size:44px}.

NEW BEST badge: bg #34d399? text dark #052e21, pill, pulse animation.

h2 styles: letter-spacing .2em uppercase, size 20px, color #f87171 for game over, #e5e7eb for paused.

Attract title big: 44px letter-spacing .3em. With svg inline sized 34.

Pill: border 1px solid rgba(52,211,153,.4); color #6ee7b7; bg rgba(52,211,153,.08); radius 999; padding 6px 12px; font 11px ls .18em. Pulse dot: 7px circle bg #34d399 animation pulse 1.2s infinite (scale/opacity).

cta: color #cbd5e1 13px. sub: color #64748b 11px.

kbd: display:inline-block; padding:1px 7px; border:1px solid #334155; border-bottom-width:2px; border-radius:6px; background:#16202b; color:#e2e8f0; font: inherit 11px.

Footer .hints: color:#64748b; font-size:12px; display:flex; gap:14px; flex-wrap:wrap; justify-content:center.

Header .brand: display flex align center gap 10. h1 font-size 20px ls .3em margin 0. tagline maybe hidden on small.

Chips: .chip{display:flex; flex-direction:column; align-items:flex-end? center; gap:2px; min-width:74px; padding:7px 12px; background:#111823; border:1px solid #1f2b38; border-radius:10px;} .chip span{font-size:9.5px; letter-spacing:.22em; color:#64748b;} .chip b{font-size:18px; color:#e7ecf3; font-variant-numeric:tabular-nums;}

sfx button: .btn{padding:8px 12px; bg #111823; border 1px #1f2b38; radius 10px; color:#94a3b8; font 11px ls .15em; cursor:pointer} hover brighten. Active state when on: color #6ee7b7 border greenish.

Layout header: width matches stage width: put header+stage+footer inside .wrap with width fit-content? Header should align with stage width: wrap {display:flex; flex-direction:column; align-items:center;} header {width:100%; max-width same as stage? } Use .frame{width:clamp(...); display:flex; flex-direction:column; gap:12px} containing header, stage, footer. So header spans board width. 

Also overall page: center via body{display:grid; place-items:center; min-height:100svh}.

Add slight top-level padding for small screens.

Now write JS fully. Let me write it out mentally with care for bugs.

```js
'use strict';
const COLS=22, ROWS=22, CELL=26, BOARD=COLS*CELL; // 572
const canvas=document.getElementById('game');
const ctx=canvas.getContext('2d');
const scoreEl=..., bestEl=..., sfxBtn=..., overlayEl=...;
const STAGE=document.getElementById('stage');

let viewScale=1;
function fit(){ const r=canvas.getBoundingClientRect(); const dpr=Math.max(1,Math.min(window.devicePixelRatio||1,2.5)); const s=Math.round(r.width*dpr); if(s>0){ canvas.width=s; canvas.height=s; viewScale=s/BOARD; } }
if(window.ResizeObserver) new ResizeObserver(fit).observe(canvas); window.addEventListener('resize',fit);

// directions
const DIRS={ 'ArrowUp':{x:0,y:-1}, 'w':..., ... };
```

Map both cases: I'll normalize key: const k = e.key.length===1? e.key.toLowerCase(): e.key; then map 'w','a','s','d','ArrowUp',...

Game vars:

let state='demo', isDemo=true, lastWasDemo=true;
let snake=[], prevSnake=[], dir={x:1,y:0}, queue=[], food=null;
let score=0, best=0, prevBest=0, newBest=false, isWin=false;
let tickMs=140, acc=0, lastT=performance.now();
let particles=[], popups=[], deathAt=0;

const key=(x,y)=>y*COLS+x;

reset(mode):
```
function reset(mode){
  isDemo = mode==='demo';
  const cx=(COLS/2)|0, cy=(ROWS/2)|0;
  snake=[]; for(let i=0;i<3;i++) snake.push({x:cx-i,y:cy});
  prevSnake=snake.map(s=>({x:s.x,y:s.y}));
  dir={x:1,y:0}; queue=[];
  score=0; tickMs=140; acc=0;
  particles.length=0; popups.length=0;
  if(!isDemo){ prevBest=best; }
  newBest=false; isWin=false;
  spawnFood();
  state = isDemo?'demo':'playing';
  updateHUD();
}
```

spawnFood as above.

die():
```
function die(win){
  isWin=!!win;
  newBest = !isDemo && score>prevBest && score>0;
  deathAt=performance.now();
  state='over';
  sfx die or win;
  if(!isDemo){ STAGE.classList.remove('shake'); void STAGE.offsetWidth; STAGE.classList.add('shake'); }
}
```
shake via CSS on .stage.shake canvas? Apply to stage: transform animation. Use animationend to remove or setTimeout 450.

Also demo death: no shake (or slight) — skip shake for demo, keep subtle.

doStep():
```
function doStep(){
  let d=null;
  if(isDemo){ d=aiDir(); }
  else{
    while(queue.length){
      const q=queue.shift();
      if(q.x===-dir.x&&q.y===-dir.y) continue;
      if(q.x===dir.x&&q.y===dir.y) continue;
      d=q; break;
    }
    if(!d) d=dir;
  }
  dir=d;
  const h=snake[0];
  const nx=h.x+d.x, ny=h.y+d.y;
  if(nx<0||ny<0||nx>=COLS||ny>=ROWS){ die(false); return; }
  for(let i=0;i<snake.length-1;i++){ const s=snake[i]; if(s.x===nx&&s.y===ny){ die(false); return; } }
  prevSnake=snake.map(s=>({x:s.x,y:s.y}));
  const eat = food && nx===food.x && ny===food.y;
  snake.unshift({x:nx,y:ny});
  if(eat) onEat(); else snake.pop();
}
```

Wait: in demo, aiDir might equal current dir object identity? DIR4 entries; fine.

Also safety: if isDemo but state==='over'? doStep only called when playing/demo ✓.

onEat():
```
function onEat(){
  score+=10;
  if(!isDemo) best=Math.max(best,score);
  burst(food.x,food.y);
  popups.push({x:food.x,y:food.y,age:0});
  tickMs=Math.max(82,tickMs-3);
  sfxEat();
  spawnFood();
  updateHUD();
}
```

burst(x,y): 16 particles.

updateHUD(): scoreEl.textContent=score; bestEl.textContent=best;

AI:

```
function aiDir(){
  const head=snake[0], tail=snake[snake.length-1];
  const occ=new Uint8Array(COLS*ROWS);
  for(let i=0;i<snake.length-1;i++) occ[key(snake[i].x,snake[i].y)]=1;
  const headI=key(head.x,head.y), tailI=key(tail.x,tail.y), foodI=food?key(food.x,food.y):-1;

  const bfsStep=(target)=>{
    if(target===headI) return null;
    const prev=new Int16Array(COLS*ROWS).fill(-1); // dir index used to enter cell
    const par=new Int32Array(COLS*ROWS).fill(-1);
    const seen=new Uint8Array(COLS*ROWS); seen[headI]=1;
    const q=[headI]; let qi=0;
    while(qi<q.length){
      const c=q[qi++], cx=c%COLS, cy=(c-cx)/COLS;
      for(let d=0;d<4;d++){
        const nx2=cx+DX[d], ny2=cy+DY[d];
        if(nx2<0||ny2<0||nx2>=COLS||ny2>=ROWS) continue;
        const n=ny2*COLS+nx2;
        if(seen[n]) continue;
        if(occ[n]&&n!==target) continue;
        seen[n]=1; par[n]=c; prev[n]=d;
        if(n===target){ let cur=n; while(par[cur]!==headI) cur=par[cur]; return DIR4[prev[cur]]; }
        q.push(n);
      }
    }
    return null;
  };
```

Hmm potential: par[cur] chain — cur's parent could equal headI; loop while(par[cur]!==headI). If target adjacent head → par[target]=headI → return DIR4[prev[target]] ✓.

DX=[1,-1,0,0], DY=[0,0,1,-1]; DIR4=[{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}].

flood:
```
  const flood=(startI,occArr)=>{
    const seen=new Uint8Array(COLS*ROWS); seen[startI]=1;
    const st=[startI]; let n=0;
    while(st.length){
      const c=st.pop(); n++;
      const cx=c%COLS, cy=(c-cx)/COLS;
      for(let d=0;d<4;d++){
        const nx2=cx+DX[d], ny2=cy+DY[d];
        if(nx2<0||ny2<0||nx2>=COLS||ny2>=ROWS) continue;
        const ni=ny2*COLS+nx2;
        if(seen[ni]||occArr[ni]) continue;
        seen[ni]=1; st.push(ni);
      }
    }
    return n;
  };
```
Wait flood counts start cell too (occupied by head) — counts area including start; fine as relative metric.

Candidates:
```
  const opts=[];
  for(let i=0;i<4;i++){
    const d=DIR4[i];
    if(d.x===-dir.x&&d.y===-dir.y) continue;
    const nx=head.x+d.x, ny=head.y+d.y;
    if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
    const ni=key(nx,ny);
    if(occ[ni]) continue;
    const eating = ni===foodI;
    const occ2=occ.slice(); occ2[ni]=1;
    if(!eating) occ2[tailI]=0;
    opts.push({d, area:flood(ni,occ2), dist:Math.abs(food.x-nx)+Math.abs(food.y-ny), r:Math.random()});
  }
  if(!opts.length) return dir;
  const need=snake.length; // rough safety threshold
  const foodStep = bfsStep(foodI);
  if(foodStep){
    const o=opts.find(o=>o.d.x===foodStep.x&&o.d.y===foodStep.y);
    if(o&&o.area>=need) return o.d;
  } else {
    const tailStep=bfsStep(tailI);
    if(tailStep){
      const o=opts.find(o=>o.d.x===tailStep.x&&o.d.y===tailStep.y);
      if(o&&o.area>=need*0.6) return o.d;
    }
  }
  opts.sort((a,b)=>b.area-a.area||a.dist-b.dist||b.r-a.r);
  return opts[0].d;
}
```

Hmm wait: bfsStep(tailI) — tail is excluded from occ (occ excludes tail) so it's reachable ✓. But following tail closely: BFS shortest path to tail might cut across... fine.

One concern: threshold `need=snake.length` might block food-chasing early when area large — early area ≈ 480 ≥ 3 ✓. When snake long, area shrinks; if food path step's area < snake.length, we fall to tail-chase/max-area → survival mode. Reasonable.

Also demo AI at very start: food may be behind (left) → path exists around; fine.

Potential subtle bug: occ excludes the CURRENT tail; but flood occ2 sets tailI=0 meaning tail vacates — but if eating, tail stays: occ2 keeps tailI=1 ✓ (occ already had... wait occ excluded tail from the start! occ built from slice(0,-1) so tailI=0 in occ; occ2 inherits 0; if !eating we... set occ2[tailI]=0 — already 0, harmless. If eating we want tailI=1: fix: if(eating) occ2[tailI]=1 else occ2[tailI]=0. Since occ has tail=0 always: set explicitly:
 occ2[ni]=1; occ2[tailI]= eating?1:0;
✓.

But note: occ excludes tail also for the candidate validity check: moving onto tail cell considered valid (tail vacates unless eating) — if eating and tail there? food never on tail ✓. But moving onto tail while eating impossible; moving onto tail otherwise valid ✓.

Another subtlety: bfsStep uses occ excluding tail — path may pass through tail cell (it will vacate) ✓ fine.

Render:

```
function render(t){
  if(!viewScale) return;
  ctx.setTransform(viewScale,0,0,viewScale,0,0);
  // board bg
  ctx.fillStyle='#0d1319';
  ctx.fillRect(0,0,BOARD,BOARD);
  ctx.fillStyle='rgba(148,163,184,0.05)';
  for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++){ if((x+y)&1) ctx.fillRect(x*CELL,y*CELL,CELL,CELL); }
  // subtle inner border
  ctx.strokeStyle='rgba(148,163,184,0.10)'; ctx.lineWidth=2; ctx.strokeRect(1,1,BOARD-2,BOARD-2);
```
Hmm strokeRect each frame fine.

Food:
```
  if(food){
    const fx=(food.x+0.5)*CELL, fy=(food.y+0.5)*CELL;
    const pulse=1+0.07*Math.sin(t/260);
    // halo
    const halo=ctx.createRadialGradient(fx,fy,2,fx,fy,CELL*0.95*pulse);
    halo.addColorStop(0,'rgba(248,113,113,0.28)');
    halo.addColorStop(1,'rgba(248,113,113,0)');
    ctx.fillStyle=halo; ctx.beginPath(); ctx.arc(fx,fy,CELL*0.95*pulse,0,7); ctx.fill();
    // body
    const r=CELL*0.33*pulse;
    const g=ctx.createRadialGradient(fx-r*0.35,fy-r*0.4,r*0.15,fx,fy,r);
    g.addColorStop(0,'#fda4af'); g.addColorStop(0.55,'#f43f5e'); g.addColorStop(1,'#be123c');
    ctx.fillStyle=g; ctx.beginPath(); ctx.arc(fx,fy,r,0,7); ctx.fill();
    // stem
    ctx.strokeStyle='#8b5e34'; ctx.lineWidth=2; ctx.lineCap='round';
    ctx.beginPath(); ctx.moveTo(fx,fy-r+1); ctx.lineTo(fx+2.5,fy-r-4); ctx.stroke();
    // leaf
    ctx.fillStyle='#4ade80';
    ctx.beginPath(); ctx.ellipse(fx+6,fy-r-3,4.4,2.2,-0.5,0,7); ctx.fill();
    // shine
    ctx.fillStyle='rgba(255,255,255,0.75)';
    ctx.beginPath(); ctx.ellipse(fx-r*0.35,fy-r*0.35,r*0.22,r*0.13,-0.6,0,7); ctx.fill();
  }
```
arc end angle 7 > 2π ✓ (7 radians covers full circle).

Snake:
```
  const alpha = (state==='playing'||state==='demo') ? Math.min(1, acc/tickMs) : 1;
  const pts = snake.map((s,i)=>{
    const p = prevSnake[i] || s;
    return { x:(p.x+(s.x-p.x)*alpha+0.5)*CELL, y:(p.y+(s.y-p.y)*alpha+0.5)*CELL };
  });
```
Wait careful: prevSnake[i] may exist but semantics: prev for new index i is old[i] ✓ as derived. When not grown arrays equal length ✓.

Edge: after reset prevSnake=snake copy → alpha irrelevant ✓.

```
  if(pts.length){
    // outline pass
    ctx.lineJoin='round'; ctx.lineCap='round';
    ctx.strokeStyle='#052e21';
    ctx.lineWidth=CELL*0.88;
    ctx.beginPath(); ctx.moveTo(pts[0].x,pts[0].y);
    for(let i=1;i<pts.length;i++) ctx.lineTo(pts[i].x,pts[i].y);
    ctx.stroke();
```
Hmm single-point path (len1) stroke draws dot with round cap ✓ (len≥3 always).

Outline as solid dark: might look heavy; use lineWidth CELL*0.84, color 'rgba(3,26,19,0.9)'. Actually crisp rim looks good.

```
    // gradient body
    const hx=pts[0].x, hy=pts[0].y;
    const tp=pts[pts.length-1];
    const grad=ctx.createLinearGradient(hx,hy,tp.x,tp.y);
    grad.addColorStop(0,'#86efac');
    grad.addColorStop(0.45,'#34d399');
    grad.addColorStop(1,'#0f766e');
    ctx.strokeStyle=grad;
    ctx.lineWidth=CELL*0.68;
    ctx.beginPath(); ctx.moveTo(hx,hy);
    for(let i=1;i<pts.length;i++) ctx.lineTo(pts[i].x,pts[i].y);
    ctx.stroke();
```

Head:
```
    const h=pts[0];
    const rr=CELL*0.46;
    ctx.fillStyle='#a7f3d0';
    ctx.strokeStyle='#052e21'; ctx.lineWidth=3;
    ctx.beginPath(); ctx.arc(h.x,h.y,rr,0,7); ctx.fill(); ctx.stroke();
    // eyes
    const fx0=dir.x, fy0=dir.y, px=-dir.y, py=dir.x;
    for(const sgn of [-1,1]){
      const ex=h.x+fx0*CELL*0.16+px*sgn*CELL*0.19;
      const ey=h.y+fy0*CELL*0.16+py*sgn*CELL*0.19;
      ctx.fillStyle='#ffffff'; ctx.beginPath(); ctx.arc(ex,ey,CELL*0.125,0,7); ctx.fill();
      ctx.fillStyle='#0b1220'; ctx.beginPath(); ctx.arc(ex+fx0*CELL*0.05,ey+fy0*CELL*0.05,CELL*0.062,0,7); ctx.fill();
    }
```

Hmm: head circle radius .46 CELL, body half width .34 — head bulge ✓.

Also: when dead, maybe tint head? Add: if state over → draw X eyes? Cute: on death draw crosses instead of eyes. Implement: if(state==='over'&&!isWin): draw two small 'x' lines dark instead of eyes. That's charming polish. Do it: replace eye drawing when dead: strokeStyle '#0b1220', lw2, small crosses at eye positions.

Particles:
```
  for(const p of particles){
    const a=Math.max(0,p.life/p.ttl);
    ctx.globalAlpha=a;
    ctx.fillStyle=p.color;
    ctx.beginPath(); ctx.arc(p.x,p.y,p.size*(0.4+0.6*a),0,7); ctx.fill();
  }
  ctx.globalAlpha=1;
```

Popups:
```
  for(const q of popups){
    const k=q.age/0.9;
    ctx.globalAlpha=1-k;
    ctx.font='700 17px ui-monospace, Menlo, Consolas, monospace';
    ctx.textAlign='center';
    ctx.lineWidth=4; ctx.strokeStyle='rgba(8,12,16,0.7)';
    const yy=(q.y+0.5)*CELL - 8 - k*26;
    ctx.strokeText('+10',(q.x+0.5)*CELL, yy);
    ctx.fillStyle='#fde68a';
    ctx.fillText('+10',(q.x+0.5)*CELL, yy);
  }
  ctx.globalAlpha=1;
```

Death flash:
```
  if(state==='over'){
    const el=(performance.now()-deathAt)/450;
    if(el<1){ ctx.fillStyle=`rgba(239,68,68,${0.32*(1-el)})`; ctx.fillRect(0,0,BOARD,BOARD); }
  }
```

updateFx(dt):
```
  for(const p of particles){ p.life-=dt; p.vy+=340*dt; p.x+=p.vx*dt; p.y+=p.vy*dt; p.vx*=Math.pow(0.9, dt*60)? }
```
Simplify drag: p.vx*= (1-2.2*dt) clamp. Use p.vx*=Math.max(0,1-2.5*dt); same vy? gravity anyway. filter particles=particles.filter(p=>p.life>0) — reassign; keep let.

popups: q.age+=dt; popups=popups.filter(q=>q.age<0.9).

burst(x,y):
```
const cx=(x+0.5)*CELL, cy=(y+0.5)*CELL;
const colors=['#f87171','#fca5a5','#fde68a','#86efac','#ffffff'];
for(let i=0;i<18;i++){
  const a=Math.random()*Math.PI*2, sp=60+Math.random()*130;
  particles.push({x:cx,y:cy,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp-40,life:0.45+Math.random()*0.35,ttl:0,size:1.6+Math.random()*2.4,color:colors[(Math.random()*colors.length)|0]});
}
particles.forEach(p=>p.ttl=p.ttl||p.life); hmm set ttl=life at creation: ttl: same value. Do: const L=0.45+Math.random()*0.35; life:L, ttl:L.
```

Main loop:
```
function frame(t){
  requestAnimationFrame(frame);
  let dt=(t-lastT)/1000; lastT=t;
  if(dt>0.25) dt=0.25;
  if(state==='playing'||state==='demo'){
    acc+=dt*1000;
    let guard=0;
    while(acc>=tickMs&&guard++<6){
      acc-=tickMs;
      doStep();
      if(state!=='playing'&&state!=='demo'){ acc=0; break; }
    }
  }
  if(state==='over'&&isDemo&&t-deathAt>1000) reset('demo');
  updateFx(dt);
  refreshOverlay();
  render(t);
}
requestAnimationFrame(t=>{ lastT=t; requestAnimationFrame(frame); }); // init lastT
```
Simpler: lastT=performance.now(); requestAnimationFrame(frame);

refreshOverlay computes visible flag:
```
function refreshOverlay(){
  const overReady = state==='over' && !isDemo && performance.now()-deathAt>450;
  let key;
  if(state==='demo') key='demo';
  else if(state==='paused') key='paused';
  else if(overReady) key='over'+(newBest?'b':'')+(isWin?'w':'');
  else key='none';
  if(key===okey) return; okey=key;
  ...build html...
  overlay.classList.toggle('is-hidden', key==='none');
  overlay.classList.toggle('dim', key==='paused'||key.startsWith('over'));
}
```

HTML strings — careful template literal with backticks inside overall file fine.

SNAKE svg string reused: define const LOGO_SVG = `<svg ...>...</svg>`; use in header (static HTML) and overlay (JS). In static header, just write inline svg directly.

Input:

```
window.addEventListener('keydown', e=>{
  const raw=e.key;
  const k=raw.length===1?raw.toLowerCase():raw;
  const isDirKey = k==='ArrowUp'||... (or lookup)
  const d=DIRKEYS[k];
  if(d||k===' '||k==='Enter'||k==='p'||k==='P') e.preventDefault();
  ensureAudio();
  if(d){
    if(state==='demo'){ startPlayer(d); }
    else if(state==='playing'){ pushDir(d); }
    else if(state==='paused'){ pushDir(d); }
  } else if(k===' '||k==='Enter'){
    if(state==='demo') startPlayer(null);
    else if(state==='over') startPlayer(null);
  } else if(k==='p'){
    if(state==='playing'){ state='paused'; sfx pause? ; }
    else if(state==='paused'){ state='playing'; lastT handled? acc keep; }
  }
});
```
DIRKEYS includes 'ArrowUp' etc and letters. Note raw ' ' length 1 → lowercased ' ' fine.

Also prevent default for arrow keys even when starting ✓ included in d branch? preventDefault executed before branch for d ✓.

startPlayer(d):
```
function startPlayer(d){
  reset('player');
  if(d) pushDir(d);
  sfxStart();
}
```
pushDir:
```
function pushDir(d){
  const last = queue.length?queue[queue.length-1]:dir;
  if(d.x===last.x&&d.y===last.y) return;
  if(d.x===-last.x&&d.y===-last.y) return;
  if(queue.length<3) queue.push(d);
}
```

Pointer:
```
document.addEventListener('pointerdown', e=>{
  if(e.target.closest && e.target.closest('#sfxBtn')) return;
  ensureAudio();
  if(state==='demo'||state==='over') startPlayer(null);
  else if(state==='paused'){ state='playing'; }
});
```
Hmm clicking to resume pause also fine.

Wait — clicking during 'over' before overlay shows (within 450ms) restarts instantly — ok.

sfxBtn:
```
sfxBtn.addEventListener('click', e=>{ e.stopPropagation(); sfxOn=!sfxOn; sfxBtn.textContent='SFX '+(sfxOn?'ON':'OFF'); sfxBtn.classList.toggle('off',!sfxOn); ensureAudio(); });
```
pointerdown on document fires before click; we skip via closest check on pointerdown target ✓.

visibility:
```
document.addEventListener('visibilitychange', ()=>{ if(document.hidden&&state==='playing') state='paused'; });
window.addEventListener('blur', ()=>{ if(state==='playing') state='paused'; });
```
blur pause might annoy when clicking iframe? blur fires when iframe loses focus — game pauses; user clicks to... clicking stage while paused resumes ✓.

Init: reset('demo'); fit(); updateHUD(); requestAnimationFrame(frame).

Order: fit before first render; ResizeObserver fires initially too.

HTML skeleton:

```
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Snake — Vanilla Canvas</title>
<style>...</style>
</head>
<body>
<main class="frame">
  <header class="top">
    <div class="brand">
      [svg]
      <h1>SNAKE</h1>
    </div>
    <div class="hud">
      <div class="chip"><span>Score</span><b id="score">0</b></div>
      <div class="chip"><span>Best</span><b id="best">0</b></div>
      <button id="sfxBtn" class="btn" type="button">SFX ON</button>
    </div>
  </header>
  <div class="stage" id="stage">
    <canvas id="game" width="572" height="572" aria-label="Snake game board"></canvas>
    <div class="overlay" id="overlay"></div>
  </div>
  <footer class="hints">
    <span><kbd>←↑↓→</kbd> or <kbd>WASD</kbd> move</span>
    <span><kbd>P</kbd> pause</span>
    <span><kbd>Space</kbd> / <kbd>Enter</kbd> restart</span>
  </footer>
</main>
<script>...</script>
</body>
</html>
```

CSS:

```
*{box-sizing:border-box; margin:0; padding:0;}
html,body{height:100%;}
body{
  background:#0a0e13 radial-gradient(...)? 
  background: radial-gradient(1100px 700px at 50% -12%, #121a24 0%, #0a0e13 60%) #0a0e13;
  color:#e7ecf3;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
  display:grid; place-items:center;
  min-height:100svh;
  padding:18px;
  -webkit-font-smoothing:antialiased;
}
.frame{ width:clamp(300px, min(92vw, 100svh - 235px), 572px); display:flex; flex-direction:column; gap:14px; }
```
Hmm clamp with min() inside: `clamp(300px, min(92vw, 100svh - 235px), 572px)` — inside clamp the preferred value can be a calc/min expression; `min(92vw, 100svh - 235px)` — math inside min needs calc? In CSS, min() accepts calculations directly, `100svh - 235px` inside min() is allowed (math functions allow calc-like expressions). ✓. Provide vh fallback line before svh line.

Header:
```
.top{display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap;}
.brand{display:flex; align-items:center; gap:10px; color:#6ee7b7;}
.brand svg{display:block;}
h1{font-size:19px; letter-spacing:0.34em; color:#f1f5f9; font-weight:800; transform:translateX(2px)? just margin}
.hud{display:flex; gap:8px; align-items:center;}
.chip{...}
.btn{...cursor}
.btn.off{color:#475569;}
```

Stage:
```
.stage{position:relative; width:100%; aspect-ratio:1; border-radius:16px; overflow:hidden;
  box-shadow:0 24px 70px -20px rgba(0,0,0,0.65), 0 0 0 1px #232f3d;
  background:#0d1319;}
canvas{display:block; width:100%; height:100%;}
```
canvas width/height 100% of stage; stage aspect-ratio 1 → square ✓. fit() reads rect ✓.

shake:
```
@keyframes shake{ 10%{transform:translate(-4px,1px)} 30%{transform:translate(4px,-2px)} 50%{transform:translate(-3px,2px)} 70%{transform:translate(3px,1px)} 90%{transform:translate(-2px,-1px)} }
.stage.shake{animation:shake 0.4s linear;}
```
transform on stage with overflow hidden fine.

Overlay:
```
.overlay{position:absolute; inset:0; display:flex; align-items:center; justify-content:center; text-align:center; padding:24px;
 background:rgba(7,11,16,0.42); transition:opacity .2s ease; }
.overlay.dim{ background:rgba(7,11,16,0.72); backdrop-filter:blur(4px); }
.overlay.is-hidden{opacity:0; pointer-events:none;}
.card{display:flex; flex-direction:column; align-items:center; gap:12px; max-width:100%;}
.attract{ background:rgba(10,15,21,0.66); border:1px solid rgba(148,163,184,0.14); border-radius:18px; padding:26px 34px; box-shadow:0 18px 50px rgba(0,0,0,0.45);}
```
Wait earlier I said attract transparent; a translucent card is fine and readable, gameplay visible around it. Use card bg for all. But demo gameplay hidden behind card center — snake visible around card. OK.

Hmm, maybe for demo make the card more compact at center — fine.

.logo-big{display:flex; align-items:center; gap:12px; color:#6ee7b7;} .logo-big span{font-size:34px; font-weight:800; letter-spacing:0.32em; color:#f1f5f9; margin-right:-0.32em?} trailing letter-spacing adds space after last letter — offset with margin-right:-0.32em for optical centering. Nice detail.

.pill{...} .pulse{width:8px;height:8px;border-radius:50%;background:#34d399; animation:pulse 1.1s ease-in-out infinite;}
@keyframes pulse{0%,100%{opacity:1; transform:scale(1)}50%{opacity:.35; transform:scale(.7)}}
.pill{display:flex;align-items:center;gap:8px; font-size:10.5px; letter-spacing:0.2em; color:#6ee7b7; border:1px solid rgba(110,231,183,0.35); background:rgba(52,211,153,0.08); padding:6px 12px; border-radius:999px;}

.cta{font-size:13px; color:#dbe4ee;}
.sub{font-size:11px; color:#64748b;}
h2{font-size:18px; letter-spacing:0.28em; text-transform:uppercase; color:#f87171; margin-right:-0.28em;}
h2.win{color:#6ee7b7;}
.final{display:flex; flex-direction:column; gap:2px;} .final span{font-size:10px; ls .25em; color:#64748b;} .final b{font-size:42px; color:#f1f5f9; font-variant-numeric:tabular-nums; line-height:1;}
.final.sub2 b{font-size:18px; color:#93c5fd?} keep #cbd5e1.
.newbest{font-size:10px; letter-spacing:0.25em; color:#052e21; background:#34d399; padding:4px 10px; border-radius:999px; font-weight:700;}
kbd{...}

hints:
.hints{display:flex; gap:16px; justify-content:center; flex-wrap:wrap; color:#5b6b7d; font-size:11.5px;}
.hints span{display:flex; gap:6px; align-items:center;}

Media small: header hud wraps; fine. Also on very narrow, .frame width = 92vw; chips shrink — set .chip padding smaller via media query maybe. Add @media (max-width:420px){ .chip{min-width:0; padding:6px 9px} .chip b{font-size:15px} h1{font-size:16px; letter-spacing:0.25em} .btn{padding:7px 9px} }.

Also the sfx button label: keep "SFX ON"/"SFX OFF".

Now double-check requirement "A grid of at least 20×20 cells, centered on the page" ✓ 22×22 centered.

"smooth movement" ✓ interpolation.

Demo behind start message ✓.

Potential issue: keydown before any click — iframe may not receive keyboard until focused by click; but if it does (e.g., after tab focus), handlers work anyway. Add autofocus? Can't rely. The message says click or press key — good.

Also add tabindex? not needed.

Check console errors: careful with ctx.ellipse availability ✓ modern. radial gradients fine.

One risk: `ctx.arc(...,0,7)` fine.

Another: overlay innerHTML with template literals containing `</div>` etc fine. But inside <script>, the sequence "</" inside strings? Template literals containing `</div>` are fine; only `</script>` is problematic — avoid. My strings contain `<kbd>` etc — no `</script>` ✓.

Now let me also think about the "first paint screenshot at ~3s": demo runs immediately, snake moving, particles when eating. Overlay attract card visible ✓. 

Let me also double check tick feel: 140ms/tick → ~7 cells/s. Good.

Food initial spawn could be under demo path instantly; fine.

Wait: one more detail — the demo AI: when reset('demo') happens at load, aiDir called each tick; state==='demo' ✓. But note in doStep for demo we call aiDir which reads global dir ✓.

Potential AI trap: BFS path first step safe-check uses area computed from occ2 — decent.

Another potential subtle bug: In doStep player branch, queue shift loop: `while(queue.length){...}` — if first queued is same as dir (e.g., pressed Right while moving Right — filtered at push, so can't happen). Push filter prevents same/reverse relative to last-queued chain, so pop loop rarely discards. Keep anyway.

Now — best-score display: during demo before any player run, best=0 shown ✓.

Game over overlay shows BEST value ✓.

Let me also handle rare win: if spawnFood finds no free cell: call die(true)?? but snake fills board — die sets state over; overlay "Perfect!". In onEat we call spawnFood after effects; if win, food=null; render skips food ✓. die(true) with shake? whatever.

Actually careful: spawnFood called within onEat after snake.unshift (snake already grown). free cells exclude snake ✓.

Edge: die() called from doStep before prevSnake update — snake stays at pre-collision state; head not moved into wall — visually snake stops just before wall ✓ good.

Hmm: die during demo → state 'over', isDemo true → overlay stays hidden, auto reset after 1s ✓. But also `if(state==='over'&&isDemo&&t-deathAt>1000) reset('demo')` — note state 'over' set; also render alpha=1 static ✓.

Also during 'over', particles keep animating ✓ nice (death burst? add small burst at head on death? yes: burst at head position with red-ish colors — reuse burst(nx,ny) before return? head didn't move; burst at head cell ✓. Add in die(): burst(head.x,head.y) with maybe more particles. I'll add burst of 20 at head.)

But die() doesn't know head coords — pass or compute snake[0] ✓ snake[0] is head (not moved). burst(snake[0].x, snake[0].y).

Wait in die from wall: head at edge, burst at edge ✓ fine.

Sound design final:
- eat: two quick blips? single: blip(660,990,'square',0.09,0.10). Let me implement blip(f0,f1,dur,type,vol).
- die: blip(200,50,0.5,'sawtooth',0.16) plus maybe noise? keep.
- start: blip(440,880,0.15,'triangle',0.08).
- pause toggle: blip(500,500,0.05,'sine',0.06)? optional skip.

Volume master via gain node? direct fine.

Also add slight tick sound? No—annoying.

Let me now also consider: `backdrop-filter` inside sandboxed iframe fine.

Also ensure no `alert` etc ✓.

Write final CSS carefully with good typography. Let me also add a subtle scanline or noise? Skip — clean.

One more polish: canvas cursor: pointer? default fine.

Focus outline on button: default ok.

Let me assemble everything and mentally run through possible bugs:

1. fit() called before canvas laid out? Script at end of body → layout done. ResizeObserver also fires. viewScale default 1 → first render maybe before RO callback? RO fires before first paint typically. Guard `if(!viewScale)`? viewScale initial 1; canvas width attr 572 default; even if fit hasn't run, render works with scale1 (572 backing). Fine.

2. prevSnake indices when growing — derived ✓.

3. When queue has multiple dirs and snake long — fine.

4. Demo AI never reverses: candidates exclude reverse of current dir ✓; dir updated only via doStep ✓.

5. Pause during demo? P in demo: ignore ✓ (state 'demo' not matched).

6. After game over → startPlayer resets tickMs and score ✓, prevBest=best ✓, overlay switches to hidden (state playing → key 'none') ✓.

7. HUD best updates live during player run ✓; during demo not ✓.

8. Popups use food position at eat time — onEat called before spawnFood replaces food; we push popup with food.x/y before spawnFood? In onEat order: burst(food.x,food.y) then popups.push using food coords — but spawnFood called after; food still old at push time ✓ order matters: burst & popup BEFORE spawnFood. In my onEat I wrote burst then popup then spawnFood ✓.

9. In doStep, when eating we don't pop tail — growth ✓ "grows the snake by one cell" ✓.

10. Score +10 ✓.

11. Key handling 'p' also 'P' via lowercase mapping (raw.length===1 → toLowerCase) ✓.

12. preventDefault on 'p' — harmless.

13. Space preventDefault avoids page scroll ✓.

14. If user presses key while state 'over' && isDemo (crash pause) — startPlayer triggers → leaves demo, starts player ✓ good.

15. Overlay pointer-events when visible: it covers canvas; pointerdown bubbles to document handler ✓. But overlay has pointer-events auto → clicks register on overlay, bubble to document ✓.

16. When hidden: pointer-events none ✓ so canvas receives? document handler catches anyway.

17. ResizeObserver observing canvas whose CSS size changes with viewport ✓.

18. High-DPI text (popup) crisp ✓ via viewScale.

19. Tab hidden → rAF stops; on return dt clamped 0.25 → up to 6 steps catch-up; visibilitychange pauses anyway when playing. In demo (not paused), catch-up bounded ✓.

Alright, also let me reconsider board colors: body bg near-black blue; board #0d1319 with faint checker; snake greens; food rose. Accent consistent emerald. Title white. Looks cohesive.

Maybe add a very subtle inner shadow at board edges via CSS inset box-shadow on stage: box-shadow: inset 0 0 40px rgba(0,0,0,0.35)? inset shadows on stage div appear above canvas? Canvas is child; inset shadow of stage renders above? Box-shadow inset of parent paints above child content? Actually inset box-shadow paints over background but under child elements? The inset shadow is drawn on the element itself, above its background, but children paint above it. So canvas covers it. Use overlay pseudo? Skip — instead draw vignette inside canvas render: radial gradient dark edges alpha .25. That adds depth. Add: after all drawing, vignette radial gradient transparent center → rgba(0,0,0,0.28) edges. But it would dim food/snake near edges slightly — acceptable subtle. Actually draw vignette before snake? Classic vignette on top. Keep alpha low 0.22. Hmm might dull the food at edges. I'll draw vignette right after board bg (under entities) — gives depth without dimming actors... vignette under actors but over checker ✓ good choice.

Also maybe rounded corners of board: canvas corners clipped by stage border-radius overflow hidden ✓ nice.

Let me write the demo AI also make snake move toward food efficiently — BFS ✓.

Potential BFS bug: prev array Int16Array fill(-1) — storing dir index 0..3 ✓.

`while(par[cur]!==headI) cur=par[cur];` — if path length ≥2, terminates at first cell whose parent is head ✓. If somehow par chain broken (shouldn't), infinite loop risk — target reachable guaranteed by BFS so chain valid ✓.

Now write shake removal: 
```
function shakeBoard(){ stage.classList.remove('shake'); void stage.offsetWidth; stage.classList.add('shake'); clearTimeout(shakeTO); shakeTO=setTimeout(()=>stage.classList.remove('shake'),450); }
```

Only for player death.

Also add on game-over: brief red flash — implemented in render ✓.

Sound toggle initial "SFX ON".

Edge: AudioContext creation might throw in sandbox? wrapped try/catch ✓.

Let me now also think about the header svg icon: inline SVG with stroke currentColor colored #6ee7b7 via .brand color. And apple dot red? My glyph: snake curve + head + eye. Add tiny apple: circle at (5.5,7) r2 fill '#f87171'. Let me finalize svg:

<svg width="26" height="26" viewBox="0 0 24 24" fill="none" aria-hidden="true">
  <path d="M3.5 17.5h7.2a4.6 4.6 0 0 0 0-9.2H8.4" stroke="#34d399" stroke-width="3.2" stroke-linecap="round"/>
  <circle cx="16.6" cy="8.3" r="2.9" fill="#86efac"/>  -- wait path ends at (8.4,8.3) heading left; head should be at path end (8.4,8.3). Hmm my path: start (3.5,17.5) → h7.2 → (10.7,17.5) → arc a4.6 4.6 0 0 0 0,-9.2: sweep 0 from (10.7,17.5) to (10.7,8.3) counterclockwise → bulges to the right? Let me recompute: sweep-flag 0 = counterclockwise. Moving from bottom point to top point counterclockwise: angle from center... The two points are vertically aligned (same x), distance 9.2 = diameter → semicircle; center at (10.7, 12.9). CCW from (10.7,17.5) to (10.7,8.3): starting at angle 90°(pointing down from center? point below center → angle +90° in screen coords where y down... ugh. Simpler heuristic: with sweep 0 (CCW in SVG's y-down coordinate system = visually clockwise? In SVG, sweep=1 is clockwise in screen coordinates (positive angle direction with y down). So sweep 0 is counterclockwise on screen. Going from bottom (10.7,17.5) to top (10.7,8.3) counterclockwise on screen: CCW means... imagine circle center (10.7,12.9); bottom point at angle 90°(screen, y-down: point below center). Moving CCW (decreasing angle in screen convention?) — honestly: sweep=1 arcs clockwise on screen. From bottom to top clockwise → passes through left side (x<10.7). Sweep=0 → passes through right side (x>10.7). I want the arc bulging right (toward head at right?) — then head at path end... then h8.4 moves LEFT back over — bad.

Redesign path: tail at left-bottom, go right, arc up bulging right, end at top-right where head sits:
M3.5 17 h7 → (10.5,17); a4.5 4.5 0 0 0 0,-9? sweep0 → bulge right → ends (10.5,8) with bulge through x=15. Then h3 → (13.5,8) moving right?? That doubles back over the bulge top? The bulge apex at (15,12.5); endpoint (10.5,8); moving h3 → (13.5,8) — horizontal line at y=8 to the right, outside the bulge? The bulge top reaches y=8 only at endpoint... the semicircle from (10.5,17) to (10.5,8) bulging right: its rightmost point (15,12.5). Then line from (10.5,8) to (13.5,8): fine, extends right at top. Head circle at (13.5,8) r2.9 — overlapping the arc end nicely. Eye at (14.3,7.4) r0.7 dark.

So: path d="M3.5 17h7a4.5 4.5 0 0 0 0-9h3"? Wait the h3 after arc: "a4.5 4.5 0 0 0 0 -9" then "h3". Combined: d="M3.5 17h7a4.5 4.5 0 0 0 0-9h3". Hmm "0-9" parsed as 0,-9 ✓.

Check arc flags: rx ry x-axis-rotation large-arc-flag sweep-flag dx dy → "a4.5 4.5 0 0 0 0 -9": large-arc 0, sweep 0 ✓ semicircle exactly (distance = 2r, large-arc irrelevant/0 ok? When distance equals diameter, both flags give semicircle; sweep decides side ✓).

Head circle cx=13.5+? path ends at (13.5,8). Head circle at (14.2,8)? Put circle at (14,8) r2.8, eye at (14.9,7.3) r0.75 fill dark #0a0e13. Also apple at (5.5,7.5) r2.1 fill #f87171 with leaf? just circle + tiny leaf line. Keep: circle red + small green leaf ellipse. Total svg 24×24 scaled 26. Fine — decorative mark; visually plausible.

Actually simpler & safer: head AT the end with the tongue? skip tongue.

Now, the overlay logo-big reuses similar svg bigger (36). I'll define the svg twice (header static, overlay via JS string) — or give overlay version without svg, just text? Reuse via JS constant injected into header too? Header static HTML simplest; overlay builds its own string — duplicate markup acceptable. I'll create const MARK = '<svg ...>' in JS and also inject into header brand via JS? Keep header static with inline svg; overlay uses its own copy string. Slight duplication fine.

Let me now write out the full file. I'll be meticulous.

Also double-check: no external requests ✓; no localStorage ✓; fonts system ✓.

Font stack define once:
:root{ --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; }

Write CSS:

```
:root{
  --bg:#0a0e13; --panel:#10161f; --line:#1f2b38; --text:#e7ecf3;
  --muted:#64748b; --accent:#34d399; --accent-soft:#6ee7b7; --danger:#f87171;
}
```

body bg: `background:radial-gradient(120% 90% at 50% -10%, #131c26 0%, #0a0e13 55%) fixed, #0a0e13;` Hmm shorthand order; write:
background-color:#0a0e13; background-image:radial-gradient(1100px 640px at 50% -8%, #14202b 0%, rgba(10,14,19,0) 70%); background-repeat:no-repeat; Subtle top glow. OK tasteful.

Add subtle texture? skip.

Selection color etc minor.

Now finalize numbers for overlay game-over: show score big + best; also "LENGTH n"? skip.

Also show in attract card: small line "Eat the apples · Don't hit walls or yourself"? Nice hint. Add .sub line: "Arrows / WASD to steer · eat apples · don't crash".

Compose attract card:
```
<div class="card attract">
  <div class="logo-big">${MARK}<span>SNAKE</span></div>
  <div class="pill"><i class="pulse"></i>AUTOPILOT DEMO</div>
  <p class="cta">Click or press any key to play</p>
  <p class="sub">Steer with Arrows / WASD · eat apples · don't crash</p>
</div>
```
i.pulse display block.

Game over card:
```
<div class="card panel">
  <h2>${isWin?'Perfect run':'Game over'}</h2>
  ${newBest?'<div class="newbest">NEW BEST</div>':''}
  <div class="final"><span>Score</span><b>${score}</b></div>
  <div class="final bestline"><span>Best</span><b>${best}</b></div>
  <p class="cta">Press <kbd>Space</kbd> or <kbd>Enter</kbd> to restart</p>
</div>
```
.panel{background:rgba(12,18,26,0.9); border:1px solid var(--line); border-radius:18px; padding:26px 40px; box-shadow:0 24px 60px rgba(0,0,0,0.5);}

Paused card:
```
<div class="card panel">
  <h2 class="pausedh">Paused</h2>
  <p class="cta">Press <kbd>P</kbd> to resume</p>
</div>
```
.pausedh color #e7ecf3.

Also when paused, dim class ✓.

Also small detail: show "resume" on click too — cta mention "or click". ok: `Press <kbd>P</kbd> or click to resume`.

Now — the overlay for demo has background rgba(7,11,16,0.42) covering board; card on top. Reasonable.

Potential nit: attract card covers center where food may spawn behind card... fine.

Let me also add tiny "session" note? skip.

Time to write final code. I'll write it completely and review once more.

Additional check — `overlay.classList.toggle('dim', ...)` with key.startsWith('over') — 'over' keys start with 'over' ✓ but 'over' vs 'overlay'... key strings: 'demo','paused','overb','overw','overbw','none' ✓ startsWith('over') ✓.

okey variable init 'init'.

One more: after reset('player') from over overlay, key becomes 'none' ✓ overlay hides.

updateHUD also on reset ✓.

Let me also make sure that when state==='paused', acc doesn't accumulate (we skip adding) ✓ and alpha for render: state not playing/demo → alpha=1 → snake drawn at final positions ✓ (mid-step? acc could be mid-tick when paused; snake positions are post-step grid — drawing alpha=1 shows grid-aligned ✓).

Hmm when pausing mid-interpolation, snake snaps to grid — minor visual jump, acceptable. Could preserve alpha... fine.

Also when resuming, acc continues from paused value → might immediately step. Let me zero acc on pause to give reaction time: on pause set acc=0 ✓ do that in the P handler and auto-pause paths. Actually careful: acc=0 while paused; resume starts fresh tick after full tickMs ✓ good.

Auto-pause on blur: state='paused'; acc=0.

Write pushDir cap 3 ✓.

Now performance: per frame we redraw everything; fine.

Also check: doStep in demo — aiDir computed even when queue irrelevant ✓.

Also: demo speed ramp tickMs decreasing — demo gets fast over long idle... auto-restarts on death reset speed ✓. If AI survives very long, speed caps at 82ms — looks energetic ✓.

Also demo score display: HUD score counts up during demo — engaging ✓. On player start resets 0 ✓.

Potential confusion: user watching demo score then starts — score resets; fine.

Now the code. Also add `image-rendering` nothing needed.

Let me write the snake body width relative: CELL=26 → inner width 17.7 (0.68), outline 21.7 (0.835). Head r 11.96 (0.46). Eyes r 3.25.

Checker alpha 0.05 might be too visible; use 0.04. Border stroke rgba(148,163,184,0.08).

Vignette: radial gradient center transparent → rgba(0,0,0,0.25) at corners: create once per frame cheap: 
```
const vg=ctx.createRadialGradient(B/2,B/2,B*0.35,B/2,B/2,B*0.75);
vg.addColorStop(0,'rgba(0,0,0,0)'); vg.addColorStop(1,'rgba(0,0,0,0.26)');
```
Creating gradient per frame ok.

Also add faint grid dots at intersections? skip.

Double-check the demo overlay timing at load: state 'demo', overlay key 'demo' → attract card visible at first paint ✓ screenshot at 3s shows game + card ✓.

Now writing final HTML.

Also: title of page. And meta.

Potential gotcha: `e.target.closest` — e.target may be document (rare) — guard typeof.

Let me also handle `keydown` when user holds arrow (auto-repeat): pushDir filters same-dir duplicates ✓ so queue won't flood.

Also handle both 'Enter' and 'Return'? e.key==='Enter' standard ✓.

Also NumpadEnter? e.key 'Enter' covers both? NumpadEnter reports 'Enter' ✓.

Write everything now.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Snake · Vanilla Canvas</title>
<style>
  :root{
    --bg:#0a0e13;
    --panel:#10161f;
    --line:#223041;
    --text:#e7ecf3;
    --muted:#64748b;
    --accent:#34d399;
    --accent-soft:#6ee7b7;
    --mono:ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
  }
  *{box-sizing:border-box;margin:0;padding:0}
  html,body{height:100%}
  body{
    background-color:var(--bg);
    background-image:radial-gradient(1100px 620px at 50% -8%, #152230 0%, rgba(10,14,19,0) 68%);
    background-repeat:no-repeat;
    color:var(--text);
    font-family:var(--mono);
    min-height:100svh;
    display:grid;place-items:center;
    padding:18px 16px;
    -webkit-font-smoothing:antialiased;
  }
  .frame{
    width:clamp(300px, min(94vw, 100svh - 240px), 572px);
    width:clamp(300px, min(94vw, 100dvh - 240px), 572px);
    display:flex;flex-direction:column;gap:13px;
  }
  ...
```

Wait duplicate width lines: second wins where dvh supported ✓.

Continue:

```
  .top{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}
  .brand{display:flex;align-items:center;gap:10px}
  .brand svg{display:block;flex:none}
  .brand h1{font-size:19px;font-weight:800;letter-spacing:.32em;color:#f1f5f9}
  .hud{display:flex;align-items:center;gap:8px}
  .chip{display:flex;flex-direction:column;align-items:center;gap:3px;min-width:78px;padding:7px 13px;background:var(--panel);border:1px solid var(--line);border-radius:10px}
  .chip span{font-size:9px;letter-spacing:.24em;color:var(--muted)}
  .chip b{font-size:18px;font-weight:700;color:var(--text);font-variant-numeric:tabular-nums;line-height:1}
  .chip.score b{color:var(--accent-soft)}
  .btn{font-family:var(--mono);font-size:10px;letter-spacing:.18em;color:var(--accent-soft);background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:9px 12px;cursor:pointer;transition:color .15s,border-color .15s}
  .btn:hover{border-color:#31465c}
  .btn.off{color:var(--muted)}
  .stage{position:relative;width:100%;aspect-ratio:1;border-radius:16px;overflow:hidden;background:#0d1319;box-shadow:0 26px 70px -22px rgba(0,0,0,.7),0 0 0 1px var(--line)}
  .stage canvas{display:block;width:100%;height:100%}
  @keyframes shake{
    15%{transform:translate(-5px,2px)}
    35%{transform:translate(5px,-3px)}
    55%{transform:translate(-4px,2px)}
    75%{transform:translate(3px,-1px)}
    92%{transform:translate(-2px,1px)}
  }
  .stage.shake{animation:shake .42s linear}
  .overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;text-align:center;padding:22px;background:rgba(7,11,16,.42);transition:opacity .2s ease}
  .overlay.dim{background:rgba(7,11,16,.72);backdrop-filter:blur(4px)}
  .overlay.is-hidden{opacity:0;pointer-events:none}
  .card{display:flex;flex-direction:column;align-items:center;gap:12px;max-width:100%}
  .panel{background:rgba(12,18,26,.92);border:1px solid var(--line);border-radius:18px;padding:26px 38px;box-shadow:0 24px 60px rgba(0,0,0,.5)}
  .attract{background:rgba(10,15,21,.72);border:1px solid rgba(148,163,184,.16);border-radius:18px;padding:24px 30px;box-shadow:0 18px 50px rgba(0,0,0,.45)}
  .logo-big{display:flex;align-items:center;gap:12px}
  .logo-big span{font-size:33px;font-weight:800;letter-spacing:.3em;margin-right:-.3em;color:#f1f5f9}
  .pill{display:flex;align-items:center;gap:8px;font-size:10px;letter-spacing:.22em;color:var(--accent-soft);border:1px solid rgba(110,231,183,.35);background:rgba(52,211,153,.08);padding:6px 13px;border-radius:999px}
  .pulse{width:8px;height:8px;border-radius:50%;background:var(--accent);animation:pulse 1.15s ease-in-out infinite}
  @keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.3;transform:scale(.65)}}
  .cta{font-size:12.5px;color:#cbd8e6}
  .sub{font-size:10.5px;color:var(--muted)}
  .overlay h2{font-size:17px;letter-spacing:.3em;margin-right:-.3em;text-transform:uppercase;color:var(--danger)}
  .overlay h2.calm{color:#e7ecf3}
  .overlay h2.win{color:var(--accent-soft)}
  .newbest{font-size:10px;font-weight:700;letter-spacing:.26em;color:#052e21;background:var(--accent);padding:5px 12px;border-radius:999px}
  .final{display:flex;flex-direction:column;gap:4px}
  .final span{font-size:9.5px;letter-spacing:.26em;color:var(--muted)}
  .final b{font-size:42px;font-weight:800;color:#f1f5f9;line-height:1;font-variant-numeric:tabular-nums}
  .final.small b{font-size:17px;color:#cbd8e6}
  kbd{display:inline-block;padding:2px 7px;border:1px solid #33465c;border-bottom-width:2px;border-radius:6px;background:#151f2a;color:#e2e8f0;font-family:var(--mono);font-size:.92em}
  .hints{display:flex;gap:16px;justify-content:center;flex-wrap:wrap;color:#5b6b7d;font-size:11.5px}
  .hints span{display:inline-flex;align-items:center;gap:6px}
  @media (max-width:430px){
    .brand h1{font-size:16px;letter-spacing:.24em}
    .chip{min-width:0;padding:6px 10px}
    .chip b{font-size:15px}
    .panel{padding:20px 22px}
    .final b{font-size:34px}
    .hints{gap:10px;font-size:10.5px}
  }
```

Chips: score chip value green tint — nice.

HTML body content with inline svg:

```
<header class="top">
  <div class="brand">
    <svg width="26" height="26" viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <circle cx="5.4" cy="7.2" r="2.1" fill="#f87171"/>
      <path d="M6.6 5.9c.9-.9 2-.9 2.6-.4" stroke="#4ade80" stroke-width="1.4" stroke-linecap="round"/>
      <path d="M3.5 17h7a4.5 4.5 0 0 0 0-9h2.6" stroke="#34d399" stroke-width="3.2" stroke-linecap="round"/>
      <circle cx="14.6" cy="8" r="2.9" fill="#86efac"/>
      <circle cx="15.6" cy="7.2" r="0.8" fill="#0a0e13"/>
    </svg>
    <h1>SNAKE</h1>
  </div>
  ...
```

Hmm leaf path floating; check circle apple at (5.4,7.2) r2.1; leaf curve from (6.6,5.9) — plausible little leaf/stem. Head circle at (14.6,8): path ends at 3.5+7+... let me compute: M3.5 17 → h7 → x=10.5,y=17. a4.5... 0 0 0 0 -9 → end (10.5,8). h2.6 → (13.1,8). Head circle cx 14.6 r2.9 → spans 11.7..17.5 — overlaps line end ✓ looks like head at end of neck. Eye at (15.6,7.2) inside head ✓. Snake body from (3.5,17) right to (10.5,17), semicircle bulging right? sweep 0 → passes through x>10.5 side (right) → bulge to x=15 — but head also at x~14.6,y8 — the bulge at y12.5 x15; head at y8 — no overlap conflict; visually: line along bottom, hook up right side to top, head at top-right pointing... eye placement top-right of head. Looks like a coiled snake ✓ good enough as a logo mark.

Wait — bulge right means arc goes from (10.5,17) rightward up to (10.5,8), i.e., through (15,12.5). Then h2.6 draws from (10.5,8) rightwards to (13.1,8) — that line sits above the arc's top region — from arc endpoint rightward. Slight visual: neck extends right at top, head circle beyond ✓.

OK.

Footer hints arrows: use "←↑↓→" glyphs? Arrows are fine text chars. `<kbd>←↑↓→</kbd>` might be wide; fine. Or separate kbds. Use `<kbd>↑</kbd><kbd>↓</kbd>...` too busy. Single kbd with four arrows ok. Hmm "←↑↓→" ordering typical "↑↓←→"? I'll write ← ↑ ↓ → inside one kbd: "←↑↓→". fine.

Now the script. Full listing:

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

const COLS=22, ROWS=22, CELL=26, BOARD=COLS*CELL;
const START_TICK=140, MIN_TICK=82, TICK_STEP=3;

const canvas=document.getElementById('game');
const ctx=canvas.getContext('2d');
const stage=document.getElementById('stage');
const overlay=document.getElementById('overlay');
const scoreEl=document.getElementById('score');
const bestEl=document.getElementById('best');
const sfxBtn=document.getElementById('sfxBtn');

const DIR4=[{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
const DX=[1,-1,0,0], DY=[0,0,1,-1];
const DIRKEYS={
  ArrowUp:DIR4[3], ArrowDown:DIR4[2], ArrowLeft:DIR4[1], ArrowRight:DIR4[0],
  w:DIR4[3], s:DIR4[2], a:DIR4[1], d:DIR4[0]
};

let viewScale=1;
let state='demo', isDemo=true;
let snake=[], prevSnake=[], dir=DIR4[0], queue=[];
let food=null, score=0, best=0, prevBest=0, newBest=false, isWin=false;
let tickMs=START_TICK, acc=0, lastT=performance.now(), deathAt=0;
let particles=[], popups=[];
let overlayKey='init';
let shakeTO=0;

/* ---------- audio ---------- */
let ac=null, sfxOn=true;
function ensureAudio(){
  if(!ac){ try{ ac=new (window.AudioContext||window.webkitAudioContext)(); }catch(err){ ac=null; } }
  if(ac && ac.state==='suspended') ac.resume().catch(()=>{});
}
function blip(f0,f1,dur,type,vol){
  if(!sfxOn||!ac) return;
  try{
    const t0=ac.currentTime;
    const o=ac.createOscillator(), g=ac.createGain();
    o.type=type;
    o.frequency.setValueAtTime(f0,t0);
    o.frequency.exponentialRampToValueAtTime(Math.max(1,f1),t0+dur);
    g.gain.setValueAtTime(vol,t0);
    g.gain.exponentialRampToValueAtTime(0.0001,t0+dur);
    o.connect(g); g.connect(ac.destination);
    o.start(t0); o.stop(t0+dur+0.03);
  }catch(err){}
}
const sfxEat =()=>blip(620,980,0.09,'square',0.10);
const sfxDie =()=>blip(210,52,0.5,'sawtooth',0.16);
const sfxGo  =()=>blip(430,760,0.14,'triangle',0.09);
```

Hmm in sandbox AudioContext allowed? It's not in the blocked list; wrapped in try/catch anyway.

```
/* ---------- sizing ---------- */
function fit(){
  const r=canvas.getBoundingClientRect();
  if(r.width<2) return;
  const dpr=Math.max(1,Math.min(window.devicePixelRatio||1,2.5));
  const s=Math.round(r.width*dpr);
  if(canvas.width!==s){ canvas.width=s; canvas.height=s; }
  viewScale=s/BOARD;
}
if('ResizeObserver' in window) new ResizeObserver(fit).observe(canvas);
window.addEventListener('resize',fit);

/* ---------- helpers ---------- */
const ckey=(x,y)=>y*COLS+x;
const cellEq=(a,b)=>a.x===b.x&&a.y===b.y;

function updateHUD(){ scoreEl.textContent=score; bestEl.textContent=best; }

function spawnFood(){
  const occ=new Uint8Array(COLS*ROWS);
  for(const s of snake) occ[ckey(s.x,s.y)]=1;
  const free=[];
  for(let i=0;i<occ.length;i++) if(!occ[i]) free.push(i);
  if(!free.length){ food=null; return true; } // board full -> win
  const i=free[(Math.random()*free.length)|0];
  food={x:i%COLS, y:(i-i%COLS)/COLS};
  return false;
}
```
Wait (i - i%COLS)/COLS = (i/COLS)|0 ✓ use Math.floor(i/COLS).

```
function reset(mode){
  isDemo = mode==='demo';
  const cx=COLS>>1, cy=ROWS>>1;
  snake=[{x:cx,y:cy},{x:cx-1,y:cy},{x:cx-2,y:cy}];
  prevSnake=snake.map(s=>({x:s.x,y:s.y}));
  dir=DIR4[0]; queue=[];
  score=0; tickMs=START_TICK; acc=0;
  particles.length=0; popups.length=0;
  newBest=false; isWin=false;
  if(!isDemo) prevBest=best;
  const full=spawnFood();
  state=isDemo?'demo':'playing';
  if(full) die(true); // degenerate, practically unreachable
  updateHUD();
}
```

Hmm spawnFood before state set; ok.

die:
```
function die(win){
  isWin=!!win;
  newBest=!isDemo && score>prevBest && score>0;
  deathAt=performance.now();
  state='over';
  burst(snake[0].x, snake[0].y, isWin?'#86efac':'#f87171', isWin?26:20);
  if(!isDemo){
    stage.classList.remove('shake'); void stage.offsetWidth; stage.classList.add('shake');
    clearTimeout(shakeTO); shakeTO=setTimeout(()=>stage.classList.remove('shake'),480);
    isWin?sfxGo():sfxDie();
  }
}
```
burst signature: burst(x,y,tint,count) — extend earlier plan.

onEat:
```
function onEat(){
  score+=10;
  if(!isDemo) best=Math.max(best,score);
  burst(food.x,food.y,'#f87171',16);
  popups.push({x:food.x,y:food.y,age:0});
  tickMs=Math.max(MIN_TICK,tickMs-TICK_STEP);
  sfxEat();
  const full=spawnFood();
  if(full) die(true);
  updateHUD();
}
```

doStep:
```
function doStep(){
  let d=null;
  if(isDemo){
    d=aiDir();
  }else{
    while(queue.length){
      const q=queue.shift();
      if(q.x===-dir.x && q.y===-dir.y) continue;
      if(q.x===dir.x && q.y===dir.y) continue;
      d=q; break;
    }
    if(!d) d=dir;
  }
  dir=d;
  const h=snake[0];
  const nx=h.x+d.x, ny=h.y+d.y;
  if(nx<0||ny<0||nx>=COLS||ny>=ROWS){ die(false); return; }
  for(let i=0;i<snake.length-1;i++){
    if(snake[i].x===nx && snake[i].y===ny){ die(false); return; }
  }
  prevSnake=snake.map(s=>({x:s.x,y:s.y}));
  const eat=food && nx===food.x && ny===food.y;
  snake.unshift({x:nx,y:ny});
  if(eat) onEat(); else snake.pop();
}
```

aiDir as planned.

Particles:
```
function burst(cx,cy,tint,count){
  const px=(cx+0.5)*CELL, py=(cy+0.5)*CELL;
  const colors=[tint,'#fca5a5','#fde68a','#86efac','#ffffff'];
  for(let i=0;i<count;i++){
    const a=Math.random()*Math.PI*2, sp=55+Math.random()*135;
    const L=0.4+Math.random()*0.4;
    particles.push({x:px,y:py,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp-46,life:L,ttl:L,size:1.6+Math.random()*2.4,color:colors[(Math.random()*colors.length)|0]});
  }
  if(particles.length>240) particles.splice(0,particles.length-240);
}
```

updateFx(dt):
```
function updateFx(dt){
  for(const p of particles){
    p.life-=dt;
    p.vy+=330*dt;
    p.vx*=Math.max(0,1-1.6*dt);
    p.x+=p.vx*dt; p.y+=p.vy*dt;
  }
  particles=particles.filter(p=>p.life>0);
  for(const q of popups) q.age+=dt;
  popups=popups.filter(q=>q.age<0.9);
}
```

Render:

```
function render(t){
  if(!(viewScale>0)) return;
  ctx.setTransform(viewScale,0,0,viewScale,0,0);

  // board
  ctx.fillStyle='#0d1319';
  ctx.fillRect(0,0,BOARD,BOARD);
  ctx.fillStyle='rgba(148,163,184,0.045)';
  for(let y=0;y<ROWS;y++){
    for(let x=(y&1);x<COLS;x+=2) ctx.fillRect(x*CELL,y*CELL,CELL,CELL);
  }
  // vignette
  const vg=ctx.createRadialGradient(BOARD/2,BOARD/2,BOARD*0.34,BOARD/2,BOARD/2,BOARD*0.74);
  vg.addColorStop(0,'rgba(0,0,0,0)');
  vg.addColorStop(1,'rgba(0,0,0,0.28)');
  ctx.fillStyle=vg; ctx.fillRect(0,0,BOARD,BOARD);
  // frame line
  ctx.strokeStyle='rgba(148,163,184,0.09)';
  ctx.lineWidth=2;
  ctx.strokeRect(1,1,BOARD-2,BOARD-2);

  if(food) drawFood(t);
  drawSnake();
  drawParticles();
  drawPopups();

  if(state==='over'){
    const el=(performance.now()-deathAt)/450;
    if(el<1){
      ctx.fillStyle=`rgba(239,68,68,${(0.3*(1-el)).toFixed(3)})`;
      ctx.fillRect(0,0,BOARD,BOARD);
    }
  }
}
```

drawFood(t): as planned.

drawSnake():
```
function drawSnake(){
  const anim=(state==='playing'||state==='demo');
  const alpha=anim?Math.min(1,acc/tickMs):1;
  const pts=snake.map((s,i)=>{
    const p=prevSnake[i]||s;
    return {x:(p.x+(s.x-p.x)*alpha+0.5)*CELL, y:(p.y+(s.y-p.y)*alpha+0.5)*CELL};
  });
  const dead=(state==='over');

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

  // dark rim
  ctx.strokeStyle='rgba(4,28,20,0.92)';
  ctx.lineWidth=CELL*0.84;
  ctx.beginPath();
  ctx.moveTo(pts[0].x,pts[0].y);
  for(let i=1;i<pts.length;i++) ctx.lineTo(pts[i].x,pts[i].y);
  ctx.stroke();

  // gradient body
  const last=pts[pts.length-1];
  const g=ctx.createLinearGradient(pts[0].x,pts[0].y,last.x,last.y);
  g.addColorStop(0,'#8df0ac');
  g.addColorStop(0.45,'#34d399');
  g.addColorStop(1,'#0f766e');
  ctx.strokeStyle=g;
  ctx.lineWidth=CELL*0.66;
  ctx.beginPath();
  ctx.moveTo(pts[0].x,pts[0].y);
  for(let i=1;i<pts.length;i++) ctx.lineTo(pts[i].x,pts[i].y);
  ctx.stroke();

  // head
  const h=pts[0];
  ctx.fillStyle='#a7f3d0';
  ctx.strokeStyle='rgba(4,28,20,0.92)';
  ctx.lineWidth=3;
  ctx.beginPath(); ctx.arc(h.x,h.y,CELL*0.46,0,7); ctx.fill(); ctx.stroke();

  const fx=dir.x, fy=dir.y, pxv=-dir.y, pyv=dir.x;
  for(const sgn of [-1,1]){
    const ex=h.x+fx*CELL*0.15+pxv*sgn*CELL*0.19;
    const ey=h.y+fy*CELL*0.15+pyv*sgn*CELL*0.19;
    if(dead && !isWin){
      // X eyes
      ctx.strokeStyle='#0b1220'; ctx.lineWidth=2.2; ctx.lineCap='round';
      const r=CELL*0.09;
      ctx.beginPath();
      ctx.moveTo(ex-r,ey-r); ctx.lineTo(ex+r,ey+r);
      ctx.moveTo(ex+r,ey-r); ctx.lineTo(ex-r,ey+r);
      ctx.stroke();
    }else{
      ctx.fillStyle='#ffffff';
      ctx.beginPath(); ctx.arc(ex,ey,CELL*0.125,0,7); ctx.fill();
      ctx.fillStyle='#0b1220';
      ctx.beginPath(); ctx.arc(ex+fx*CELL*0.05,ey+fy*CELL*0.05,CELL*0.062,0,7); ctx.fill();
    }
  }
}
```

Wait dead && isWin: show normal eyes with happy? isWin shows normal eyes fine.

drawParticles/drawPopups as planned.

refreshOverlay:
```
function refreshOverlay(){
  const overReady = state==='over' && !isDemo && (performance.now()-deathAt>450);
  let key;
  if(state==='demo') key='demo';
  else if(state==='paused') key='paused';
  else if(overReady) key='over'+(newBest?'b':'')+(isWin?'w':'');
  else key='none';
  if(key===overlayKey) return;
  overlayKey=key;

  let html='';
  if(key==='demo'){
    html=`<div class="card attract">
      <div class="logo-big">${MARK}<span>SNAKE</span></div>
      <div class="pill"><i class="pulse"></i><span>AUTOPILOT DEMO</span></div>
      <p class="cta">Click or press any key to play</p>
      <p class="sub">Arrows / WASD to steer &middot; eat apples &middot; don't crash</p>
    </div>`;
  }else if(key==='paused'){
    html=`<div class="card panel">
      <h2 class="calm">Paused</h2>
      <p class="cta">Press <kbd>P</kbd> or click to resume</p>
    </div>`;
  }else if(key.startsWith('over')){
    html=`<div class="card panel">
      <h2 class="${isWin?'win':''}">${isWin?'Perfect run':'Game over'}</h2>
      ${newBest?'<div class="newbest">NEW BEST</div>':''}
      <div class="final"><span>SCORE</span><b>${score}</b></div>
      <div class="final small"><span>BEST</span><b>${best}</b></div>
      <p class="cta">Press <kbd>Space</kbd> or <kbd>Enter</kbd> to restart</p>
    </div>`;
  }
  overlay.innerHTML=html;
  overlay.classList.toggle('is-hidden',key==='none');
  overlay.classList.toggle('dim',key==='paused'||key.startsWith('over'));
}
```

MARK const string with the svg.

Input:

```
window.addEventListener('keydown', e=>{
  const raw=e.key;
  const k=raw.length===1?raw.toLowerCase():raw;
  const d=DIRKEYS[k]||DIRKEYS[raw]; // raw covers Arrow keys (already same)
```
Actually DIRKEYS keys: 'ArrowUp' etc exact e.key; letters lowercase after toLowerCase. k for 'ArrowUp' length>1 → k=raw → DIRKEYS['ArrowUp'] ✓. For 'W' → 'w' ✓. So just const d=DIRKEYS[k].

```
  if(d || raw===' ' || raw==='Enter' || k==='p') e.preventDefault();
  ensureAudio();
  if(d){
    if(state==='demo') startPlayer(d);
    else if(state==='playing'||state==='paused') pushDir(d);
  }else if(raw===' '||raw==='Enter'){
    if(state==='demo'||state==='over') startPlayer(null);
  }else if(k==='p'){
    if(state==='playing'){ state='paused'; acc=0; }
    else if(state==='paused'){ state='playing'; }
  }
});
```

Hmm preventDefault on 'p' — harmless.

Edge: keydown 'over' && isDemo → startPlayer ✓ handled in space/enter branch; arrow keys in demo-over: state==='demo'? No — state==='over' with isDemo. d branch: state 'over' → none of demo/playing/paused → ignored. So during demo crash pause, arrows do nothing; space/click starts. Hmm slight inconsistency: "press any key to play" during demo — arrow during the 1s crash window ignored; acceptable (rare). Could add: if(state==='over'&&isDemo&&d) startPlayer(d). Add for robustness ✓.

startPlayer:
```
function startPlayer(d){
  reset('player');
  if(d) pushDir(d);
  sfxGo();
}
```

pushDir as planned.

pointer:
```
document.addEventListener('pointerdown', e=>{
  if(e.target && e.target.closest && e.target.closest('#sfxBtn')) return;
  ensureAudio();
  if(state==='demo'||state==='over') startPlayer(null);
  else if(state==='paused') state='playing';
});
```

sfx button:
```
sfxBtn.addEventListener('click', e=>{
  e.stopPropagation();
  sfxOn=!sfxOn;
  sfxBtn.textContent='SFX '+(sfxOn?'ON':'OFF');
  sfxBtn.classList.toggle('off',!sfxOn);
  ensureAudio();
  if(sfxOn) blip(520,700,0.08,'triangle',0.08);
});
```

visibility/blur:
```
document.addEventListener('visibilitychange',()=>{ if(document.hidden&&state==='playing'){ state='paused'; acc=0; } });
window.addEventListener('blur',()=>{ if(state==='playing'){ state='paused'; acc=0; } });
```

Init:
```
reset('demo');
fit();
updateHUD();
requestAnimationFrame(t=>{ lastT=t; requestAnimationFrame(frame); });
```

frame:
```
function frame(t){
  requestAnimationFrame(frame);
  let dt=(t-lastT)/1000; lastT=t;
  if(dt<0) dt=0; if(dt>0.25) dt=0.25;
  if(state==='playing'||state==='demo'){
    acc+=dt*1000;
    let guard=0;
    while(acc>=tickMs && guard++<6){
      acc-=tickMs;
      doStep();
      if(state!=='playing'&&state!=='demo'){ acc=0; break; }
    }
    if(acc>tickMs) acc=tickMs; // hmm not needed
  }
  if(state==='over' && isDemo && t-deathAt>1000) reset('demo');
  updateFx(dt);
  refreshOverlay();
  render(t);
}
```

Wait: when doStep causes die → state 'over', we break ✓.

Also: while loop with acc: after death acc=0 ✓.

aiDir full code:

```
function aiDir(){
  const head=snake[0], tail=snake[snake.length-1];
  const occ=new Uint8Array(COLS*ROWS);
  for(let i=0;i<snake.length-1;i++) occ[ckey(snake[i].x,snake[i].y)]=1;
  const headI=ckey(head.x,head.y), tailI=ckey(tail.x,tail.y);
  const foodI=food?ckey(food.x,food.y):-1;

  const bfsStep=(targetI)=>{
    if(targetI<0||targetI===headI) return null;
    const par=new Int32Array(COLS*ROWS).fill(-1);
    const stepDir=new Int8Array(COLS*ROWS).fill(-1);
    const seen=new Uint8Array(COLS*ROWS);
    seen[headI]=1;
    const q=[headI]; let qi=0;
    while(qi<q.length){
      const c=q[qi++];
      const cx=c%COLS, cy=(c/COLS)|0;
      for(let dd=0;dd<4;dd++){
        const nx2=cx+DX[dd], ny2=cy+DY[dd];
        if(nx2<0||ny2<0||nx2>=COLS||ny2>=ROWS) continue;
        const n=ny2*COLS+nx2;
        if(seen[n]) continue;
        if(occ[n] && n!==targetI) continue;
        seen[n]=1; par[n]=c; stepDir[n]=dd;
        if(n===targetI){
          let cur=n;
          while(par[cur]!==headI) cur=par[cur];
          return DIR4[stepDir[cur]];
        }
        q.push(n);
      }
    }
    return null;
  };

  const floodFrom=(startI,occArr)=>{
    const seen=new Uint8Array(COLS*ROWS);
    seen[startI]=1;
    const st=[startI]; let n=0;
    while(st.length){
      const c=st.pop(); n++;
      const cx=c%COLS, cy=(c/COLS)|0;
      for(let dd=0;dd<4;dd++){
        const nx2=cx+DX[dd], ny2=cy+DY[dd];
        if(nx2<0||ny2<0||nx2>=COLS||ny2>=ROWS) continue;
        const ni=ny2*COLS+nx2;
        if(seen[ni]||occArr[ni]) continue;
        seen[ni]=1; st.push(ni);
      }
    }
    return n;
  };

  const opts=[];
  for(let i=0;i<4;i++){
    const d=DIR4[i];
    if(d.x===-dir.x && d.y===-dir.y) continue;
    const nx=head.x+d.x, ny=head.y+d.y;
    if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
    const ni=ckey(nx,ny);
    if(occ[ni]) continue;
    const eating=(ni===foodI);
    const occ2=occ.slice();
    occ2[ni]=1;
    occ2[tailI]=eating?1:0;
    opts.push({d, area:floodFrom(ni,occ2), dist:food?Math.abs(food.x-nx)+Math.abs(food.y-ny):0, r:Math.random()});
  }
  if(!opts.length) return dir;

  const need=snake.length+1;
  const toFood=bfsStep(foodI);
  if(toFood){
    const o=opts.find(o=>o.d.x===toFood.x&&o.d.y===toFood.y);
    if(o && o.area>=need) return o.d;
  }else{
    const toTail=bfsStep(tailI);
    if(toTail){
      const o=opts.find(o=>o.d.x===toTail.x&&o.d.y===toTail.y);
      if(o && o.area>=need) return o.d;
    }
  }
  opts.sort((a,b)=> b.area-a.area || a.dist-b.dist || a.r-b.r);
  return opts[0].d;
}
```

Int8Array for stepDir values 0..3 ✓.

Hmm `opts.find(o=>...)` shadows outer o fine.

One catch: bfsStep to tail: tail may be adjacent... fine. But tail-chasing with shortest path to tail can cause tight loops where snake follows tail forever and never eats — but food path check happens each tick; if a food path appears (snake moved), it takes it. In pure tail-chase mode snake circles; as it circles, cells free up, eventually BFS to food succeeds. Usually works. Even if demo loops a while, it looks alive.

Also possible: bfsStep returns step whose area check fails, then fallback sorts by area — decent.

Another subtle: when food is null (win) — aiDir foodI=-1: bfsStep(-1) returns null; dist uses food? guard ✓.

Now drawFood:

```
function drawFood(t){
  const fx=(food.x+0.5)*CELL, fy=(food.y+0.5)*CELL;
  const pulse=1+0.07*Math.sin(t/240);
  const halo=ctx.createRadialGradient(fx,fy,CELL*0.1,fx,fy,CELL*1.0*pulse);
  halo.addColorStop(0,'rgba(248,113,113,0.30)');
  halo.addColorStop(1,'rgba(248,113,113,0)');
  ctx.fillStyle=halo;
  ctx.beginPath(); ctx.arc(fx,fy,CELL*1.0*pulse,0,7); ctx.fill();

  const r=CELL*0.33*pulse;
  const g=ctx.createRadialGradient(fx-r*0.35,fy-r*0.42,r*0.12,fx,fy,r*1.05);
  g.addColorStop(0,'#fda4af');
  g.addColorStop(0.55,'#f43f5e');
  g.addColorStop(1,'#be123c');
  ctx.fillStyle=g;
  ctx.beginPath(); ctx.arc(fx,fy,r,0,7); ctx.fill();

  ctx.strokeStyle='#8b5e34';
  ctx.lineWidth=2; ctx.lineCap='round';
  ctx.beginPath(); ctx.moveTo(fx+0.5,fy-r+1.5); ctx.lineTo(fx+2.6,fy-r-3.6); ctx.stroke();

  ctx.fillStyle='#4ade80';
  ctx.beginPath(); ctx.ellipse(fx+6.2,fy-r-2.6,4.2,2.1,-0.55,0,7); ctx.fill();

  ctx.fillStyle='rgba(255,255,255,0.75)';
  ctx.beginPath(); ctx.ellipse(fx-r*0.34,fy-r*0.36,r*0.22,r*0.13,-0.6,0,7); ctx.fill();
}
```

drawParticles:
```
function drawParticles(){
  for(const p of particles){
    const a=Math.max(0,p.life/p.ttl);
    ctx.globalAlpha=a;
    ctx.fillStyle=p.color;
    ctx.beginPath();
    ctx.arc(p.x,p.y,p.size*(0.35+0.65*a),0,7);
    ctx.fill();
  }
  ctx.globalAlpha=1;
}
```

drawPopups:
```
function drawPopups(){
  if(!popups.length) return;
  ctx.font='700 17px '+MONO;
  ctx.textAlign='center';
  ctx.textBaseline='middle';
  for(const q of popups){
    const k=q.age/0.9;
    const x=(q.x+0.5)*CELL, y=(q.y+0.5)*CELL-9-k*26;
    ctx.globalAlpha=1-k*k;
    ctx.lineWidth=4; ctx.strokeStyle='rgba(8,12,16,0.65)';
    ctx.strokeText('+10',x,y);
    ctx.fillStyle='#fde68a';
    ctx.fillText('+10',x,y);
  }
  ctx.globalAlpha=1;
}
```
MONO string const: "ui-monospace, Menlo, Consolas, monospace" — canvas font parsing with 'ui-monospace'? Canvas font shorthand accepts font-family list; ui-monospace is a valid generic-ish keyword (CSS Fonts 4); Chrome supports in canvas? It should parse; if not, falls back? Invalid font string → font unchanged (keeps default '10px sans-serif'). Risk: whole assignment ignored → font 10px sans. Safer: use `'700 17px Menlo, Consolas, monospace'` (skip ui-monospace in canvas). ✓

const FONT="'700 17px Menlo, Consolas, monospace'".

Reset textAlign? set each time ✓.

Now MARK string:
```
const MARK='<svg width="34" height="34" viewBox="0 0 24 24" fill="none" aria-hidden="true">'
 +'<circle cx="5.4" cy="7.2" r="2.1" fill="#f87171"/>'
 +'<path d="M6.6 5.9c.9-.9 2-.9 2.6-.4" stroke="#4ade80" stroke-width="1.4" stroke-linecap="round"/>'
 +'<path d="M3.5 17h7a4.5 4.5 0 0 0 0-9h2.6" stroke="#34d399" stroke-width="3.2" stroke-linecap="round"/>'
 +'<circle cx="14.6" cy="8" r="2.9" fill="#86efac"/>'
 +'<circle cx="15.6" cy="7.2" r="0.8" fill="#0a0e13"/>'
 +'</svg>';
```

Header uses same markup inline (write it directly in HTML).

Double-check overlay pill: `<i class="pulse"></i><span>AUTOPILOT DEMO</span>` — i styled.

Check potential issue: `.overlay` transition opacity — when state changes to 'none' overlay fades ✓.

Also overlay when 'none' remains in DOM empty ✓.

Now think about the "dim" for game over covering the crashed board — after 450ms; flash finishes ~450ms; ok sequence: crash → flash + shake → overlay fades in ✓ nice choreography.

Score in overlay final uses current score ✓.

Also for the win case (board full) — practically unreachable; code handles.

Potential problem: The demo AI occasionally dies → flash suppressed (isDemo skip shake/sfx but burst still happens ✓ via die()). Flash renders regardless (state over) — red flash in demo ok subtle. Fine.

Another check — `while(par[cur]!==headI) cur=par[cur];` with par filled -1: if chain hits -1 → par[-1]? cur=-1 → par[-1] undefined → loop forever? Chain guaranteed rooted at headI because BFS builds parents from head ✓ target reachable ⇒ chain valid.

Food spawn: uses global snake ✓.

One more feature: prevent starting player game via clicking during 'playing' — nothing ✓.

Also after starting player run, pointerdown during play ignored — maybe click to pause? Could confuse; skip.

Now let me reconsider tick speed & grid: 22×22 with 140ms feels moderate; good.

Also initial snake at center heading right; food random; if food spawns at column 20 far right — AI goes ✓.

Check alpha clamp: acc/tickMs could exceed 1 briefly before step? While loop steps when acc>=tickMs so after loop acc<tickMs (unless guard). alpha=min(1,...) ✓.

Between reset and first step: prevSnake==snake → static ✓.

I think code is solid. Let me also add a subtle detail: snake "breathing" glow? skip.

Let me also double check CSS `width:clamp(300px, min(94vw, 100dvh - 240px), 572px)` — inside min(), `100dvh - 240px` needs calc()? Per spec, math functions like min() accept calc-like expressions directly — `min(94vw, 100dvh - 240px)` is valid ✓ (calc literals allowed inside).

But careful: older parsing — Chrome fine.

Also add fallback plain: `width:min(572px, 94vw);` before the clamp lines for very old — fine.

Height concern: at 800 viewport height: 100dvh-240=560 → stage 560px; header/footer fit: header ~54 (chip padding), gap 13, footer ~30, body padding 36 → total 560+54+13+30+36 ≈ 693 ✓.

At 375×667 phone: 667-240=427 → stage min(94vw=352, 427, ...) → 352 ✓ fits width; total height fine, scroll maybe slight if 300 floor > available? 300+overheads ≈ 430 <667 ✓.

Now, "No horizontal scrollbar": widths use vw-based clamp ✓; body padding ok.

Also `place-items:center` with content taller than viewport → grid centering causes overflow clipping top? Use `min-height:100svh` on body + place-items center: when content taller, it overflows both directions? With grid place-items center and min-height, content taller than container → overflow at top hidden? Actually overflow accessible via scroll but top part can be unreachable (classic centering cutoff issue). Use safer: body{display:flex; align-items:center; justify-content:center;} with min-height — same issue; mitigate with `align-items:center` + margin auto trick: .frame{margin:auto}. Use body{display:flex} .frame{margin:auto} — safe auto margins prevent cutoff ✓. Do that.

body{display:flex} .frame{margin:auto}.

Also html,body height 100% + min-height:100svh on body — use min-height:100svh on body only; html height auto. Keep body{min-height:100svh; display:flex}.

Alright — also `overscroll-behavior` none? fine default.

Let me also give the canvas `tabindex`? not needed.

Possible console warning: ResizeObserver loop — fine.

Also `e.target.closest('#sfxBtn')` — for pointerdown on button ✓.

One more polish idea: show a tiny "speed" indicator? skip — clean.

Also maybe display grid coordinates? no.

I also want to double check the requirement "new food never spawns on the snake" ✓ via free-cell selection.

"Quick successive key presses must both be honored, but the snake can never reverse onto itself" ✓ queue design.

"Game over when the snake hits a wall or itself, with an overlay showing the score; Space or Enter restarts, P pauses." ✓.

"smooth movement" ✓ interpolation. "distinct head and eyes facing its direction" ✓. "small particle burst when eating" ✓.

"Display the current score and the best score of the session." ✓.

"Games must start in a self-playing demo mode behind a 'Click or press a key to start' message" ✓.

Now assemble final file. Also I'll include a small comment header? Keep code clean with section comments.

Let me also verify no use of blocked APIs ✓.

Potential issue: In sandboxed iframe, `document.hidden` etc fine.

Edge: `performance.now()` in frame t param is same timebase ✓.

Let me write drawPopups font: `ctx.font='700 17px Menlo, Consolas, monospace';` — note canvas font requires size+family; '700 17px Menlo, Consolas, monospace' valid ✓.

textBaseline set 'middle' persists — also affects nothing else (no other text). ✓ Reset globalAlpha=1 after ✓.

Also strokeText joins default fine.

Let me also make sure the snake head doesn't visually detach at corners: path through centers with round join ✓.

Potential visual: outline pass over previous gradient pass? Order: rim first, then gradient — gradient on top ✓.

Head drawn after body so rim of head over body start ✓.

Eyes at 0.15 forward of center, radius head .46 → eyes inside head ✓.

When dir is up: eyes at y-0.15±x0.19 ✓ facing up.

Now write out the entire file cleanly. I'll aim for clarity with brief comments.

Also last check of the tick loop: `while(acc>=tickMs && guard++<6)` — if guard exceeded, leftover acc stays → next frames catch up gradually ✓.

dt clamp 0.25 → acc max ~250 → up to ~2-3 ticks; guard 6 ok.

Also when tab returns after long, visibilitychange paused (playing) — but demo continues; dt clamp prevents burst ✓.

One more: on `startPlayer`, ensure stage shake class removed: reset doesn't clear shake — timeout removes ✓.

Also clear particles on reset ✓.

Final check on overlay markup injection & CSS class names consistent.

Also `#overlay` initial empty with class overlay — key 'init' ≠ 'none' → first refresh sets is-hidden ✓ but innerHTML '' ✓ fine. Actually initial overlayKey='init' and computed 'demo' → builds demo card ✓ visible at load. Good — first paint shows attract + playing demo behind ✓.

Wait: overlay default CSS has background visible even when empty before first refresh — first refresh runs in first frame before paint ✓.

Edge: if JS fails entirely, canvas blank dark — acceptable.

Also make sure `<canvas id="game" width="572" height="572">` initial attrs so pre-fit render sane ✓.

Double-check keydown preventDefault list also includes arrows always (even during typing? no inputs on page) ✓.

Also: should Space during 'playing' do nothing — but preventDefault still stops scroll ✓.

Hmm — one more consideration about P during 'over': ignored ✓.

Also Enter during 'paused': ignored (only P/click resumes) — fine.

I think we're ready to write. Also small: add `aria-live` to score? skip.

Let me write the complete final answer file now, carefully, ensuring syntax correctness.

Also remember: reply must be exactly ONE fenced code block, no text before/after. Use ```html.

Let me write it.

Additional review while writing render: `ctx.strokeRect(1,1,BOARD-2,BOARD-2)` with lineWidth 2 → half outside; fine.

For checker loop `for(let x=(y&1);x<COLS;x+=2)` ✓ alternate.

In drawSnake when snake length 1? never.

In render, when state 'over' we still call drawSnake with alpha=1 ✓ using last prevSnake/snake (post-last-step) ✓.

Note: after die (wall hit), snake unchanged, prevSnake is from previous step → alpha 1 → renders at final grid ✓.

Also careful: after die, prevSnake might be from the last successful step; snake==prevSnake after that step? After a normal step, prevSnake holds pre-step; alpha at 1 renders at snake ✓ correct.

Popups reference dead food coords — fine.

Font in canvas: MONOF const.

Also define `const TAU=7;`? just use 7 literal... use Math.PI*2 for clarity. I'll define TAU=Math.PI*2 and use it.

Now writing final.

Also check: `overlay.classList.toggle('dim', ...)` string keys: 'paused' exact; over variants start with 'over' ✓ but note 'over' also... 'none','demo' don't ✓.

Potential subtle CSS: `.overlay h2` selectors — my h2 inside .card ✓.

Let me finalize chip label texts: "SCORE", "BEST" uppercase via CSS text-transform? Write directly uppercase in HTML.

Write the file:

Also add meta theme-color #0a0e13 nicety.

Counting potential pitfalls done. Compose now.

One more thought on demo autopilot safety threshold `need=snake.length+1`: floodFrom counts reachable free cells INCLUDING start(head cell). The metric: after moving, free region reachable. For safety want area ≥ snake length (each segment needs eventual space). Using need=len+1 slightly conservative ✓ ok. Early game: area ~478 ≥ 4 ✓.

When snake very long (>200), tail-chase area threshold may often fail → fallback max-area still picks something reasonable ✓.

Also add tiny randomized delay in demo decisions? no.

Alright, final answer time. Write full HTML.

Also, subtle: `background-image:radial-gradient(...)` with `background-repeat:no-repeat` and background-color — when page taller than gradient, bottom plain #0a0e13 ✓.

Also selection styling: `::selection{background:#34d39933}` optional; skip.

Let me write everything now in final form, mentally compiling for syntax errors as I go.

Also: in HTML, `don't` apostrophe inside template literal fine; inside single-quoted JS strings I use template literals with backticks — apostrophes fine.

The `&middot;` in template string — innerHTML renders entity ✓. Or use '·' char directly — fine UTF-8. Use '·'.

Also in footer static HTML use ·.

Final code:

(Write carefully...)

Also I realize: when the user presses a direction key to start the game (startPlayer(d)), reset('player') runs then pushDir(d) with dir=right: if d=Left → filtered (reverse) → snake goes right; acceptable.

And when starting via arrow while state 'over' player (not demo) — ignored by design (must press Space/Enter/click). Hmm is that annoying? Spec says Space or Enter restarts — keep.

Also add: clicking canvas during 'playing' — maybe ignore; done.

Let me also handle iOS touch: pointerdown works ✓; game is keyboard-based — mobile users can't play without keys... Not required; maybe add swipe? Out of scope; the demo covers judging. But adding simple swipe support is ~15 lines: track pointerdown/up on stage during play, compute delta, if |dx|>|dy| & >24px → pushDir horizontal else vertical. Could conflict with click-to-start (click = tap). During playing, swipe moves. I'll add lightweight swipe — improves demo completeness on touch. Implement in stage pointerdown/pointerup? Since document pointerdown already used for start; add separate logic: on pointerdown record start point if state playing; on pointerup compute swipe if playing. Use pointer events on stage with capture... keep simple:

let swipe=null;
document pointerdown: record swipe={x,y} always (after existing handling).
document pointerup: if(state==='playing'&&swipe){ dx,dy; if(max(|dx|,|dy|)>24){ pushDir(|dx|>|dy| ? (dx>0?R:L) : (dy>0?D:U)) } swipe=null; }

Also preventDefault on touchmove to avoid scroll while swiping? Page doesn't scroll (content fits) mostly; skip complexity? On mobile, swiping on canvas might scroll page if content overflows — content fits vertically? At small heights it might scroll; adding `touch-action:none` on .stage prevents scroll gestures starting on canvas ✓ CSS-only. Add .stage{touch-action:none}.

Good, include swipe.

Also note: pointerdown starting the game AND then pointerup immediately with tiny move → no swipe ✓.

Now finalize. Writing the complete document:

Also, chip for score uses class "chip score".

Double-check `updateHUD` initial: best 0, score 0 ✓.

Also during demo the HUD SCORE counts — chip labeled SCORE; fine.

Edge: `sfxGo` on win & start both — fine.

Also: On restart from game over via Space: startPlayer → sfxGo ✓.

Everything good. Compose final HTML now — I'll be careful with braces.

Let me also pre-verify aiDir candidate find for toFood: bfsStep returns DIR4 element reference; compare by components ✓.

Potential: multiple opts with same d? no, 4 unique dirs.

Write out:

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#0a0e13">
<title>Snake · Vanilla Canvas</title>
<style>
  :root{
    --bg:#0a0e13;
    --panel:#10161f;
    --line:#223041;
    --text:#e7ecf3;
    --muted:#64748b;
    --accent:#34d399;
    --accent-soft:#6ee7b7;
    --danger:#f87171;
    --mono:ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
  }
  *{box-sizing:border-box;margin:0;padding:0}
  body{
    min-height:100svh;
    display:flex;
    background-color:var(--bg);
    background-image:radial-gradient(1100px 620px at 50% -8%, #152230 0%, rgba(10,14,19,0) 68%);
    background-repeat:no-repeat;
    color:var(--text);
    font-family:var(--mono);
    padding:18px 16px;
    -webkit-font-smoothing:antialiased;
  }
  .frame{
    margin:auto;
    width:min(572px, 94vw);
    width:clamp(300px, min(94vw, 100svh - 240px), 572px);
    width:clamp(300px, min(94vw, 100dvh - 240px), 572px);
    display:flex;flex-direction:column;gap:13px;
  }
  .top{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}
  .brand{display:flex;align-items:center;gap:10px}
  .brand svg{display:block;flex:none}
  .brand h1{font-size:19px;font-weight:800;letter-spacing:.32em;color:#f1f5f9}
  .hud{display:flex;align-items:center;gap:8px}
  .chip{display:flex;flex-direction:column;align-items:center;gap:3px;min-width:78px;padding:7px 13px;background:var(--panel);border:1px solid var(--line);border-radius:10px}
  .chip span{font-size:9px;letter-spacing:.24em;color:var(--muted)}
  .chip b{font-size:18px;font-weight:700;line-height:1;font-variant-numeric:tabular-nums}
  .chip.score b{color:var(--accent-soft)}
  .btn{font-family:var(--mono);font-size:10px;letter-spacing:.18em;color:var(--accent-soft);background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:10px 12px;cursor:pointer;transition:color .15s,border-color .15s}
  .btn:hover{border-color:#33475e}
  .btn.off{color:var(--muted)}
  .stage{position:relative;width:100%;aspect-ratio:1;border-radius:16px;overflow:hidden;background:#0d1319;touch-action:none;box-shadow:0 26px 70px -22px rgba(0,0,0,.7),0 0 0 1px var(--line)}
  .stage canvas{display:block;width:100%;height:100%}
  @keyframes shake{15%{transform:translate(-5px,2px)}35%{transform:translate(5px,-3px)}55%{transform:translate(-4px,2px)}75%{transform:translate(3px,-1px)}92%{transform:translate(-2px,1px)}}
  .stage.shake{animation:shake .42s linear}
  .overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;text-align:center;padding:22px;background:rgba(7,11,16,.42);transition:opacity .2s ease}
  .overlay.dim{background:rgba(7,11,16,.74);backdrop-filter:blur(4px)}
  .overlay.is-hidden{opacity:0;pointer-events:none}
  .card{display:flex;flex-direction:column;align-items:center;gap:12px;max-width:100%}
  .panel{background:rgba(12,18,26,.93);border:1px solid var(--line);border-radius:18px;padding:26px 40px;box-shadow:0 24px 60px rgba(0,0,0,.5)}
  .attract{background:rgba(10,15,21,.74);border:1px solid rgba(148,163,184,.16);border-radius:18px;padding:24px 32px;box-shadow:0 18px 50px rgba(0,0,0,.45)}
  .logo-big{display:flex;align-items:center;gap:12px}
  .logo-big span{font-size:32px;font-weight:800;letter-spacing:.3em;margin-right:-.3em;color:#f1f5f9}
  .pill{display:flex;align-items:center;gap:8px;font-size:10px;letter-spacing:.22em;color:var(--accent-soft);border:1px solid rgba(110,231,183,.35);background:rgba(52,211,153,.08);padding:6px 13px;border-radius:999px}
  .pulse{width:8px;height:8px;border-radius:50%;background:var(--accent);animation:pulse 1.15s ease-in-out infinite}
  @keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.3;transform:scale(.65)}}
  .cta{font-size:12.5px;color:#cbd8e6}
  .sub{font-size:10.5px;color:var(--muted)}
  .overlay h2{font-size:17px;letter-spacing:.3em;margin-right:-.3em;text-transform:uppercase;color:var(--danger)}
  .overlay h2.calm{color:#e7ecf3}
  .overlay h2.win{color:var(--accent-soft)}
  .newbest{font-size:10px;font-weight:700;letter-spacing:.26em;color:#052e21;background:var(--accent);padding:5px 12px;border-radius:999px}
  .final{display:flex;flex-direction:column;gap:5px}
  .final span{font-size:9.5px;letter-spacing:.26em;color:var(--muted)}
  .final b{font-size:42px;font-weight:800;line-height:1;color:#f1f5f9;font-variant-numeric:tabular-nums}
  .final.small b{font-size:17px;color:#cbd8e6}
  kbd{display:inline-block;padding:2px 7px;border:1px solid #33465c;border-bottom-width:2px;border-radius:6px;background:#151f2a;color:#e2e8f0;font-family:var(--mono);font-size:.92em}
  .hints{display:flex;gap:16px;justify-content:center;flex-wrap:wrap;color:#5b6b7d;font-size:11.5px}
  .hints span{display:inline-flex;align-items:center;gap:6px}
  @media (max-width:430px){
    .brand h1{font-size:16px;letter-spacing:.24em}
    .chip{min-width:0;padding:6px 10px}
    .chip b{font-size:15px}
    .panel{padding:20px 22px}
    .final b{font-size:34px}
    .hints{gap:10px;font-size:10.5px}
  }
</style>
</head>
<body>
<main class="frame">
  <header class="top">
    <div class="brand">
      <svg width="26" height="26" viewBox="0 0 24 24" fill="none" aria-hidden="true">
        <circle cx="5.4" cy="7.2" r="2.1" fill="#f87171"/>
        <path d="M6.6 5.9c.9-.9 2-.9 2.6-.4" stroke="#4ade80" stroke-width="1.4" stroke-linecap="round"/>
        <path d="M3.5 17h7a4.5 4.5 0 0 0 0-9h2.6" stroke="#34d399" stroke-width="3.2" stroke-linecap="round"/>
        <circle cx="14.6" cy="8" r="2.9" fill="#86efac"/>
        <circle cx="15.6" cy="7.2" r="0.8" fill="#0a0e13"/>
      </svg>
      <h1>SNAKE</h1>
    </div>
    <div class="hud">
      <div class="chip score"><span>SCORE</span><b id="score">0</b></div>
      <div class="chip"><span>BEST</span><b id="best">0</b></div>
      <button id="sfxBtn" class="btn" type="button">SFX ON</button>
    </div>
  </header>

  <div class="stage" id="stage">
    <canvas id="game" width="572" height="572" aria-label="Snake board"></canvas>
    <div class="overlay" id="overlay"></div>
  </div>

  <footer class="hints">
    <span><kbd>←↑↓→</kbd> / <kbd>WASD</kbd> steer</span>
    <span><kbd>P</kbd> pause</span>
    <span><kbd>Space</kbd>/<kbd>Enter</kbd> restart</span>
  </footer>
</main>

<script>
(() => {
'use strict';
... all js ...
})();
</script>
</body>
</html>
```

Wait — footer hints mention swipe? add `<span>swipe on touch</span>`? Only show relevant; keep three hints, add "swipe also works" maybe too much. Skip.

JS final assembly — let me write it completely and check every function reference exists.

Also `DIRKEYS` values reference DIR4 indices: DIR4=[R,L,D,U] as I ordered: index0 {1,0} R; 1 {-1,0} L; 2 {0,1} D; 3 {0,-1} U. Map ArrowUp→DIR4[3] ✓.

In aiDir, `dir` global compare ✓.

Let me be careful: in doStep demo branch: d=aiDir() could be `dir` itself (fallback) ✓ fine.

Everything referenced: MARK, MONOF, TAU.

Also — one thing: in `refreshOverlay` for demo the pill `<i class="pulse"></i>` — <i> italic default; content empty so fine; add font-style normal? empty element no text ✓.

Also add `aria-hidden` on overlay? fine without.

Potential issue: `overlay.classList.toggle('dim', key==='paused'||key.startsWith('over'))` — key 'overb' etc ✓; but also 'over' itself never (always has suffix b/w maybe empty → key='over' when no newBest & not win → startsWith('over') ✓).

Great.

Now — think again about first-frame: `reset('demo')` runs at script end; spawnFood picks food; frame loop starts. Render draws ✓.

fit(): canvas rect — stage sized by .frame width clamp → at load ~572 or less; RO callback ensures.

viewScale = s/BOARD where s=canvas.width (device px) — mapping board coords→device px ✓.

Hmm: canvas CSS height 100% of stage; stage aspect-ratio 1 → square ✓; but if .frame width clamp yields e.g. 560 → stage 560 ✓.

One concern: `aspect-ratio` on stage + canvas height 100%: canvas block with height 100% of stage ✓.

Also overlay inset 0 ✓.

Potential: On very first frames before RO fires, canvas.width=572 attr, rect maybe 572 → viewScale=1 ✓ consistent.

Now, is there any chance `ctx.setTransform(viewScale,...)` with viewScale from previous size after resize mid-frame? fit() updates on RO ✓.

OK. Also remove shake class initial? none.

Sound: iOS requires resume within gesture — ensureAudio in pointerdown ✓.

Let me also double check that pressing 'p' while demo does nothing but ALSO preventDefault — fine.

Potential risk: user presses 'P' as first key expecting pause but it starts game? No — 'p' isn't a start key; in demo, pressing P: d=null, not space/enter → k==='p' branch: state 'demo' not playing/paused → nothing. So P in demo does nothing ✓ good (spec: P pauses gameplay).

Now finalize popups font const: `const POPFONT='700 17px Menlo, Consolas, monospace';`

Also I should double check `ctx.ellipse` args (x,y,rx,ry,rotation,start,end) ✓.

Alright — also small: after death in player mode, HUD score remains final ✓.

Compose the final JS in full:

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

/* ------- constants ------- */
const COLS=22, ROWS=22, CELL=26, BOARD=COLS*CELL, TAU=Math.PI*2;
const START_TICK=140, MIN_TICK=82, TICK_STEP=3;
const MONOF='Menlo, Consolas, monospace';
const MARK='<svg width="34" height="34" viewBox="0 0 24 24" fill="none" aria-hidden="true">'
  +'<circle cx="5.4" cy="7.2" r="2.1" fill="#f87171"/>'
  +'<path d="M6.6 5.9c.9-.9 2-.9 2.6-.4" stroke="#4ade80" stroke-width="1.4" stroke-linecap="round"/>'
  +'<path d="M3.5 17h7a4.5 4.5 0 0 0 0-9h2.6" stroke="#34d399" stroke-width="3.2" stroke-linecap="round"/>'
  +'<circle cx="14.6" cy="8" r="2.9" fill="#86efac"/>'
  +'<circle cx="15.6" cy="7.2" r="0.8" fill="#0a0e13"/>'
  +'</svg>';

const DIR4=[{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
const DX=[1,-1,0,0], DY=[0,0,1,-1];
const DIRKEYS={
  ArrowUp:DIR4[3], ArrowDown:DIR4[2], ArrowLeft:DIR4[1], ArrowRight:DIR4[0],
  w:DIR4[3], s:DIR4[2], a:DIR4[1], d:DIR4[0]
};

/* ------- dom ------- */
const canvas=document.getElementById('game');
const ctx=canvas.getContext('2d');
const stage=document.getElementById('stage');
const overlay=document.getElementById('overlay');
const scoreEl=document.getElementById('score');
const bestEl=document.getElementById('best');
const sfxBtn=document.getElementById('sfxBtn');

/* ------- state ------- */
let viewScale=1;
let state='demo', isDemo=true;
let snake=[], prevSnake=[], dir=DIR4[0], queue=[];
let food=null, score=0, best=0, prevBest=0, newBest=false, isWin=false;
let tickMs=START_TICK, acc=0, lastT=0, deathAt=0;
let particles=[], popups=[];
let overlayKey='init', shakeTO=0, swipe=null;
```

...rest as planned. frame first call sets lastT.

Let me make sure `frame` declared before use in requestAnimationFrame — function declaration hoisted ✓.

Now write everything into final file. Also add comment header sections.

Potential oversight: `.chip b` for best not colored — default #e7ecf3 via inherited? b color default: set `.chip b{color:var(--text)}` ✓ included.

Also: h1 letter-spacing leaves trailing gap; wrap with margin-right? minor.

Ensure `body` flex centering with margin:auto on .frame ✓ handles overflow gracefully.

One more possible bug: `overlay` has `transition:opacity` and initially empty with bg visible → fine.

When state transitions demo→playing: overlay fades out (is-hidden) ✓ pointer-events none ✓.

Swipe detail: after starting game via tap, the same tap's pointerup: state now 'playing', swipe recorded at pointerdown {x,y}; pointerup delta small (<24) → no push ✓.

But careful: swipe recorded even when starting (pointerdown starts game, sets swipe). Then pointerup: state 'playing' && swipe && delta small → nothing ✓. If user swipes to start (big delta) → after start, pointerup pushes a dir — actually nice: swipe-to-start immediately steers ✓.

Implement:
```
document.addEventListener('pointerdown',e=>{
  swipe={x:e.clientX,y:e.clientY};
  if(e.target&&e.target.closest&&e.target.closest('#sfxBtn')) return;
  ensureAudio();
  if(state==='demo'||state==='over') startPlayer(null);
  else if(state==='paused') state='playing';
});
document.addEventListener('pointerup',e=>{
  if(!swipe) return;
  const dx=e.clientX-swipe.x, dy=e.clientY-swipe.y; swipe=null;
  if(state!=='playing') return;
  if(Math.max(Math.abs(dx),Math.abs(dy))<24) return;
  pushDir(Math.abs(dx)>Math.abs(dy)?(dx>0?DIR4[0]:DIR4[1]):(dy>0?DIR4[2]:DIR4[3]));
});
```

ok.

Also `pointercancel` → swipe=null. minor; add.

Final: also add `window.addEventListener('keydown')` handles e.repeat? pushDir filters duplicates; startPlayer on repeat space during over → repeated restarts? state 'over' → startPlayer → state 'playing'; repeat space next: state playing → space branch does nothing ✓. Holding arrow from demo start: repeats push same dir filtered ✓.

Now — potential subtle rendering nit: the gradient stroke path includes the full polyline each frame; when snake long (200+ segments) still trivial.

I'm confident. Also double-check that the head rim stroke doesn't create weird overlap with body rim at neck — same color, fine.

Write final file now. Also include a few concise comments.

Also: ensure I don't accidentally use reserved `opts.find(o=>...)` arrow param shadowing — fine.

Let me also double check `spawnFood` division: `Math.floor(i/COLS)` ✓.

And `ckey` consistent ✓.

deathAt when reset from demo: reset sets state playing/demo; deathAt stale irrelevant ✓.

Overlay overReady condition uses `!isDemo` — after demo crash, isDemo still true until reset('demo') at 1000ms → overlay stays hidden through crash → then demo resumes ✓. But during that 1s, snake frozen with X eyes? dead-eyes drawn when state 'over' — in demo too (isWin false → X eyes) — cute ✓.

Also flash red in demo ✓ subtle.

Edge: demo crash by wall: burst at head which is at edge → particles fly inward/outward; some outside board clipped ✓ fine.

Now writing the complete final file for real. I'll go through it once more after writing to catch syntax slips.

Actually one more design check: the score chip label "SCORE" green value; BEST white. Good hierarchy.

Also, should the HUD show during demo that score is "demo"? The attract pill communicates autopilot ✓.

Final answer below. Let me write the code block.

While writing drawSnake: guard pts empty? snake always ≥3 ✓.

Also in render, draw food before snake so snake overlaps food when passing ✓; when eating, food respawns elsewhere immediately — the eaten food disappears same tick with particle burst ✓.

Hmm: visually, head reaches food at alpha=1 of the tick when eaten — but food respawned at eat-time (step start). So during the interpolation of that tick, new food already visible elsewhere while head slides into old food cell — slight oddity: apple vanishes the instant the head starts moving toward it. Better: delay food respawn visually? Classic approach: respawn immediately — acceptable; but nicer: keep drawing the eaten apple at old spot until head arrives? Simple improvement: on eat, set food=null and schedule spawn at next step? Then during growing tick there's no food on board; new food appears when the next step begins. That reads well: apple consumed as head reaches it, then new apple appears a beat later. But requirement "new food never spawns on snake" still ✓. Also board without food for one tick (≤140ms) — barely noticeable, looks natural.

Implement: in onEat: store eaten pos for popup/burst; food=null; then at beginning of each doStep, if(!food) spawnFood(). Wait — spawn at next doStep start means new food appears exactly when head starts next move ✓. Also on reset, spawnFood immediately ✓. Also win check moves to that spawn. Careful: doStep collision uses food for eat detection — if food null (just eaten last tick), eat=false ✓ then spawnFood at step start... order: spawn first, then compute move — so new food exists before move ✓:

doStep(): if(!food){ if(spawnFood()){ die(true); return; } } — win case.

Then proceed. ✓ Nice polish.

But wait: with food=null during the grow-tick, aiDir uses foodI=-1 → BFS skip → tail-chase fallback... during that single tick AI wanders by max-area — fine. Actually better: spawn food at END of the eat tick for AI logic continuity? The one-tick null is fine.

Hmm, but there's an edge: after onEat sets food=null, remaining same-tick code continues (returns to loop) ✓ next doStep spawns ✓.

But what if game over occurs in the tick right after eating? food null; fine.

Also popup drawn at eaten position ✓ (stored).

Adjust burst/popup to use eaten coords before nulling:

```
function onEat(){
  const fx=food.x, fy=food.y;
  score+=10;
  if(!isDemo) best=Math.max(best,score);
  burst(fx,fy,'#f87171',16);
  popups.push({x:fx,y:fy,age:0});
  tickMs=Math.max(MIN_TICK,tickMs-TICK_STEP);
  sfxEat();
  food=null;
  updateHUD();
}
```
And doStep head: `if(!food && spawnFood()){ die(true); return; }` at top — but careful: this runs also on the very first step after reset where food already exists ✓ skip.

Also in aiDir: food may be null → foodI=-1; dist computed with guard `food?...:0` ✓; bfsStep(-1) → null ✓ tail-chase branch ✓.

Update spawnFood: remove win handling there (returns full bool) ✓ as written.

reset(): `if(spawnFood()) ...` can't be full on reset (3 cells) — ignore.

OK now truly write it.

Also one more: during the foodless tick in demo, aiDir may pick moves ignoring food — 1 tick, fine.

Let me also reconsider MIN borders: strokeRect at edges under rounded clip ✓.

Write final file:

I'll now produce the complete HTML. Careful bracket-by-bracket.

After writing, do a final mental lint pass.

Also decide: `MIN_TICK=82`, TICK_STEP=3 → 20 apples to min. Good pacing.

Let me write the script fully:

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

/* ================= constants ================= */
const COLS = 22, ROWS = 22, CELL = 26, BOARD = COLS * CELL, TAU = Math.PI * 2;
const START_TICK = 140, MIN_TICK = 82, TICK_STEP = 3;
const MONOF = 'Menlo, Consolas, monospace';
const MARK = '...svg...';

const DIR4 = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}]; // R L D U
const DX = [1,-1,0,0], DY = [0,0,1,-1];
const DIRKEYS = {
  ArrowUp:DIR4[3], ArrowDown:DIR4[2], ArrowLeft:DIR4[1], ArrowRight:DIR4[0],
  w:DIR4[3], s:DIR4[2], a:DIR4[1], d:DIR4[0]
};

/* ================= dom ================= */
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const stage = document.getElementById('stage');
const overlay = document.getElementById('overlay');
const scoreEl = document.getElementById('score');
const bestEl = document.getElementById('best');
const sfxBtn = document.getElementById('sfxBtn');

/* ================= state ================= */
let viewScale = 1;
let state = 'demo', isDemo = true;
let snake = [], prevSnake = [], dir = DIR4[0], queue = [];
let food = null, score = 0, best = 0, prevBest = 0, newBest = false, isWin = false;
let tickMs = START_TICK, acc = 0, lastT = 0, deathAt = 0;
let particles = [], popups = [];
let overlayKey = 'init', shakeTO = 0, swipe = null;

/* ================= audio ================= */
let ac = null, sfxOn = true;
function ensureAudio(){
  if(!ac){ try{ ac = new (window.AudioContext || window.webkitAudioContext)(); }catch(err){ ac = null; } }
  if(ac && ac.state === 'suspended') ac.resume().catch(()=>{});
}
function blip(f0, f1, dur, type, vol){
  if(!sfxOn || !ac) return;
  try{
    const t0 = ac.currentTime;
    const o = ac.createOscillator(), g = ac.createGain();
    o.type = type;
    o.frequency.setValueAtTime(f0, t0);
    o.frequency.exponentialRampToValueAtTime(Math.max(1, f1), t0 + dur);
    g.gain.setValueAtTime(vol, t0);
    g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
    o.connect(g); g.connect(ac.destination);
    o.start(t0); o.stop(t0 + dur + 0.03);
  }catch(err){}
}
const sfxEat = () => blip(620, 990, 0.09, 'square', 0.10);
const sfxDie = () => blip(210, 52, 0.5, 'sawtooth', 0.16);
const sfxGo  = () => blip(430, 780, 0.14, 'triangle', 0.09);

/* ================= sizing ================= */
function fit(){
  const r = canvas.getBoundingClientRect();
  if(r.width < 2) return;
  const dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 2.5));
  const s = Math.round(r.width * dpr);
  if(canvas.width !== s){ canvas.width = s; canvas.height = s; }
  viewScale = s / BOARD;
}
if('ResizeObserver' in window) new ResizeObserver(fit).observe(canvas);
window.addEventListener('resize', fit);

/* ================= helpers ================= */
const ckey = (x, y) => y * COLS + x;

function updateHUD(){ scoreEl.textContent = score; bestEl.textContent = best; }

function spawnFood(){
  const occ = new Uint8Array(COLS * ROWS);
  for(const s of snake) occ[ckey(s.x, s.y)] = 1;
  const free = [];
  for(let i = 0; i < occ.length; i++) if(!occ[i]) free.push(i);
  if(!free.length){ food = null; return true; }
  const i = free[(Math.random() * free.length) | 0];
  food = { x: i % COLS, y: Math.floor(i / COLS) };
  return false;
}

function reset(mode){
  isDemo = mode === 'demo';
  const c = COLS >> 1, cy = ROWS >> 1;
  snake = [{x:c,y:cy},{x:c-1,y:cy},{x:c-2,y:cy}];
  prevSnake = snake.map(s => ({x:s.x, y:s.y}));
  dir = DIR4[0]; queue = [];
  score = 0; tickMs = START_TICK; acc = 0;
  particles.length = 0; popups.length = 0;
  newBest = false; isWin = false;
  if(!isDemo) prevBest = best;
  spawnFood();
  state = isDemo ? 'demo' : 'playing';
  updateHUD();
}
```

Wait variable name `c` for cx conflicts with later usage? local fine.

Continue:

```
function burst(cx, cy, tint, count){
  const px = (cx + 0.5) * CELL, py = (cy + 0.5) * CELL;
  const colors = [tint, '#fca5a5', '#fde68a', '#86efac', '#ffffff'];
  for(let i = 0; i < count; i++){
    const a = Math.random() * TAU, sp = 55 + Math.random() * 135, L = 0.4 + Math.random() * 0.4;
    particles.push({ x:px, y:py, vx:Math.cos(a)*sp, vy:Math.sin(a)*sp - 46, life:L, ttl:L, size:1.6 + Math.random()*2.4, color:colors[(Math.random()*colors.length)|0] });
  }
  if(particles.length > 260) particles.splice(0, particles.length - 260);
}

function die(win){
  isWin = !!win;
  newBest = !isDemo && score > prevBest && score > 0;
  deathAt = performance.now();
  state = 'over';
  burst(snake[0].x, snake[0].y, isWin ? '#86efac' : '#f87171', isWin ? 26 : 20);
  if(!isDemo){
    stage.classList.remove('shake'); void stage.offsetWidth; stage.classList.add('shake');
    clearTimeout(shakeTO); shakeTO = setTimeout(() => stage.classList.remove('shake'), 480);
    isWin ? sfxGo() : sfxDie();
  }
}

function onEat(){
  const fx = food.x, fy = food.y;
  score += 10;
  if(!isDemo) best = Math.max(best, score);
  burst(fx, fy, '#f87171', 16);
  popups.push({ x:fx, y:fy, age:0 });
  tickMs = Math.max(MIN_TICK, tickMs - TICK_STEP);
  sfxEat();
  food = null;
  updateHUD();
}

function doStep(){
  if(!food && spawnFood()){ die(true); return; } // board full -> perfect run
  let d = null;
  if(isDemo){
    d = aiDir();
  }else{
    while(queue.length){
      const q = queue.shift();
      if(q.x === -dir.x && q.y === -dir.y) continue;
      if(q.x === dir.x && q.y === dir.y) continue;
      d = q; break;
    }
    if(!d) d = dir;
  }
  dir = d;
  const h = snake[0];
  const nx = h.x + d.x, ny = h.y + d.y;
  if(nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS){ die(false); return; }
  for(let i = 0; i < snake.length - 1; i++){
    if(snake[i].x === nx && snake[i].y === ny){ die(false); return; }
  }
  prevSnake = snake.map(s => ({x:s.x, y:s.y}));
  const eat = food && nx === food.x && ny === food.y;
  snake.unshift({x:nx, y:ny});
  if(eat) onEat(); else snake.pop();
}
```

Hmm — spawn at step start happens even during the tick following eat ✓. But also on the very first tick after reset food exists ✓ skip.

Wait: there's a subtle interplay — food spawns at start of doStep BEFORE direction chosen; the AI sees fresh food ✓; player too ✓. 

But one wrinkle: new food may spawn under the snake's next head cell? It's free of snake now ✓ can't overlap snake; head will move there — that's fine (eating next tick).

aiDir & render & rest as planned.

Loop and events as planned.

Now — one more consideration on `dir` for eyes when snake just started moving? dir right ✓.

Also, `popups` use grid coords; drawPopups multiplies ✓.

Assemble final. Also handle `sfxBtn` id in HTML: id="sfxBtn" ✓.

Potential CSS var use in canvas? no.

Also the footer: mentions restart keys ✓.

Also I want the overlay attract sub line mention P pause? footer covers ✓.

Write it all out now — final. I'll count braces mentally per function.

One more inspection of refreshOverlay usage of template `${...}` inside a normal script — fine.

Also isWin h2 class: `class="${isWin?'win':''}"` may produce class="" ok.

Also note: `key.startsWith('over')` includes 'over' itself ✓.

Also careful: overlay key for over uses newBest/isWin captured at die time ✓ stable.

Alright, produce final output. Also remember NO text outside the code block.

Let me write the entire thing now, and do one more pass for errors afterward (particularly: template literal nesting, arrow funcs, unmatched parens).

Additional final check on the demo AI bfsStep when target is food but food is currently under... food never under snake ✓.

Also `opts` uses `food` global for dist; guard food null ✓.

Edge: state 'demo' but food null right after eat — aiDir still returns something ✓.

Also: potential infinite growth of popups/particles — capped ✓.

Writing now.

Hmm, also: the `while(par[cur]!==headI)` uses `par` Int32Array with -1 fill; if target===headI early-returned ✓; unreachable target → returns null before ✓.

Also in floodFrom, counting includes start cell — semantics fine.

Let me also sanity-check aiDir's reverse-filter uses global `dir` which during demo equals last move ✓.

Also the AI checks `if(occ[ni]) continue;` where occ excludes tail — moving into tail allowed ✓ but then occ2[tailI]=eating?1:0 — if moving into tail cell and not eating: ni===tailI, occ2[ni]=1 then occ2[tailI]=0 → net tail cell free → flood counts it — hmm the head occupies tail cell this tick and tail moves away next — area counts cell as free (it will be vacated) ✓ reasonable.

Done deliberating; write final.

Also the header svg uses fixed colors not currentColor — fine.

Final pass on HTML entities in JS strings: '·' directly ✓ charset utf-8.

Also one more: title tag "Snake · Vanilla Canvas".

Now produce.

Wait — also need to double check `.frame` width clamp floor 300px with min-width considerations at 375px viewport: 94vw=352.5; 100dvh-240 at 667 = 427 → clamp → min(352.5? within clamp: preferred=min(94vw,100dvh-240)=352.5 → clamp(300,352.5,572)=352.5 ✓ stage 352 wide ✓.

At 1280×800: 100dvh-240=560; 94vw huge → preferred 560 → clamp → 560px stage ✓ under 572 max. Good — board 560px, cells ~25.5px CSS. ✓

Header chips: total width fits 560 ✓.

Great. Also ensure hints don't overflow: flex-wrap ✓.

Output time.

One more subtle thing: `backdrop-filter` on overlay while is-hidden toggling — fine.

Also `will-change`? skip.

Also ensure `canvas` doesn't show focus ring — no focus.

Final: write code.

Actually — one more thought about `pushDir` pre-filtering relative to last queued: consider moving Right, queue empty, user quickly presses Up then Down within same tick: Up queued (perp ok). Down: last=Up → reverse → filtered. So only Up. ✓ desired.

Moving Right; press Down then Up quickly: Down queued; Up filtered (reverse of Down) → snake goes Down. Reasonable.

Also cap 3: pressing 4th within one tick drops — fine.

Also — I realize I should double-check the interpolation when the queue applies a turn: head slides from old head cell to new; body follows path through cell centers — turning looks smooth due to round joins ✓.

Writing final file NOW.

def check one more time the frame loop start:

```
function frame(t){
  requestAnimationFrame(frame);
  ...
}
lastT = performance.now();
requestAnimationFrame(frame);
```
First frame dt = t-lastT small ✓.

OK writing.

Also inside `render`, `drawFood` only if food ✓; during foodless tick skipped ✓.

Also update popups draw when state over ✓ continues.

Let me also add subtle "eyes look toward food"? extra; skip.

FINAL CODE:

I'll write it in one go.

(Also note: `Int8Array.fill(-1)` valid ✓.)

After the code block — nothing else. Ensure exactly one fenced block.