Models 44

MiniMax M3.1 Flash

by MiniMaxReasoningClean
Open in new window

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

How it went

Total time
2 min 6 s
Cost
$0.013
Output tokens
21,615
Spent thinking
13,770 tokens
Speed
171.8 tok/s
Console errors
0
First token after
505 ms
First visible text after
1 min 30 s
Prompt tokens
677
Code size
24.3 KB
Finish reason
stop
Served by
Fireworks
Model id
minimax/minimax-m3.1-flash
Reasoning sent
{"effort":"xhigh"}
Max output
64,000 tokens
Generated
Nov 11, 2026

What it was asked

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

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

Code run-1.html · 760 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Neon Snake</title>
<style>
  :root{
    --accent:#34d399;
    --accent2:#22d3ee;
    --text:#e7eefb;
    --muted:#8ea2c0;
    --stroke:rgba(255,255,255,.11);
    --panel:rgba(255,255,255,.045);
  }
  *{box-sizing:border-box;}
  html,body{height:100%;}
  body{
    margin:0;
    font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
    color:var(--text);
    background:
      radial-gradient(900px 620px at 12% -12%, rgba(34,211,238,.17), transparent 62%),
      radial-gradient(900px 720px at 92% 112%, rgba(52,211,153,.16), transparent 62%),
      linear-gradient(180deg,#0a1122 0%,#05070f 100%);
    display:flex;align-items:center;justify-content:center;
    padding:14px;overflow:hidden;
    -webkit-font-smoothing:antialiased;
    user-select:none;-webkit-user-select:none;
  }
  .app{width:100%;display:flex;flex-direction:column;align-items:center;gap:12px;}

  header{
    width:min(92vw,560px);
    display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;
  }
  .brand{display:flex;align-items:baseline;gap:8px;}
  .brand h1{
    margin:0;font-size:clamp(20px,3.4vw,27px);font-weight:800;letter-spacing:.16em;
    background:linear-gradient(92deg,#a7f3d0,#34d399 45%,#22d3ee);
    -webkit-background-clip:text;background-clip:text;color:transparent;
    text-shadow:0 0 26px rgba(52,211,153,.28);
  }
  .brand span{font-size:11px;color:var(--muted);letter-spacing:.22em;text-transform:uppercase;}
  .stats{display:flex;gap:8px;}
  .stat{
    min-width:72px;padding:6px 11px;border-radius:11px;
    background:var(--panel);border:1px solid var(--stroke);
    backdrop-filter:blur(6px);
    display:flex;flex-direction:column;align-items:center;gap:1px;
  }
  .stat .label{font-size:9.5px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);}
  .stat .value{font-size:19px;font-weight:700;font-variant-numeric:tabular-nums;
    font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;}
  .stat.score .value{color:#6ee7b7;}
  .stat.best .value{color:#67e8f9;}
  .stat.len .value{color:#c4b5fd;}

  .board{
    position:relative;
    width:min(92vw,62vh,560px);
    height:min(92vw,62vh,560px);
    border-radius:20px;
    border:1px solid rgba(255,255,255,.12);
    background:#0b1224;
    box-shadow:
      0 30px 70px rgba(0,0,0,.55),
      0 0 0 1px rgba(52,211,153,.10),
      0 0 60px rgba(34,211,238,.10) inset;
    overflow:hidden;
  }
  canvas{display:block;width:100%;height:100%;touch-action:none;}

  .overlay{
    position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
    background:radial-gradient(circle at 50% 45%, rgba(9,14,28,.58), rgba(4,7,15,.80));
    backdrop-filter:blur(3px);
    opacity:0;transition:opacity .28s ease;pointer-events:none;
  }
  .overlay.show{opacity:1;}
  .card{
    text-align:center;padding:22px 26px;border-radius:18px;
    background:linear-gradient(180deg,rgba(255,255,255,.075),rgba(255,255,255,.03));
    border:1px solid rgba(255,255,255,.14);
    box-shadow:0 18px 50px rgba(0,0,0,.5), 0 0 40px rgba(52,211,153,.10) inset;
    max-width:86%;
    animation:pop .35s cubic-bezier(.2,1.2,.4,1);
  }
  @keyframes pop{from{transform:scale(.9);opacity:0}to{transform:scale(1);opacity:1}}
  .badge{
    display:inline-block;font-size:9.5px;letter-spacing:.22em;text-transform:uppercase;
    color:#0b1224;background:linear-gradient(90deg,#6ee7b7,#22d3ee);
    padding:4px 10px;border-radius:999px;font-weight:800;margin-bottom:10px;
  }
  .card h2{margin:0 0 6px;font-size:clamp(17px,4.4vw,25px);font-weight:800;letter-spacing:.01em;}
  .card p{margin:0 0 12px;font-size:13px;color:var(--muted);line-height:1.5;}
  .ovstats{display:flex;gap:10px;justify-content:center;margin-bottom:12px;}
  .ovstats div{
    background:rgba(0,0,0,.28);border:1px solid rgba(255,255,255,.10);
    padding:7px 16px;border-radius:12px;min-width:88px;
  }
  .ovstats span{display:block;font-size:9.5px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);}
  .ovstats b{font-size:22px;font-variant-numeric:tabular-nums;
    font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;}
  .hint{font-size:11.5px;color:var(--muted);}
  .hint b{color:#cfe0f5;font-weight:600;}

  footer{
    width:min(92vw,560px);display:flex;align-items:center;justify-content:center;
    gap:8px;flex-wrap:wrap;font-size:11.5px;color:var(--muted);
  }
  kbd{
    display:inline-block;min-width:22px;text-align:center;padding:3px 6px;border-radius:6px;
    background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.16);
    border-bottom-width:2px;color:#dbe6f7;font-size:10.5px;font-family:inherit;font-weight:700;
  }
  .sep{opacity:.35}

  @media (max-width:430px){
    header{justify-content:center;gap:8px;}
    .brand span{display:none;}
    .stat{min-width:60px;padding:5px 8px;}
    .stat .value{font-size:16px;}
    footer{font-size:10.5px;}
  }
</style>
</head>
<body>
<div class="app">
  <header>
    <div class="brand">
      <h1>SNAKE</h1>
      <span>grid 24 × 24</span>
    </div>
    <div class="stats">
      <div class="stat score"><span class="label">Score</span><span class="value" id="uiScore">0</span></div>
      <div class="stat best"><span class="label">Best</span><span class="value" id="uiBest">0</span></div>
      <div class="stat len"><span class="label">Length</span><span class="value" id="uiLen">3</span></div>
    </div>
  </header>

  <div class="board" id="board">
    <canvas id="game"></canvas>
    <div class="overlay show" id="overlay">
      <div class="card">
        <div class="badge" id="ovBadge">AI Demo</div>
        <h2 id="ovTitle">Click or press a key to start</h2>
        <p id="ovText">An AI snake is playing by itself — take the controls any time.</p>
        <div class="ovstats" id="ovStats" style="display:none">
          <div><span>Score</span><b id="ovScore">0</b></div>
          <div><span>Best</span><b id="ovBest">0</b></div>
        </div>
        <div class="hint" id="ovHint"><b>Space / Enter</b> restart &nbsp;·&nbsp; <b>P</b> pause</div>
      </div>
    </div>
  </div>

  <footer>
    <kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd> or <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> to steer
    <span class="sep">•</span> <kbd>P</kbd> pause
    <span class="sep">•</span> <kbd>Space</kbd> restart
  </footer>
</div>

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

  // ---------------------------------------------------------------- config
  const COLS = 24, ROWS = 24;
  const canvas = document.getElementById('game');
  const ctx = canvas.getContext('2d');
  const boardEl = document.getElementById('board');
  const overlay = document.getElementById('overlay');
  const ovBadge = document.getElementById('ovBadge');
  const ovTitle = document.getElementById('ovTitle');
  const ovText = document.getElementById('ovText');
  const ovStats = document.getElementById('ovStats');
  const ovScore = document.getElementById('ovScore');
  const ovBest = document.getElementById('ovBest');
  const ovHint = document.getElementById('ovHint');
  const uiScore = document.getElementById('uiScore');
  const uiBest = document.getElementById('uiBest');
  const uiLen = document.getElementById('uiLen');

  const DIRS = {
    up:    {x: 0, y:-1},
    down:  {x: 0, y: 1},
    left:  {x:-1, y: 0},
    right: {x: 1, y: 0}
  };
  const DIR_LIST = [DIRS.up, DIRS.down, DIRS.left, DIRS.right];
  const KEYMAP = {
    arrowup: DIRS.up, w: DIRS.up,
    arrowdown: DIRS.down, s: DIRS.down,
    arrowleft: DIRS.left, a: DIRS.left,
    arrowright: DIRS.right, d: DIRS.right
  };

  // ---------------------------------------------------------------- state
  let cells = [];        // snake cells, head first
  let prevCells = [];    // cells before the last step (for smooth interpolation)
  let dir = DIRS.right;
  let queue = [];        // buffered direction changes (max 2) -> quick key presses honored
  let food = {x:0,y:0};
  let score = 0;
  let best = 0;
  let state = 'running'; // running | paused | over
  let mode = 'demo';     // demo (AI) | manual
  let stepMs = 140;
  let acc = 0;
  let lastT = performance.now();
  let particles = [];
  let popups = [];
  let demoTimer = 0;
  let eatPulse = 0;

  // ---------------------------------------------------------------- canvas
  let cssW = 0, cssH = 0, dpr = 1, cell = 20;
  const bg = document.createElement('canvas');

  function resize(){
    const rect = canvas.getBoundingClientRect();
    cssW = Math.max(1, rect.width);
    cssH = Math.max(1, rect.height);
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    canvas.width = Math.round(cssW * dpr);
    canvas.height = Math.round(cssH * dpr);
    cell = cssW / COLS;
    buildBackground();
  }

  function buildBackground(){
    bg.width = canvas.width; bg.height = canvas.height;
    const b = bg.getContext('2d');
    b.setTransform(dpr,0,0,dpr,0,0);
    b.fillStyle = '#0b1224';
    b.fillRect(0,0,cssW,cssH);
    // faint checker
    b.fillStyle = 'rgba(255,255,255,.020)';
    for(let y=0;y<ROWS;y++)
      for(let x=0;x<COLS;x++)
        if((x+y)&1) b.fillRect(x*cell, y*cell, cell+0.6, cell+0.6);
    // grid lines
    b.strokeStyle = 'rgba(125,175,255,.065)';
    b.lineWidth = 1;
    b.beginPath();
    for(let i=0;i<=COLS;i++){
      const p = Math.round(i*cell)+0.5;
      b.moveTo(p,0); b.lineTo(p,cssH);
      b.moveTo(0,p); b.lineTo(cssW,p);
    }
    b.stroke();
    // soft inner light
    const g = b.createRadialGradient(cssW/2, cssH*0.42, cell*1.5, cssW/2, cssH/2, Math.max(cssW,cssH)*0.78);
    g.addColorStop(0,'rgba(56,132,235,.14)');
    g.addColorStop(1,'rgba(0,0,0,.42)');
    b.fillStyle = g;
    b.fillRect(0,0,cssW,cssH);
  }

  // ---------------------------------------------------------------- helpers
  const key = (x,y) => x + ',' + y;
  function rnd(n){ return Math.floor(Math.random()*n); }

  function spawnFood(){
    const free = [];
    for(let y=0;y<ROWS;y++)
      for(let x=0;x<COLS;x++){
        let onSnake = false;
        for(const c of cells) if(c.x===x && c.y===y){ onSnake = true; break; }
        if(!onSnake) free.push({x,y});
      }
    if(!free.length) return null;
    return free[rnd(free.length)];
  }

  function updateHUD(){
    uiScore.textContent = score;
    uiBest.textContent = best;
    uiLen.textContent = cells.length;
  }

  // ---------------------------------------------------------------- overlay
  function showOverlay(cfg){
    ovBadge.textContent = cfg.badge;
    ovTitle.textContent = cfg.title;
    ovText.textContent = cfg.text;
    ovHint.innerHTML = cfg.hint;
    if(cfg.stats){
      ovStats.style.display = 'flex';
      ovScore.textContent = score;
      ovBest.textContent = best;
    } else {
      ovStats.style.display = 'none';
    }
    overlay.classList.add('show');
  }
  function hideOverlay(){ overlay.classList.remove('show'); }

  const DEMO_OVERLAY = {
    badge:'AI Demo',
    title:'Click or press a key to start',
    text:'An AI snake is playing by itself — take the controls any time.',
    hint:'<b>↑ ↓ ← →</b> / <b>WASD</b> steer &nbsp;·&nbsp; <b>P</b> pause',
    stats:false
  };

  // ---------------------------------------------------------------- game
  function resetBoard(asManual){
    const cx = Math.floor(COLS/2), cy = Math.floor(ROWS/2);
    cells = [{x:cx,y:cy},{x:cx-1,y:cy},{x:cx-2,y:cy}];
    prevCells = cells.map(c => ({x:c.x,y:c.y}));
    dir = DIRS.right;
    queue = [];
    score = 0;
    particles = [];
    popups = [];
    eatPulse = 0;
    stepMs = 140;
    acc = 0;
    state = 'running';
    food = spawnFood();
    if(asManual){
      mode = 'manual';
      hideOverlay();
    } else {
      mode = 'demo';
      showOverlay(DEMO_OVERLAY);
    }
    updateHUD();
  }

  function endGame(){
    state = 'over';
    if(score > best) best = score;
    updateHUD();
    if(mode === 'manual'){
      showOverlay({
        badge:'Game Over',
        title: score >= best && score > 0 ? 'New personal best!' : 'Game Over',
        text: score >= best && score > 0
          ? 'That was the best run of this session. Go again?'
          : 'The snake hit the wall or itself. Ready for another run?',
        hint:'<b>Space</b> or <b>Enter</b> to play again',
        stats:true
      });
    } else {
      showOverlay({
        badge:'AI Demo',
        title:'Demo run finished',
        text:'Score ' + score + ' — restarting the AI demo…',
        hint:'Press any key to take over',
        stats:true
      });
      clearTimeout(demoTimer);
      demoTimer = setTimeout(()=>{ if(mode==='demo' && state==='over') resetBoard(false); }, 1500);
    }
  }

  function togglePause(){
    if(state === 'running'){
      state = 'paused';
      showOverlay({
        badge:'Paused', title:'Paused',
        text:'The snake is waiting for you.',
        hint:'<b>P</b> to resume &nbsp;·&nbsp; <b>Space</b> to restart',
        stats:false
      });
    } else if(state === 'paused'){
      state = 'running';
      acc = 0;
      hideOverlay();
    }
  }

  function step(){
    // ---- pick direction
    let nd = dir;
    if(mode === 'demo'){
      const d = aiDirection();
      if(d) nd = d;
    } else if(queue.length){
      nd = queue.shift();
    }
    dir = nd;

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

    // wall
    if(nh.x < 0 || nh.y < 0 || nh.x >= COLS || nh.y >= ROWS){
      prevCells = cells.map(c => ({x:c.x,y:c.y}));
      endGame(); return;
    }
    const willEat = food && nh.x === food.x && nh.y === food.y;
    // the tail cell frees up unless we grow
    const bodyLen = willEat ? cells.length : cells.length - 1;
    for(let i=0;i<bodyLen;i++){
      if(cells[i].x === nh.x && cells[i].y === nh.y){
        prevCells = cells.map(c => ({x:c.x,y:c.y}));
        endGame(); return;
      }
    }

    prevCells = cells.map(c => ({x:c.x,y:c.y}));
    cells.unshift(nh);

    if(willEat){
      score += 10;
      if(score > best) best = score;
      burst(food);
      popups.push({x:(food.x+0.5)*cell, y:(food.y+0.5)*cell, life:1, text:'+10'});
      eatPulse = 1;
      food = spawnFood();
      stepMs = Math.max(72, 140 - Math.floor(score/10)*5);
      if(!food){ endGame(); updateHUD(); return; }
    } else {
      cells.pop();
    }
    updateHUD();
  }

  // ---------------------------------------------------------------- AI
  function aiDirection(){
    const h = cells[0];
    const blocked = new Set();
    for(let i=0;i<cells.length-1;i++) blocked.add(key(cells[i].x, cells[i].y));
    if(food){
      const path = bfs(h, food, blocked);
      if(path && path.length){
        const f = path[0];
        return {x:f.x-h.x, y:f.y-h.y};
      }
    }
    // fallback: pick the neighbour with the most free space
    let bestD = null, bestScore = -Infinity;
    for(const d of DIR_LIST){
      const nx = h.x+d.x, ny = h.y+d.y;
      if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
      if(blocked.has(key(nx,ny))) continue;
      const area = freeArea(nx, ny, blocked);
      const dist = food ? Math.abs(nx-food.x)+Math.abs(ny-food.y) : 0;
      const s = area*100 - dist;
      if(s > bestScore){ bestScore = s; bestD = d; }
    }
    return bestD;
  }

  function bfs(start, target, blocked){
    const startK = key(start.x,start.y);
    const targetK = key(target.x,target.y);
    const seen = new Set([startK]);
    const prev = new Map();
    const q = [start];
    let head = 0;
    while(head < q.length){
      const c = q[head++];
      if(c.x === target.x && c.y === target.y) break;
      for(const d of DIR_LIST){
        const nx = c.x+d.x, ny = c.y+d.y;
        if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
        const k = key(nx,ny);
        if(seen.has(k) || blocked.has(k)) continue;
        seen.add(k); prev.set(k,c); q.push({x:nx,y:ny});
      }
    }
    if(!seen.has(targetK)) return null;
    const path = [];
    let cur = {x:target.x,y:target.y};
    let guard = 0;
    while(!(cur.x===start.x && cur.y===start.y) && guard++ < 2000){
      path.push(cur);
      const nxt = prev.get(key(cur.x,cur.y));
      if(!nxt) return null;
      cur = nxt;
    }
    path.reverse();
    return path;
  }

  function freeArea(sx, sy, blocked){
    const seen = new Set([key(sx,sy)]);
    const q = [[sx,sy]];
    let head = 0, count = 0;
    while(head < q.length){
      const [x,y] = q[head++];
      count++;
      for(const d of DIR_LIST){
        const nx = x+d.x, ny = y+d.y;
        if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
        const k = key(nx,ny);
        if(seen.has(k) || blocked.has(k)) continue;
        seen.add(k); q.push([nx,ny]);
      }
    }
    return count;
  }

  // ---------------------------------------------------------------- effects
  function burst(f){
    const cx = (f.x+0.5)*cell, cy = (f.y+0.5)*cell;
    const colors = ['#f43f5e','#fb7185','#fda4af','#fde68a','#ffffff'];
    for(let i=0;i<18;i++){
      const a = Math.random()*Math.PI*2;
      const sp = 1.2 + Math.random()*4.2;
      particles.push({
        x:cx, y:cy,
        vx:Math.cos(a)*sp, vy:Math.sin(a)*sp - 1.2,
        life:1, decay:0.018 + Math.random()*0.02,
        size: cell*(0.07 + Math.random()*0.10),
        color: colors[(Math.random()*colors.length)|0]
      });
    }
  }

  function updateEffects(){
    for(let i=particles.length-1;i>=0;i--){
      const p = particles[i];
      p.x += p.vx; p.y += p.vy;
      p.vy += 0.14; p.vx *= 0.985; p.vy *= 0.985;
      p.life -= p.decay;
      if(p.life <= 0) particles.splice(i,1);
    }
    for(let i=popups.length-1;i>=0;i--){
      const p = popups[i];
      p.y -= 0.9; p.life -= 0.022;
      if(p.life <= 0) popups.splice(i,1);
    }
    if(eatPulse > 0) eatPulse = Math.max(0, eatPulse - 0.05);
  }

  // ---------------------------------------------------------------- render
  function renderPoints(t){
    const pts = [];
    for(let i=0;i<cells.length;i++){
      const c = cells[i];
      const a = prevCells[Math.min(i, prevCells.length-1)] || c;
      pts.push({
        x: (a.x + (c.x-a.x)*t + 0.5) * cell,
        y: (a.y + (c.y-a.y)*t + 0.5) * cell
      });
    }
    return pts;
  }

  function strokeSmooth(pts){
    ctx.beginPath();
    ctx.moveTo(pts[0].x, pts[0].y);
    if(pts.length === 1){ ctx.lineTo(pts[0].x + .01, pts[0].y); ctx.stroke(); return; }
    for(let i=1;i<pts.length-1;i++){
      const mx = (pts[i].x + pts[i+1].x)/2;
      const my = (pts[i].y + pts[i+1].y)/2;
      ctx.quadraticCurveTo(pts[i].x, pts[i].y, mx, my);
    }
    const last = pts[pts.length-1];
    ctx.lineTo(last.x, last.y);
    ctx.stroke();
  }

  function drawSnake(pts){
    if(!pts.length) return;
    const head = pts[0], tail = pts[pts.length-1];
    ctx.save();
    ctx.lineCap = 'round'; ctx.lineJoin = 'round';

    // outer glow
    ctx.strokeStyle = 'rgba(45,212,191,.16)';
    ctx.lineWidth = cell*0.98;
    ctx.shadowColor = 'rgba(45,212,191,.55)';
    ctx.shadowBlur = cell*0.75;
    strokeSmooth(pts);
    ctx.shadowBlur = 0;

    // body
    const g = ctx.createLinearGradient(head.x, head.y, tail.x, tail.y);
    g.addColorStop(0,'#6ee7b7');
    g.addColorStop(.45,'#34d399');
    g.addColorStop(1,'#0f9b6c');
    ctx.strokeStyle = g;
    ctx.lineWidth = cell*0.74;
    strokeSmooth(pts);

    // top highlight
    ctx.strokeStyle = 'rgba(255,255,255,.14)';
    ctx.lineWidth = cell*0.2;
    strokeSmooth(pts.map(p => ({x:p.x, y:p.y - cell*0.14})));
    ctx.restore();

    // head
    const hr = cell*0.40 * (1 + eatPulse*0.12);
    const hg = ctx.createRadialGradient(head.x - hr*.34, head.y - hr*.38, hr*.08, head.x, head.y, hr);
    hg.addColorStop(0,'#d1fae5');
    hg.addColorStop(.55,'#4ade80');
    hg.addColorStop(1,'#0f9b6c');
    ctx.fillStyle = hg;
    ctx.beginPath(); ctx.arc(head.x, head.y, hr, 0, Math.PI*2); ctx.fill();

    // eyes, facing the travel direction
    const d = dir;
    const perpX = -d.y, perpY = d.x;
    const fx = head.x + d.x*cell*0.16, fy = head.y + d.y*cell*0.16;
    const off = cell*0.17;
    const eyes = [
      {x: fx + perpX*off, y: fy + perpY*off},
      {x: fx - perpX*off, y: fy - perpY*off}
    ];
    for(const e of eyes){
      ctx.fillStyle = '#f8fafc';
      ctx.beginPath(); ctx.arc(e.x, e.y, cell*0.135, 0, Math.PI*2); ctx.fill();
      ctx.fillStyle = '#0b1224';
      ctx.beginPath();
      ctx.arc(e.x + d.x*cell*0.055, e.y + d.y*cell*0.055, cell*0.068, 0, Math.PI*2);
      ctx.fill();
    }
  }

  function drawFood(now){
    if(!food) return;
    const cx = (food.x+0.5)*cell, cy = (food.y+0.5)*cell;
    const pulse = 1 + 0.07*Math.sin(now/170);
    ctx.save();
    ctx.shadowColor = 'rgba(244,63,94,.85)';
    ctx.shadowBlur = cell*0.9;
    const fg = ctx.createRadialGradient(cx-cell*0.10, cy-cell*0.12, cell*0.04, cx, cy, cell*0.32*pulse);
    fg.addColorStop(0,'#fecdd3');
    fg.addColorStop(.45,'#fb7185');
    fg.addColorStop(1,'#be123c');
    ctx.fillStyle = fg;
    ctx.beginPath(); ctx.arc(cx, cy, cell*0.30*pulse, 0, Math.PI*2); ctx.fill();
    ctx.shadowBlur = 0;
    // leaf
    ctx.fillStyle = '#4ade80';
    ctx.beginPath();
    ctx.ellipse(cx + cell*0.11, cy - cell*0.27, cell*0.115, cell*0.055, -0.55, 0, Math.PI*2);
    ctx.fill();
    // shine
    ctx.fillStyle = 'rgba(255,255,255,.7)';
    ctx.beginPath(); ctx.arc(cx - cell*0.10, cy - cell*0.10, cell*0.06, 0, Math.PI*2); ctx.fill();
    ctx.restore();
  }

  function drawEffects(){
    for(const p of particles){
      ctx.globalAlpha = Math.max(0, p.life);
      ctx.fillStyle = p.color;
      ctx.beginPath();
      ctx.arc(p.x, p.y, p.size * (0.4 + p.life*0.6), 0, Math.PI*2);
      ctx.fill();
    }
    ctx.globalAlpha = 1;
    for(const p of popups){
      ctx.globalAlpha = Math.max(0, p.life);
      ctx.fillStyle = '#fde68a';
      ctx.font = '700 ' + Math.round(cell*0.7) + 'px ui-monospace, Menlo, monospace';
      ctx.textAlign = 'center';
      ctx.shadowColor = 'rgba(253,230,138,.6)';
      ctx.shadowBlur = cell*0.4;
      ctx.fillText(p.text, p.x, p.y);
      ctx.shadowBlur = 0;
    }
    ctx.globalAlpha = 1;
  }

  function render(t, now){
    ctx.setTransform(dpr,0,0,dpr,0,0);
    ctx.clearRect(0,0,cssW,cssH);
    ctx.drawImage(bg, 0, 0, cssW, cssH);
    drawFood(now);
    drawSnake(renderPoints(t));
    drawEffects();
  }

  // ---------------------------------------------------------------- loop
  function frame(now){
    let dt = now - lastT;
    lastT = now;
    if(dt > 250) dt = 250;

    if(state === 'running'){
      acc += dt;
      let guard = 0;
      while(acc >= stepMs && state === 'running' && guard++ < 5){
        acc -= stepMs;
        step();
      }
      updateEffects();
    }
    const t = state === 'running' ? Math.min(1, acc/stepMs) : 1;
    render(t, now);
    requestAnimationFrame(frame);
  }

  // ---------------------------------------------------------------- input
  function pushDir(d){
    const last = queue.length ? queue[queue.length-1] : dir;
    if(d.x === -last.x && d.y === -last.y) return;   // never reverse
    if(d.x ===  last.x && d.y ===  last.y) return;   // already heading there
    if(queue.length >= 2) return;                   // keep at most 2 buffered turns
    queue.push(d);
  }

  function takeControl(){
    if(mode === 'demo'){
      mode = 'manual';
      queue = [];
      hideOverlay();
    }
  }

  window.addEventListener('keydown', (e) => {
    const keyName = e.key;
    const lower = typeof keyName === 'string' ? keyName.toLowerCase() : '';
    if(['arrowup','arrowdown','arrowleft','arrowright',' ','spacebar'].includes(lower)) e.preventDefault();

    if(state === 'over'){
      if(mode === 'demo'){
        clearTimeout(demoTimer);
        resetBoard(true);
      } else if(keyName === ' ' || keyName === 'Enter'){
        resetBoard(true);
      }
      return;
    }
    if(state === 'paused'){
      if(lower === 'p'){ togglePause(); }
      else if(keyName === ' ' || keyName === 'Enter'){ resetBoard(true); }
      return;
    }
    // running
    takeControl();
    if(lower === 'p'){ togglePause(); return; }
    const d = KEYMAP[lower];
    if(d) pushDir(d);
  }, {passive:false});

  document.addEventListener('pointerdown', () => {
    if(state === 'over'){
      clearTimeout(demoTimer);
      resetBoard(true);
      return;
    }
    if(state === 'paused'){ togglePause(); return; }
    takeControl();
  });

  window.addEventListener('blur', () => {
    if(mode === 'manual' && state === 'running') togglePause();
  });

  window.addEventListener('resize', resize);

  // ---------------------------------------------------------------- go
  resize();
  resetBoard(false);      // starts in AI demo mode, behind the "click to start" message
  requestAnimationFrame(frame);
})();
</script>
</body>
</html>
Reasoning trace (13,770 tokens)
Let me build a polished Snake game.

Key features:
- Canvas grid 24x24 cells, centered.
- Arrow keys + WASD, buffered input queue so quick successive presses honored (up to 2 buffered turns).
- No reversal onto itself (perpendicular only relative to current direction).
- Food: +10 points, grow one cell, spawns off snake.
- Game over overlay with score, Space/Enter restarts, P pauses.
- Smooth movement: animate interpolation between grid cells. Use requestAnimationFrame with a step timer, and render interpolated positions.
- Snake head with distinct eyes facing direction.
- Particle burst when eating.
- Score and best score displayed.

Implementation details for smooth movement: keep the snake as an array of grid coordinates (head first). Maintain `prevPositions` for interpolation. Each tick, compute new head, shift body. For rendering, interpolate between previous grid position and current grid position using `t = progress` eased.

For growing: when eating, don't remove tail but add new segment — the tail stays so it grows. Handle interpolation: maintain `segments` array with {x, y, px, py}. On tick, for each segment, px=x, py=y (old), then shift. For growth, new tail segment appended with px/py equal to the last segment's old position so it appears smoothly.

Let me design:

State:
- `snake`: array of {x,y} cells head→tail.
- `prev`: array of previous positions (same length as snake before tick) — for interpolation.

Simplest: each segment stores `x,y` (current grid) and `rx,ry` (render position = grid pos of previous step). When tick happens:
1. Compute newHead.
2. For each existing segment i: seg.rx = seg.x; seg.ry = seg.y.
3. If not growing: pop tail segment (which had rx,ry = its previous render) — but that leaves a gap: the tail's render position should stay where it was last frame to smoothly shrink away. Actually for a smooth "shrink", the tail cell should animate out. Common approach: keep a separate "ghost" of the removed tail segment that fades.

Alternative simpler and smooth: render the snake as a continuous path of points with rounded joints, using interpolation of each segment's position between prev and current. When tail is removed, the removed tail segment remains rendered at its last render position, interpolating toward the new tail's position? Hmm.

Let's do this: maintain `snake` array of cells (grid coordinates). Maintain `path` of render points used for drawing. On each tick:
- newHead added at front.
- tail removed (unless growing).
Then for rendering, I want each segment to slide from its previous grid cell to its new grid cell over the tick duration.

Approach: keep `segments` array of objects {x, y, rx, ry} where rx,ry = the grid cell it occupied before the last tick (render start). On tick:
- new head segment: {x: nx, y: ny, rx: nx, ry: ny}? No — head should slide in from the direction of travel. Actually the head moves from the old head cell to the new head cell. So the new head's rx,ry should be the old head's position? Hmm, if we insert the new head at front, the old head becomes segment 1. Each existing segment i (in old order) becomes new index i+1 and its grid position doesn't change; its render should slide from... nothing — it stays. Wait, but with continuous motion, the body should move forward. Consider snake at cells A(head), B, C. Tick: head moves to D (in front of A). Body should shift: A→? Actually in classic snake, the head moves forward into D, and the body follows: the segment that was at A moves to D (the head), B moves to A, C moves to B, and tail cell C's content is removed.

So rendering: the new head at D should animate sliding from A to D. Segment at A should animate from B to A. Segment at B animates from C to B. Segment at C is removed — the tail should shrink out. To handle the tail, we can keep the removed tail's render point and animate it toward the new tail position (which is B's old cell) so the body visually retracts smoothly. Or just fade out the removed tail.

Simplest good-looking approach: render the snake as a polyline of interpolated points, where point i position = lerp(oldPos[i], newPos[i], t). Since newPos[i] = oldPos[i-1] (each segment moves into the cell ahead), lerp gives: newPos[i] = oldPos[i-1]. So point i moves from oldPos[i] to oldPos[i-1] — which is exactly the "follow" motion. Head: point 0 moves from oldPos[0] to newHead cell.

So: keep `body` = array of {x,y} grid cells of current state and `render` array = interpolated positions.

Let me maintain:
- `cells`: array of grid cells, head first (current state after last tick).
- `prevCells`: array of grid cells before last tick (same length or one less when grown).

At render time with progress t:
- point[0] = lerp(prevCells[0], cells[0], t)  — head moving from old head cell to new head cell.
- point[i] = lerp(prevCells[i], cells[i], t) for i≥1. But cells[i] = prevCells[i-1] generally. When growing, cells has one extra entry at the end: cells[n] = prevCells[n-1] (new tail stays) and prevCells has n entries. So point[i] for i = n (the new tail) = lerp(prevCells[n-1], cells[n]) but cells[n]==prevCells[n-1], so it's stationary — good, tail stays put and body doesn't retract.

For the removal case: prevCells has n entries, cells has n entries too but shifted: cells[i] = prevCells[i-1] for i≥1, cells[0]=newHead. Wait that means point[n-1] = lerp(prevCells[n-1], prevCells[n-2]) — the tail moves forward, and the previous tail cell content is gone. Visually, the tail tip slides forward one cell each tick; that's the classic look and looks fine (no gap since the tip just retracts). Actually there will be a visual gap of... no, the tip moves from cell T to cell T-1, so the tail appears to retract into the body. Fine — that's smooth.

But when growing, the tail doesn't retract so the body appears to extend. Good.

So: prevCells needs to be a copy before the tick. Let's implement:

```
function step() {
  // compute dir
  const head = cells[0];
  const nh = {x: head.x + dir.x, y: head.y + dir.y};
  // check collision with walls and with body (cells except tail if not growing? standard: tail moves away so hitting the tail cell is ok if not growing)
  ...
  prevCells = cells.map(c => ({...c}));  // copy
  cells.unshift(nh);
  if (!ate) cells.pop();
}
```

Wait, careful with `prevCells` when growing: prevCells has length n (before), cells has length n+1. point[i] for i in 0..n: for i<n: lerp(prevCells[i], cells[i]); for i=n: cells[n] = old tail (prevCells[n-1]) and there's no prevCells[n]. Hmm: cells.unshift(nh) makes cells = [nh, ...old]. If not growing, cells.pop() removes old tail → cells length n, prevCells length n. cells[i] = old[i-1] = prevCells[i-1]. Good.

If growing: cells = [nh, ...old] length n+1, prevCells length n. cells[n] = old[n-1] = prevCells[n-1]. So point[n] should be stationary at prevCells[n-1]: lerp with prev = cells[n]. So general rule: for i from 0..cells.length-1: p = prevCells[min(i, prevCells.length-1)]... let's check: for i in 0..n-1, prevCells[i] exists. For i = n (growing case), prevCells[n] doesn't exist; use prevCells[n-1] = cells[n] so it's stationary. 

But wait: for i≥1 in the growing case, cells[i] = old[i-1] = prevCells[i-1]. And point[i] = lerp(prevCells[i], prevCells[i-1]). That's the moving body. Correct.

In the non-growing case, point[n-1] = lerp(prevCells[n-1], prevCells[n-2]) — tail retracts forward. Good.

So define renderPoints: for i in 0..cells.length-1: a = prevCells[Math.min(i, prevCells.length-1)], b = cells[i], pos = lerp(a,b,ease(t)).

Hmm but for i=0 in growing case: a = prevCells[0] = old head, b = cells[0] = new head. Good.

Edge: first tick after start, prevCells empty. Initialize prevCells = cells copy at start.

Interpolation for head: with easing it might look slightly rubbery; use linear or slight ease-out. I'll use a subtle ease (smoothstep) but that causes the head to lag. Linear is standard for smooth snake. I'll use linear-ish with slight ease-in maybe. Let's use `t` linear for positions but the step duration constant.

Speed: start at ~140ms per step, decrease slightly as score grows, min ~70ms.

Input buffering: queue of direction changes. On key press, validate against last queued direction (not current) to avoid reversal: e.g., current dir = right, queue empty. Press up → queue [up]. Press left → left is opposite of right... but the queue's last is up, and left is not opposite of up, so it's allowed by queue rule; but when applied after up, moving left would be... snake moving up then left is fine (not a reversal of up). But it would be a reversal relative to the original direction right? No — snake can turn left after going up. The problem: if the player presses up then left quickly, we must honor both: up then left. That's fine and legal since up→left is a 90° turn.

But if current dir is right and player presses left, that's an immediate reversal — disallowed. With queue, when checking, compare against the last queued direction (or current if queue empty). Then pressing "up" then "left" while moving right: queue check for left: last queued = up, left vs up = not opposite → allowed. Then at the next tick, dir = up; next tick dir = left. Snake moves right→up→left. But the snake's body is behind it to the left (moving right means body extends left). Turning left immediately after up: at the tick when it turns left, the head moves left into the cell that... The head is at position after moving up; the body below it. Moving left into an empty cell is fine. Actually it's fine as long as there's space. The classic bug: pressing "up" then "left" while moving right causes the snake to reverse direction onto itself within 2 ticks if it turns left immediately after up? Let's see: snake moving right occupies (5,5),(4,5),(3,5). Tick 1 with dir up: head → (5,4), body (5,5),(4,5),(3,5). Tick 2 with dir left: head → (4,4). Cell (4,4) is empty → OK. Body: (5,4),(5,5),(4,5). Fine.

But if the queue allowed left when moving right directly: head → (4,5) which is body → collision. So the rule "compare against last queued direction" handles this: when queue is empty, compare with current dir → left opposite of right → rejected. Good.

Also, to prevent double-turn issues, limit queue to 2 entries.

Also when the game is over, restart with Space/Enter.

Pause with P: also Escape maybe.

Rendering polish:
- Dark background with subtle grid lines, rounded cells.
- Snake drawn as a smooth rounded polyline (using quadratic curves through midpoints) with gradient fill, glow.
- Head: circle/rounded square with eyes (white sclera + dark pupil) offset toward facing direction.
- Food: glowing apple/berry with pulse, maybe with a leaf.
- Particles on eat: small circles with gravity/fade.
- Score HUD: current score, best score, maybe level. Styled panels.
- Game over overlay: translucent panel with "Game Over", score, best, "Press Space or Enter to restart".
- Pause overlay: "Paused — press P to resume".

Canvas sizing: grid of 24×24 cells; cell size computed from available width. Use fixed logical resolution: canvas width = 24*cell. Let's pick cell = 26px → 624px canvas. Center on page. On small screens, scale down using CSS (canvas element with width/height set to CSS size; use devicePixelRatio for crispness).

Better approach: set canvas CSS size responsive: `size = Math.min(560, Math.floor(min(availW, availH)))`, cells = 24, cell = size/24. Set canvas.width = size*dpr, and scale ctx.setTransform(dpr,0,0,dpr,0,0). Use CSS width/height = size px. Recompute on resize.

Let me be careful with layout: page 1280×800. Header with title + score cards, canvas in center, footer with controls hint. Total should fit within 800 height without scroll. Use flex column with min-height 100vh, and canvas sized via JS based on remaining space maybe. Simpler: canvas max 520px, header ~90px, footer ~40px → fits.

Let me write the code.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Neon Snake</title>
<style> ... </style>
</head>
<body>
<div class="wrap">
  <header>
    <h1>SNAKE<span class="dot">.</span></h1>
    <div class="stats">
      <div class="stat"><span class="label">Score</span><span class="value" id="score">0</span></div>
      <div class="stat"><span class="label">Best</span><span class="value" id="best">0</span></div>
      <div class="stat"><span class="label">Length</span><span class="value" id="len">3</span></div>
    </div>
  </header>
  <div class="board">
    <canvas id="game"></canvas>
    <div class="overlay" id="overlay"> ... </div>
  </div>
  <footer>...</footer>
</div>
<script>...</script>
</body>
</html>
```

The overlay needs to show different content for "ready", "paused", "gameover". Requirement: "Games must start in a self-playing demo mode (a simple AI plays) behind a 'Click or press a key to start' message". So implement an AI that plays the snake automatically (greedy pathfinding to food, safe). Then show "Click or press a key to start" overlay; when user clicks or presses a key, the game starts in manual mode (or AI continues? Typically the demo mode plays until the user interacts). I'll do: on load, AI demo plays automatically in the background with the overlay message "Click or press a key to start" — clicking starts manual control. Hmm, but AI demo mode might be visible behind overlay; that's the intent: "so the game can be judged in action without playing."

Actually maybe better: the AI demo plays; when user presses a key/click, switch to manual mode and hide overlay. Let's keep AI playing until user input.

AI implementation: BFS from head to food over free cells (excluding snake body), then move along shortest path; verify the resulting move is safe (not into body/wall). If no path, move to the safest neighbor (max free space reachable). Recompute each tick (grid 24x24 = 576 cells, BFS is cheap).

Careful with AI + food: BFS treats snake body as obstacles except tail cell (since tail moves). Use body cells excluding tail as obstacles, and target food.

Safe fallback: compute reachable area with flood fill from each candidate neighbor, choose the one with the largest area (and no immediate collision). If all bad, just pick any safe direction; else game over happens.

Let me write the AI:

```js
function aiDirection() {
  const h = cells[0];
  const blocked = new Set();
  for (let i=0; i<cells.length-1; i++) blocked.add(key(cells[i].x, cells[i].y)); // exclude tail
  // BFS from head to food
  const path = bfs(h, food, blocked);
  if (path) { const next = path[0]; return dirFrom(next, h); }
  // fallback: flood fill
  ...
}
```

BFS returns list of cells from head's neighbor to food.

Also need to ensure the move doesn't immediately cause self collision: BFS avoids body cells, so first step is free (unless the only path requires stepping onto a body cell — excluded).

Tail consideration: body excludes tail, so stepping onto the tail cell is allowed (tail will move away) — fine.

Fallback: choose neighbor with max reachable free space (flood fill ignoring body as blocked but including tail as free).

Let's write:

```js
function safeMoves(h, blocked) {
  const res = [];
  for (const d of DIRS) {
    const nx = h.x+d.x, ny = h.y+d.y;
    if (nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
    if (blocked.has(nx+','+ny)) continue;
    res.push({x:nx,y:ny,d});
  }
  return res;
}
```

Flood fill area: from candidate cell, BFS over cells not in blocked.

AI chooses direction with largest area; ties → closest to food.

Also AI should avoid trapping itself. Good enough.

Now the demo mode: the AI plays, and if the AI dies, we can restart the demo automatically (so the screenshot always shows action). Yes: in demo mode, on game over, restart after a short delay.

Let's handle mode: `mode = 'demo' | 'manual'`. Overlay shown initially with "Click or press a key to start". When user clicks canvas or presses a key → mode='manual', overlay hidden, game continues (AI stops controlling). Actually nicer: keep the AI running in demo; when user takes over, the AI stops.

Also, the game over overlay in demo mode: show "Demo finished" maybe; but requirement says game over overlay with score. I'll show the overlay in both modes; in demo mode, after ~1.2s, restart automatically.

Score during demo counts toward best? The "best score of the session" — I'll include demo scores in best. Fine.

Now the rendering:

Background: dark navy with a radial gradient, grid lines subtly drawn, maybe a vignette.

Snake body: build a path of rounded points from render points (excluding head), draw as thick stroke with lineJoin round and lineCap round, with a gradient. Use two layers: outer glow (shadowBlur) and inner.

Head: rounded rect / circle at head point, with a slight scale pulse. Eyes: two white ellipses positioned relative to direction perpendicular offsets, pupils offset forward.

Eyes: given head direction d (unit), perpendicular p = (-d.y, d.x). Eye centers at head + d*0.22*cell ± p*0.20*cell. Draw white circle radius 0.14*cell, then pupil circle radius 0.07*cell at eye center + d*0.05*cell.

Food: pulsing circle with glow, maybe a berry with a leaf and shine.

Particles: array of {x,y,vx,vy,life,maxLife,color,size}. On eat: spawn ~14 particles from the food position, radial velocities, gravity, fade. Also a ring shockwave.

Also floating "+10" text on eat that rises and fades.

Let's also add a subtle "scanline" or checkerboard pattern for the board.

Now input handling:

```js
const DIRS = { up:{x:0,y:-1}, down:{x:0,y:1}, left:{x:-1,y:0}, right:{x:1,y:0} };
let dir = DIRS.right; let queue = [];
window.addEventListener('keydown', e => {
  const k = e.key.toLowerCase();
  ...
});
```

Keys: arrowup/w, arrowdown/s, arrowleft/a, arrowright/d. Space/Enter restart, p pause. Prevent default for arrows/space to avoid page scroll.

On keydown:
- if mode==='demo' → switch to manual (start) and then handle the key (if it's a direction key, apply it; if space/enter, just start).
- if state==='gameover' and (space/enter) → restart.
- if state==='paused' and p → resume.
- if direction key: push to queue if valid.

Validation:
```js
function pushDir(d) {
  const last = queue.length ? queue[queue.length-1] : dir;
  if (d.x === -last.x && d.y === -last.y) return; // opposite
  if (d.x === last.x && d.y === last.y) return; // same
  if (queue.length >= 2) return;
  queue.push(d);
}
```
Hmm, should we ignore same-direction presses? Yes, no need. But if the queue is empty and the player presses the same direction as current, ignore — fine.

Wait, one subtlety: if queue = [up] and current dir = right, and player presses right again — ignore (same as current but queue last is up, so "right vs up" is perpendicular → would be pushed). Pushing right after up would make the snake turn right after up: legal. But player pressed right intending to continue right; the result up→right is a slight change from intent but honoring both presses is per requirement ("Quick successive key presses must both be honored"). Fine.

Careful about queue limit 2: if queue has 2 items, ignore further. But maybe the queue should allow up to 2 pending turns. Yes.

Timer/step: use a fixed step accumulator with rAF:

```js
let stepMs = 140;
let acc = 0, last = performance.now();
function frame(now) {
  let dt = now - last; last = now;
  if (state === 'running') {
    acc += dt;
    while (acc >= stepMs) { acc -= stepMs; tick(); }
  }
  const t = state==='running' ? Math.min(1, acc/stepMs) : 1;
  render(t);
  requestAnimationFrame(frame);
}
```

Careful: tick may end the game (state='over'), the while loop should break.

Speed: stepMs decreases with score: `stepMs = Math.max(70, 140 - score*0.5)`. Let me use a level-based speed.

Interpolation with linear t. For the head, use the eased value? I'll just use linear for all; maybe apply a slight ease to make it feel snappy: ease = t (linear). Actually a smooth "step" look: use smoothstep on the whole snake (all segments move together) — that gives a slight pause at each cell. I'll use linear, which is the classic smooth snake.

Hmm, one issue: with linear interpolation and a constant step time, the snake looks like it moves in discrete jumps if the render doesn't interpolate. With interpolation it's smooth. Good.

Let me also add a subtle scale pulse on the head when eating.

Overlay content updated by JS:
- ready: "SNAKE" big, "Click or press a key to start", small hint about controls.
- paused: "Paused", "Press P to resume".
- over: "Game Over", "Score: X", "Best: Y", "Press Space or Enter to play again".

Also show the overlay element with CSS transitions (fade in).

Let's write the HTML/CSS.

Design: modern dark UI, accent color neon green (#4ade80 / #22d3ee). Use system fonts (no external). Maybe use a monospace-ish font for numbers: `ui-monospace, SFMono-Regular, Menlo, monospace`.

Layout CSS:

```css
* { box-sizing: border-box; }
body {
  margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;
  background: radial-gradient(1200px 700px at 50% 0%, #16213a 0%, #0b1020 55%, #070a14 100%);
  color:#e8eefc; font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
  overflow:hidden; /* avoid scrollbars */
}
```

Hmm, on 375px width the layout should adapt. Use flex-wrap and clamp font sizes.

Board: `position:relative;` with canvas and overlay absolutely positioned over it.

Canvas border radius, border, box-shadow glow.

Responsive sizing via JS:

```js
function resize() {
  const wrap = document.querySelector('.board');
  const availW = wrap.clientWidth;
  const availH = window.innerHeight - wrap.getBoundingClientRect().top - 90; // header+footer space
  ...
}
```

Simpler: compute size = Math.min(availW, availH, 560). Let me set CSS: `.board { width: min(92vw, 560px); height: min(92vw, 560px); }` using aspect-ratio 1. But on short viewports (800px height), 560 + header 90 + footer 40 = 690 < 800. OK. On mobile 375 wide: board = min(345, 560)=345. Header height maybe 120 on mobile → 345+120+50 = 515 < 800. Fine. But if the viewport is short (e.g., 600px tall), the board would overflow. Add media query: `@media (max-height: 700px) { .board { width: min(92vw, 52vh); } }` Hmm, use `min(92vw, 56vh, 560px)`. Let's set board size = `min(92vw, 58vh, 560px)` via CSS calc. Then the canvas fills the board with width:100%;height:100%.

But then cell size = boardSize/24, non-integer → rendering with DPR scaling handles it.

Let me set canvas CSS width/height 100% and internal resolution = clientWidth*dpr.

I need cell size in CSS pixels for drawing: `cell = cssSize / COLS`.

Resize handler: read canvas.getBoundingClientRect(), set canvas.width = round(rect.width*dpr), height similarly, ctx.setTransform(dpr,0,0,dpr,0,0).

Note: setting canvas.width resets the context state, so set transform each frame or after resize. I'll set transform at the start of each render call (setTransform(dpr,0,0,dpr,0,0)) — cheap.

Now write drawing code.

```js
function render(t) {
  const w = cssW, h = cssH;
  ctx.setTransform(dpr,0,0,dpr,0,0);
  ctx.clearRect(0,0,w,h);
  // board bg
  drawGrid();
  drawFood();
  drawSnake(t);
  drawParticles();
  if (state==='over') maybe dim handled by overlay CSS.
}
```

drawGrid: fill background color; draw checkerboard subtly; draw grid lines with low alpha; maybe a soft radial highlight.

Let me write it:

```js
function drawBoard() {
  ctx.fillStyle = '#0d1426';
  ctx.fillRect(0,0,w,h);
  // checker
  ctx.fillStyle = 'rgba(255,255,255,0.022)';
  for (y..) for (x..) if ((x+y)%2) fillRect(x*cell, y*cell, cell, cell);
  // grid lines
  ctx.strokeStyle='rgba(120,160,255,0.07)'; ctx.lineWidth=1;
  for (i=0..COLS) line...
  // border glow
}
```

Drawing many small rects each frame (576 rects) — fine performance-wise, but could be optimized by caching to an offscreen canvas. Let's cache the background to an offscreen canvas created once per resize. Cleaner: create `bg` canvas of same pixel size, draw once, then drawImage each frame. I'll do that for performance and simplicity.

Snake drawing: build points array (render positions in pixels). Then:

```js
function drawSnake(t) {
  const pts = cells.map((c,i) => {
    const a = prevCells[Math.min(i, prevCells.length-1)];
    const p = { x: (a.x + (c.x-a.x)*t + 0.5)*cell, y: (a.y + (c.y-a.y)*t + 0.5)*cell };
    return p;
  });
  // body path
  ctx.lineCap='round'; ctx.lineJoin='round';
  // glow
  ctx.strokeStyle = 'rgba(52,211,153,0.25)'; ctx.lineWidth = cell*0.95; ctx.shadowBlur = cell*0.6; ctx.shadowColor='rgba(45,212,191,0.55)';
  strokePath(pts);
  // main
  ctx.shadowBlur = 0;
  const grad = ctx.createLinearGradient(...); 
  ctx.strokeStyle = grad; ctx.lineWidth = cell*0.78;
  strokePath(pts);
  // inner highlight
  ctx.strokeStyle='rgba(255,255,255,0.13)'; ctx.lineWidth=cell*0.3; strokePath(pts shifted?) 
```

For a nicer look, draw the body as a thick rounded polyline, then a thinner lighter line offset by -2px in y for a highlight.

strokePath with quadratic smoothing:

```js
function strokeSmooth(pts) {
  ctx.beginPath();
  ctx.moveTo(pts[0].x, pts[0].y);
  if (pts.length === 1) { ctx.lineTo(pts[0].x+0.01, pts[0].y); return; }
  for (let i=1;i<pts.length-1;i++){
    const mx=(pts[i].x+pts[i+1].x)/2, my=(pts[i].y+pts[i+1].y)/2;
    ctx.quadraticCurveTo(pts[i].x, pts[i].y, mx, my);
  }
  ctx.lineTo(pts[pts.length-1].x, pts[pts.length-1].y);
  ctx.stroke();
}
```

Head: draw a circle at pts[0] with radius cell*0.44 in a brighter color, plus eyes.

Actually the body stroke of width cell*0.78 already includes the head region. Then draw the head circle on top with a slightly different color and a highlight.

Eyes: direction `dir` (current). But during interpolation, the head is between cells; the facing direction = dir used for the current step (the direction it will move). Use `dir`.

Eye offsets:
```
const hw = cell*0.22, hh = cell*0.20; // half spacing
const ex = head.x + dir.x*cell*0.20, ey = head.y + dir.y*cell*0.20;
const px = -dir.y, py = dir.x; // perpendicular
eye1 = (ex + px*hh, ey + py*hh), eye2 = (ex - px*hh, ey - py*hh)
```
Wait, perpendicular offsets should be scaled: use `cell*0.18`.

Sclera radius cell*0.15 (white), pupil radius cell*0.075 dark, offset further forward by cell*0.05 along dir.

Since the head might be near a wall, eyes could overflow — but the head radius 0.44*cell and eye at 0.20 forward + 0.15 radius = 0.35 < 0.5 fine.

Food: 
```
const pulse = 1 + Math.sin(now/180)*0.08;
ctx.shadowBlur = cell*0.9; shadowColor = 'rgba(248,113,113,0.8)';
fill circle radius cell*0.3 with gradient red-orange
then a small green leaf, and a white shine.
```

Particles: 
```
{p.x,p.y,vx,vy,life,size,color}
```
update: vy += 0.15 (gravity in px/frame scaled by dt), p.x+=vx*dt... Use dt in seconds normalized: I'll store velocities in px per second and multiply by dt/16.67. Simpler: per frame update with fixed factor 1 assuming 60fps: x+=vx; vx*=0.98; y+=vy; vy+=0.12; life--. Fine.

Text popups: "+10" floating up, fade.

Overlay: HTML div with content; CSS transitions for show/hide.

Now, let's handle the pause: state 'paused' → overlay shows "Paused". When paused, the render still runs (frozen at t=1). 

Let me also add a "READY" state? The initial state: 'demo' running with overlay "Click or press a key to start". So the game is running (AI playing) behind the overlay. The overlay is semi-transparent so the action shows through. Make the overlay background rgba(6,10,20,0.55) with backdrop-filter blur(2px) so the snake is visible behind. Good — "something meaningful must be visible on first paint".

Also the demo should start immediately on load (not waiting for a click), per the requirement "Games must start in a self-playing demo mode (a simple AI plays) behind a 'Click or press a key to start' message". Yes — AI plays immediately; the message invites interaction.

So states: 'running' | 'paused' | 'over'. Plus `mode`: 'demo' | 'manual'.

Initial: state='running', mode='demo', overlay visible with the start message.

On click/keydown (when mode==='demo'): mode='manual'; hide the overlay; if the key was a direction key, apply it; if state==='over', restart instead.

Hmm, when the user clicks during the demo, the demo snake continues from where it is, and the user takes control. Good.

Also: when the user takes over, the queue should be empty.

In demo mode, when the AI dies → state='over', overlay shows "Demo Over" with score; after 1.5s restart the demo automatically. But if the user interacts, cancel the auto-restart.

Let me now also handle the best score: update best when score > best (both modes).

Let me write the final code.

One more detail: `prevCells` initialization and the `tick` function:

```js
function tick() {
  // determine next direction
  let nd = dir;
  if (mode === 'demo') {
    const d = aiDir();
    if (d) nd = d;
  } else if (queue.length) {
    nd = queue.shift();
  }
  dir = nd;
  const head = cells[0];
  const nh = {x: head.x+dir.x, y: head.y+dir.y};
  // wall
  if (nh.x<0||nh.y<0||nh.x>=COLS||nh.y>=ROWS) { prevCells = cells.slice(); endGame(); return; }
  // self: check collision with body excluding tail if not growing (we don't know yet) 
  // Food check
  const willEat = (nh.x===food.x && nh.y===food.y);
  const bodyLen = cells.length - (willEat?0:1);
  for (let i=0;i<bodyLen;i++) if (cells[i].x===nh.x && cells[i].y===nh.y) { prevCells = cells.slice(); endGame(); return; }
  prevCells = cells.map(c=>({x:c.x,y:c.y}));
  cells.unshift(nh);
  if (willEat) { score+=10; spawnParticles(food); food = randomFood(); popupText(...); }
  else cells.pop();
  updateHUD();
}
```

Note: collision check should include the tail cell when the snake is not growing (the tail moves away, so hitting the tail cell is OK) — hence bodyLen excludes the tail when not eating. But careful: when not eating, cells.length-1 excludes the last (tail). When eating, the tail stays, so include it: bodyLen = cells.length. Wait, when eating, cells.pop() isn't executed, so the tail remains in the body → the tail cell is occupied → collision. So bodyLen = willEat ? cells.length : cells.length-1. Yes, as written.

Also need to handle the case where the AI's chosen move is illegal (shouldn't happen since AI avoids blocked cells).

endGame: state='over'; update best; show overlay; in demo mode, schedule auto-restart after 1500ms (store a timer id).

Restart: reset snake to center, length 3, dir right, queue empty, score 0, spawn food, state='running'. If the restart came from the user (Space/Enter), set mode='manual'.

Food spawn: random free cell not on the snake; if the board is full → win state (just handle gracefully).

Now AI:

```js
function aiDir() {
  const h = cells[0];
  const blocked = new Set();
  for (let i=0;i<cells.length-1;i++) blocked.add(cells[i].x+','+cells[i].y);
  // BFS to food
  const path = bfsPath(h, food, blocked);
  if (path && path.length) {
    const first = path[0];
    const d = {x:first.x-h.x, y:first.y-h.y};
    // ensure no reversal? AI can reverse? Reversing would hit its own body — blocked includes body so BFS path first step is free.
    return d;
  }
  // fallback: maximize free space
  let best=null, bestArea=-1;
  for (const d of [DIRS.right, DIRS.down, DIRS.left, DIRS.up]) {
    const nx=h.x+d.x, ny=h.y+d.y;
    if (out of bounds) continue;
    if (blocked.has(nx+','+ny)) continue;
    const area = freeArea(nx,ny,blocked);
    const dist = Math.abs(nx-food.x)+Math.abs(ny-food.y);
    const score = area*100 - dist;
    if (score>bestArea){bestArea=score;best=d;}
  }
  return best;
}
```

Wait: the BFS path's first step is a neighbor of the head that is free. But could the AI choose a move that's a reversal into the neck? The neck is in blocked, so no. Good.

But there's a subtlety: BFS finds a path that may lead the AI into a trap. Acceptable.

Also, the AI shouldn't reverse onto itself — handled.

Hmm, one problem: if the AI's path leads it to eat food but then traps itself, whatever. Fine.

Additionally, the AI should avoid the case where after eating, the food spawns adjacent... fine.

freeArea: flood fill from (nx,ny) over cells not blocked and in bounds, count.

Let me now also handle: the AI in demo mode uses `dir` changes directly, ignoring the queue. Fine.

Let's write BFS:

```js
function bfsPath(start, target, blocked) {
  const startK = start.x+','+start.y;
  const prev = new Map(); const q=[start]; const seen=new Set([startK]);
  const targetK = target.x+','+target.y;
  while(q.length){
    const c=q.shift();
    if(c.x===target.x&&c.y===target.y) break;
    for(const d of DIR_LIST){
      const nx=c.x+d.x, ny=c.y+d.y;
      if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
      const k=nx+','+ny;
      if(seen.has(k)||blocked.has(k)) continue;
      seen.add(k); prev.set(k,c); q.push({x:nx,y:ny});
    }
  }
  if(!seen.has(targetK)) return null;
  const path=[]; let cur={x:target.x,y:target.y};
  while(!(cur.x===start.x&&cur.y===start.y)){ path.push(cur); cur=prev.get(cur.x+','+cur.y); if(!cur) return null; }
  path.reverse();
  return path; // path[0] is the first step
}
```

Good.

Now HUD updates: score, best, length.

Now the CSS for the stats cards: label small uppercase letterspaced, value big monospace.

Also add a subtle animated gradient border on the board? Keep it reasonable.

Let me write everything now, carefully.

Additional polish: a "combo"/streak? Not needed.

Also I should add a start overlay title with the game name and hint about controls, plus a small "AI demo playing" badge.

Let me write the overlay HTML dynamically:

```js
function setOverlay(html) { overlay.innerHTML = html; overlay.classList.add('show'); }
```

Better to have fixed structure with elements: title, subtitle, hint, stats.

I'll build the inner HTML in JS.

Overlay CSS: position absolute inset 0, display flex center, background rgba(8,12,22,0.62), backdrop-filter blur(3px), border-radius, opacity transition, pointer-events none? We want clicks on the board to start; but the overlay covers the board. Set pointer-events:none on the overlay so clicks reach the canvas/document; but then the user can't click buttons — there are none. Use pointer-events:none so clicks pass to the canvas. Good.

Wait: the "Click or press a key to start" — clicking anywhere (document) starts manual mode.

Let's implement document.addEventListener('click', ...) → startManual.

Now let's also ensure the keydown handler works: the iframe receives keyboard events after the user clicks it — our click handler switches to manual mode, so fine.

Let me write the final code.

One more consideration on the "quick successive key presses must both be honored": my queue of up to 2 handles that.

Also need: "the snake can never reverse onto itself" — the queue validation handles the direction; plus the collision check handles the actual body.

Edge case: the user presses 'up' then 'down' quickly while moving right. Queue: last=up, down is opposite of up → rejected. Good.

Another: moving right, press 'left' → rejected. Good.

Another: moving right, press 'up' (queue [up]), then 'left' → last queued = up; left vs up: not opposite → pushed. Queue = [up, left]. Tick1: dir=up. Tick2: dir=left. As computed earlier, the snake turns left after moving up; the head moves into (4,4) if the body was at (5,5),(4,5),(3,5) → (4,4) is free. OK. But wait: after moving up, the body occupies (5,4),(5,5),(4,5). Moving left → head (4,4), body (5,4),(5,5),(4,5). The head is adjacent to the body at (4,5) below — fine, it's not a collision since we only check the head cell.

Hmm, but is moving left after up actually a "reversal onto itself"? No.

But there's a classic problem: the player holds right, then presses up then left quickly intending... whatever, honoring both is the requirement.

However, there's a risk: if the player presses "up" then "left" and the snake is moving right, the snake does a 180° over two ticks, which could cause a self-collision if the snake is only length 3 and tight. Handled by the collision check → game over. That's legit behavior.

Hmm, but maybe I should be smarter: prevent queueing a direction that would reverse relative to the *current* dir if the queue's first element hasn't been consumed yet? No — the requirement says both presses must be honored. Keep as is.

Actually, a subtle improvement: check the reversal against the last *queued or current* direction (as I do). That satisfies "never reverse onto itself" in the sense of never moving into its own neck.

Now write the code. I'll be careful with the render interpolation when prevCells is empty (initial). I'll initialize prevCells = cells copy.

Let me write the whole file.

```js
const COLS = 24, ROWS = 24;
```

Canvas grid at least 20×20 ✓.

Let me write final HTML.

For the food pulse and head pulse, use performance.now() based time.

Particles update should run even when paused? Pause should freeze everything. I'll update particles only when state==='running'. Actually, when the game ends, particles freeze — that's fine; the overlay covers.

Let me finalize the code.

```js
// ---------- setup
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
let cssW=0, cssH=0, dpr=1, cell=24;
let bgCanvas = document.createElement('canvas');

function resize(){
  const rect = canvas.getBoundingClientRect();
  cssW = rect.width; cssH = rect.height;
  dpr = Math.min(window.devicePixelRatio||1, 2);
  canvas.width = Math.round(cssW*dpr);
  canvas.height = Math.round(cssH*dpr);
  cell = cssW/COLS;
  buildBackground();
}
```

buildBackground uses an offscreen canvas at device resolution:
```js
function buildBackground(){
  bgCanvas.width = canvas.width; bgCanvas.height = canvas.height;
  const b = bgCanvas.getContext('2d');
  b.setTransform(dpr,0,0,dpr,0,0);
  b.fillStyle='#0b1224'; b.fillRect(0,0,cssW,cssH);
  // subtle checker
  b.fillStyle='rgba(255,255,255,0.018)';
  for(let y=0;y<ROWS;y++) for(let x=0;x<COLS;x++) if((x+y)%2) b.fillRect(x*cell,y*cell,cell,cell);
  // grid
  b.strokeStyle='rgba(125,170,255,0.06)'; b.lineWidth=1;
  b.beginPath();
  for(let i=0;i<=COLS;i++){ const p=Math.round(i*cell)+0.5; b.moveTo(p,0); b.lineTo(p,cssH); b.moveTo(0,p); b.lineTo(cssW,p);}
  b.stroke();
  // vignette
  const g=b.createRadialGradient(cssW/2,cssH/2,cell, cssW/2,cssH/2, Math.max(cssW,cssH)*0.75);
  g.addColorStop(0,'rgba(60,120,220,0.10)'); g.addColorStop(1,'rgba(0,0,0,0.35)');
  b.fillStyle=g; b.fillRect(0,0,cssW,cssH);
}
```

Then in render: ctx.drawImage(bgCanvas, 0,0, cssW, cssH) — note bgCanvas is at device pixels; drawImage with dest size cssW/cssH in the transformed context works (the transform scales it). Since ctx has the dpr transform, drawImage(bgCanvas,0,0,cssW,cssH) draws the full-res bg scaled to css size → 1:1 device pixels. 

Now the full code. Let me write it out.

For the score popups:
```js
popups.push({x:foodx*cell+cell/2, y:..., text:'+10', life:1});
```
Update: y -= 0.8 per frame, life -= 0.02.

Draw with ctx.globalAlpha = life.

OK, writing the final file now.

Also HUD: update the DOM only when values change.

Let me write out the complete HTML.

Regarding fonts: use system stack. For the title, maybe use a bold letter-spaced sans.

Final code below. Let me be thorough but careful.

Also: pause overlay should show "Press P to resume" and pressing P toggles.

Also handle 'p'/'P' when in demo mode: pressing p starts manual + pauses? Let's: if the key is 'p' and mode is demo → switch to manual, then pause.

Order of handling in keydown:
```js
function onKey(e){
  const k = e.key;
  const lower = k.toLowerCase();
  if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' ','Spacebar'].includes(k)) e.preventDefault();
  if (mode==='demo'){ startManual(); }
  // then continue processing
  if (state==='over'){ if (k===' '||k==='Enter') restart(true); return; }
  if (lower==='p'){ togglePause(); return; }
  const d = KEYMAP[lower] || KEYMAP[k];
  if (d){ pushDir(d); }
}
```
Note KEYMAP should map both 'arrowup' and 'w'. Use lower for arrows: 'arrowup'.

Careful: e.key for space is ' '. preventDefault on ' '.

Also, in demo mode when the user presses a key, we call startManual() which sets mode='manual' and hides the overlay; but if state==='over' in demo, we want to restart manual. Let's handle: in onKey, if state==='over' → if space/enter → restart with manual mode; else (any key) → restart manual too? The requirement: "Space or Enter restarts". So in the over state, only Space/Enter restarts. But the overlay message initially says "Click or press a key to start" (demo). OK:

```js
function onKey(e){
  const key = e.key;
  ...
  if (state === 'over') {
    if (key === ' ' || key === 'Enter') { restart(true); }
    else if (mode === 'demo') { /* keep demo, but user pressed a key → take over? */ }
    return;
  }
  if (mode === 'demo') startManual();
  ...
}
```

Hmm, if the demo died and the user presses a direction key, the demo restarts automatically after a delay; taking over then would be odd. Let's make it: in the over state, if mode==='demo', any key press restarts in manual mode. That's friendlier. Actually simpler: if state==='over': if mode==='demo' → restart(true) on any key; else if space/enter → restart(true). Good.

Also the click handler: if state==='over' and mode==='manual' → restart? Clicking the board when the game is over could restart as well. But the spec says Space/Enter restarts. I'll allow the click to restart too — harmless. Hmm, but "Space or Enter restarts" — allowing a click is a superset. Fine, but let me keep the click → start manual only when in demo/paused states; when over in manual mode, click restarts too (feels natural). I'll do it.

Let's write the code.

Another detail: after restart, the snake starts in the middle: `cx = Math.floor(COLS/2), cy = Math.floor(ROWS/2)`, cells = [{x:cx,y:cy},{x:cx-1,y:cy},{x:cx-2,y:cy}], dir = right.

Also: the demo AI takes over immediately at the start (mode='demo').

I think we're ready. Let me write the final file with careful attention to syntax.

For the head color gradient along the body: create a linear gradient from the head point to the tail point.

Let me write drawSnake:

```js
function drawSnake(t){
  const pts = renderPoints(t);
  if(!pts.length) return;
  const head = pts[0], tail = pts[pts.length-1];
  // outer glow
  ctx.save();
  ctx.lineCap='round'; ctx.lineJoin='round';
  ctx.strokeStyle='rgba(45,212,191,0.18)';
  ctx.lineWidth=cell*0.95;
  ctx.shadowColor='rgba(45,212,191,0.55)'; ctx.shadowBlur=cell*0.7;
  strokeSmooth(pts);
  ctx.shadowBlur=0;
  // body
  const g = ctx.createLinearGradient(head.x,head.y,tail.x,tail.y);
  g.addColorStop(0,'#5eead4'); g.addColorStop(0.55,'#22c55e'); g.addColorStop(1,'#15803d');
  ctx.strokeStyle=g; ctx.lineWidth=cell*0.74;
  strokeSmooth(pts);
  // highlight
  ctx.strokeStyle='rgba(255,255,255,0.16)'; ctx.lineWidth=cell*0.22;
  strokeSmooth(pts.map(p=>({x:p.x, y:p.y - cell*0.14})));
  ctx.restore();
  // head
  ...
}
```

Hmm, the highlight offset could look odd; I'll offset upward by cell*0.13 and keep the same path shape.

Head drawing:
```js
const hr = cell*0.40;
const hg = ctx.createRadialGradient(head.x - hr*0.3, head.y - hr*0.35, hr*0.1, head.x, head.y, hr);
hg.addColorStop(0,'#a7f3d0'); hg.addColorStop(1,'#22c55e');
ctx.fillStyle=hg; ctx.beginPath(); ctx.arc(head.x,head.y,hr,0,Math.PI*2); ctx.fill();
```
Then eyes.

Note: when the snake has length 1 (not possible; min 3), fine.

Eye code:
```js
const d = dir; const px = -d.y, py = d.x;
const fx = head.x + d.x*cell*0.17, fy = head.y + d.y*cell*0.17;
const off = cell*0.17;
const e1 = {x: fx+px*off, y: fy+py*off};
const e2 = {x: fx-px*off, y: fy-py*off};
for (const e of [e1,e2]) {
  ctx.fillStyle='#f8fafc'; ctx.beginPath(); ctx.arc(e.x,e.y,cell*0.135,0,Math.PI*2); ctx.fill();
  ctx.fillStyle='#0b1224'; ctx.beginPath(); ctx.arc(e.x+d.x*cell*0.05, e.y+d.y*cell*0.05, cell*0.07,0,Math.PI*2); ctx.fill();
}
```

Good. Food:
```js
const f = food; const cx=(f.x+0.5)*cell, cy=(f.y+0.5)*cell;
const pulse = 1+0.07*Math.sin(now/160);
ctx.save();
ctx.shadowColor='rgba(244,63,94,0.9)'; ctx.shadowBlur=cell*0.8;
const fg=ctx.createRadialGradient(cx-cell*0.1,cy-cell*0.12,cell*0.05,cx,cy,cell*0.34*pulse);
fg.addColorStop(0,'#fda4af'); fg.addColorStop(0.5,'#f43f5e'); fg.addColorStop(1,'#be123c');
ctx.fillStyle=fg; ctx.beginPath(); ctx.arc(cx,cy,cell*0.3*pulse,0,Math.PI*2); ctx.fill();
ctx.shadowBlur=0;
// leaf
ctx.fillStyle='#4ade80'; ctx.beginPath(); ctx.ellipse(cx+cell*0.1, cy-cell*0.26, cell*0.11, cell*0.055, -0.5, 0, Math.PI*2); ctx.fill();
// shine
ctx.fillStyle='rgba(255,255,255,0.65)'; ctx.beginPath(); ctx.arc(cx-cell*0.1, cy-cell*0.1, cell*0.06, 0, Math.PI*2); ctx.fill();
ctx.restore();
```

Particles on eat: spawn at food center.

Now writing the file. I'll also add a small "level/speed" indicator maybe not.

Let me write the final answer.

Also need `updateHUD()` to set textContent.

Also the best score persists across restarts within the session (a variable).

Let me write out the final code now, in full.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>Neon Snake</title>
<style>
  :root{
    --bg:#070b16;
    --panel:rgba(255,255,255,0.045);
    --stroke:rgba(255,255,255,0.10);
    --accent:#34d399;
    --accent2:#22d3ee;
    --text:#e6edf7;
    --muted:#8ea0bd;
  }
  *{box-sizing:border-box;}
  html,body{height:100%;}
  body{
    margin:0;
    font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
    color:var(--text);
    background:
      radial-gradient(900px 600px at 15% -10%, rgba(34,211,238,.16), transparent 60%),
      radial-gradient(900px 700px at 90% 110%, rgba(52,211,153,.14), transparent 60%),
      linear-gradient(180deg,#0a1122 0%,#060912 100%);
    display:flex; align-items:center; justify-content:center;
    padding:16px;
    overflow:hidden;
    -webkit-font-smoothing:antialiased;
  }
  .app{width:100%; display:flex; flex-direction:column; align-items:center; gap:14px;}
  header{...}
  ...
</style>
```

I'll write the final version now. Layout: header (title + stats), board, footer.

Make sure total height fits 800: header ~72px + gap 14 + board (min(92vw,58vh,560)) = at 800 height: 58vh = 464px; plus footer 40 + gaps 28 + padding 32 → 636. Fits. At 1280 width: 92vw = 1177 → capped 560, and 58vh=464 → board = 464. Hmm, board = min(92vw, 58vh, 560) = 464 on a 1280×800 screen. That's decent (24 cells → 19.3px cells). Maybe allow 62vh → 496. Let's use `min(92vw, 62vh, 560px)`: 496px board → cell 20.7px. Good.

Footer: controls hint with key caps.

Let me write the key caps style: small rounded rectangles.

Alright, final code.

One risk: `overflow:hidden` on body prevents scrolling on mobile; content should fit anyway. At 375×667: board = min(345, 413, 560) = 345 → cell 14.4. Header ~100 (stacked stats?) + board 345 + footer 60 = 505 < 667. Fine.

Media query for narrow: make the header wrap, reduce the title size.

Let me write everything.

For the overlay, structure:

```html
<div class="overlay" id="overlay">
  <div class="card">
    <div class="ov-badge" id="ovBadge">AI Demo</div>
    <h2 id="ovTitle">Click or press a key to start</h2>
    <p id="ovText">Watch the AI play — then take control.</p>
    <div class="ov-stats" id="ovStats">
      <div><span>Score</span><b id="ovScore">0</b></div>
      <div><span>Best</span><b id="ovBest">0</b></div>
    </div>
    <div class="ov-hint" id="ovHint">Space / Enter to restart · P to pause</div>
  </div>
</div>
```

I'll manage visibility via a class 'show'.

CSS: .overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity .25s ease; pointer-events:none; background:rgba(6,10,20,.55); backdrop-filter:blur(3px); border-radius:18px;}
.overlay.show{opacity:1;}

The card: text-align center, with a glow border.

Now write the JS fully.

I also want the demo badge to say "AI DEMO — press any key to play" initially.

Let me write the final code.

Also, when the game is over in demo mode, we auto-restart after 1600ms; the overlay during that time shows "Demo over" and then hides.

Implementation of the auto-restart: `demoRestartTimer = setTimeout(()=>{ if(mode==='demo' && state==='over'){ restart(false); } }, 1600);` and restart(false) keeps mode='demo' and shows the overlay again.

Careful: restart() sets state='running' and the overlay content depends on the mode.

Let me define:
```js
function restart(asManual){
  cells = [...]; dir = DIRS.right; queue=[]; score=0; particles=[]; popups=[];
  food = spawnFood(); prevCells = cells.map(c=>({...c}));
  state='running'; acc=0;
  if(asManual){ mode='manual'; hideOverlay(); }
  else { mode='demo'; showOverlay(demoOverlayContent()); }
  updateHUD();
}
```

showOverlay sets innerHTML/fields.

OK writing now. I'll produce the final code.

One more: prevent the page from scrolling on arrow keys — preventDefault on those.

Also handle window blur → auto-pause? Could be nice but might interfere with the screenshot. I'll skip auto-pause on blur, or only pause in manual mode. Let's skip to avoid weirdness. Actually pausing on blur in manual mode is standard and harmless (the screenshot happens in demo mode). But if the user clicks away, the demo continues. I'll implement blur → pause only if mode==='manual' && state==='running'. Fine.

Let me finalize.