Models 44
Train snakeOne-line

GLM 5.3

by Z.aiReasoningClean
Open in new window

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

How it went

Total time
8 min 33 s
Cost
$0.238
Output tokens
79,310
Spent thinking
65,685 tokens
Speed
154.8 tok/s
Console errors
0
First token after
556 ms
First visible text after
7 min 47 s
Prompt tokens
363
Code size
37.1 KB
Finish reason
stop
Served by
CoreWeave
Model id
z-ai/glm-5.3
Reasoning sent
{"effort":"max"}
Max output
943,718 tokens
Generated
Nov 12, 2026

What it was asked

The prompt
“make me a snake game where the snake is a little train with a locomotive and colorful wagons, picking up passengers waiting on the platform”
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 · 983 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Snake Express — the little train that picks up passengers</title>
<style>
  :root{
    --pad: clamp(9px, 1.3vw, 14px);
    --ink:#4a3222;
    --red:#c0453c;
    --cream:#fff7e6;
    --frame:#8a5a33;
  }
  *{box-sizing:border-box;margin:0;padding:0}
  html,body{height:100%}
  body{
    font-family:"Trebuchet MS","Segoe UI",Verdana,sans-serif;
    color:var(--ink);
    background-color:#f2e3c6;
    background-image:
      radial-gradient(rgba(150,110,60,.14) 1.1px, transparent 1.3px),
      linear-gradient(180deg,#f8efdc 0%,#efe0c0 100%);
    background-size:22px 22px, 100% 100%;
    height:100vh;height:100dvh;
    display:flex;flex-direction:column;align-items:center;
    overflow:hidden;
    -webkit-user-select:none;user-select:none;
  }

  /* ---------- header ---------- */
  header.top{
    width:min(100%,1100px);
    display:flex;align-items:center;justify-content:space-between;
    flex-wrap:wrap;gap:10px 16px;
    padding:10px clamp(12px,3vw,28px) 6px;
  }
  .brand{display:flex;align-items:center;gap:12px;min-width:0}
  .logo{width:clamp(58px,7vw,92px);height:auto;flex:0 0 auto;filter:drop-shadow(0 2px 2px rgba(90,60,20,.25))}
  h1{font-size:clamp(19px,3vw,30px);font-weight:900;letter-spacing:.05em;text-transform:uppercase;line-height:1;white-space:nowrap}
  h1 .accent{color:var(--red)}
  .tagline{font-size:clamp(11px,1.4vw,13px);color:#8a6a45;margin-top:3px;font-style:italic}
  .stats{display:flex;gap:8px;flex-wrap:wrap}
  .chip{
    background:var(--cream);border:2px solid #e0cba2;border-radius:12px;
    padding:5px 12px 6px;min-width:82px;text-align:center;box-shadow:0 2px 0 #e0cba2;
  }
  .chip .lbl{display:block;font-size:9.5px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:#a3865c}
  .chip .val{display:block;font-size:clamp(16px,2.2vw,21px);font-weight:900;color:var(--ink)}
  .chip.pop .val{animation:valpop .35s ease}
  @keyframes valpop{40%{transform:scale(1.4);color:var(--red)}}

  /* ---------- board ---------- */
  #stage{flex:1 1 auto;width:100%;min-height:0;display:flex;align-items:center;justify-content:center;padding:2px clamp(8px,2vw,24px) 10px}
  #boardWrap{
    position:relative;
    background:var(--frame);
    background-image:repeating-linear-gradient(97deg, rgba(0,0,0,.07) 0 6px, rgba(255,255,255,.04) 6px 13px);
    padding:var(--pad);
    border-radius:22px;
    box-shadow:inset 0 0 0 3px #6b4526, 0 18px 40px rgba(96,64,30,.30), 0 4px 0 #6b4526;
    max-width:100%;
  }
  canvas{display:block;border-radius:12px;background:#a7c982;touch-action:none}

  /* ---------- overlay & chips ---------- */
  #overlay{
    position:absolute;inset:0;display:flex;align-items:flex-end;justify-content:center;
    padding:0 10px calc(var(--pad) + 10px);pointer-events:none;
  }
  .card{
    background:rgba(255,247,230,.95);border:2px solid #e0cba2;border-radius:14px;
    padding:10px 18px 11px;text-align:center;max-width:min(92%,440px);
    box-shadow:0 10px 26px rgba(70,45,20,.28);
    animation:cardIn .35s ease;
  }
  @keyframes cardIn{from{opacity:0;transform:translateY(10px)}}
  .card .t{font-size:10.5px;font-weight:800;letter-spacing:.16em;text-transform:uppercase;color:#a3865c}
  .card .b{font-size:clamp(14px,2.3vw,18px);font-weight:900;color:var(--red);margin-top:2px;animation:bob 1.7s ease-in-out infinite}
  .card .s{font-size:clamp(10px,1.5vw,12px);color:#8a6a45;margin-top:3px}
  @keyframes bob{50%{transform:translateY(-2px)}}
  #modeChip{
    position:absolute;top:calc(var(--pad) + 8px);left:calc(var(--pad) + 8px);
    background:rgba(58,40,24,.72);color:#ffe9c0;
    font-size:10.5px;font-weight:800;letter-spacing:.1em;text-transform:uppercase;
    padding:5px 11px;border-radius:999px;pointer-events:none;white-space:nowrap;
  }
  #modeChip.player{background:rgba(192,69,60,.88);color:#fff3dd}

  /* ---------- footer ---------- */
  footer.foot{
    width:100%;display:flex;justify-content:center;align-items:center;gap:8px;flex-wrap:wrap;
    padding:2px 12px 10px;font-size:12px;color:#8a6a45;text-align:center;
  }
  kbd{
    display:inline-block;background:var(--cream);border:1.5px solid #d9c39c;border-bottom-width:3px;
    border-radius:6px;padding:0 6px;font-family:inherit;font-size:11px;font-weight:800;color:#6b4d2c;line-height:1.5;
  }
  .foot .sep{opacity:.45}
  @media (max-width:560px){
    .foot .long{display:none}
    .chip{min-width:64px;padding:4px 8px 5px}
    .tagline{display:none}
  }
</style>
</head>
<body>

  <header class="top">
    <div class="brand">
      <svg class="logo" viewBox="0 0 100 52" aria-hidden="true">
        <g fill="#c9b9a2" opacity=".85">
          <circle cx="62" cy="9" r="4.4"/><circle cx="71" cy="5.5" r="3.1"/><circle cx="79" cy="3" r="2.1"/>
        </g>
        <rect x="58" y="12" width="7" height="12" rx="2" fill="#4a3222"/>
        <rect x="6" y="8" width="24" height="28" rx="3" fill="#a33a30"/>
        <rect x="3" y="5" width="30" height="6" rx="3" fill="#5a2e28"/>
        <rect x="11" y="14" width="13" height="11" rx="2" fill="#cfe8ef"/>
        <rect x="26" y="19" width="40" height="16" rx="7" fill="#c0453c"/>
        <rect x="44" y="13" width="11" height="8" rx="3.5" fill="#e2b34b"/>
        <rect x="34" y="19" width="4" height="16" fill="#e2b34b" opacity=".9"/>
        <rect x="50" y="19" width="4" height="16" fill="#e2b34b" opacity=".9"/>
        <rect x="62" y="17" width="6" height="20" rx="2" fill="#8f2f28"/>
        <polygon points="68,38 79,29 81,39" fill="#8d8375"/>
        <rect x="0" y="47" width="100" height="2.6" rx="1.3" fill="#c9a269"/>
        <g fill="#3a2a20">
          <circle cx="16" cy="41" r="7"/><circle cx="34" cy="41" r="7"/><circle cx="50" cy="41" r="6"/><circle cx="68" cy="42" r="5"/>
        </g>
        <g fill="#d9a94e">
          <circle cx="16" cy="41" r="2.4"/><circle cx="34" cy="41" r="2.4"/><circle cx="50" cy="41" r="2"/>
        </g>
      </svg>
      <div>
        <h1>Snake <span class="accent">Express</span></h1>
        <p class="tagline">a pocket-sized railway where the snake is a little train</p>
      </div>
    </div>
    <div class="stats">
      <div class="chip" id="chipScore"><span class="lbl">Passengers</span><span class="val" id="statScore">0</span></div>
      <div class="chip"><span class="lbl">Cars</span><span class="val" id="statCars">4</span></div>
      <div class="chip"><span class="lbl">Best</span><span class="val" id="statBest">0</span></div>
    </div>
  </header>

  <div id="stage">
    <div id="boardWrap">
      <canvas id="game"></canvas>
      <div id="modeChip">Demo · conductor-bot driving</div>
      <div id="overlay">
        <div class="card">
          <div class="t" id="ovTitle">Demo route · conductor-bot is driving</div>
          <div class="b" id="ovBig">Click or press any key to start</div>
          <div class="s" id="ovSub">Watch the little train, then hop into the driver's cab</div>
        </div>
      </div>
    </div>
  </div>

  <footer class="foot">
    <span>Steer with <kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd> or <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> — swipe works too</span>
    <span class="sep">·</span>
    <span class="long">board every waiting passenger — the meadow's edges are tunnels, but your own wagons will derail you</span>
  </footer>

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

/* ================= DOM ================= */
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const stage = document.getElementById('stage');
const boardWrap = document.getElementById('boardWrap');
const overlay = document.getElementById('overlay');
const ovTitle = document.getElementById('ovTitle');
const ovBig = document.getElementById('ovBig');
const ovSub = document.getElementById('ovSub');
const modeChip = document.getElementById('modeChip');
const statScore = document.getElementById('statScore');
const statCars = document.getElementById('statCars');
const statBest = document.getElementById('statBest');
const chipScore = document.getElementById('chipScore');

/* ================= helpers ================= */
const TAU = Math.PI * 2;
const DIRS = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
const WAGON_COLORS = ['#3d8f7c','#e8913f','#e5c04a','#4d7cc4','#c75d8f','#5ea152','#8d6bc6','#d9705f','#3aa0a8','#b48ec9'];
const SKIN = ['#f2c9a1','#e0a878','#c98a5b','#8c5a33','#f7dcb9'];
const SHIRT = ['#c75d5d','#4d7cc4','#5ea152','#e0a33c','#8d6bc6','#3aa0a8','#c75d8f','#6b7f36'];
const HAIR = ['#4a3222','#2c2c2c','#a3672e','#d9b356','#7a4a22'];

function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296;};}
function clamp(v,a,b){return v<a?a:(v>b?b:v);}
function easeOutBack(t){const c1=1.70158,c3=c1+1;return 1+c3*Math.pow(t-1,3)+c1*Math.pow(t-1,2);}
function easeOutCubic(t){return 1-Math.pow(1-t,3);}
function dist(a,b){return Math.hypot(a.x-b.x,a.y-b.y);}
function shade(hex,amt){const n=parseInt(hex.slice(1),16);const r=clamp((n>>16)+amt,0,255),g=clamp(((n>>8)&255)+amt,0,255),b=clamp((n&255)+amt,0,255);return 'rgb('+r+','+g+','+b+')';}
function rr(c,x,y,w,h,r){r=Math.min(r,w/2,h/2);c.beginPath();c.moveTo(x+r,y);c.arcTo(x+w,y,x+w,y+h,r);c.arcTo(x+w,y+h,x,y+h,r);c.arcTo(x,y+h,x,y,r);c.arcTo(x,y,x+w,y,r);c.closePath();}
function rrFill(x,y,w,h,r,col){rr(ctx,x,y,w,h,r);ctx.fillStyle=col;ctx.fill();}
function rrStroke(x,y,w,h,r,col,lw){rr(ctx,x,y,w,h,r);ctx.strokeStyle=col;ctx.lineWidth=lw;ctx.stroke();}

/* ================= state ================= */
let COLS = 21, ROWS = 14;
let cell = 32, W = 0, H = 0, dpr = 1, bg = null;
let cells = [], prevCells = [], dir = {x:1,y:0}, queuedDirs = [];
let foods = [], smoke = [], tickets = [], rings = [], ghosts = [];
let driver = 'demo', running = true;
let score = 0, best = 0, tickMs = 150, acc = 0;
let crashAt = -1e9, lastBorn = -1e9, lastPuff = 0;
let DECOS = [];

function chooseGrid(){
  const r = stage.getBoundingClientRect();
  const portrait = (r.width / Math.max(1, r.height)) < 0.95;
  COLS = portrait ? 14 : 21;
  ROWS = portrait ? 20 : 14;
}
function buildDecos(){
  const rnd = mulberry32(987654);
  DECOS = [];
  const n = COLS*ROWS;
  for(let i=0;i<Math.round(n*0.38);i++) DECOS.push({t:0,x:rnd()*COLS,y:rnd()*ROWS,r:rnd()});          // grass
  for(let i=0;i<Math.round(n*0.11);i++) DECOS.push({t:1,x:0.35+rnd()*(COLS-0.7),y:0.35+rnd()*(ROWS-0.7),c:(rnd()*4)|0,r:rnd()}); // flowers
  for(let i=0;i<Math.max(5,Math.round(n*0.026));i++) DECOS.push({t:2,x:1+rnd()*(COLS-2),y:0.8+rnd()*(ROWS-1.6),c:(rnd()*3)|0,r:rnd()}); // bushes
}

/* ================= layout / background ================= */
function fit(){
  dpr = Math.min(2, window.devicePixelRatio || 1);
  const r = stage.getBoundingClientRect();
  const cs = getComputedStyle(boardWrap);
  const padX = parseFloat(cs.paddingLeft) || 12;
  const padY = parseFloat(cs.paddingTop) || 12;
  const availW = Math.max(140, r.width - 2*padX - 4);
  const availH = Math.max(140, r.height - 2*padY - 4);
  cell = clamp(Math.floor(Math.min(availW/COLS, availH/ROWS, 50)), 9, 50);
  W = cell*COLS; H = cell*ROWS;
  canvas.style.width = W+'px';
  canvas.style.height = H+'px';
  canvas.width = Math.round(W*dpr);
  canvas.height = Math.round(H*dpr);
  ctx.setTransform(dpr,0,0,dpr,0,0);
  buildBg();
}

function buildBg(){
  bg = document.createElement('canvas');
  bg.width = Math.round(W*dpr); bg.height = Math.round(H*dpr);
  const b = bg.getContext('2d');
  b.setTransform(dpr,0,0,dpr,0,0);
  b.fillStyle = '#a7c982'; b.fillRect(0,0,W,H);
  b.fillStyle = 'rgba(255,255,255,.05)';
  for(let y=0;y<ROWS;y++) for(let x=0;x<COLS;x++){ if((x+y)%2===0) b.fillRect(x*cell,y*cell,cell,cell); }

  for(const d of DECOS){
    const x = d.x*cell, y = d.y*cell;
    if(d.t===0){ // grass tuft
      const s = cell*0.10*(0.8+d.r*0.5);
      b.strokeStyle = 'rgba(96,136,64,.5)'; b.lineWidth = Math.max(1,cell*0.035); b.lineCap='round';
      b.beginPath();
      b.moveTo(x-s,y+s); b.lineTo(x-s*1.6,y-s);
      b.moveTo(x,y+s);   b.lineTo(x,y-s*1.3);
      b.moveTo(x+s,y+s); b.lineTo(x+s*1.6,y-s);
      b.stroke();
    } else if(d.t===1){ // flower
      const pc = ['#ffffff','#f6c9d4','#f3d77a','#e8b7c8'][d.c];
      const pr = Math.max(1.4, cell*0.055);
      b.fillStyle = pc;
      for(let k=0;k<5;k++){
        const a = k/5*TAU + d.r;
        b.beginPath(); b.arc(x+Math.cos(a)*pr*1.4, y+Math.sin(a)*pr*1.4, pr*0.95, 0, TAU); b.fill();
      }
      b.fillStyle = '#e2a63c'; b.beginPath(); b.arc(x,y,pr*0.8,0,TAU); b.fill();
    } else { // bush
      const g = ['#7fae63','#74a55b','#8ab56e'][d.c];
      b.fillStyle = 'rgba(70,105,50,.18)';
      b.beginPath(); b.ellipse(x,y+cell*0.16,cell*0.52,cell*0.30,0,0,TAU); b.fill();
      b.fillStyle = g;
      for(let k=0;k<4;k++){
        const a = d.r*TAU + k*1.7;
        b.beginPath(); b.arc(x+Math.cos(a)*cell*0.16, y+Math.sin(a)*cell*0.10, cell*(0.18+d.r*0.14), 0, TAU); b.fill();
      }
      b.fillStyle = 'rgba(255,255,255,.14)';
      b.beginPath(); b.arc(x-cell*0.1, y-cell*0.08, cell*0.13, 0, TAU); b.fill();
    }
  }
}

/* ================= audio (synthesized, gesture-unlocked) ================= */
let AC = null, master = null;
function ensureAudio(){
  try{
    if(AC){ if(AC.state==='suspended') AC.resume().catch(()=>{}); return; }
    AC = new (window.AudioContext || window.webkitAudioContext)();
    master = AC.createGain(); master.gain.value = 0.14; master.connect(AC.destination);
  }catch(e){ AC = null; }
}
function tone(freq,dur,type,vol,when,glideTo){
  if(!AC) return;
  try{
    const t0 = AC.currentTime + (when||0);
    const o = AC.createOscillator(), g = AC.createGain();
    o.type = type; o.frequency.setValueAtTime(freq,t0);
    if(glideTo) o.frequency.exponentialRampToValueAtTime(glideTo, t0+dur);
    g.gain.setValueAtTime(0.0001,t0);
    g.gain.exponentialRampToValueAtTime(vol, t0+0.012);
    g.gain.exponentialRampToValueAtTime(0.0001, t0+dur);
    o.connect(g); g.connect(master);
    o.start(t0); o.stop(t0+dur+0.05);
  }catch(e){}
}
function ding(){ tone(1046,0.16,'sine',0.5,0); tone(1318,0.22,'sine',0.4,0.09); }
function toot(){ tone(311,0.15,'square',0.22,0); tone(311,0.22,'square',0.22,0.2); tone(415,0.3,'square',0.16,0); }
function crashSnd(){ tone(220,0.4,'sawtooth',0.3,0,60); tone(110,0.5,'square',0.2,0.05,50); }

/* ================= game logic ================= */
function resetGame(mode){
  driver = mode; running = true;
  const cy = (ROWS/2)|0;
  cells = [];
  for(let i=0;i<4;i++) cells.push({x:6-i, y:cy});
  prevCells = cells.map(c=>({x:c.x,y:c.y}));
  dir = {x:1,y:0}; queuedDirs = [];
  foods = []; smoke = []; tickets = []; rings = []; ghosts = [];
  score = 0; tickMs = 150; acc = 0; lastBorn = -1e9; crashAt = -1e9;
  for(let i=0;i<3;i++) spawnFood();
  updateStats(false);
  updateOverlayUI();
}

function spawnFood(){
  const head = cells[0];
  const occ = new Set(cells.map(c=>c.y*COLS+c.x));
  foods.forEach(f=>occ.add(f.y*COLS+f.x));
  for(let relax=0; relax<2; relax++){
    const minD = relax===0 ? 4 : 0;
    for(let t=0;t<120;t++){
      const x = (Math.random()*COLS)|0, y = (Math.random()*ROWS)|0;
      if(occ.has(y*COLS+x)) continue;
      const dx = Math.min(Math.abs(x-head.x), COLS-Math.abs(x-head.x));
      const dy = Math.min(Math.abs(y-head.y), ROWS-Math.abs(y-head.y));
      if(dx+dy < minD) continue;
      foods.push({
        x, y, born: performance.now(),
        skin: SKIN[(Math.random()*SKIN.length)|0],
        shirt: SHIRT[(Math.random()*SHIRT.length)|0],
        hair: HAIR[(Math.random()*HAIR.length)|0],
        hat: Math.random()<0.4,
        phase: Math.random()*TAU,
        bag: Math.random()<0.55
      });
      return true;
    }
  }
  return false;
}

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

function doTick(){
  if(driver==='demo'){ const d = aiChoose(); if(d) dir = d; }
  else if(queuedDirs.length){ dir = queuedDirs.shift(); }

  const head = cells[0];
  const nx = (head.x + dir.x + COLS) % COLS;
  const ny = (head.y + dir.y + ROWS) % ROWS;
  const nk = ny*COLS + nx;
  const fi = foods.findIndex(f=>f.x===nx && f.y===ny);
  const grow = fi >= 0;

  const limit = cells.length - (grow?0:1);
  for(let i=0;i<limit;i++){ if(cells[i].y*COLS + cells[i].x === nk){ crash(); return; } }

  const old = cells;
  cells = [{x:nx,y:ny}].concat(grow ? old : old.slice(0, old.length-1));
  prevCells = old;

  if(grow){
    const f = foods.splice(fi,1)[0];
    score++;
    tickMs = Math.max(80, 150 - score*2.2);
    lastBorn = performance.now();
    ghosts.push({x:f.x, y:f.y, t:0});
    pickupFx(f);
    spawnFood();
    updateStats(true);
  }
}

function pickupFx(f){
  const px = (f.x+0.5)*cell, py = (f.y+0.5)*cell;
  rings.push({x:px, y:py, t:0});
  tickets.push({x:px, y:py - cell*0.2, t:0});
  for(let i=0;i<6;i++){
    smoke.push({
      x:px+(Math.random()-0.5)*cell*0.5, y:py+(Math.random()-0.5)*cell*0.5,
      r:cell*0.09, life:0, maxLife:700+Math.random()*400,
      vx:(Math.random()-0.5)*22, vy:(Math.random()-0.5)*22
    });
  }
  ding();
}

function crash(){
  running = false;
  crashAt = performance.now();
  const hx = (cells[0].x+0.5)*cell, hy = (cells[0].y+0.5)*cell;
  for(let i=0;i<16;i++){
    smoke.push({
      x:hx+(Math.random()-0.5)*cell*0.6, y:hy+(Math.random()-0.5)*cell*0.6,
      r:cell*(0.10+Math.random()*0.10), life:0, maxLife:900+Math.random()*700,
      vx:(Math.random()-0.5)*cell*1.2, vy:(Math.random()-0.5)*cell*1.2
    });
  }
  crashSnd();
  if(driver==='player' && score>best) best = score;
  updateStats(false);
  updateOverlayUI();
  if(driver==='demo'){
    setTimeout(()=>{ if(!running && driver==='demo') resetGame('demo'); }, 1700);
  }
}

/* ================= the conductor-bot (demo AI) ================= */
function bfs(sx, sy, blocked, freeK){
  const N = COLS*ROWS;
  const d = new Int16Array(N).fill(-1);
  const q = new Int16Array(N);
  let qh = 0, qt = 0;
  const s = sy*COLS + sx;
  d[s] = 0; q[qt++] = s;
  while(qh < qt){
    const k = q[qh++];
    const x = k % COLS, y = (k / COLS) | 0, nd = d[k] + 1;
    for(let i=0;i<4;i++){
      const nx = (x + DIRS[i].x + COLS) % COLS;
      const ny = (y + DIRS[i].y + ROWS) % ROWS;
      const nk = ny*COLS + nx;
      if(d[nk] !== -1) continue;
      if(blocked[nk] && nk !== freeK) continue;
      d[nk] = nd; q[qt++] = nk;
    }
  }
  return d;
}

function aiChoose(){
  const head = cells[0], L = cells.length;
  const tail = cells[L-1], tailK = tail.y*COLS + tail.x;
  const isFoodCell = new Uint8Array(COLS*ROWS);
  for(const f of foods) isFoodCell[f.y*COLS + f.x] = 1;
  const bodyAll = new Uint8Array(COLS*ROWS);
  for(let i=0;i<L;i++) bodyAll[cells[i].y*COLS + cells[i].x] = 1;

  const cands = [];
  for(const d of DIRS){
    if(d.x===-dir.x && d.y===-dir.y) continue;
    const nx = (head.x+d.x+COLS)%COLS, ny = (head.y+d.y+ROWS)%ROWS, nk = ny*COLS+nx;
    const eating = isFoodCell[nk] === 1;
    if(bodyAll[nk] && !(nk===tailK && !eating)) continue;

    const keep = eating ? L : L-1;
    const blockedAfter = new Uint8Array(COLS*ROWS);
    for(let i=0;i<keep;i++) blockedAfter[cells[i].y*COLS + cells[i].x] = 1;
    blockedAfter[nk] = 1;
    const newTail = eating ? cells[L-1] : cells[L-2];
    const newTailK = newTail.y*COLS + newTail.x;

    const dist = bfs(nx, ny, blockedAfter, newTailK);
    let pathLen = 9000;
    for(const f of foods){ const fd = dist[f.y*COLS + f.x]; if(fd>=0 && fd<pathLen) pathLen = fd; }
    let space = 0;
    for(let i=0;i<dist.length;i++) if(dist[i]>=0) space++;
    const safe = dist[newTailK] >= 0 ? 0 : 1;
    cands.push({d, eating, pathLen, safe, space});
  }
  if(!cands.length) return dir;
  cands.sort((a,b)=> (b.eating-a.eating) || (a.safe-b.safe) || (a.pathLen-b.pathLen) || (b.space-a.space));
  return cands[0].d;
}

/* ================= UI ================= */
function updateStats(pop){
  statScore.textContent = score;
  statCars.textContent = cells.length;
  statBest.textContent = best;
  if(pop){ chipScore.classList.remove('pop'); void chipScore.offsetWidth; chipScore.classList.add('pop'); }
}

function updateOverlayUI(){
  if(!running){
    if(driver==='player'){
      ovTitle.textContent = 'Derailed!';
      ovBig.textContent = 'Click or press any key to try again';
      ovSub.textContent = score + ' passenger' + (score===1?'':'s') + ' picked up on this run · best ' + best;
    }else{
      ovTitle.textContent = 'Derailed!';
      ovBig.textContent = 'Re-railing the train…';
      ovSub.textContent = 'Click or press any key to take the wheel';
    }
    overlay.style.display = 'flex';
  } else if(driver==='demo'){
    ovTitle.textContent = 'Demo route · conductor-bot is driving';
    ovBig.textContent = 'Click or press any key to start';
    ovSub.textContent = "Watch the little train, then hop into the driver's cab";
    overlay.style.display = 'flex';
  } else {
    overlay.style.display = 'none';
  }
  modeChip.textContent = driver==='demo' ? 'Demo · conductor-bot driving' : "You're driving";
  modeChip.classList.toggle('player', driver==='player');
}

function startPlayer(d){
  resetGame('player');
  if(d) queueDir(d);
  toot();
}

/* ================= input ================= */
const KEYMAP = {
  ArrowUp:{x:0,y:-1}, ArrowDown:{x:0,y:1}, ArrowLeft:{x:-1,y:0}, ArrowRight:{x:1,y:0},
  w:{x:0,y:-1}, s:{x:0,y:1}, a:{x:-1,y:0}, d:{x:1,y:0}
};

window.addEventListener('keydown', e=>{
  if(e.metaKey || e.ctrlKey || e.altKey) return;
  const k = e.key.length===1 ? e.key.toLowerCase() : e.key;
  if(k in KEYMAP || k===' ') e.preventDefault();
  ensureAudio();
  if(!running || driver!=='player'){ startPlayer(KEYMAP[k]); return; }
  const d = KEYMAP[k];
  if(d) queueDir(d);
});

let swipe = null;
boardWrap.addEventListener('pointerdown', e=>{
  ensureAudio();
  if(!running || driver!=='player'){ startPlayer(); swipe = null; return; }
  swipe = {x:e.clientX, y:e.clientY};
});
window.addEventListener('pointermove', e=>{
  if(!swipe || !running || driver!=='player') return;
  const dx = e.clientX - swipe.x, dy = e.clientY - swipe.y;
  if(Math.abs(dx)<24 && Math.abs(dy)<24) return;
  const d = Math.abs(dx)>Math.abs(dy) ? {x:dx>0?1:-1, y:0} : {x:0, y:dy>0?1:-1};
  queueDir(d);
  swipe = {x:e.clientX, y:e.clientY};
});
window.addEventListener('pointerup', ()=>{ swipe = null; });
window.addEventListener('pointercancel', ()=>{ swipe = null; });

/* ================= drawing ================= */
function interpPoint(a, b, t){
  let ax = a.x, ay = a.y;
  if(b.x - ax > 1) ax += COLS; else if(b.x - ax < -1) ax -= COLS;
  if(b.y - ay > 1) ay += ROWS; else if(b.y - ay < -1) ay -= ROWS;
  return { x:(ax + (b.x-ax)*t + 0.5)*cell, y:(ay + (b.y-ay)*t + 0.5)*cell };
}
function angleBetween(a, b){
  let dx = b.x-a.x, dy = b.y-a.y;
  if(dx > 1) dx -= COLS; else if(dx < -1) dx += COLS;
  if(dy > 1) dy -= ROWS; else if(dy < -1) dy += ROWS;
  return Math.atan2(dy, dx);
}
function carAngle(i, a, b){
  if(a.x!==b.x || a.y!==b.y) return angleBetween(a, b);
  if(i>0) return angleBetween(cells[i], cells[i-1]);
  return Math.atan2(dir.y, dir.x);
}

function drawSlab(){
  const w = cell*0.94, h = cell*0.56;
  rr(ctx, -w/2, -h*0.10, w, h, cell*0.10);
  ctx.fillStyle = '#cfc6b4'; ctx.fill();
  ctx.strokeStyle = 'rgba(120,104,78,.6)'; ctx.lineWidth = Math.max(1, cell*0.03); ctx.stroke();
  ctx.fillStyle = '#e5b93c';
  ctx.fillRect(-w/2 + cell*0.05, h*0.52 - cell*0.055, w - cell*0.10, cell*0.055);
}

function drawSign(){
  ctx.fillStyle = '#7a5a3a';
  ctx.fillRect(-cell*0.42, -cell*0.40, cell*0.045, cell*0.42);
  rrFill(-cell*0.50, -cell*0.545, cell*0.22, cell*0.17, cell*0.04, '#33547a');
  ctx.fillStyle = '#f4ead2';
  ctx.fillRect(-cell*0.435, -cell*0.485, cell*0.10, cell*0.032);
  ctx.fillStyle = '#e2b34b';
  ctx.fillRect(-cell*0.435, -cell*0.425, cell*0.10, cell*0.032);
}

function drawPassenger(f, now){
  const bob = Math.sin(now*0.005 + f.phase)*cell*0.015;
  ctx.save();
  ctx.translate(cell*0.06, bob);
  // shadow
  ctx.fillStyle = 'rgba(70,50,25,.18)';
  ctx.beginPath(); ctx.ellipse(0, cell*0.12, cell*0.15, cell*0.045, 0, 0, TAU); ctx.fill();
  // suitcase
  if(f.bag){
    rrFill(cell*0.20, -cell*0.02, cell*0.20, cell*0.14, cell*0.03, '#8a5a33');
    ctx.strokeStyle = '#6d4526'; ctx.lineWidth = Math.max(1, cell*0.02);
    ctx.beginPath(); ctx.arc(cell*0.30, -cell*0.02, cell*0.045, Math.PI, 0, false); ctx.stroke();
  }
  // legs
  ctx.fillStyle = '#4a3222';
  ctx.fillRect(-cell*0.075, -cell*0.02, cell*0.055, cell*0.12);
  ctx.fillRect( cell*0.02, -cell*0.02, cell*0.055, cell*0.12);
  // far arm
  ctx.fillStyle = f.shirt;
  rr(ctx, -cell*0.20, -cell*0.30, cell*0.07, cell*0.20, cell*0.03); ctx.fill();
  // body
  rr(ctx, -cell*0.14, -cell*0.34, cell*0.28, cell*0.34, cell*0.08);
  ctx.fillStyle = f.shirt; ctx.fill();
  ctx.strokeStyle = shade(f.shirt, -40); ctx.lineWidth = Math.max(1, cell*0.02); ctx.stroke();
  // waving arm
  const wv = Math.sin(now*0.011 + f.phase);
  ctx.save();
  ctx.translate(cell*0.13, -cell*0.28);
  ctx.rotate(-2.2 + wv*0.45);
  rr(ctx, -cell*0.033, 0, cell*0.066, cell*0.22, cell*0.03);
  ctx.fillStyle = f.shirt; ctx.fill();
  ctx.fillStyle = f.skin;
  ctx.beginPath(); ctx.arc(0, cell*0.23, cell*0.045, 0, TAU); ctx.fill();
  ctx.restore();
  // head
  ctx.fillStyle = f.skin;
  ctx.beginPath(); ctx.arc(0, -cell*0.45, cell*0.115, 0, TAU); ctx.fill();
  if(!f.hat){
    ctx.fillStyle = f.hair;
    ctx.beginPath(); ctx.arc(0, -cell*0.46, cell*0.118, Math.PI, TAU); ctx.fill();
  } else {
    ctx.fillStyle = '#5a4632';
    rrFill(-cell*0.15, -cell*0.60, cell*0.30, cell*0.08, cell*0.035);
    ctx.fillRect(-cell*0.17, -cell*0.545, cell*0.34, cell*0.028);
  }
  if(cell >= 20){
    ctx.fillStyle = '#33241a';
    ctx.beginPath(); ctx.arc(-cell*0.038, -cell*0.435, cell*0.013, 0, TAU); ctx.fill();
    ctx.beginPath(); ctx.arc( cell*0.038, -cell*0.435, cell*0.013, 0, TAU); ctx.fill();
  }
  ctx.restore();
}

function drawFoods(now){
  for(const f of foods){
    const age = now - f.born;
    const s = age < 380 ? Math.max(0.05, easeOutBack(age/380)) : 1;
    ctx.save();
    ctx.translate((f.x+0.5)*cell, (f.y+0.5)*cell);
    ctx.scale(s, s);
    drawSlab();
    drawSign();
    drawPassenger(f, now);
    ctx.restore();
  }
}

function drawTrack(pts){
  if(pts.length < 2) return;
  const runs = [];
  let cur = [pts[0]];
  for(let i=1;i<pts.length;i++){
    if(dist(pts[i-1], pts[i]) > cell*1.6){ runs.push(cur); cur = [pts[i]]; }
    else cur.push(pts[i]);
  }
  runs.push(cur);
  ctx.lineCap = 'round'; ctx.lineJoin = 'round';

  for(const run of runs){
    if(run.length < 2) continue;
    const path = ()=>{ ctx.beginPath(); ctx.moveTo(run[0].x, run[0].y); for(let i=1;i<run.length;i++) ctx.lineTo(run[i].x, run[i].y); };
    // ballast bed (edge + surface)
    path(); ctx.strokeStyle = '#bd9d6e'; ctx.lineWidth = cell*0.56; ctx.stroke();
    path(); ctx.strokeStyle = '#dcc9a0'; ctx.lineWidth = cell*0.56 - Math.max(3, cell*0.12); ctx.stroke();
    // sleepers
    ctx.strokeStyle = '#8a6a48'; ctx.lineWidth = Math.max(1.5, cell*0.06);
    for(let i=1;i<run.length;i++){
      const a = run[i-1], b = run[i];
      const dx = b.x-a.x, dy = b.y-a.y, len = Math.hypot(dx,dy);
      if(len < 1) continue;
      const ux = dx/len, uy = dy/len, nx = -uy, ny = ux, hl = cell*0.27;
      for(let s=cell*0.15; s<len; s+=cell*0.38){
        const x = a.x+ux*s, y = a.y+uy*s;
        ctx.beginPath(); ctx.moveTo(x+nx*hl, y+ny*hl); ctx.lineTo(x-nx*hl, y-ny*hl); ctx.stroke();
      }
    }
    // two rails (offset segments, extended so corners join)
    const off = cell*0.15, ext = cell*0.18;
    ctx.strokeStyle = '#5b4a39'; ctx.lineWidth = Math.max(1.5, cell*0.07);
    ctx.beginPath();
    for(let i=1;i<run.length;i++){
      const a = run[i-1], b = run[i];
      const dx = b.x-a.x, dy = b.y-a.y, len = Math.hypot(dx,dy);
      if(len < 1) continue;
      const ux = dx/len, uy = dy/len, nx = -uy, ny = ux;
      for(const sgn of [-1,1]){
        ctx.moveTo(a.x - ux*ext + nx*off*sgn, a.y - uy*ext + ny*off*sgn);
        ctx.lineTo(b.x + ux*ext + nx*off*sgn, b.y + uy*ext + ny*off*sgn);
      }
    }
    ctx.stroke();
  }
}

function drawCar(i, p, ang, now){
  const L = cell*0.92, Wd = cell*0.58;
  const isHead = i===0;
  ctx.save();
  ctx.translate(p.x, p.y);
  // ground shadow
  ctx.fillStyle = 'rgba(60,42,20,.16)';
  ctx.beginPath(); ctx.ellipse(0, cell*0.10, L*0.55, Wd*0.62, 0, 0, TAU); ctx.fill();
  ctx.rotate(ang);
  // scale-in for freshly added wagon
  if(i===cells.length-1){
    const bt = (now - lastBorn)/350;
    if(bt>=0 && bt<1){ const s = 0.2 + 0.8*easeOutBack(clamp(bt,0,1)); ctx.scale(s,s); }
  }
  // rattle
  ctx.translate(0, Math.sin(now*0.011 - i*0.8)*cell*0.018);
  // wheels
  ctx.fillStyle = '#3a2a20';
  const wx = isHead ? [-L*0.28, 0, L*0.28, L*0.42] : [-L*0.28, L*0.28];
  for(const x of wx) for(const y of [-Wd*0.52, Wd*0.52]){
    ctx.beginPath(); ctx.arc(x, y, cell*0.072, 0, TAU); ctx.fill();
  }

  if(isHead){
    // cowcatcher
    ctx.beginPath();
    ctx.moveTo(L*0.44, -Wd*0.42); ctx.lineTo(L*0.64, -cell*0.035);
    ctx.lineTo(L*0.64, cell*0.035); ctx.lineTo(L*0.44, Wd*0.42); ctx.closePath();
    ctx.fillStyle = '#d9d2c2'; ctx.fill();
    ctx.strokeStyle = '#a99f8a'; ctx.lineWidth = Math.max(1, cell*0.03); ctx.stroke();
    // boiler
    rrFill(-L/2, -Wd/2, L, Wd, cell*0.13, '#c0453c');
    rrStroke(-L/2, -Wd/2, L, Wd, cell*0.13, shade('#c0453c',-45), Math.max(1.5, cell*0.045));
    rrFill(-L*0.42, -Wd*0.14, L*0.82, Wd*0.28, cell*0.07, 'rgba(255,235,210,.16)');
    // smokebox
    rrFill(L*0.28, -Wd/2+1, L*0.18, Wd-2, cell*0.09, '#8f2f28');
    // gold band
    ctx.fillStyle = '#e2b34b';
    ctx.fillRect(L*0.12, -Wd/2+1, cell*0.05, Wd-2);
    // cab
    rrFill(-L*0.50, -Wd*0.62, L*0.42, Wd*1.24, cell*0.11, '#8f2f28');
    rrStroke(-L*0.50, -Wd*0.62, L*0.42, Wd*1.24, cell*0.11, shade('#8f2f28',-30), Math.max(1, cell*0.03));
    rrFill(-L*0.46, -Wd*0.50, L*0.30, Wd, cell*0.08, '#5a2e28');
    ctx.fillStyle = '#7a4a3a';
    ctx.beginPath(); ctx.arc(-L*0.31, 0, cell*0.05, 0, TAU); ctx.fill();
    // chimney
    ctx.fillStyle = '#3b2a22';
    ctx.beginPath(); ctx.arc(L*0.18, 0, cell*0.095, 0, TAU); ctx.fill();
    ctx.strokeStyle = '#e2b34b'; ctx.lineWidth = Math.max(1, cell*0.028); ctx.stroke();
    ctx.fillStyle = '#161009';
    ctx.beginPath(); ctx.arc(L*0.18, 0, cell*0.05, 0, TAU); ctx.fill();
    // lamp
    ctx.fillStyle = '#ffd97a';
    ctx.beginPath(); ctx.arc(L*0.42, 0, cell*0.055, 0, TAU); ctx.fill();
  } else {
    const color = WAGON_COLORS[(i-1) % WAGON_COLORS.length];
    if(i % 5 === 0){ // tanker
      ctx.beginPath(); ctx.ellipse(0, 0, L*0.46, Wd*0.48, 0, 0, TAU);
      ctx.fillStyle = color; ctx.fill();
      ctx.strokeStyle = shade(color,-45); ctx.lineWidth = Math.max(1.5, cell*0.045); ctx.stroke();
      ctx.strokeStyle = 'rgba(0,0,0,.16)'; ctx.lineWidth = Math.max(1, cell*0.05);
      ctx.beginPath(); ctx.moveTo(-L*0.34, 0); ctx.lineTo(L*0.34, 0); ctx.stroke();
      ctx.fillStyle = '#f4ead2';
      ctx.beginPath(); ctx.arc(0, 0, cell*0.10, 0, TAU); ctx.fill();
      ctx.strokeStyle = shade(color,-30); ctx.lineWidth = Math.max(1, cell*0.025); ctx.stroke();
    } else { // boxcar
      rrFill(-L/2, -Wd/2, L, Wd, cell*0.11, color);
      rrStroke(-L/2, -Wd/2, L, Wd, cell*0.11, shade(color,-45), Math.max(1.5, cell*0.045));
      rrFill(-L*0.30, -Wd*0.33, L*0.60, Wd*0.66, cell*0.07, 'rgba(255,252,240,.38)');
      ctx.fillStyle = shade(color,-25);
      ctx.fillRect(-L/2, -Wd/2, cell*0.05, Wd);
      ctx.fillRect(L/2 - cell*0.05, -Wd/2, cell*0.05, Wd);
      if(cell >= 26){
        ctx.fillStyle = 'rgba(255,255,255,.92)';
        ctx.font = '900 ' + Math.round(cell*0.24) + 'px "Trebuchet MS",Verdana,sans-serif';
        ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
        ctx.fillText(String(i), 0, cell*0.02);
      }
    }
  }
  ctx.restore();
}

function drawTunnels(pts, angs){
  const m = cell*0.8;
  for(let i=0;i<pts.length;i++){
    const p = pts[i], ang = angs[i];
    const outside = p.x<0 || p.x>W || p.y<0 || p.y>H;
    let bestA = 0, bx = 0, by = 0;
    const cand = [];
    if(p.x < m)   cand.push({x:0, y:p.y, nx:-1, ny:0, a:1 - p.x/m});
    if(W-p.x < m) cand.push({x:W, y:p.y, nx:1,  ny:0, a:1 - (W-p.x)/m});
    if(p.y < m)   cand.push({x:p.x, y:0, nx:0, ny:-1, a:1 - p.y/m});
    if(H-p.y < m) cand.push({x:p.x, y:H, nx:0, ny:1,  a:1 - (H-p.y)/m});
    for(const c of cand){
      const dot = Math.abs(Math.cos(ang)*c.nx + Math.sin(ang)*c.ny);
      if(!(outside || dot > 0.45)) continue;
      if(c.a > bestA){ bestA = c.a; bx = c.x; by = c.y; }
    }
    if(bestA <= 0) continue;
    const a = Math.min(1, bestA*1.4);
    ctx.globalAlpha = a;
    ctx.fillStyle = '#7ba35c';
    ctx.beginPath(); ctx.arc(bx, by, cell*0.75, 0, TAU); ctx.fill();
    ctx.fillStyle = '#2f2717';
    ctx.beginPath(); ctx.arc(bx, by, cell*0.52, 0, TAU); ctx.fill();
    ctx.globalAlpha = a*0.6;
    ctx.strokeStyle = '#1d1810'; ctx.lineWidth = Math.max(2, cell*0.08);
    ctx.beginPath(); ctx.arc(bx, by, cell*0.40, 0, TAU); ctx.stroke();
    ctx.globalAlpha = 1;
  }
}

function updateAndDrawSmoke(dt){
  for(const p of smoke){
    p.life += dt;
    p.x += p.vx*dt/1000; p.y += p.vy*dt/1000;
    p.r += cell*0.12*dt/1000;
  }
  smoke = smoke.filter(p=>p.life < p.maxLife);
  if(smoke.length > 130) smoke.splice(0, smoke.length-130);
  ctx.fillStyle = '#f7f3e8';
  for(const p of smoke){
    ctx.globalAlpha = (1 - p.life/p.maxLife)*0.5;
    ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, TAU); ctx.fill();
  }
  ctx.globalAlpha = 1;
}

function drawFx(dt){
  for(const r of rings) r.t += dt;
  rings = rings.filter(r=>r.t < 500);
  for(const r of rings){
    const k = r.t/500;
    ctx.globalAlpha = (1-k)*0.7;
    ctx.strokeStyle = '#fff8e8'; ctx.lineWidth = Math.max(2, cell*0.06);
    ctx.beginPath(); ctx.arc(r.x, r.y, cell*(0.2 + 0.8*k), 0, TAU); ctx.stroke();
  }
  for(const t of tickets) t.t += dt;
  tickets = tickets.filter(t=>t.t < 950);
  for(const t of tickets){
    const k = t.t/950;
    const y = t.y - cell*1.1*easeOutCubic(k);
    const w = cell*0.95, h = cell*0.5;
    ctx.globalAlpha = k < 0.7 ? 1 : 1 - (k-0.7)/0.3;
    rrFill(t.x - w/2, y - h/2, w, h, cell*0.08, '#fff7e2');
    rrStroke(t.x - w/2, y - h/2, w, h, cell*0.08, '#c8a86a', Math.max(1, cell*0.035));
    ctx.save();
    ctx.setLineDash([Math.max(1, cell*0.05), Math.max(1, cell*0.05)]);
    ctx.strokeStyle = '#c8a86a'; ctx.lineWidth = 1;
    ctx.beginPath(); ctx.moveTo(t.x + w*0.22, y - h/2 + 2); ctx.lineTo(t.x + w*0.22, y + h/2 - 2); ctx.stroke();
    ctx.restore();
    ctx.fillStyle = '#c0453c';
    ctx.font = '900 ' + Math.max(9, Math.round(cell*0.26)) + 'px "Trebuchet MS",Verdana,sans-serif';
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    ctx.fillText('+1', t.x - w*0.14, y + 1);
  }
  ctx.globalAlpha = 1;
}

/* ================= main render ================= */
function render(now, dt){
  let sx = 0, sy = 0;
  if(!running){
    const ct = (now - crashAt)/450;
    if(ct >= 0 && ct < 1){
      const amp = (1-ct)*cell*0.18;
      sx = (Math.random()*2-1)*amp; sy = (Math.random()*2-1)*amp;
    }
  }
  ctx.setTransform(dpr,0,0,dpr,0,0);
  ctx.clearRect(0,0,W,H);
  ctx.translate(sx, sy);
  if(bg) ctx.drawImage(bg, 0, 0, W, H);

  // fading platform ghosts
  for(const g of ghosts) g.t += dt;
  ghosts = ghosts.filter(g=>g.t < 380);
  for(const g of ghosts){
    const a = 1 - g.t/380;
    if(a <= 0) continue;
    ctx.save();
    ctx.translate((g.x+0.5)*cell, (g.y+0.5)*cell);
    ctx.globalAlpha = a*0.9;
    drawSlab();
    ctx.restore();
    ctx.globalAlpha = 1;
  }

  drawFoods(now);

  // interpolated train points (with a gentle chug rhythm)
  const tt = running ? clamp(acc/tickMs, 0, 1) : 1;
  const chug = tt + Math.sin(tt*Math.PI*2)*0.045;
  const pts = [], angs = [];
  for(let i=0;i<cells.length;i++){
    const a = prevCells[i] || cells[i];
    pts.push(interpPoint(a, cells[i], chug));
    angs.push(carAngle(i, a, cells[i]));
  }

  drawTrack(pts);

  // couplings
  ctx.strokeStyle = '#43301e';
  ctx.lineWidth = Math.max(2, cell*0.09);
  ctx.lineCap = 'round';
  for(let i=0;i<pts.length-1;i++){
    if(dist(pts[i], pts[i+1]) > cell*1.6) continue;
    ctx.beginPath(); ctx.moveTo(pts[i].x, pts[i].y); ctx.lineTo(pts[i+1].x, pts[i+1].y); ctx.stroke();
  }

  for(let i=cells.length-1;i>=0;i--) drawCar(i, pts[i], angs[i], now);

  drawTunnels(pts, angs);

  // steam from the chimney
  if(running){
    if(now - lastPuff > Math.max(65, tickMs*0.55)){
      lastPuff = now;
      if(smoke.length < 110){
        const ox = cell*0.166;
        const hx = pts[0].x + Math.cos(angs[0])*ox;
        const hy = pts[0].y + Math.sin(angs[0])*ox;
        const sp = cell*0.5;
        smoke.push({
          x:hx + (Math.random()-0.5)*cell*0.08,
          y:hy + (Math.random()-0.5)*cell*0.08,
          r:cell*(0.08 + Math.random()*0.05),
          life:0, maxLife:800 + Math.random()*600,
          vx:-Math.cos(angs[0])*sp + (Math.random()-0.5)*cell*0.3,
          vy:-Math.sin(angs[0])*sp + (Math.random()-0.5)*cell*0.3 - cell*0.08
        });
      }
    }
  }
  updateAndDrawSmoke(dt);
  drawFx(dt);
}

/* ================= loop ================= */
let lastT = performance.now();
function frame(now){
  const dt = Math.min(60, now - lastT);
  lastT = now;
  if(running){
    acc += dt;
    let guard = 0;
    while(acc >= tickMs && guard < 5){
      doTick();
      guard++;
      if(!running){ acc = 0; break; }
      acc -= tickMs;
    }
  }
  render(now, dt);
  requestAnimationFrame(frame);
}

/* ================= init ================= */
function init(){
  chooseGrid();
  buildDecos();
  fit();
  resetGame('demo');
  lastT = performance.now();
  requestAnimationFrame(frame);
}
window.addEventListener('resize', fit);
init();
})();
</script>
</body>
</html>
Reasoning trace (65,685 tokens)
Let me plan this carefully. The task: a snake game where the snake is a little train (locomotive + colorful wagons), picking up passengers waiting on platforms. It must be a self-contained HTML file, with:

- Self-playing demo mode (AI plays) behind a "Click or press a key to start" message
- Visible, meaningful content within first seconds (screenshot at ~3s)
- Responsive from 1280×800 down to 375px, no horizontal scrollbar
- No console errors
- Single fenced code block with complete HTML

Design ideas: I want a distinctive, memorable look — not a generic dark neon arcade. Let me think about a charming aesthetic: a wooden toy train set feel, or an isometric miniature railway. I'll go with a warm, hand-crafted "children's book / model railway" aesthetic: cream/paper background, pastel terrain, rounded tiles, the train drawn with rounded rects, smoke puffs from the locomotive chimney, passengers as cute little characters on platform tiles with tiny suitcases, waving animation.

Key mechanics:
- Grid-based snake game. Head = locomotive, body = wagons, each wagon a different color.
- Food = passengers waiting at platforms. Each platform spawns with N passengers; the train picks them up one at a time (or picks up... in snake, eating one grows by one). Let's do: platforms appear with a small group of passengers (2-4). Each passenger eaten = +1 wagon... Hmm, but a train with endless wagons is fine (that's snake). Maybe each passenger adds a wagon. Score = passengers delivered? Or passengers picked up.

To make it more train-like: passengers wait on platform tiles; when the train passes over a passenger, they board (passenger disappears, train grows by one wagon). Score increases. Maybe also a "station" where the train delivers? That complicates. Keep it snake-like: pick up passenger → grow.

Extra flair:
- Smoke particles from the locomotive chimney that drift and fade.
- Wheel rotation animation? On a grid it moves cell by cell; I can animate smooth movement between cells by interpolating positions. Smooth movement makes it feel much better than jumpy grid steps. I'll implement smooth interpolation: the train logically moves on the grid at a fixed tick, but rendering interpolates between previous and current positions using per-cell progress. Actually, a common approach: keep the snake as a list of grid cells; render each segment interpolated between its previous cell and current cell based on time since last tick / tick duration. This gives smooth continuous motion.

- Track visual: draw rails! Since the train moves on the grid, I could draw rails under the train — like the train is laying track or riding on invisible rails. A nice touch: draw faint rail lines connecting the snake's body path (sleepers/ties along the path). That would look great — the snake body visually becomes a train on a track. I can draw the track as a path through the segment centers with railroad ties.

Actually drawing rails along the entire snake path each frame: for each pair of adjacent segments, draw a rounded line (the rail) plus small ties. With smooth interpolation this looks like a moving train on tracks. 

- Self-playing AI: pathfind to the nearest passenger avoiding collisions. Simple approach: BFS on grid from head to nearest passenger, avoiding the snake body (with tail-vacating consideration). BFS is easy and robust. If no path, try safe moves; else any move. Also avoid reversing direction.

Let me write the AI:
1. Compute BFS from head over free cells (cells not occupied by body, excluding tail if it will move... simpler: treat body cells as blocked except the last segment since it moves away, unless growing).
2. Find shortest path to nearest passenger. Take first step.
3. If no path exists to any passenger, pick any safe adjacent move (prefer current direction).
4. If no safe move, keep direction (will die; then reset in demo mode).

Also add slight imperfection? No — demo should look competent. But maybe make it not too perfect so it doesn't grow absurdly; that's fine, it can keep going. When the AI crashes, auto-restart in demo mode.

Controls: arrow keys / WASD for player mode. Click or press key → start player mode (game restarts or continues from demo? Better: pressing key/click starts player game fresh with countdown or immediately). The message overlay: "Click or press a key to start". In demo mode, show "DEMO — conductor-bot is driving" label.

After game over in player mode: show a "crashed" overlay with score and "Press any key / click to try again". Also maybe revert to demo after inactivity? Keep simple: game over overlay, click/key restarts player mode.

Grid size: Something like 21×15 or responsive. Let me pick fixed logical grid, e.g., cols=24, rows=16? Canvas scaled to fit. For 1280×800 viewport with side panel? Let's design layout:

Layout: centered game area with a header strip (title "Tiny Rails" / "Le Petit Train"?) — user wrote in English so all copy in English. Title: "PLATFORM 9 · Tiny Rails" or "SNAKE EXPRESS". Let's call it "SNAKE EXPRESS" with a subtitle "a little train that loves picking up passengers".

Layout plan:
- Full page background: warm paper cream (#f6ead8 or similar), subtle texture via CSS radial dots or a repeating pattern (no external images). Maybe a subtle checkered picnic? Keep it: soft cream with faint dot grid.
- Centered card/board: the railway board as a rounded panel with a wooden-ish border? I'll draw the board on canvas with a soft green meadow background inside, plus decorative elements: a few bushes/flowers/trees on random static cells (decor, not obstacles) — careful they don't confuse with gameplay. Better: draw soft grass tone variation per cell (checker of two very close greens) plus a few flowers as tiny dots drawn deterministically (seeded). That adds charm without clutter.
- Header above the board: game title in a display font (can't load external fonts... "No external files, images, fonts or network requests, except CDN URLs explicitly allowed by the task" — task doesn't list any CDN, so no Google Fonts). Use system font stacks with character: e.g., "Georgia", "Times New Roman" for a storybook feel? Or use a rounded stack: "Verdana"? Hmm. I can craft a distinctive look with letter-spacing, weights, and SVG-drawn title. Actually I could hand-draw the title as SVG letters... too much. Use font stack: `'Trebuchet MS', 'Comic Sans MS'...` Comic Sans might be charming for a toy train but risky. Let's use a stack: `"Baloo 2", "Comic Sans MS", "Chalkboard SE", sans-serif`? Comic Sans fits a children's train theme honestly. Hmm, but I want to avoid looking cheap. Alternative: Georgia italic serif gives storybook charm. I'll go with a bold condensed feel using system-ui heavy weights plus letterspacing... Let me decide: Title in `Georgia, 'Times New Roman', serif` with bold weight, plus a small "ALL ABOARD!" pill. Actually for toy train, rounded is better. `"Trebuchet MS", Verdana` is semi-rounded. I'll craft: title uses `font-family: 'Verdana', 'DejaVu Sans', sans-serif; font-weight: 900; letter-spacing: 0.08em;` in a deep red-brown color. Fine — typography isn't the star here; the canvas art is.

HUD: Score (passengers aboard), Length (wagons), Best (stored in a JS variable, since localStorage is banned — keep in-memory best only). Speed level.

Side/bottom: small "how to play" hints: arrows/WASD to steer, don't hit the walls or your own wagons.

Responsiveness: canvas board sized via JS: compute cell size from available space. Use a fixed logical grid (e.g., 22×14) and scale canvas by devicePixelRatio for crispness. On small screens, grid might be too dense; I can reduce grid columns on narrow screens? Simplest robust approach: choose grid dims based on aspect and available area at load; e.g., compute cell size = floor(min(availW/cols, availH/rows)). Keep fixed cols/rows for consistent gameplay (22×14) but allow cell size to shrink on mobile. 375px wide → cell ~ 375/22 ≈ 17px, okay. Board height 14*17=238px. Fine.

Actually let me choose grid to fit nicely: cols=21, rows=14 (aspect 1.5). With cell 48px → 1008×672, plus HUD fits 1280×800 nicely. Good.

Game details:

Tick rate: start ~7.5 ticks/sec, speed up slightly as score grows (cap). Smooth interpolation between ticks.

Snake data: array of {x,y} cells, head first. Direction {x,y}. Next direction queue to prevent 180° turns within a tick.

Passengers/platforms: Spawn platforms: a tile marked as platform (concrete/stone look with a small bench/lamp?) with 1–3 passengers standing on/adjacent? For snake mechanic, food occupies one cell. Simplest: each food = one passenger waiting on a little platform tile (a roundel with a sign). Multiple passengers can be present at once (2–3 active) to keep action going and give the AI choices. When a passenger is picked up, spawn a new one after a short delay at random free cell (not too close to head? any free cell). Occasionally spawn a small "crowd" of 2-3 on adjacent cells? Keep it simple: up to 3 passengers at once, each on their own platform patch.

Passenger appearance: cute little person: head (circle, skin tone variety), body (rounded rect in a color), maybe a hat or a suitcase beside them, and a tiny "hand raised / waving" animation (arm oscillates). They stand on a platform: a gray-ish rounded rect tile with yellow safety line (like platform edge) and a tiny sign post. Bobbing idle animation.

Locomotive rendering: front-facing direction changes. Drawing a top-down train vs side view? Top-down view of a locomotive is less iconic. Options:
1. Top-down: rounded rectangle with chimney (circle near front), cab, and the direction indicated by nose shape. Wheels visible at sides. This works for all 4 directions via rotation.
2. Side-view sprites that rotate — looks weird when moving vertically.

I'll do top-down stylized: each segment drawn as a rounded rect sized ~0.78 of cell, oriented along movement direction. The locomotive: distinct — dark red body, chimney with animated smoke puffs, front bumper (cowcatcher) triangle, cab window. Wagons: colorful rounded rects (rotating palette: teal, orange, yellow, blue, pink, green, purple) with a white roof stripe, tiny wheels (dark circles) on sides, and coupling gaps between segments.

Between segments, draw a small coupling link (dark short rect) connecting consecutive cars — nice detail. With smooth interpolation, compute each car's position and angle; angle from movement direction of that car (from its previous cell to current cell).

Rendering with rotation: for each car, ctx.save(); translate to interpolated center; rotate by angle; draw car shape centered; restore. The angle should be the direction of motion. When turning, angle snaps — could be slightly jarring but acceptable at small scale; I could smooth by using the interpolated angle via atan2 of velocity vector (current pos - prev pos of that segment). Each segment i has prev cell and curr cell; interpolated position p = lerp(prev, curr, t). Angle from (curr - prev) direction; that's constant per segment per tick but different segments have different directions during a turn, which looks like the train bending around a corner — good!

Rails/tracks: draw beneath cars: for the path of cells, draw a light brown "ballast" line + two rails? Simplest attractive approach: draw thick line (ballast, e.g., tan #d9b98a width ~ cell*0.55) along the polyline of segment centers (interpolated), then draw ties: small perpendicular ticks spaced along the path. Then rails: two thin lines... With a top-down view, rails along the path: draw two parallel lines offset perpendicular. That's doable with the polyline path. Let me implement:

- Build the polyline points: interpolated positions of segments from head to tail (plus small extension at tail? Not needed).
- Stroke with ballast color, round joins/caps, lineWidth = cell*0.6.
- Stroke same polyline with rail color darker (#8a6a45?) thin lines: To get two rails, I can stroke the path offset. Canvas can't offset paths easily. Trick: stroke the path twice with a wide line in rail color (width = cell*0.6) then stroke again with ballast (width = cell*0.6 - 3px) → creates two rail edges visible on the sides. Then ties: draw perpendicular ticks every ~cell*0.45 along the path in dark brown, but ties would be under the ballast... Order: ballast wide → ties (short perpendicular lines spanning the ballast width) → inner ballast? Hmm.

Better order:
1. Stroke path, color = tie/ballast base tan, lineWidth = cell*0.58, round caps/joins.
2. Draw ties: perpendicular small rects along path at spacing ~cell*0.4, color darker brown #b08a5f... wait ties should be darker wood over lighter ballast. So ballast base = light tan (#e3c893), ties = medium brown (#a97e4f) short lines across.
3. Rails: stroke path with lineWidth = cell*0.58 in rail color, then stroke again with lineWidth = cell*0.58 - railWidth*2 in ballast color — that leaves the two outer rail strips. But that would cover the ties. Ties should be under rails? In real top-down, ties are under rails and visible between them. If the inner ballast restroke covers the middle, ties get hidden except the outer slivers. 

Alternative simpler: don't do two rails; just ballast + ties looks like a nice toy track (like wooden train sets where the track is grooved wood). Brio-style: a single wooden track line with ties looks great and is simpler. So: path stroked in wood color (#d4a86a) width ~0.5 cell with rounded caps, then darker ties perpendicular (#a9793f) every 0.4 cell, then maybe two thin darker "grooves" using the double-stroke trick with small inset (stroke width w+? ). Let's do: 
1. stroke path with color wood, width W = cell*0.52, round caps.
2. stroke path with color slightly darker wood (inner), width W - 5 → gives a border edge look.
3. ties: for each pair of adjacent path points... ties along a curve need sampling. I can sample the polyline: walk along cumulative distances, place ties perpendicular to local direction. Implementable: build flattened point list (segments between car centers + extensions), compute cumulative lengths, step through.

Actually the polyline between car centers is basically straight lines along grid axes, corners at car centers. Walking along it is straightforward.

I'll implement drawTrack(points): strokes ballast, then computes arc-length walk placing ties (skip ties near corners? ties at corners fine, perpendicular to average direction — small artifact acceptable, or place tie only when the local direction is axis-aligned i.e., at sample point compute direction from neighboring samples).

Smoke: particles emitted from the locomotive chimney: position = chimney world pos (computed from head pos + rotated offset). Each tick or per frame, spawn a puff (circle) that grows and fades, drifting opposite of motion + upward-left. Cap particle count.

Also "chugging" sound? Audio via WebAudio is allowed (no network). Could add tiny toot when picking up a passenger and clickety-clack? Autoplay policies: AudioContext needs user gesture; in demo mode audio would be blocked until interaction. I can create the context lazily on first user gesture and add subtle sounds in player mode. Sounds risky for console errors — handle with try/catch and resume on gesture. I think a small "ding" when boarding and a "toot" on start adds delight. I'll implement a tiny synth (oscillator + gain envelope). Guard everything. Keep it optional and safe: only create AudioContext on first pointer/key event.

Score popups: floating "+1" or a little "All aboard!" text that floats up when picking up a passenger. Cute: passenger waves, then hops onto the train with a small animation — a "boarding" animation where the passenger sprite tweens from platform to the new wagon position. Since the new wagon appears at the tail, tween passenger to the tail. Simple version: spawn a "boarding ghost" that lerps to the tail over 0.4s then vanishes; the new wagon appears. Actually growth in snake: when eating, we don't remove the tail this tick, so a new segment appears. Let me handle: on eating, append a copy of the current tail cell (so the new wagon "emerges" from the tail). Visually, the new wagon scales up from 0 → 1 over the next tick. I'll add a per-segment "bornAt" time and scale-in animation for the newest wagon. 

Passenger pickup: also tween: a small "hop" — I'll spawn a floating emoji-free sprite: draw the passenger mini-figure flying from the platform to the train head with an arc. Nice but let's keep: floating text "+1 🎫"? No emoji per my taste (no emoji anywhere as icons). Use text "ALL ABOARD!" floating up, or draw a tiny ticket. I'll do floating text "+1" in a small ticket-shaped bubble drawn on canvas. Plus a chime.

Walls: snake dies on wall hit? For a train, maybe wrap-around tunnels? Classic snake: walls kill. But wrapping through "tunnels" is very train-like! Option: edges are tunnels — the train exits one side and enters the other. That's forgiving and thematic (draw tunnel arches on the border). But classic snake game expectation... The user said "snake game", walls or wrap both fine. Tunnels add charm: when the train wraps, draw a dark arch. However, wrapping complicates track drawing (path across the boundary) and interpolation. I could handle wrap by splitting the polyline. Hmm, complexity vs charm. Alternatively walls kill: draw a decorative wooden fence / hedge border. Self-collision kills.

I think wrap-around with tunnels is delightful and shows craft. Implementation details:
- Grid wraps: (x+cols)%cols. Movement: head moves to wrapped cell.
- Interpolation across boundary: when computing interpolated positions, if |curr-prev| > 1 in an axis, it wrapped: adjust prev by ±cols/rows to make it adjacent, compute interpolated pos, then wrap the rendered pos into canvas bounds — but the car would slide off one edge and appear... Actually smooth: let the car go off-screen (pos beyond edge) for that segment while its "wrapped" twin appears on the other side. Simplest visually pleasing: render each car twice when it's near/over a boundary crossing? That's complex.

Simpler compromise: walls kill, but draw a lovely border. The AI in demo mode must avoid walls — BFS naturally handles that. Death by wall in demo → quick restart with a "whistle" reset. Hmm, frequent AI deaths look bad. BFS + safe-move fallback should mostly avoid death; with wrap-around, the AI basically never dies of walls. With walls, BFS avoids them too. Self-collision deaths happen when snake is long and boxed in. BFS avoids body cells (with tail vacating), so it's decent.

Let me estimate AI safety: BFS to nearest passenger with body blocked (except cells that will be vacated by the time... a good heuristic: block body except the last k segments proportional to path length; simpler: block all body except tail). This is the classic AI snake which survives decently but can trap itself when long. When it dies, demo restarts after a brief "crash" pause. Acceptable — even fun ("conductor-bot derailed! restarting").

Decision: I'll do walls-kill with a decorative border (hedge/fence) — classic snake feel, simpler and robust. Actually... hmm, let me reconsider tunnels once more, since "little train" theme begs for it. The complexity: interpolation with wrap. Trick: I can render with translation modding: for each car, compute interpolated position in "unwrapped" coordinates relative to head. Alternative approach: keep each segment's render position; when a segment wraps, treat prev = curr - dir (i.e., pretend it came from the adjacent cell on the wrapped side) → the car slides in from the edge it entered. Visually: car A at x=cols-1 moving right; next tick head at x=0. Interp: prev for rendering = curr - dir = (0) - (1,0) = (-1, 0). So the car slides from x=-1 → 0, i.e., appears sliding in from the left edge. Meanwhile at the previous tick it was sliding toward the right edge and would visually pop from right edge to left edge. There'd be a one-frame jump at t crossing... Let's see: at tick n, head interp from (cols-2)→(cols-1), reaches right edge at t=1. At tick n+1, head interp from (-1)→0: starts just outside left edge. So the head visually exits right, then pops into left. There's a moment where the head is fully off right edge and then appears off left edge — actually that's fine and natural if we draw tunnel arches at the border: the train enters the tunnel on the right and exits from the left tunnel. The pop happens off-screen (car at x=-0.5 is half off-screen left, previously half off-screen right) — no on-screen discontinuity except the car vanishing from right and appearing left, which reads as "went through a tunnel". And the track polyline would need splitting at wrap points — when drawing the track path, break the path where consecutive points are far apart. That's easy: start a new subpath when distance > 2 cells.

That's quite manageable! And the AI never dies by wall. Self-collision still possible. I'll add tunnel arches: dark半圆 shapes on each border cell? Draw tunnels only at the middle of each side? The train can exit anywhere along the border, so any border cell could be a tunnel. Drawing a tunnel arch on every border cell = cluttered. Alternative: draw the border as a hedge/forest edge with occasional gaps... Or skip drawing tunnels and just draw a subtle border shading; the wrap reads naturally anyway. I'll draw a dark rounded "tunnel mouth" only at the exact cell where a wrap is imminent/in progress: when a car is within 1 cell of the border moving outward, draw a tunnel arch at that border cell. Dynamic tunnels! That's a cute effect: the arch fades in as the train approaches the edge, fades out after. Implementation: track wrap events: when the head crosses (tick time), record tunnel at (exit border cell, side) with time; also draw while any car is overlapping the border area. Simpler: each frame, for each car near the edge (within 0.75 cell of the border and moving outward), draw an arch at that border position. Since arches are drawn from current train positions, they appear/disappear naturally. Slight complexity but doable: for head (and each car), compute its interpolated render pos; if pos.x < 0.6 cell → arch on left edge at that row; if > width-0.6 cell → right edge; similarly for y. Draw arch: dark circle/semicircle clipped at edge, like a tunnel opening. Render beneath the train? The train slides "into" the arch: draw arch (dark hole) then train on top, then maybe a front lip. Just a dark rounded rect straddling the border with an arch opening. Keep it simple: dark semicircular arch (fill #5b4632, with darker inner hole) drawn at the border cell, on top of background but below train? If below train, the train slides over the dark hole and looks like entering a tunnel. 

Hmm wait — but the tunnel appears anywhere on the border as the train approaches. When train leaves right edge at row 5 and appears at left row 5, both an exit arch (right, row5) and entry arch (left, row5) show simultaneously. That's exactly right!

OK here's my plan for wrap + interpolation + track splitting. Let me also double check collisions with wrap: neighbor cells computed with wrap for both movement and BFS.

Now self-collision: new head cell equals any body cell (excluding tail that's vacating when not growing) → crash.

Crash sequence: screen shake? small "derailment": cars scatter slightly / tumble? Keep it tasteful: freeze train, show "Oh no! Derailed!" overlay with score, smoke burst particles, whistle sound (if audio unlocked). After 1.6s in demo mode → auto restart. In player mode → wait for key/click to restart (also show "Press any key").

Speed: base tick 150ms (6.7/s), decreasing by ~3ms per passenger to min 80ms. That's decent.

Player input: arrows/WASD; queue up to 2 direction changes; prevent reversing. Also swipe support for touch — nice for 375px. Implement touchstart/touchmove basic swipe detection. And on-screen? Maybe not needed; swipe suffices. Add click-to-start overlay anyway (required by instructions).

Also pause when tab hidden: use delta-time clamping to avoid huge steps (if requestAnimationFrame gap, clamp dt). Use a fixed-tick accumulator.

HUD design: Top bar with:
- Left: title "SNAKE EXPRESS" + tiny subtitle.
- Right: stat chips: "PASSENGERS 12" (score), "CARS 13", "BEST 21".
Bottom bar: hint text: "Steer with ↑←↓→ or WASD · grab every passenger · don't derail into your own wagons". Since no emoji... arrows ↑←↓→ are fine (they're symbols, not emoji — typographic arrows are acceptable; they're standard Unicode arrows, not emoji). Hmm, my taste says no emoji as icons/placeholder. Arrow glyphs are borderline; safer to write "Arrow keys / WASD" in words, or draw little key caps in HTML/CSS. I'll render small keycap spans styled with CSS (kbd elements) — classy: <kbd>W</kbd> etc. Nice.

Overall palette (storybook poster):
- Page bg: warm cream #F5E9D3 with subtle noise? Use CSS radial-gradient dots pattern (tiny) — okay, subtle dot texture: `background-image: radial-gradient(#e8d7b6 1px, transparent 1px); background-size: 22px 22px;` subtle.
- Board (canvas) meadow: two-tone soft green checker: #9BC17C / #93B974? Warmer: #A8C97F and #A1C278. Plus scattered flower dots and a couple of ponds? Keep: per-cell checker + deterministic flowers (tiny 5-petal white/pink dots + yellow center) and a few bushes (darker green blobs). Drawn each frame (cheap) or pre-render to offscreen canvas once (better perf). I'll pre-render the background to an offscreen canvas at current cell size, redraw on resize.
- Border around canvas: rounded wooden frame: CSS border with brown color and inner shadow, or draw frame in canvas. I'll wrap the canvas in a div with CSS: border: 10px solid #7a5230? With rounded corners via border-radius and overflow hidden. Then a soft drop shadow. Like a wooden train-table.

Header typography: cream page, deep brown text #4a3220, red accent #C0392B-ish (train red #B33A3A). Title maybe with a small drawn train glyph? I can inline an SVG logo next to the title (inline SVG allowed — it's within the HTML file, not external). Small SVG of a side-view locomotive in the header — charming and shows craft. Yes! Draw a mini side-view steam locomotive SVG: boiler, cab, chimney, wheels with spokes. I'll hand-craft: circles + rects. 

Stat chips: cream cards with brown border, rounded, small caps labels.

Demo overlay: a translucent panel bottom-center or over the whole board: "DEMO · the conductor-bot is driving" and center big "CLICK OR PRESS ANY KEY TO START". Required: display a clear "Click or press a key to start" message. I'll show a centered card: title "All aboard?", big button-like "CLICK OR PRESS ANY KEY TO START", small note "Watch the demo until you're ready". Overlay should not fully hide the demo — semi-transparent, pointer-events... it needs to capture clicks to start. Make the card compact at bottom-center so the train is visible. Also allow clicking anywhere on the board to start.

In player mode, hide the demo card, show "YOU'RE DRIVING" small badge + score emphasis. Also speed increases.

Game-over card (player mode): "Derailed! N passengers were expecting you…" plus "Press any key or click to try again". After 6s idle maybe return to demo? Skip; keep waiting.

Now the AI specifics:

```
function bfsNext(start, targets, blocked) // returns first-step direction toward nearest target
```
Grid: cols×rows, wrap neighbors. Blocked: set of body cells except tail (tail vacates unless eating this move — but we don't know if next move eats; if the step lands on food... if the nearest target path's first step leads to food, tail won't vacate; handle by treating tail as blocked if the head is adjacent to food? Simplification: treat tail as free; the rare failure causes self-collision → demo restart, acceptable. Actually let me add lookahead: after computing the move, simulate one step and check that the resulting head cell is not equal to any body cell that will still be there... The classic approach: after moving, also verify the snake can still reach its tail (flood fill from new head to tail cell). That "follow tail" fallback keeps the AI alive much longer. Implementation:

1. Compute path to nearest food via BFS avoiding body (minus tail).
   - If found and first step is safe (new head not colliding with body-minus-tail), take it — but also verify safety via tail-reachability? For demo, a decent greedy + tail check is nice but greedy is more fun to watch (it goes straight for passengers). I'll do: if a path exists, take the first step, but check that after the step, flood-fill from new head can reach the current tail position (i.e., not trapped in a pocket). If trapped-check fails, prefer a move that keeps tail reachable; among safe moves prefer the one that minimizes distance to food or maximizes reachable area.
   
Let me keep it robust but simple:
- Candidate moves: 4 directions, excluding reverse of current dir.
- For each candidate, compute new head cell (wrapped); discard if it collides with body (excluding tail if not growing; if the candidate cell is food, then growing → tail stays → include tail as blocked... i.e., blocked set = body minus (tail if not eating)). If candidate cell is food → blocked set includes tail.
- Among surviving candidates, prefer ones where new head is food (immediate eat).
- Else prefer candidates with a BFS path to some food (shorter path better).
- Else prefer the candidate with the largest free-space flood fill (survival mode).
- Else no candidates → keep current direction → crash → restart.

This is a solid, watchable AI. Also maybe make the demo AI slightly imperfect to create occasional drama? No — reliability looks better.

To prevent infinite growth stalling: it's fine, snake grows until it traps itself, then restarts with a wipe transition. Fine.

Now, code structure:

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Snake Express — a little train that picks up passengers</title>
<style> ... </style>
</head>
<body>
  <div id="app">
    <header> logo svg + title + stats </header>
    <div id="boardWrap"> <canvas id="game"></canvas> <div id="overlay">...</div> </div>
    <footer> hints </footer>
  </div>
<script> ... </script>
</body>
</html>
```

CSS: flex column centered, 100dvh height with fallback 100vh; overflow hidden? Careful: no horizontal scrollbar. Use `overflow-x: hidden` on body for safety, but ensure vertical fits at 800px height. Design to fit: header ~64px, board flexible, footer ~40px. Use `height: 100dvh; display:flex; flex-direction:column;` board wrap flex:1 with min-height:0; canvas sized by JS to fit the wrap while preserving grid aspect (cols/rows). On resize, recompute cell size = floor(min(wrapW/cols, wrapH/rows)), canvas CSS size = cell*cols × cell*rows; backing store × dpr.

For 375px: header stats might overflow — make stats wrap or shrink; use flex-wrap and smaller fonts via clamp(). Title font-size clamp(18px, 3.5vw, 30px). Stats font small. Fine.

Now canvas drawing details:

Cell coordinates → pixel: px = x*cell + cell/2 for center.

Background pre-render:
- fill base green.
- checker: for each cell, if (x+y)%2, fill slightly different green with rounded rect inset? A soft checker via fillRect with alpha. Use subtle: rgba(255,255,255,0.05) or a slightly lighter green.
- Random-but-deterministic decorations: use a seeded PRNG (mulberry32) to place ~40 flowers and ~10 bushes at cell corners, drawn small. Also small grass tufts (3 short strokes). Pre-rendered once.
- Border: none in canvas (tunnels handled dynamically); but maybe soft vignette inside edges.

Platforms & passengers (dynamic food): 
```
foods = [{x, y, bornAt, palette: {shirt, skin, hat?}, wavePhase, suitcaseColor}]
```
Up to 3 concurrently. Spawn: pick free cell not within 2 cells of head and not adjacent to a wall? Any free cell; avoid cells adjacent to head (cheep). Also avoid spawning right in front of another food? fine.

Draw platform: rounded rect tile slightly larger than a cell? Platform patch ~cell×cell with gray fill (#cfc8bd), a yellow warning line strip along one edge, maybe a tiny sign post with the "SNAKE EXPRESS" sign or a station sign — a little post with a round sign. And the passenger standing on it.

Passenger drawing (top-down or front view?) The scene is top-down for the train... but passengers drawn front-facing (like a board-game meeple) reads better and is charming. Mixing perspectives is common in cute games. I'll draw passengers front-facing: little meeple with head, body, legs? At small size (cell ~40px), a meeple ~ 24px tall works: 
- shadow ellipse
- body: rounded rect (shirt color)
- head: circle (skin tone), maybe hair arc or hat
- one arm raised waving (rotating small rect) with sin(time)
- tiny suitcase on ground beside
- idle bob: y offset sin.

Locomotive (top-down, oriented by angle):
- Overall car body: rounded rect length ~1.15*cell? Cars can be slightly longer than a cell? With interpolation, cars on a straight line are spaced exactly 1 cell apart; car length ≤ cell*0.95 to avoid overlap. Use length cell*0.92, width cell*0.62.
- Locomotive: 
  - Cowcatcher: triangle at front (light gray).
  - Boiler: front portion — darker band.
  - Chimney: small circle near front with dark rim (smoke source).
  - Cab: rear portion, slightly wider rect with roof and a window (light blue square).
  - Color: classic red #C94F4F / dark red, roof dark.
  - Wheels: 3 small dark circles per side? At this scale, maybe side rods: two dark dots per side.
- Wagons: body rounded rect in palette color, white/darker roof stripe down the middle (top-down roof), tiny windows (two squares) or a cargo look. Alternate: some wagons carry passengers visible! Cute: little heads peeking from windows. Keep: roof stripe + 2 window squares on sides + wheels (4 dark dots at corners). Also a small shadow under each car.
- Couplings: between consecutive cars, draw a small dark link: from car i rear to car i+1 front, a short 3-4px dark rect. Since positions/angles are per-car, draw a thick short line between their centers, under the cars (draw before cars). Actually draw the track first, then couplings, then cars.

Head direction when idle at start: choose right.

Smooth motion: maintain `prevCells` (positions at previous tick) parallel to `cells`. On tick: shift. For rendering, t = clamp(accumulated/tickDur, 0, 1), eased? Linear is best for constant-speed trains. Actually slight easing makes chugging motion: ease with a subtle "chug" — t' = t + 0.08*sin(t*2π)? That could look like the train chugging. Cute but risky for visual gaps between cars (they all share the same t so spacing preserved — yes! Since all cars interpolate with the same t, spacing remains consistent even with easing). Chug easing = charming. Small amplitude 0.05.

New segment spawn: when growing, push tail duplicate with prev=curr (so it doesn't slide) and scale-in over 300ms.

Let me now also handle "boarding" flair: when the train picks up a passenger: 
- spawn floating text "+1" ticket: draw a small cream rounded rect with "+1" and a dashed ticket edge, floating up and fading over 0.9s.
- smoke puff burst, whistle sound (if unlocked).
- Score++.

Maybe also periodic "clickety-clack" sound? Skip ambient sound; only pickup ding + crash sound + start toot. Guarded by audio availability.

Extra polish: engine headlight cone? At night? No, daytime scene. 

Let me think about the page composition beyond the board to make the first screenshot attractive:
- Header: left = SVG logo (side-view loco) + "SNAKE EXPRESS" title + subtitle "the little train that picks up passengers". Right = stat chips.
- Board: rounded wooden frame, cream mat, green meadow inside. 
- Footer: kbd hints + demo status text.
- Maybe soft "clouds" drawn in the meadow background? Could add a pond. Keep flowers/bushes.

Colors:
- cream page: #F4E7CE
- dots: rgba(160,120,60,0.08)
- text brown: #4A3222
- red accent: #C0453C? Train red: #B9433C
- wood frame: #8A5A33 with darker edge #6d4526
- meadow greens: #A9CB85 / #A3C57E; bushes #7FAF63; flowers white/#F6C9D4 petals yellow center.
- ballast wood: #D8AE72; ties #A6793F; (rails via edge stroke #8a6238 maybe)
- locomotive: body #C7483D, dark #8E2F27, roof #5A2E28? plus gold trim #E8B84B.
- wagons palette: ["#3E8E7E", "#E8913F", "#E7C24A", "#4E79C7", "#C75D8F", "#5FA052", "#8C6BC7", "#D9705F"]
- passengers skin tones: ["#F2C9A1", "#D9A06B", "#8C5A33", "#F7D9B8"], shirts from a palette, hats sometimes.

Fonts: I'll use a stack with personality: `font-family: "Trebuchet MS", "Segoe UI", Verdana, sans-serif;` body; title maybe same with 900 weight and tight letters, uppercase, letter-spacing. Add `font-variant`? Keep clean.

Now the overlay messaging states:
- state = "demo" (AI drives, overlay card visible: "CLICK OR PRESS ANY KEY TO START" + subtitle "Conductor-bot is showing you the route"). 
- state = "playing" (overlay hidden or small badge).
- state = "over" (card: "Derailed!" + stats + "click or press a key to go again").
- transitions: demo→playing on first keydown/pointerdown; playing→over on crash; over→playing on keydown/pointerdown (restart); demo crash→auto restart after delay.

The overlay card should be non-blocking visually: place at bottom center inside boardWrap with backdrop blur slight. pointer-events: the whole boardWrap listens for pointerdown.

Keyboard: window keydown; if state != playing → start/restart; else map arrows/WASD to direction queue. Prevent default for arrows to avoid page scroll (page shouldn't scroll anyway).

Restart logic: resetGame(mode) builds fresh snake at center-ish with length 4, direction right, spawns 3 foods, score 0, speed base. In demo, AI drives.

Timing loop:
```
let last=performance.now();
function frame(now){
  dt = min(now-last, 100); last=now;
  update(dt); render(now);
  requestAnimationFrame(frame);
}
```
update: if state playing or demo (AI moves): accumulate dt; while acc >= tickDur: doTick(); acc -= tickDur; Also clamp acc to avoid spiral. interpT = acc/tickDur.

Careful: after crash, we stop ticking but keep rendering (smoke etc.).

doTick:
1. dir = queued dir if valid (not reverse) else current.
2. newHead = wrap(head + dir).
3. check food at newHead: if food → grow (don't pop tail), remove food, score++, effects, spawn new food (after delay or immediately), speed update. else pop tail.
4. self-collision: if newHead in body (after accounting tail pop — i.e., check against the body list after tail removal) → crash. Note: with wrap, body cells are wrapped coords — use key x+y*cols.
5. prevCells = copy of cells (before adding head) — for interpolation each segment i moves from prevCells[i] to cells[i]. Standard: store prev per segment. Implementation: before mutation, prev = cells.map(clone). Then cells.unshift(newHead); if !grow cells.pop(); Then for interpolation: segment i interpolates from prev[i]... Hmm indices: after unshift, cells[i] corresponds to prev[i-1] for i>=1 and head has prev = prev[0] (old head). So set prevCells such that prevCells[i] = cells_old[i] but for the head, prev = old head. If grown, tail new segment: prev = same as its cell (no motion) — cells[last] = old tail (since nothing popped when growing? wait if growing we don't pop, so cells[last] = old tail cell, and its prev should equal itself so it doesn't slide; but prev array from old cells has prev[last] = old cells[last]... old cells had same length L; new cells length L+1; cells_new = [newHead, old0..old(L-1)]. prev for segment i (>=1) should be old cells[i-1]?? No wait.

Let me think in terms of each rendered car keeping its own (prevPos, currPos). Simplest: keep per-segment prev & curr arrays recomputed each tick:
- Before: cells_old (length L).
- Compute newCells (length L or L+1).
- For i in newCells: prev_i = i === 0 ? cells_old[0] : newCells[i] ... no.

Standard snake interpolation: each body segment moves to the position of the segment ahead of it. So newCells[i] = cells_old[i-1] for i>=1, newCells[0] = newHead. Then prev_i (the position the segment was at last tick) = cells_old[i] for i < L; and for the new grown tail segment (i = L), prev = cells_old[L-1] = its own current position (since newCells[L] = cells_old[L-1]) → prev == curr → it stays still then starts moving next tick. 

So prevCells = cells_old (padded: for grown segment prev = same). Implementation: prev[i] = cells_old[i] ?? newCells[i]. That gives correct smooth motion: each car slides from its old cell to the next cell along the path. 

6. If AI mode: compute AI decision for next tick here or immediately after tick (so direction decided ahead). I'll compute at tick time: at start of doTick, if demo: dir = aiChoose().

Wrap-aware interpolation: for rendering, for each segment: p0 = prev (maybe unwrapped), p1 = curr. If |p1.x - p0.x| > 1 → wrapped in x: adjust p0.x += sign(p1.x - p0.x) * -cols? Let me: if p1.x - p0.x > 1 → p0.x += cols (prev was actually at p0.x+cols? e.g., prev.x = cols-1, curr.x = 0: diff = -(cols-1) < -1 → prev visual should continue to the right: p0.x = -1 equivalent: p0.x = p1.x - 1 = -1. Simplest: if |dx|>1: p0.x = p1.x - Math.sign(dx)... hmm we want p0 such that p0 is adjacent to p1 in the direction of motion. If dx = p1.x - p0.x wrapped crossing: set p0.x = p1.x - signWrap where signWrap = the actual movement direction (which we know = dir? no, per-segment movement direction = curr - prev in wrapped sense). Compute d = ((p1.x - p0.x) + cols) % cols; if d === 1 → moving +1: p0.x = p1.x - 1. if d === cols-1 → moving -1: p0.x = p1.x + 1. Similarly y. If d===0 → same. Good.

Render position: ip = lerp(p0adj, p1, t). This may be off-canvas (x=-0.5); that's fine (train slides out). The track polyline: use these ip points; break path when gap > 1.5*cell.

Tunnel arches: for each segment whose ip is within 0.9*cell of an edge (x < 0.5cell... let me define: if ip.x*? Let me compute pixel pos; if px < cell*0.55 → near left edge: draw arch centered at (0? left border) at row py, facing right (opening to the right? no—the tunnel opening faces into the board). Arch: draw a dark shape: half-ellipse/rounded rect straddling x∈[-cell*0.4, cell*0.25]? Simpler: draw a filled dark green/brown "hill" semicircle at the border: center at (0, py), radius cell*0.55, but clipped to board? A semicircle centered on the left edge: fill dark #5c4a33, with an inner darker arch (the hole) radius cell*0.38 also centered on edge. The train slides under it (draw arch after train? If drawn before the train, the train passes over the hole — looks wrong. If drawn after (on top), the train slides underneath the arch — correct for entering). But the arch on top would cover the train's part that's still inside... The arch is a ring: outer hill color + inner hole (through which we see the train? no, hole is where the train enters—dark). Hmm: correct look: tunnel = dark opening; train slides INTO the dark opening, i.e., the front of the train gets covered by darkness. So draw: train first, then a dark半clip rect at the border region with an arch-shaped boundary? The opening should be a dark arch shape drawn on top of the train, but the arch shape must be bounded so it doesn't cover the rest of the board. Draw shape: intersection of (border strip: x from -cell to cell*0.5) and (circle/ellipse)? I'll draw: a dark arch = rounded rectangle "tunnel mouth" of size (cell*1.2 wide along the edge? no.

Simplify: draw an ellipse centered ON the edge point (px=0 or W), radii (cell*0.55 along the edge, cell*0.55 perpendicular)? If centered exactly at the edge, half is off-canvas (invisible), half sticks into the board as a dark半ellipse = reads as a tunnel opening in a hillside. Draw it ON TOP of the train: then the train sliding to the edge gets progressively covered by the dark半ellipse = looks like entering a tunnel. And add a lighter rim arc for the tunnel mouth edge. This works! The arch "sticks into" the meadow ~ cell*0.55 — acceptable, it appears dynamically only when the train is near that edge cell. To smooth appearance, fade in alpha based on how close the train is (distance to edge < 1.2 cells → alpha ramps). Also when train head just wrapped to the other side, an arch appears there as the head slides in. 

Compute for each segment: nearLeft = ip.x < 0.7 (in cell units) → draw arch at (0, ip.y) with alpha = 1 - ip.x/0.7. Similarly right: ip.x > cols-1.7? in cell coords: ip.x > cols-0.7 → arch at (cols, ip.y). top/bottom similarly. Multiple cars near the edge → overlapping arches at same spot — fine (same position roughly, or draw only for head + when positions differ > 0.5 cell draw each). Just draw per car; cheap.

Edge case: arch drawn on top of train covers the loco front as it exits — good. When the loco wraps and slides in from the other side, arch there covers... the entering loco would be under the entering arch only while ip.x<0.7 — the loco appears emerging from the dark opening — actually since arch is drawn over it, the loco emerges from under the arch — perfect.

Also: draw the track only within canvas (lineTo with off-canvas points is fine, clipped automatically).

Now, the background pre-render should also include a subtle inner shadow / mowed-stripe pattern. Fine.

Let me write the AI more concretely:

```js
function aiChoose(){
  const head = cells[0];
  const bodySet = new Set(cells.map(c=>idx(c)));
  const tailIdx = idx(cells[cells.length-1]);
  const dirs = DIRS; // 4
  const cands = [];
  for(const d of dirs){
    if(d.x === -dir.x && d.y === -dir.y) continue; // reverse
    const nx = (head.x + d.x + COLS) % COLS, ny = (head.y + d.y + ROWS) % ROWS;
    const ni = ny*COLS + nx;
    const isFood = foods.some(f=> f.x===nx && f.y===ny);
    // blocked: body except tail (tail vacates if we don't eat)
    let hit = false;
    for(let i=0;i<cells.length - (isFood?0:1);i++){ if(idx(cells[i])===ni){hit=true;break;} }
    if(hit) continue;
    // BFS distance from (nx,ny) to nearest food, avoiding body (minus tail) — also treat (nx,ny) as start.
    const pathLen = bfsDist(nx, ny, isFood ? 0 : 1); // returns steps to nearest food or Infinity
    const space = floodSize(nx, ny); // free area reachable
    cands.push({d, isFood, pathLen, space});
  }
  // choose: food first; then min pathLen; then max space; 
  cands.sort((a,b)=> (b.isFood - a.isFood) || (a.pathLen - b.pathLen) || (b.space - a.space));
  return cands.length ? cands[0].d : dir;
}
```
bfsDist with wrap; blocked cells = body minus tail (tail cell free). BFS over up to 21*14=294 cells — trivial per tick. floodSize: BFS count of reachable free cells (also cheap). Might run 4 BFS + 4 floods per tick — fine at ~8 ticks/sec.

Also small optimization: compute bfsDist once per candidate start. OK.

Food spawn: choose random free cell where not on snake, not on other food, distance (BFS or Chebyshev with wrap) from head ≥ 4. Try up to 100 random attempts.

Keep foods count = 3 always (respawn immediately on pickup). Maybe 2 for smaller boards? 3 fine.

Score/level: score = passengers picked. Speed: tickMs = max(78, 150 - score*2.2).

Growth: grow 1 wagon per passenger. Length start 4 (loco + 3 wagons).

Now, sizes: canvas logical: cell computed; but drawing code uses `cell` and coordinates in device px with ctx.scale(dpr). Let me set canvas.width = W*dpr; ctx.setTransform(dpr,0,0,dpr,0,0). All drawing in CSS pixels.

Resize handling: recompute cell + re-render background offscreen. Keep gameplay grid same.

Fit: boardWrap has flex:1; I'll compute available size from its clientWidth/Height each resize + a ResizeObserver? window resize event is enough (plus orientation change). Also initial call after layout (requestAnimationFrame first tick).

Header stats update via DOM text (score, cars, best). Best kept in a JS variable (per session), updates on game over in player mode (or any mode? only player mode counts — but demo shows off; let's count best only from player runs to be fair).

Now the crash animation: on crash: state='over'; crashTime=now; screen shake: render with ctx translate small random decay over 400ms; spawn smoke burst at head; sound crash (short noise burst via WebAudio if unlocked — player mode likely unlocked since they clicked/pressed to start; demo mode audio not started, silent, fine).

In demo mode crash: after 1.4s auto-resetGame(demo).

In player crash: overlay card appears (after short delay 500ms) with score and best; any key/click restarts.

Sound design (WebAudio, all synthesized):
- ensureAudio() on first user gesture: create AudioContext.
- ding(): two quick sine notes (E6→G6?) with fast decay — for boarding: a bright "ding-dong" like a station bell. 
- toot(): square wave low (200Hz) twice — train whistle, on start and pickup? Whistle on start, ding on pickup.
- crash: filtered noise burst + descending tone.
Implement helper: playTone(freq, dur, type, gain, when, slideTo).

Volume modest (0.12). Wrap in try/catch.

Also add subtle motion: page decorations? Keep focus.

Footer hints: "Steer: <kbd>←</kbd><kbd>↑</kbd><kbd>↓</kbd><kbd>→</kbd> or <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> · swipe on touch". These arrow characters are keyboard glyphs — appropriate as literal key labels, not decorative emoji. OK.

Demo badge: In demo mode, small pill top-left inside board: "DEMO — conductor-bot driving". Overlay bottom card: big "CLICK OR PRESS ANY KEY TO START". Should be visible immediately for screenshot — yes.

Hmm, one requirement: "Something meaningful must be visible on first paint" — the demo starts immediately with the train moving and the overlay. Good. Start the game loop right away; initial snake moving right, foods placed. Also maybe a brief "3-2-1"? No, start instantly so the 3s screenshot shows motion.

Mobile: touch swipe → set direction. Also tapping the overlay starts the game; then swipes steer. Make sure tap doesn't immediately steer (fine either way).

Edge: keydown while state demo → start player game (fresh reset). Prevent the same keydown from also applying a direction? Reset and set direction if arrow — actually nice: reset then apply chosen direction if it's a valid initial direction (not left, since snake starts moving right; starting cells at center facing right; pressing ← would be a reverse — ignore). Handle: on start via key: if key maps to dir and not reverse of initial, set as first dir.

Overlay HTML:
```html
<div id="overlay" class="overlay">
  <div class="card" id="overlayCard">
    <div class="card-title" id="ovTitle">All aboard!</div>
    <div class="card-big" id="ovBig">Click or press any key to start</div>
    <div class="card-sub" id="ovSub">Conductor-bot is driving the demo route</div>
  </div>
</div>
```
Position overlay absolute inset-0, flex align-end center, padding-bottom, pointer-events none but boardWrap click captured at wrapper level. Keep card compact.

States adjust text:
- demo: title "Demo route", big "Click or press any key to start", sub "The conductor-bot is driving — take over whenever you like."
- over: title "Derailed!", big "Click or press any key to try again", sub "N passengers picked up · best M".
- playing: overlay hidden (display none), show small floating badge? I'll show a separate little chip top-left: "You're driving" with mode. Actually put chip outside overlay: `#modeChip` positioned top-left inside board; text changes by state: "DEMO · conductor-bot" (cream) vs "You're driving!" in player mode. Chip pointer-events none.

Stat chips in header: PASSENGERS (score), CARS (length), BEST. Update on change via small function. Maybe animate score pop with CSS transform scale — set class briefly.

Now the header logo SVG: side-view locomotive ~64×40:
- Ground line? no.
- Body: red boiler horizontal rounded rect, cab rect at rear with window, chimney small rect on top front, dome, cowcatcher triangle at front, wheels: 2 big circles with lighter inner hub + small leading wheel. Plus 3 little wagons behind? Logo small — loco only + smoke puffs (two circles). Colors matching. I'll hand-write the SVG.

Let me write SVG coordinates (viewBox 0 0 96 48):
- smoke: circles at (20,8) r4 opacity .5, (28,5) r3.
- chimney: rect x22 y10 w6 h10 fill dark.
- boiler: rounded rect x20 y18 w44 h16 r7 fill #C7483D; front cap: circle at x64? Cowcatcher: polygon (66,34)(74,24)(74,34) fill gray.
- cab: rect x52 y8 w20 h26 r3 fill #a03a31? plus roof rect x50 y6 w24 h5 fill #5A2E28, window rect x56 y12 w12 h10 fill #cfe8ef.
- wheels: circles (30,36) r7, (48,36) r7, (62,36) r5 fill #3a2a22 with inner #d9a? spokes skip; small highlight.
- Hmm coordinates rough; I'll fine-tune mentally later. Also add a gold boiler band line.

Title text: "SNAKE EXPRESS". Subtitle: "the little train that picks up waiting passengers".

Also I could add a "route ticket" style card behind stats? Keep simple chips: cream bg, 2px brown border, radius 10px, label tiny uppercase letterspaced, value big bold.

Board frame: `#boardWrap { position:relative; padding: 10px; background: #8a5a33; border-radius: 22px; box-shadow: inset 0 0 0 4px #6d4526, 0 18px 40px rgba(90,60,30,.25); }` and canvas with border-radius 14px. Wooden frame look with subtle grain? Could add repeating-linear-gradient for grain: `background-image: repeating-linear-gradient(98deg, rgba(0,0,0,.06) 0 6px, transparent 6px 14px)`. Nice subtle.

The wrap must size to canvas: I'll size the canvas element via JS (style width/height) and let boardWrap shrink-wrap (display:inline-block? within flex column with align-items:center). Structure: `#stage` (flex:1, display:flex, align-items:center, justify-content:center, min-height:0, width:100%) containing `#boardWrap` (position:relative) containing canvas + overlays. JS measures #stage to compute cell.

Also add a soft drop-shadow "table" under the frame.

Small screens: header wraps: use flex-wrap wrap, gap. Stats maybe move below title; fine automatically.

Footer: centered small text; hide "keys" hint on coarse pointers? Show both anyway; keep short.

Now write the passenger platform drawing:

```
function drawFood(f, now){
  const px = f.x*cell+cell/2, py = f.y*cell+cell/2;
  // platform tile
  roundRect(px-cell*0.48, py-cell*0.30, cell*0.96, cell*0.6? ...
```
Hmm platform: draw a horizontal-ish gray rounded slab covering most of the cell, with a yellow safety stripe along the bottom edge, and the passenger standing on it (drawn slightly above center). Add a small sign post on one corner: pole + rounded sign with "SE" or a tiny train icon — drawing text at small size: use tiny font "SE" in bold; or draw a simple blue round sign with a white loco glyph? Too fiddly at 17px cells. The sign: circle sign with letter "S"? Just a circle sign red/white like a station sign. Keep: pole + small rounded rect sign (dark blue with white dot). Fine.

Also spawn animation: scale from 0 with elastic over 400ms. Wave: arm.

Passenger look (front view, centered at platform, feet at platform):
- shadow: ellipse under.
- legs: two tiny rects dark.
- body: rounded rect shirt color w=cell*0.3 h=cell*0.32.
- head: circle r=cell*0.14, skin.
- hair: arc over top (brown/black/blonde), or hat (chance): flat cap = small rect + brim.
- waving arm: line/rect from shoulder rotating with sin(now/150 + phase), skin color hand dot.
- suitcase: beside, small rounded rect brown with handle, sits on platform.
Bob: whole passenger translate sin(now/300+phase)*1.5px.
Also an occasional "!" anticipation? skip.

At cell=17px (375 width), these details are ~5px — it'll read as blobs but still colorful. Acceptable.

Also draw a small "waiting" thought: maybe a tiny speech bubble "!"? skip.

Board decorations (pre-rendered): flowers: 4-6 petals via small circles around center + yellow dot; bushes: 2-3 overlapping darker green circles; grass tufts: 3 strokes. Seeded random for stable layout.

Now couplings: for i from 0..n-2: draw line between car centers (i and i+1) with lineWidth cell*0.16, color #4a3222, short — actually draw a small rect between the cars' facing ends. Simplest: strokeStyle dark brown, lineWidth 4, line from center i to center i+1 but drawn UNDER cars so only the gap shows. Since cars are shorter than the cell spacing, the line shows in the gap = coupling. 

Track: draw before couplings/cars, using ip points list, splitting when jump > 1.5 cell.

drawTrack(points):
```
ctx.lineCap='round'; ctx.lineJoin='round';
// ballast
strokePath(points, cell*0.55, '#dcc08d');
// darker edge: stroke with slightly smaller width in lighter inner color to create border
strokePath(points, cell*0.55 - Math.max(2,cell*0.10), '#c9a469');
// ties: walk along path
```
Wait, that yields outer border of darker color — looks like edge/border of the wooden track: fine (like Brio groove edges).
Ties: sample along path every cell*0.42, draw perpendicular short line length cell*0.5, width cell*0.07, color '#a97b45'... ties over ballast but under cars. Also ties should be under the rails? There are no drawn rails, just ties over ballast → reads as toy track. Good. Maybe ALSO two thin darker grooves where wheels run: stroke path twice offset — can't offset easily; skip; the ties are enough.

Stroke path helper handles subpaths (split at big jumps).

Sampling ties: iterate consecutive points pairs; for each pair (a,b): len in px; steps along; direction unit; normal; for s in 0..len step: pos = a + dir*s; draw line pos±normal*half. To avoid heavy draw at corners double-drawing: acceptable.

Perf: snake up to maybe 60 cars * simple shapes — fine.

Smoke: array of {x,y,r,age,life,vx,vy}. Spawn from chimney each tick (or each frame with rate limit): chimney offset from head center rotated: local (cell*0.30 forward toward front, -? top-down: chimney near front center: local (+0.30*cell along dir, 0 lateral)). Spawn every ~90ms while moving; velocity: opposite dir * 10px/s plus slight lateral random; r grows from cell*0.10 to cell*0.28; alpha fades 0.5→0. Also idle chug when crashed: stop.

Pickup effect: ring pulse (expanding circle stroke fading) + ticket float + smoke puff burst.

Ticket float: {x,y,vy,-, text:'+1'} draw cream rounded rect w=cell*0.9,h=cell*0.5, dark dashed line, text bold brown '+1'. Floats up ~30px over 0.9s, fade.

Also on pickup, the wagon added flashes? skip.

Now the crash: also derail the cars visually? On crash, cars stay, add smoke burst at head + shake. Fine.

Let me also add a subtle "chug" bounce to cars: each car bobs perpendicular slightly with phase per index: offset = sin(now/90 - i*0.9)*cell*0.02 perpendicular to its direction — gives rattle. Cute, subtle. Amplitude tiny.

Also wheels? skip wheels at top view or draw small dark stubs on sides of each car: two rects each side. I'll add 4 small dark rounded rects at corners-ish of each car (like wheel trucks visible from top). At 40px cell, wheels as small dark rects width cell*0.08 protruding sides. Draw before body so they peek from under. Let me: for each car, draw wheels: 4 dark circles r=cell*0.07 at (±len*0.28, ±wid*0.62)? For loco maybe 6. Simple.

Locomotive detail (in local coords, +x = forward):
- Shadow: rounded rect blur? use simple translucent black rounded rect offset (2,3).
- Body: rounded rect from -L/2..L/2, width W: fill red.
- Nose/cowcatcher: at front (+L/2): triangle polygon points (L/2+cell*0.10, 0), (L/2, -W/2), (L/2, W/2)? Actually cowcatcher is like a wedge widening forward? In top view cowcatcher is a trapezoid narrowing forward: points: (L/2 - 2, -W/2*0.9), (L/2 + cell*0.12, 0), (L/2 - 2, W/2*0.9)? Let me draw a light gray trapezoid at front: moveTo(L/2*0.9, -W/2) ... hmm keep: polygon [(L/2, -W/2*0.8),(L/2 + cell*0.14, -cell*0.02),(L/2 + cell*0.14, cell*0.02),(L/2, W/2*0.8)] fill #cfd6d3. OK-ish.
- Boiler band: gold rects across body at front area.
- Chimney: circle at (L*0.22, 0) r=cell*0.09 fill #3b2a24 with rim stroke.
- Cab: rounded rect at rear: from -L/2 to -L*0.05, width W*1.06 fill darker red; roof: inset rect fill #54322a; window: two small light squares at cab sides? At top view, windows are on roof edges — skip windows, add roof rect + small circle vent.
- Front lamp: tiny gold circle at very front center.

Wagon (local):
- wheels (as above)
- body rounded rect fill color, stroke darker shade (mix).
- roof stripe: inner rounded rect fill rgba(255,255,255,0.35) length L*0.55 at center (like roof).
- OR cargo variants: some wagons could be "tanker" (rounded circle blob) or "hopper" — variety! Assign type per wagon index deterministically: boxcar (rect + stripe), tanker (big rounded/elliptical), caboose (last? no, last changes). Keep two types: boxcar and tanker alternating? Colorful boxcars with white stripe are enough, plus every 4th a tanker for texture. I'll do: type = i % 4 === 3 ? 'tanker' : 'box'. Tanker: ellipse fill color, darker cap circle center, small ladder lines. 

Colors per wagon: palette[i % palette.length] but ensure adjacent differ: palette arranged so consecutive differ — fine.

Also the first 3 initial wagons colored nicely.

Now DOM/CSS writing. Let me draft the full code.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Snake Express · a little train that picks up passengers</title>
<style>
  :root{
    --paper:#f4e7cf;
    --ink:#4a3222;
    --red:#c0453c;
    --cream:#fff7e6;
    --wood:#8a5a33;
    --wood-dark:#6b4526;
  }
  *{box-sizing:border-box; margin:0; padding:0;}
  html,body{height:100%;}
  body{
    font-family:"Trebuchet MS","Segoe UI",Verdana,sans-serif;
    color:var(--ink);
    background:
      radial-gradient(rgba(150,110,60,.10) 1.2px, transparent 1.3px) 0 0/22px 22px,
      linear-gradient(180deg,#f7ecd8, #f0e0c0);
    height:100dvh; /* fallback needed: set both */
    display:flex; flex-direction:column; align-items:center;
    overflow:hidden;
  }
```
Hmm body height: use `min-height:100vh; height:100dvh`. overflow hidden prevents scrollbars; but if content taller than viewport on small heights, it gets clipped. Risky: at 800 height it fits; at 375 wide, mobile heights vary (~600-800). Board sized dynamically to fit stage so fine. Keep overflow:hidden but ensure layout fits by design (flex with min-height:0). Actually to be safe about "no horizontal scrollbar" only, I could set overflow-x hidden and overflow-y auto. If vertical content overflows slightly, scrollbar appears — but our stage shrinks so it won't. I'll use overflow:hidden on html,body but the design is flexible. Hmm, mobile browsers dynamic toolbars: 100dvh handles it.

Header:
```html
<header class="top">
  <div class="brand">
    <svg ...></svg>
    <div>
      <h1>Snake Express</h1>
      <p class="tag">the little train that picks up waiting passengers</p>
    </div>
  </div>
  <div class="stats">
    <div class="chip"><span class="lbl">Passengers</span><span class="val" id="statScore">0</span></div>
    <div class="chip"><span class="lbl">Cars</span><span class="val" id="statCars">4</span></div>
    <div class="chip"><span class="lbl">Best</span><span class="val" id="statBest">0</span></div>
  </div>
</header>
```

CSS for header: display:flex; justify-content:space-between; align-items:center; width:min(96vw, 1060px); padding:10px 0 8px; flex-wrap:wrap; gap:10px.

h1: font-size clamp(20px, 3.4vw, 32px); font-weight 900; letter-spacing:.04em; text-transform:uppercase; color var(--ink). Add red accent: maybe "SNAKE" ink, "EXPRESS" red? `h1 b{color:var(--red)}`. Tag: 12-13px, italic? color #7a5c3e.

Chips: background var(--cream); border:2px solid #d9c39c... shadow small; radius 12px; padding 6px 12px; min-width 84px; text-align center. .lbl font-size 10px uppercase letterspace 0.12em color #8a6a45. .val font-size 20px font-weight 800.

Stage:
```css
#stage{flex:1 1 auto; width:100%; min-height:0; display:flex; align-items:center; justify-content:center; padding:6px 10px;}
#boardWrap{position:relative; background:var(--wood); padding:clamp(8px,1.4vw,14px); border-radius:20px;
  box-shadow: inset 0 0 0 3px rgba(0,0,0,.18), inset 0 0 0 6px rgba(255,255,255,.06), 0 16px 34px rgba(96,64,30,.28);
  background-image: repeating-linear-gradient(97deg, rgba(0,0,0,.05) 0 7px, rgba(255,255,255,.03) 7px 15px);
}
canvas{display:block; border-radius:12px; background:#a9cb85;}
```

Overlay:
```css
#overlay{position:absolute; inset:clamp(8px,1.4vw,14px); display:flex; align-items:flex-end; justify-content:center; padding-bottom:14px; pointer-events:none;}
.card{background:rgba(255,247,230,.94); border:2px solid #d9c39c; border-radius:14px; padding:10px 18px; text-align:center; box-shadow:0 10px 24px rgba(70,45,20,.25); }
.card .big{font-size:clamp(13px,2.2vw,17px); font-weight:800; color:var(--red); letter-spacing:.02em;}
.card .sub{font-size:12px; color:#7a5c3e; margin-top:2px;}
```
Make the "click to start" pulse subtly: animation pulse opacity? A gentle scale keyframe on .big. Add `animation: bob 1.6s ease-in-out infinite` translateY ±2px. Subtle.

Mode chip:
```css
#modeChip{position:absolute; top:calc(clamp(8px,1.4vw,14px) + 10px); left: calc(clamp(8px,1.4vw,14px) + 10px); ...}
```
Actually simpler: put chip inside overlay layer? It must show during play when overlay hidden. I'll have `#hud` absolutely positioned div inside boardWrap containing #modeChip (top-left) — pointer-events none.

chip style: background rgba(74,50,34,.75); color #ffe9c4; font-size 11px; font-weight 700; letter-spacing .08em; text-transform uppercase; padding 5px 10px; border-radius 999px; backdrop-filter: blur(2px).

Footer:
```html
<footer class="foot">
  <span class="hint"><kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd> / <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> to steer</span>
  <span class="dot">·</span>
  <span>pick up every passenger on the platform</span>
  <span class="dot">·</span>
  <span>edges are tunnels — go through, don't hit your own wagons</span>
</footer>
```
kbd style: cream bg, border, radius 6, padding 1px 6px, font 11px, shadow inset. Foot font-size 12px color #7a5c3e; flex-wrap; center; gap 8px; padding 8px 12px 10px. On small screens maybe hide some segments: media query max-width 560px hide .opt spans? I'll wrap the long hints in span.opt hidden on small.

Wait — foot content vs vertical space: keep to one line, small.

Now JS. Let me write it fully and carefully. I'll be methodical to avoid runtime errors.

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

// ---------- DOM ----------
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const stage = document.getElementById('stage');
const boardWrap = document.getElementById('boardWrap');
const overlay = document.getElementById('overlay');
const ovTitle = document.getElementById('ovTitle');
const ovBig = document.getElementById('ovBig');
const ovSub = document.getElementById('ovSub');
const modeChip = document.getElementById('modeChip');
const statScore = ..., statCars = ..., statBest = ...;

// ---------- constants ----------
const COLS = 21, ROWS = 14;
const DIRS = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
const WAGON_COLORS = ["#3d8f7c","#e8913f","#e5c04a","#4d7cc4","#c75d8f","#5ea152","#8d6bc6","#d9705f","#3aa0a8","#b48ec9"];
const SKIN = ["#f2c9a1","#e0a878","#c98a5b","#8c5a33","#f7d9b8"];
const SHIRT = ["#c75d5d","#4d7cc4","#5ea152","#e0a33c","#8d6bc6","#3aa0a8","#c75d8f","#6b7f36"];
const HAIR = ["#4a3222","#2c2c2c","#a3672e","#d9b356","#7a4a22","#b03a3a"];

// ---------- state ----------
let cell = 32, W=0, H=0, dpr=1;
let state = 'demo'; // demo | playing | over
let cells, prevCells, dir, queuedDirs, foods, smoke, tickets, rings;
let score=0, best=0, tickMs=150, acc=0, crashAt=0, shake=0;
let startedAt = performance.now();
let bg = null; // offscreen background
let foodSeq = 0;
```

resetGame(mode):
```js
function resetGame(mode){
  state = mode; // 'demo' or 'playing'
  const cy = Math.floor(ROWS/2);
  cells = [];
  for(let i=0;i<4;i++) cells.push({x: 8 - i, y: cy}); // head at x=8 facing right
  prevCells = cells.map(c=>({...c}));
  dir = {x:1,y:0};
  queuedDirs = [];
  foods = []; smoke = []; tickets = []; rings = [];
  score = 0; tickMs = 150; acc = 0; shake = 0;
  for(let i=0;i<3;i++) spawnFood();
  updateStats();
  updateOverlayUI();
}
```
Wait, spawnFood requires cells defined — ok after. Also initial prevCells = same → cars static first tick then move. Fine.

spawnFood:
```js
function spawnFood(){
  const occupied = new Set(cells.map(c=>c.y*COLS+c.x));
  foods.forEach(f=>occupied.add(f.y*COLS+f.x));
  const head = cells[0];
  for(let t=0;t<200;t++){
    const x = (Math.random()*COLS)|0, y=(Math.random()*ROWS)|0;
    const k=y*COLS+x;
    if(occupied.has(k)) continue;
    // wrapped manhattan distance from head >= 4
    const dx = Math.min(Math.abs(x-head.x), COLS-Math.abs(x-head.x));
    const dy = Math.min(Math.abs(y-head.y), ROWS-Math.abs(y-head.y));
    if(dx+dy < 4) continue;
    foods.push({
      x,y, born: performance.now(), id: foodSeq++,
      skin: SKIN[(Math.random()*SKIN.length)|0],
      shirt: SHIRT[(Math.random()*SHIRT.length)|0],
      hair: HAIR[(Math.random()*HAIR.length)|0],
      hat: Math.random()<0.4,
      phase: Math.random()*Math.PI*2,
      case: Math.random()<0.55
    });
    return true;
  }
  return false;
}
```

Tick:
```js
function doTick(){
  if(state==='demo'){ setDir(aiChoose()); }
  else if(queuedDirs.length){ setDir(queuedDirs.shift()); }
  const head = cells[0];
  const nx = (head.x + dir.x + COLS)%COLS;
  const ny = (head.y + dir.y + ROWS)%ROWS;
  const nk = ny*COLS+nx;
  const foodIdx = foods.findIndex(f=>f.x===nx&&f.y===ny);
  const grow = foodIdx>=0;
  // self collision check: body excluding tail if not growing
  const limit = cells.length - (grow?0:1);
  for(let i=0;i<limit;i++){ if(cells[i].y*COLS+cells[i].x===nk){ crash(); return; } }
  const old = cells;
  prevCells = old.map(c=>({x:c.x,y:c.y}));
  cells = [{x:nx,y:ny}].concat(old.slice(0, grow? old.length : old.length-1));
  if(grow){
    prevCells.push({...cells[cells.length-1]}); // new tail: prev = curr (static)
    // wait: prevCells currently length == old length; cells length = old+1 when grow.
  }
  ...
}
```
Hold on: prevCells mapping: For render segment i: prev_i should be old[i] for i < old.length; and for grown tail (i = old.length): prev = same as curr. Let me recompute: cells_new[0]=newHead, cells_new[i]=old[i-1] for i≥1. Render: car i moves from prev_i to cells_new[i]. Natural motion: car i was at old[i]... hmm for i≥1, cells_new[i] = old[i-1] = the position of the segment ahead last tick. The car occupying that position last tick was segment i-1... but visually the "same car" is the one that was at old[i]. So prev_i = old[i] (for i ≤ old.length-1). For the new tail car (i = old.length when growing): prev = cells_new[i] = old[old.length-1] (stays put, then starts moving). And head (i=0): prev_0 = old[0] (old head). So prevCells = old (length L). cells_new length L (no grow) or L+1 (grow). For i in [0..L-1]: prev_i = old[i]; for i=L (grow case): prev_i = cells_new[L].

So: `prevCells = old.slice()` (shallow ok since we don't mutate cells in place — we replace arrays each tick; but careful: old array elements are shared objects; since we never mutate cell objects after creation, shallow copy fine).

Then if grow: prevCells.push({x: cells[L].x, y: cells[L].y}).

Then food handling:
```js
if(grow){
  const f = foods.splice(foodIdx,1)[0];
  score++;
  effects at pixel pos of (nx,ny)
  tickMs = Math.max(80, 150 - score*2.2);
  spawnFood();
  updateStats();
  ding(); ring; ticket; smoke burst
}
```
Also spawn smoke from chimney periodically in render based on time & head position.

setDir(d): if d is reverse of current dir → ignore. Also can't be zero.
```js
function setDir(d){ if(d.x===-dir.x && d.y===-dir.y) return; if(d.x===dir.x&&d.y===dir.y) return; dir=d; }
```
Hmm for queued player dirs, they queue in order; applying each tick. Queue up to 3.

crash():
```js
function crash(){
  state='over'; crashAt = performance.now(); shake=1;
  burst of smoke at head
  crashSound();
  if(state was playing... 
}
```
Need to know previous mode for over flow: store `mode` separately ('demo'/'player') plus state ('run'/'over'). Let me restructure:
- `state`: 'run' | 'over'
- `driver`: 'demo' | 'player'

Cleaner:
```js
let driver = 'demo'; let running = true;
```
crash(): running=false; crashAt=now; if driver==='player': best=max(best,score); updateStats; show overlay "Derailed!" after 600ms (use a timeout guarded). if driver==='demo': schedule auto-restart at +1500ms.

Overlay visibility: show when driver==='demo' && running (the start card), or when state over. Hide while player running.

Let me define updateOverlayUI():
```js
function updateOverlayUI(){
  if(!running){
    ovTitle.textContent = 'Derailed!';
    ovBig.textContent = 'Click or press any key to try again';
    ovSub.textContent = `${score} passenger${score===1?'':'s'} picked up · best run: ${best}`;
    overlay.style.display='flex';
  } else if(driver==='demo'){
    ovTitle.textContent='Demo route';
    ovBig.textContent='Click or press any key to start';
    ovSub.textContent='Conductor-bot is driving — hop on whenever you like';
    overlay.style.display='flex';
  } else {
    overlay.style.display='none';
  }
  modeChip.textContent = driver==='demo' ? 'DEMO · conductor-bot driving' : "YOU'RE DRIVING";
  modeChip.classList.toggle('player', driver==='player');
}
```

Input handlers:
```js
function startPlayer(dirHint){
  driver='player';
  resetGame();
  running=true;
  if(dirHint) queueDir(dirHint);
  toot();
  updateOverlayUI();
}
window.addEventListener('keydown', e=>{
  const k = e.key;
  const map = {ArrowUp:{x:0,y:-1}, ArrowDown:{x:0,y:1}, ArrowLeft:{x:-1,y:0}, ArrowRight:{x:1,y:0},
               w:{x:0,y:-1}, s:{x:0,y:1}, a:{x:-1,y:0}, d:{x:1,y:0},
               W:..., S:...};
  const d = map[k];
  if(k.startsWith('Arrow')||' wasdWASD'.includes(k)) e.preventDefault();
  ensureAudio();
  if(!running){ startPlayer(d); return; }
  if(driver!=='player') { startPlayer(d); return; } // taking over from demo
  if(d) queueDir(d);
});
```
Wait: taking over from demo mid-run: should the player continue the current train or start fresh? Design decision: starting fresh is clearer ("your run from zero"). But taking over the demo train seamlessly is also delightful. Hmm. The instruction: "Games must start in a self-playing demo mode (a simple AI plays) behind a 'Click or press a key to start' message". The typical pattern: click → game starts (fresh). I'll do fresh start on takeover, with a quick transition (maybe fade? just instant reset + whistle). But a subtle nicety: when the player takes over, keep... no, fresh is fine and fair for scoring.

But careful: if the player presses an arrow to start, we reset and then that arrow should apply (unless it's Left which reverses initial right direction → ignored by setDir anyway).

Pointer:
```js
boardWrap.addEventListener('pointerdown', e=>{ ensureAudio(); if(!running || driver!=='player'){ startPlayer(); } });
```
Hmm if player is running and clicks the board, do nothing (or pause? no). Also clicking when running & demo → startPlayer. Good.

Touch swipe for steering:
```js
let touchStart=null;
canvas... use boardWrap pointerdown record; pointermove: if driver player and running: compute delta; if |dx|>|dy| and |dx|>24 → queue left/right; else if |dy|>24 → up/down; reset touchStart to current to allow continuous swipes.
```
Implement with pointer events on window? Use boardWrap pointerdown/pointermove/pointerup. Only when driver==='player' && running. Fine.

AI:

```js
function aiChoose(){
  const head = cells[0];
  const L = cells.length;
  const bodyBlocked = new Uint8Array(COLS*ROWS);
  for(let i=0;i<L;i++) bodyBlocked[cells[i].y*COLS+cells[i].x]=1;
  const results=[];
  for(const d of DIRS){
    if(d.x===-dir.x && d.y===-dir.y) continue;
    const nx=(head.x+d.x+COLS)%COLS, ny=(head.y+d.y+ROWS)%ROWS, nk=ny*COLS+nx;
    const isFood = foods.some(f=>f.y*COLS+f.x===nk);
    const tailIdx = cells[L-1].y*COLS+cells[L-1].x;
    if(bodyBlocked[nk] && !(nk===tailIdx && !isFood)) continue;
    const pathLen = bfs(nx,ny,bodyBlocked,tailIdx,isFood);
    const space = flood(nx,ny,bodyBlocked,tailIdx,isFood);
    results.push({d,isFood,pathLen,space});
  }
  results.sort((a,b)=> (b.isFood-a.isFood) || (a.pathLen-b.pathLen) || (b.space-a.space));
  return results.length? results[0].d : dir;
}
```
Wait, careful with the "space" tie-break ordering: If pathLen is Infinity for all, we compare space. Since Infinity - Infinity = NaN... sort comparator with Infinity: a.pathLen-b.pathLen → Infinity-Infinity=NaN → comparator returns NaN, treated as 0 → falls to space. Ok but risky. Better: use a large number (COLS*ROWS*4) as sentinel instead of Infinity. I'll have bfs return 9999 when unreachable.

Also safety refinement: prefer moves where the snake can still reach its tail after moving. Classic trap-avoidance: after choosing best by path, verify `canReachTail(newHead)`; if not, fall back to the move with max space. Let me include:

```js
// prefer food-adjacent/path, but among equal path prefer safer:
```
Simplest robust: score each candidate: 
- primary: isFood (desc)
- secondary: pathLen (asc)
- tertiary: space (desc)
Then check the chosen one for tail-reachability; if not reachable, re-sort by space desc and pick that (if its tail-reachable, else just pick max space). Implement tailReachable via BFS from candidate head to tail cell in the grid blocked by body minus tail minus... After the move, the body will be: newHead + old[0..L-2] (tail gone, unless eating). Check BFS from newHead to old tail cell (which is where the tail will be after move? the tail cell after move is old[L-2]... The "follow your tail" heuristic: after the move, can the head reach the (new) tail position old[L-2]? For growth, tail stays old[L-1]. Let me simply check reachability from new head to the cell old[L-1] (current tail) in grid blocked by body minus tail. Hmm subtle. 

Honestly, for a demo AI this level of sophistication is optional; the greedy+space approach already runs well for a long time. And crashes auto-restart. But better demo = fewer crashes. I'll add the simple tail-reach check as described (BFS from candidate cell to current tail cell, blocked = body minus tail; if candidate is food, tail stays blocked? if eating, tail doesn't vacate this tick — so include tail as blocked when isFood). If not reachable → candidate gets penalty (pathLen += 500? or sort key). Let me integrate into sort: compute `safe = reachTail?0:1` and sort by (safe asc) first? No — that would make it overly conservative, always safe moves, might ignore food. Priority: isFood > safe > pathLen > space. Reasonable: eat food if adjacent (eating immediately is nearly always fine), else prefer safe shortest path. 

Hmm wait, there's a subtlety: eating food grows the snake — greedy eating in a tight spot is the main death cause. The safe check with tail blocked handles that.

Let me write helpers:

```js
function bfsDist(sx,sy,bodyBlocked,tailIdx,blockTail, targets){ 
  // returns shortest steps to nearest target cell, or 9999
  const q=[sy*COLS+sx]; const seen=new Uint8Array(COLS*ROWS); seen[q[0]]=1;
  const dist=new Int16Array(COLS*ROWS); 
  const targetSet = new Uint8Array(COLS*ROWS); foods.forEach(f=>targetSet[f.y*COLS+f.x]=1);
  let qi=0;
  while(qi<q.length){
    const k=q[qi++]; 
    if(targetSet[k] && k!== sy*COLS+sx) return dist[k];
    const x=k%COLS, y=(k/COLS)|0;
    for(const d of DIRS){
      const nx2=(x+d.x+COLS)%COLS, ny2=(y+d.y+ROWS)%ROWS, nk2=ny2*COLS+nx2;
      if(seen[nk2]) continue; seen[nk2]=1;
      if(bodyBlocked[nk2] && !(nk2===tailIdx && !blockTail && ...)) ...
    }
  }
}
```
Getting complicated. Let me simplify by building the blocked array per candidate move (cheap: 294 cells × 4 = tiny). Define:

```js
function makeBlocked(excludeTail){
  const b = new Uint8Array(COLS*ROWS);
  for(let i=0;i<cells.length;i++){
    if(excludeTail && i===cells.length-1) continue;
    b[cells[i].y*COLS+cells[i].x]=1;
  }
  // also block nothing else
  return b;
}
function gridBFS(startK, blocked, goalTest){
  const seen=new Uint8Array(COLS*ROWS); const dist=new Int16Array(COLS*ROWS).fill(-1)? 
```
I'll write two functions:

```js
// BFS from cell (x,y), blocked[] marks impassable. Returns dist array (Int16, -1 unseen).
function bfs(sx, sy, blocked){
  const N=COLS*ROWS;
  const dist=new Int16Array(N).fill(-1);
  const q=new Int16Array(N); let qh=0,qt=0;
  const s=sy*COLS+sx; dist[s]=0; q[qt++]=s;
  while(qh<qt){
    const k=q[qh++]; const x=k%COLS, y=(k/COLS)|0, d=dist[k]+1;
    for(let i=0;i<4;i++){
      const nx=(x+DIRS[i].x+COLS)%COLS, ny=(y+DIRS[i].y+ROWS)%ROWS, nk=ny*COLS+nx;
      if(dist[nk]!==-1 || blocked[nk]) continue;
      dist[nk]=d; q[qt++]=nk;
    }
  }
  return dist;
}
```
Note: start cell may be "blocked" per array (candidate head cell is free by construction since we filtered collisions). Fine.

Then aiChoose:
```js
function aiChoose(){
  const head=cells[0], L=cells.length;
  const tail=cells[L-1];
  const blockedStrict = new Uint8Array(COLS*ROWS);
  for(let i=0;i<L;i++) blockedStrict[cells[i].y*COLS+cells[i].x]=1;
  
  // food target array
  const isFoodCell=new Uint8Array(COLS*ROWS);
  foods.forEach(f=>isFoodCell[f.y*COLS+f.x]=1);
  
  const cands=[];
  for(const d of DIRS){
    if(d.x===-dir.x&&d.y===-dir.y) continue;
    const nx=(head.x+d.x+COLS)%COLS, ny=(head.y+d.y+ROWS)%ROWS, nk=ny*COLS+nx;
    const eating = isFoodCell[nk]===1;
    // collision check: body minus tail unless eating
    if(blockedStrict[nk]){ 
      if(!(nk===tail.y*COLS+tail.x && !eating)) continue;
    }
    // simulate board after this move
    const blockedAfter = new Uint8Array(COLS*ROWS);
    // new body: head at nk, plus old cells 0..L-2 (tail vacated) if not eating; if eating, 0..L-1
    const keep = eating? L : L-1;
    for(let i=0;i<keep;i++) blockedAfter[cells[i].y*COLS+cells[i].x]=1;
    blockedAfter[nk]=1;
    const newTail = eating? tail : cells[L-2] ?? cells[L-1];
    // path from nk to food
    const dist = bfs(nx,ny, blockedAfter);
    let pathLen = 9000;
    for(const f of foods){ const fd=dist[f.y*COLS+f.x]; if(fd>=0 && fd<pathLen) pathLen=fd; }
    // tail reachable?
    let safe=0;
    const td = dist[newTail.y*COLS+newTail.x];
    if(td===-1) safe=1;
    // free space from nk
    let space=0; for(let i=0;i<dist.length;i++) if(dist[i]>=0) space++;
    cands.push({d,eating,pathLen,safe,space});
  }
  cands.sort((a,b)=> (b.eating-a.eating)||(a.safe-b.safe)||(a.pathLen-b.pathLen)||(b.space-a.space));
  return cands.length? cands[0].d : dir;
}
```
Hmm, wait: `bfs` with blockedAfter: the start cell nk is marked blocked (I set blockedAfter[nk]=1) — but BFS starts there; my bfs doesn't check blocked on start, only on neighbors. dist[start]=0. Good. But the start being blocked means... fine.

One more consideration: tail cell blockedAfter: when not eating, tail vacated so old tail cell not blocked — but newTail = cells[L-2] which is part of body (blocked). Reaching "the tail" means reaching the segment that will be at the tail after the move so the head can chase it. If dist to newTail is -1 → head can't reach tail → potentially trapped. But actually "reach the tail cell" is a proxy; if newTail is blocked in the BFS grid, dist=-1 trivially! Oops: newTail=cells[L-2] is in blockedAfter (since keep=L-1 includes indices 0..L-2). So dist to it is always -1. I need to check reachability to the tail allowing stepping onto it (as it will move away as you approach). Standard trick: check if any free cell adjacent to newTail is reachable, or treat newTail as passable in a variant BFS. Simpler: compute reachability with blockedAfter2 = blockedAfter minus newTail cell. Then safe = dist2[newTail] >= 0.

Let me add param: bfs(sx,sy,blocked, freeCell) where freeCell is treated as passable. Implement: passable(k) = !blocked[k] || k===freeCell.

Also for space count use the plain bfs from nk.

Also: `eating` check — if eating, the tail stays, so keep=L (body 0..L-1 all stay + new head). Right.

Edge: L-2 when L=1? L starts at 4, never below. OK. Actually after growth L increases; fine.

Also careful: `cells[L-2]` exists since L≥4.

Performance: 4 candidates × (build 294-byte arrays + BFS + space count) per tick ≈ trivial.

One more AI touch: avoid the AI hugging tightly? Not needed.

Now rendering. Main render(now):

```js
function render(now){
  ctx.save();
  ctx.clearRect... 
  // shake
  if(shake>0){ const s=shake*5; ctx.translate((Math.random()-0.5)*s,(Math.random()-0.5)*s); shake=Math.max(0,shake-0.03?) }
```
Better: shake decays over time: `const t=(now-crashAt)/400; if(t<1) shakeAmp=6*(1-t)`. Compute per frame from crashAt.

Steps:
1. draw bg image (offscreen) at 0,0 with size W,H.
2. compute interpolated points for cars:
```js
const tt = clamp(acc/tickMs,0,1);
const chug = tt + Math.sin(tt*Math.PI*2)*0.045; // ease chug
const pts = [];
for(let i=0;i<cells.length;i++){
  pts.push(interpPoint(prevCells[i]||cells[i], cells[i], chug));
}
```
interpPoint(a,b,t): adjust a for wrap; return pixel coords {x,y} in CSS px (center).

3. draw track (if running or over — always draw).
4. draw couplings.
5. draw cars: for i from cells.length-1 down to 0 (draw tail first so head on top): drawCar(i, pts[i], now).
6. draw tunnels (on top of cars).
7. draw foods (platforms + passengers). Order relative to train: train passes over platforms? Platforms are flat on ground; train on track drawn above ground — so draw foods BEFORE the train (under it). But the platform tile under the train would be hidden by the track anyway (different cells though — food is never under the train cell simultaneously, except the moment of pickup). Draw foods after bg, before track. Good: foods visible; when train passes over that cell, track+train cover the platform, passenger disappears (picked). At pickup moment, cell had food; new head lands there; we removed food at tick start — so no overlap artifacts.
8. smoke particles (above train, semi-transparent).
9. rings & tickets.
10. restore.

interpPoint:
```js
function interpPoint(a,b,t){
  let ax=a.x, ay=a.y;
  const dx=b.x-ax, dy=b.y-ay;
  if(dx>1) ax+=COLS; else if(dx<-1) ax-=COLS;
  if(dy>1) ay+=ROWS; else if(dy<-1) ay-=ROWS;
  return { x:(ax+(b.x-ax)*t+0.5)*cell, y:(ay+(b.y-ay)*t+0.5)*cell };
}
```
Wait if wrapped, b.x - ax after adjustment is ±1. E.g., a.x=COLS-1,b.x=0: dx=-COLS+1 < -1 → ax -= COLS → ax=-1; then b.x-ax = 0-(-1)=1. Point goes from -1→0 crossing left edge. Good. Also possible both dx,dy wrap? Only one axis moves per tick. OK.

Also a===b (new tail): point static at b. dx=0 fine.

Now angle per car: direction from prev to curr (wrapped-adjusted): use the adjusted ax, ay: angle = atan2(b.y-ay, b.x-ax) — if a===b (new tail), angle = previous car's angle or the car ahead direction. For the static tail, atan2(0,0)=0 → faces right; next tick it gets a proper angle. One tick of wrong orientation on the tiny new wagon — barely noticeable, but I can improve: if a===b, use angle of car i-1... Let me store the computed angle array and for the static case reuse angle of previous index (i-1, toward head) — actually new tail follows the car ahead; its angle should equal the direction from itself toward the car ahead? The car ahead is at cells[i-1] which equals... cells[i]=old tail, cells[i-1]=old tail-1... the new tail will next tick move to cells[i-1]'s old position, so its facing = direction from cells[i] to cells[i-1] (wrapped). Use that. Implement fallback: angle = dirAngle(cells[i], cells[Math.max(0,i-1)])... for i=0 impossible (head always moves). Good:

```js
function angleBetween(a,b){ // direction from a to b wrapped
  let dx=b.x-a.x, dy=b.y-a.y;
  if(dx>1) dx-=COLS; else if(dx<-1) dx+=COLS;
  if(dy>1) dy-=ROWS; else if(dy<-1) dy+=ROWS;
  return Math.atan2(dy,dx);
}
```
carAngle(i): if prev!=curr → angleBetween(prev, curr); else if i>0 → angleBetween(cells[i], cells[i-1]); else 0.

drawCar(i, pt, angle, now):
```js
function drawCar(i,p,ang,now){
  const L = cell*0.92, Wd = cell*0.60;
  const isHead = i===0;
  const color = WAGON_COLORS[(i-1+WAGON_COLORS.length)%WAGON_COLORS.length]; // i-1 for wagons
  ctx.save();
  ctx.translate(p.x,p.y);
  // chug bob perpendicular
  const bob = Math.sin(now*0.012 - i*0.9)*cell*0.02;
  ctx.translate(Math.cos(ang)*0, ... 
```
Perpendicular offset: perp = (−sin ang, cos ang)*bob. So ctx.translate(-Math.sin(ang)*bob, Math.cos(ang)*bob). Add before rotate (translation in world) — fine either order; do after rotate along local y: ctx.rotate(ang); ctx.translate(0, bob). Simpler.

Then scale-in for newest: if bornAt recently... I track growth via a `bornTick` per segment? Simpler: track `newestBornAt` time when growing; the last car scales from 0.15→1 over 350ms. Store `lastBorn = performance.now()` on grow. In drawCar, if i===cells.length-1 && now-lastBorn<350: s=easeOutBack(t). Apply ctx.scale(s,s). Good enough (only affects the last car; if multiple pickups quickly, still fine).

Draw shadow: ctx.fillStyle='rgba(60,40,20,0.18)'; roundRect(-L/2+2, -Wd/2+3, L, Wd, r) — but shadow in world before rotate? Shadow offset should be world-down; drawing after rotate offsets in local coords. Minor; acceptable — or draw shadow before rotate: translate(p.x, p.y+cell*0.07) draw ellipse. I'll draw a soft ellipse shadow in world coords before the car: at (p.x, p.y+cell*0.10), rx=L*0.55, ry=Wd*0.5, fill rgba(70,50,25,.15). Then car on top. OK.

Wheels (under body): 4 dark circles at local (±L*0.30, ±Wd*0.52), r=cell*0.075, fill #3a2a20. Draw after rotate before body.

Body: 
```js
ctx.fillStyle=color; roundRectPath(-L/2,-Wd/2,L,Wd, cell*0.16); ctx.fill();
ctx.lineWidth=Math.max(1.5,cell*0.045); ctx.strokeStyle=shade(color,-25); ctx.stroke();
```
shade(hex, amt): parse hex → darken. Write helper.

Wagon box type: white roof band: roundRect(-L*0.28,-Wd*0.28,L*0.56,Wd*0.56, cell*0.08) fill rgba(255,255,255,0.4)? Hmm top-down boxcar roof is usually a contrasting color; white/cream roof looks nice: '#f6ecd6' with slight alpha? Solid cream roof band + tiny center ridge line darker. Then two "vents" small rects.

Tanker type: ellipse (ctx.ellipse) rx=L*0.42, ry=Wd*0.42 fill color; dome: circle r=cell*0.09 darker at center; stroke shade.

Assign type by (i-1)%WAGON... let me store per index: type = (i%5===0)?'tanker':'box' — every 5th wagon a tanker (i includes head). Compute deterministic: const kind = i>0 && (i%5===0) ? 'tanker' : 'box'.

Locomotive (i===0): body color red #c0453c; 
- body roundRect fill red, stroke dark red.
- cowcatcher: polygon at front: moveTo(L/2-2,-Wd*0.42) lineTo(L/2+cell*0.16, -cell*0.05) lineTo(L/2+cell*0.16, cell*0.05) lineTo(L/2-2, Wd*0.42) close → fill '#e8e3d8' stroke gray. Hmm shape: a trapezoid narrow at front. Fine.
- boiler front band: rect at x=L*0.18..L*0.30 across width, gold #e2b34b; plus thin dark line.
- chimney: circle at (L*0.24, 0) r=cell*0.10 fill '#3b2a22', inner r*0.55 fill '#1f150f'; plus gold rim ring stroke.
- cab: roundRect from -L/2 to -L*0.12, width Wd*1.12? Make cab slightly wider: roundRect(-L/2, -Wd*0.62, L*0.38, Wd*1.24, cell*0.10) fill '#8f2f28'; roof inner: roundRect(-L*0.46,-Wd*0.5, L*0.26, Wd, cell*0.07) fill '#5a2e28'; small vent circle.
- front lamp: circle at (L/2-cell*0.05, 0) r=cell*0.06 fill '#ffd97a'.
- side rods / wheels already drawn.

Store chimney world position for smoke: computed in render: chimney local (L*0.24, 0) rotated by head angle → world = p + rotate(local). Emit smoke there.

Track drawing:
```js
function drawTrack(pts){
  // split into runs where consecutive distance <= cell*1.6
  ctx.save();
  ctx.lineCap='round'; ctx.lineJoin='round';
  strokeRuns(pts, cell*0.52, '#dcc08d');  // base
  strokeRuns(pts, cell*0.52 - Math.max(2,cell*0.11), '#c9a269'); // inner lighter? 
```
Hmm think: base wide color A, then narrower stroke color B on top → result: wide band A with center band B. For a wood track: outer edge darker (A darker), inner lighter (B). So A='#b98d58' (edge), B='#d9b47c' (top surface). Then ties darker over B: '#a6793f' small perpendicular lines — wait ties on top of the surface would be the visual "sleepers" — yes wooden railway has dark stripes across. Let me do: edge dark #b58a55 width cell*0.55; surface #dcb87e width cell*0.55-3; ties #a1743f width cell*0.06 length cell*0.42 spaced cell*0.38. Then optionally two thin darker "rails"? Nah.

Hmm, actually maybe cuter: draw the track as ballast gray + two silver rails — reads instantly as railway. Top-down rails: offset lines. Can I stroke two offset polylines? I can compute offset points: for each point in run, offset by normal*±cell*0.14. Normals per point: direction between neighbors. For axis-aligned segments it's exact; at corners, the offset points create a small square corner — acceptable (real tracks have squared corners-ish? actually curves; fine at this scale). Let me do it properly-ish: build the path with rounded joins and stroke twice with different widths to fake two rails: stroke wide dark (#7a5a3a width cell*0.5) = ballast bed; then stroke (#d9c9a8? no. 

Simplest visually correct trick for two rails: stroke path with width w1=cell*0.34 in rail color (#6e5540), then stroke same path with width w1-2*rw where rw=cell*0.05 in ballast color (#cbb78a)? That yields center ballast stripe with two rail stripes at ±(w1-rw)... yes! stroke width a then narrower stroke width b on top leaves two side stripes of thickness (a-b)/2 = rails. Then ties UNDER everything: draw ties first (perpendicular dark lines across full width cell*0.5, spaced cell*0.36), then ballast-transparent can't... order: 1) ties (dark #8a6238, they're wide: length cell*0.5), 2) then rails pair: stroke wide w=cell*0.34 color #6e5540? Wait rails should be ON ties visually (rails on top of ties from above). From top view: you see ballast? Typically model: ties (sleepers) across, two rails running along on top. So: 1) stroke path with wood/tie base? No — draw the ballast first (a soft gray-brown band), then ties across, then rails as two thin lines on top. Two thin lines = the double-stroke trick but it must not cover the middle where ties show. The double-stroke trick: stroke wide (rails color) then stroke narrower (ballast color) on top would erase the middle ties. Instead compute offset polylines explicitly for the two rails and stroke them thin (lineWidth cell*0.07, color #5c4a3a? rails read as dark metal or light? silver-ish #9a8f86). Offsetting polylines: for each point, normal = perpendicular of average of adjacent segment directions. Corners on grid are 90°; average normal at corner points outward diagonally — rail line will cut the corner slightly. Visually fine.

Given time, I'll implement offset rail drawing:
```js
function drawTrack(pts){
  const runs=splitRuns(pts);
  for(const run of runs){
    // ballast
    stroke(run, cell*0.5, 'rgba(210,190,150,0.55)'); // soft sand
    // ties
    ...walk along run placing ties (dark #8a6238, width cell*0.06, len cell*0.44)
    // rails: offset polylines
    for(s of [-1,1]) stroke(offsetRun(run, s*cell*0.13), cell*0.075, '#6b5a48');
  }
}
```
Hmm rails dark on sand ballast with dark ties — contrast okay. Let me pick colors: ballast #d8c9a3 (sandy), ties #8a6a48, rails #554437. Under the colorful cars, this reads well on green grass.

stroke(run, w, color): ctx.beginPath; moveTo run[0]; lineTo rest; ctx.stroke with settings.

offsetRun(run, off): compute per-point normals; at corner points average of adjacent segment normals normalized... For 90° corners, using the average direction gives 45° corner point which cuts the corner leaving a small gap — rails slightly disconnected at corners. To fix, at corners insert two points (corner ± tangent*off). Full offsetting is getting heavy. Alternative accepted approach for corner: offset each SEGMENT independently and let round line caps/joins... If I stroke each segment separately with round caps, each rail segment is a straight capsule shifted sideways — at corners, two capsules at right angles overlap forming an L with rounded corner — actually that's correct-looking! Because each segment's rail is offset perpendicular along its own direction; at the corner the horizontal rail ends shifted up, the vertical rail starts shifted left — they meet near the corner point offset diagonally... Let me think concretely: path A:(0,0)→(10,0)→(10,10). Rail offset +0.5 with normal... seg1 normal (0,-1)*0.5 → line (0,-0.5)→(10,-0.5). seg2 normal (1,0)*0.5? direction (0,1), normal perpendicular (−1,0)? offset line (9.5,0)→(9.5,10). The two rail segments: first ends at (10,-0.5), second starts (9.5,0) — gap of ~0.7 diag. With round caps and lineWidth ~2, caps at those points might not connect. Small gaps in rails at corners — at small scale probably unnoticeable, and ties+ballast cover the area anyway. OR I can extend each rail segment by railGap at both ends (draw from t=-0.15 to 1.15 along segment): overlapping slightly → connected corners. Do that: extend each segment by cell*0.12 on each end before offsetting. Overlap at corners → seamless. 

So rails: for each segment in run: compute offset endpoints p±normal*off, extended along direction by ext=cell*0.15. Stroke each as its own path? Batch into one path with moveTo/lineTo pairs — fine.

ties: walk each segment: from 0.2 to len-0.2 step spacing: draw perpendicular tick: line from pos+normal*hl to pos-normal*hl (hl=cell*0.24), lineWidth cell*0.07, color ties. Rounded caps.

This is all doable. Since runs are mostly axis-aligned (they follow the snake path), it looks neat.

splitRuns(pts): runs where dist(pts[i-1],pts[i]) > cell*1.6 → new run. Also drop runs of length 1.

Now tunnels: for each car point p (maybe just check head + all cars — cheap):
```js
function drawTunnels(pts, now){
  const m=cell*0.75; // proximity threshold in px
  for(const p of pts){
    let edge=null, ex,ey, alpha=0;
    if(p.x < m){ edge='L'; alpha = 1 - p.x/m; }
    else if(p.x > W-m){ edge='R'; alpha = 1-(W-p.x)/m; }
    else if(p.y < m){ edge='T'; ...}
    else if(p.y > H-m){ edge='B'; ...}
    if(edge) drawArch(edge, p, alpha)
  }
}
```
Hmm, checking all four: cars could be near corner — draw both horizontal & vertical arch? Rare; pick the closest edge. Compute distances and pick min.

drawArch(edge, p, alpha): alpha=clamp; also multiply 0.9. Draw in board coords: 
- For left edge at (0, p.y): dark半ellipse: ctx.save(); translate(0?); Actually draw a full ellipse centered at (xEdge, p.y) with radiusX=cell*0.55 (perpendicular? no—) Let me define: tunnel mouth = circle of radius r=cell*0.62 centered at the edge point ON the border line (e.g., (0, p.y) — half outside canvas). The visible half inside the board is the dark hole. Add a rim: stroke arc of a slightly bigger circle, color hill green (#7fae63?) to look like an opening through a hedge: draw hedge semicircle (r=cell*0.72, fill bush green #6f9e55) then hole (r=cell*0.5, fill #3a2f22 dark). Both centered at the border point. Draw order: hedge, hole, then... we draw arches AFTER cars (on top) so the train slides "under" — but then the hedge/hole covers the train near edge — the hole is dark, train inside hole region gets covered = entering dark. But we want to still see a bit of the loco as it enters? Once fully under the hole area it's dark — that's the point of a tunnel. The moment of disappearance is right at the edge. 

Hmm but there's an issue: the hole (dark) drawn over the train will hide the loco even when it's, say, 0.6 cells from the edge and still fully visible on the grass with only its nose under the arch — the arch is only r=0.5cell radius from the border, so the loco front (which is near the border as it approaches) gets covered progressively. Correct!

Also both exit and entry arches appear (exit at right where the car was, entry at left where the head re-enters). With alpha ramp they fade in/out. When the train is far from edges (typical), no arches → clean look. 

One more: for corner proximity both edges — pick nearest single edge. Also multiple cars near the same edge spot → multiple identical arches stacked with different alphas — fine (same look).

Clipping: ellipse centered at border; half is outside canvas — auto-clipped. For left edge center at x=0: visible right half. Use ctx.beginPath(); ctx.arc(cx, cy, r, -Math.PI/2, Math.PI/2) for left? Simply full circle and let canvas clip — the outside part is clipped by canvas bounds automatically (canvas doesn't draw outside). Yes! Canvas clips to its bitmap automatically. So draw full circles centered on the border coordinate. 

But the boardWrap has rounded corners and padding — canvas edges are the board edges, fine.

drawArch:
```js
function drawArch(cx, cy, a){
  ctx.globalAlpha = a;
  // hedge rim
  ctx.fillStyle='#6f9e55'; circle(cx,cy, cell*0.72) fill;
  // hole
  ctx.fillStyle='#39301f'; circle(cx,cy, cell*0.5) fill;
  // inner darker
  ctx.fillStyle='#241d12'; circle(cx,cy, cell*0.38) fill? maybe
  ctx.globalAlpha=1;
}
```
Hmm hedge rim as a circle around a hole at the border: from top view a tunnel entrance on a flat board would look like a dark circle/hole rather than arch. A dark hole with green ring reads as "hole in hedge". Fine, it's stylized. Maybe make the rim look like grass mound: color slightly darker green than meadow with a few darker arcs. Keep simple: ring #77a258, hole #2e2618 with a subtle highlight arc on the inner edge.

Alpha ramp: fade based on proximity: when the head is 0.75cell from edge alpha=0 → at edge alpha=1. But when the head has crossed and is at -0.5 (exiting), p.x<0 → alpha clamps to 1 (still drawn while tail cars pass). Good.

Also the track near the edge should visually lead into the tunnel — it does (the track polyline extends to off-canvas points). 

Now background offscreen:
```js
function buildBg(){
  bg = document.createElement('canvas');
  bg.width = Math.max(1, Math.round(W*dpr)); bg.height=Math.round(H*dpr);
  const b = bg.getContext('2d'); b.setTransform(dpr,0,0,dpr,0,0);
  b.fillStyle='#a7c982'; b.fillRect(0,0,W,H);
  // checker
  b.fillStyle='rgba(255,255,255,0.045)';
  for(y...) for(x...) if((x+y)%2===0) b.fillRect(x*cell,y*cell,cell,cell);
  // mow stripes? skip
  // decorations with seeded rng
  const rnd = mulberry32(1234);
  // grass tufts
  for(let i=0;i<90;i++){
    const x=rnd()*W, y=rnd()*H; 
    b.strokeStyle='rgba(90,130,60,0.35)'; b.lineWidth=1.2;
    3 short strokes
  }
  // flowers ~28
  for(let i=0;i<26;i++){
    const x=rnd()*W,y=rnd()*H; petal color pick white/pink/yellow
    petals: 5 circles r2.2 around + center circle gold
  }
  // bushes ~8: cluster of 3 circles darker green + lighter top? 
  // pond? skip
}
```
Draw decorations away from edges? Doesn't matter.

Draw flowers/bushes each on bg so no per-frame cost. 

Now foods drawing per frame (they animate):
```js
function drawFoods(now){
  for(const f of foods){
    const px=(f.x+0.5)*cell, py=(f.y+0.5)*cell;
    const age=(now-f.born);
    const s = easeOutBack(Math.min(1, age/380)); // spawn pop
    ctx.save(); ctx.translate(px,py); ctx.scale(s,s);
    // platform slab: rounded rect w=cell*1.0? slightly smaller: w=cell*0.94,h=cell*0.62, centered at y offset +cell*0.14 (so passenger stands above)
    slab: roundRect(-cell*0.47, -cell*0.28, cell*0.94, cell*0.60, cell*0.10) fill '#cfc6b4'; stroke '#b5ab97'
    // yellow safety line along the slab edge nearest to board bottom? Just along bottom of slab:
    rect(-cell*0.42, cell*0.24, cell*0.84, cell*0.055) fill '#e5b93c'
    // little tactile dots? skip
    // sign post at left corner: pole
    pole: rect(-cell*0.36, -cell*0.52, cell*0.05, cell*0.30) fill '#7a5a3a'; sign roundRect(-cell*0.44,-cell*0.66, cell*0.2, cell*0.18, cell*0.05) fill '#33547a'; white dot? tiny text? skip text; white bar rect inside.
    // passenger (standing on slab, centered x maybe offset right a bit)
    drawPassenger(f, now);
    ctx.restore();
  }
}
```
Passenger:
```js
function drawPassenger(f, now){
  const bob = Math.sin(now*0.006 + f.phase)*cell*0.02;
  const cx = cell*0.08, groundY = -cell*0.30; // feet on slab top? 
  Actually define: feet at y=-cell*0.24 (on slab surface); body above.
  ctx.save(); ctx.translate(cx, bob);
  // shadow on slab
  ellipse(0, -cell*0.22, cell*0.16, cell*0.05) fill rgba(0,0,0,.15)
  // legs
  fill '#4a3222': two rects (-cell*0.07,-cell*0.24,cell*0.05,cell*0.12) and mirrored
  // body/shirt
  roundRect(-cell*0.13, -cell*0.52, cell*0.26, cell*0.32, cell*0.08) fill f.shirt; stroke shade
  // arm waving: from shoulder (cell*0.10, -cell*0.46) rotate
  const wave = Math.sin(now*0.012 + f.phase);
  ctx.save(); ctx.translate(cell*0.10, -cell*0.44); ctx.rotate(-0.6 + wave*0.5);
  rect(0,0,cell*0.05,cell*0.20) fill f.shirt dark? arm in shirt color; hand circle skin at end (cell*0.025 r=cell*0.045)
  ctx.restore();
  // other arm static: small rect other side
  // head
  circle(0, -cell*0.62, cell*0.115) fill f.skin
  // hair arc: circle slightly higher clipped? draw arc: ctx.beginPath(); ctx.arc(0,-cell*0.64,cell*0.115, Math.PI, 2*Math.PI) fill f.hair
  if(f.hat){ // flat cap: rect over top + brim
    roundRect(-cell*0.14, -cell*0.76, cell*0.28, cell*0.08, cell*0.04) fill '#5a4632'; brim rect(-cell*0.16,-cell*0.70,cell*0.32,cell*0.03)
  }
  // eyes: two tiny dark dots at (-cell*0.04,-cell*0.62),(cell*0.04,-cell*0.62) r=cell*0.012
  ctx.restore();
  // suitcase beside on slab
  if(f.case){ roundRect(cell*0.22, -cell*0.40, cell*0.20, cell*0.16, cell*0.03) fill '#8a5a33'; handle arc; }
}
```
These pixel values scale with cell; at cell=17 some sub-pixel but okay.

Also a "!" bubble? skip.

Smoke:
```js
function emitSmoke(x,y,now,big){
  if(smoke.length>90) return;
  smoke.push({x: x + (Math.random()-0.5)*3, y: y + (Math.random()-0.5)*3, r: cell*(big?0.16:0.10), life:0, maxLife: 900+Math.random()*500, vx:(Math.random()-0.5)*8 - dir.x*12, vy:(Math.random()-0.5)*8 - dir.y*12 - 6});
}
```
Wait — smoke drifts: from the chimney, puffs should lag behind (opposite of motion) and drift "up" (screen up) slightly. Since it's top-down, up doesn't exist; drift opposite motion + random. Fine.

In render: if running, every 90ms emit at chimney pos. Track lastSmoke time. Also on pickup emit 6 puffs at head; on crash emit 14 puffs.

Update particles in render with dt (store last frame time): p.life+=dt; p.x+=p.vx*dt/1000; p.r += cell*0.12*dt/1000; alpha = (1-p.life/p.maxLife)*0.5. Draw: fill 'rgba(240,240,235,α)' circle. Actually gray-white: '#f2efe7'.

Tickets: {x,y,t}: draw at y - t*40, alpha 1-t; rounded rect cream w=cell*0.9 h=cell*0.46 centered; dashed perforation line vertical at right third (setLineDash); text '+1' bold font size cell*0.26 fill '#4a3222'. Duration 900ms.

Rings: expanding circle stroke '#fff' alpha fading, r from cell*0.2 → cell*0.9.

Pickup effects function:
```js
function pickupFx(f){
  const px=(f.x+0.5)*cell, py=(f.y+0.5)*cell;
  rings.push({x:px,y:py,t:0});
  tickets.push({x:px,y:py - cell*0.2,t:0});
  for(let i=0;i<6;i++) emitSmoke(px+(Math.random()-0.5)*cell*0.4, py+(Math.random()-0.5)*cell*0.4, true);
  ding();
}
```

Audio:
```js
let AC=null, master=null;
function ensureAudio(){
  if(AC) { if(AC.state==='suspended') AC.resume().catch(()=>{}); return; }
  try{
    AC = new (window.AudioContext||window.webkitAudioContext)();
    master = AC.createGain(); master.gain.value=0.14; master.connect(AC.destination);
  }catch(e){ AC=null; }
}
function tone(freq, dur, type, vol, when, glideTo){
  if(!AC) return;
  try{
    const t0 = AC.currentTime + (when||0);
    const o=AC.createOscillator(), g=AC.createGain();
    o.type=type; o.frequency.setValueAtTime(freq,t0);
    if(glideTo) o.frequency.exponentialRampToValueAtTime(glideTo, t0+dur);
    g.gain.setValueAtTime(0.0001,t0);
    g.gain.exponentialRampToValueAtTime(vol,t0+0.012);
    g.gain.exponentialRampToValueAtTime(0.0001,t0+dur);
    o.connect(g); g.connect(master);
    o.start(t0); o.stop(t0+dur+0.05);
  }catch(e){}
}
function ding(){ tone(1046,0.18,'sine',0.5,0); tone(1318,0.22,'sine',0.4,0.09); }
function toot(){ tone(311,0.16,'square',0.25,0); tone(311,0.22,'square',0.25,0.22); tone(415,0.3,'square',0.18,0); }
function crashSnd(){ tone(220,0.4,'sawtooth',0.3,0,60); tone(110,0.5,'square',0.2,0.05,50); }
```
Noise for crash would be nicer but oscillators suffice.

Note: ensureAudio is called on keydown/pointerdown. In demo mode before interaction, no sound (fine — AC null, tone() returns early). 

Now the main loop and resize:

```js
function fit(){
  const rect = stage.getBoundingClientRect();
  const availW = Math.max(200, rect.width - 2*framePad?);
```
Actually stage contains boardWrap which has padding (the frame). I'll compute: pad = frame padding (read from CSS var? compute via getComputedStyle? Simpler: measure stage size, then cell = floor(min((availW - 2*pad)/COLS, (availH - 2*pad)/ROWS)) where pad = clamp(8,1.4vw,14) — compute pad = Math.max(8, Math.min(14, availW*0.014))? Eh, easier: read computed style of boardWrap padding:
```js
const cs = getComputedStyle(boardWrap);
const padX = parseFloat(cs.paddingLeft)+parseFloat(cs.paddingRight);
const padY = parseFloat(cs.paddingTop)+parseFloat(cs.paddingBottom);
cell = Math.max(10, Math.floor(Math.min((availW-padX)/COLS, (availH-padY)/ROWS)));
```
Then W=cell*COLS; H=cell*ROWS; canvas.style.width=W+'px'; height H+'px'; canvas.width=W*dpr; canvas.height=H*dpr; ctx.setTransform(dpr,0,0,dpr,0,0); buildBg();

Also cap cell at max 52? On huge screens the board could be gigantic; 1280×800 stage ~ (1060 avail?) Let's see: page width min(96vw,1060)? Actually I didn't constrain a max width for the stage; stage is width:100%. At 1280 wide, availW≈1280-20=1260, pad≈28 → cell=min(1232/21≈58, (stageH-pad)/14). stageH ≈ 800 - header(~70) - foot(~36) - paddings ≈ 690 → (690-28)/14 ≈ 47. So cell≈47, board 987×658. Nice big. Cap cell at 50 for sanity: cell=Math.min(cell, 50). Then board 1050×700 fits.

Resize listener: window resize → fit(); Also after fonts load? Not needed.

Main loop:
```js
let lastT = performance.now();
function frame(now){
  let dt = Math.min(50, now-lastT); lastT=now;
  if(running){
    acc += dt;
    let guard=0;
    while(acc >= tickMs && guard<4){ doTick(); acc-=tickMs; guard++; if(!running) {acc=0;break;} }
    // auto-restart demo after crash
  }
  render(now, dt);
  requestAnimationFrame(frame);
}
```
Demo auto-restart: in crash() if driver==='demo': setTimeout(()=>{ if(!running && driver==='demo') { resetGame('demo'); } }, 1500). Guard with the check. Also for over state in player mode: overlay shows; but I set overlay after small delay? Simpler: show immediately in updateOverlayUI called from crash(). Add class for a fade-in CSS animation.

Wait, one catch: setTimeout in sandboxed iframe fine (not blocked).

Also "updateStats": statScore.textContent=score; statCars=cells.length; statBest=best. Call on changes and reset.

Also I want score chip pop animation: add class 'pop' then remove after 300ms (use animationend or timeout). Minor; implement with timeout guarded.

Overlay pulse for "click to start": CSS animation on .big.

Edge: `requestAnimationFrame(frame)` start after DOM ready — script at end of body, fine.

Also initial: resetGame('demo'); fit(); requestAnimationFrame. Order: fit needs stage laid out — script runs at end of body, layout available (clientHeight computed). Might be 0 if fonts pending? getBoundingClientRect works. Also call fit() once more on 'load' just in case.

Careful: buildBg uses cell — call after fit sets it.

Also handle document visibility: on visibilitychange reset lastT to avoid dt spike (we clamp dt to 50 anyway, and acc accumulates max 4 ticks). Fine.

Now, wagon color assignment: I want the wagons behind the loco in a colorful repeating sequence. car i (i≥1): color = WAGON_COLORS[(i-1) % len]. When snake grows, existing cars keep colors (since index-based). Good.

Also draw a subtle number on wagons? Tiny index number like car number "2","3" in white on the roof — cute detail at big cells: font size cell*0.22, fillStyle rgba(255,255,255,0.85), centered. Loco gets no number. At cell≥28 draw. Let me add: on boxcars only.

Wait, top-down: writing a number on the roof, rotated with the car — fine.

Let me also draw little "passenger heads" in the wagons? After pickups, wagons could show tiny passenger circles... The wagons are top-down, can't show passengers. Skip.

Let me think about the "chug" easing more: t' = t + 0.05*sin(2πt). At t=0: 0. At t=1: 1. Monotonic? derivative 1+0.1πcos ≥ 1-0.314 >0. Yes monotonic. Amplitude 0.05 gives subtle surge per cell. Keep 0.045.

Chimney world position: headAngle from angle function; local offset (L*0.24? wait chimney at local x=+L*0.24... L=cell*0.92, so local x=cell*0.22, y=0. World: p + (cos a*ox, sin a*oy). Emit smoke at that world pos.

Also while running, emit interval scaled with speed: every max(70, tickMs*0.6) ms.

Draw order recap in render:
1. bg
2. foods (platform + passenger)
3. track
4. couplings
5. car shadows (draw per car just before its body, fine)
6. cars (tail→head)
7. tunnels
8. smoke
9. rings, tickets
10. HUD-in-canvas? none; DOM handles.

Also draw a subtle vignette on top? The frame handles it.

Let me now also double check the wrap tunnel draw for vertical edges: edge 'T': center (p.x, 0). Same arch. OK.

Potential visual issue: the track under the platform slabs — platforms are drawn before track; when the track passes over a platform cell... can the track overlap a platform cell? The snake body could pass adjacent cells; the platform slab is within its own cell (0.94 width). The track is 0.5 cell wide along the snake path — if the snake runs through a cell adjacent to a platform, the track (centered on snake cells) stays within snake cells ±0.25cell — no overlap with the neighboring platform. Corners: track rounds corners within cells. OK, minor overlaps unlikely and harmless anyway (draw order: track over platform edge = train track passing in front of platform — realistic anyway).

Pickup timing note: the food is removed when the head enters its cell; at that moment the platform disappears entirely (platform + passenger gone at once). Slight abruptness; nicer: keep platform slab fading? Add "recently picked" ghost: store ghost {x,y,t} draw slab fading 300ms. Nice small touch — add ghosts array: {x,y,t}, drawn with alpha (1-t/0.35s), just the slab without passenger. I'll add.

Now, writing everything with care for correctness. Also `roundRect` — canvas has ctx.roundRect in modern Chrome; but to be safe implement helper:

```js
function rr(c,x,y,w,h,r){
  r=Math.min(r,w/2,h/2);
  c.beginPath();
  c.moveTo(x+r,y);
  c.arcTo(x+w,y,x+w,y+h,r);
  c.arcTo(x+w,y+h,x,y+h,r);
  c.arcTo(x,y+h,x,y,r);
  c.arcTo(x,y,x+w,y,r);
  c.closePath();
}
```
Use ctx = main ctx named `ctx`; careful the helper param name. I'll write rr(x,y,w,h,r) using the global ctx, and a variant accepting ctx? bg uses its own context `b`. I'll make rr(c,...).

shade(hex,amt): 
```js
function shade(hex, amt){
  const n=parseInt(hex.slice(1),16);
  let r=(n>>16)+amt, g=((n>>8)&255)+amt, b=(n&255)+amt;
  r=Math.max(0,Math.min(255,r)); ...
  return `rgb(${r},${g},${b})`;
}
```

mulberry32:
```js
function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296;}}
```

easeOutBack: t=>1+2.7*Math.pow(t-1,3)+1.7*Math.pow(t-1,2)? Standard: c1=1.70158, c3=c1+1; 1 + c3*(t-1)^3 + c1*(t-1)^2.

Now stats & best: best updates only on player crash. Also show "best" persistent during session. Fine.

Footer copy: keep short.

Let me also consider the requirement "no horizontal scrollbar": body overflow-x hidden (I set overflow:hidden overall — but that also kills vertical scrolling; our layout fits by flex, so fine). To be extra safe on small heights, maybe `overflow:auto` on #stage? If the board doesn't fit due to rounding, stage flex will shrink canvas since we compute cell from stage size. OK.

Also `touch-action: none` on canvas/boardWrap to prevent scroll gestures interfering with swipes: `#boardWrap{touch-action:none;}`? That might block page scroll entirely — page doesn't scroll anyway. Set on canvas.

Text selection: user-select none on UI.

One more feature: show a little "speed" indicator? Skip.

Let me now also reconsider the visual first-impression: green meadow + wooden frame + cream page + red title + colorful train + smoke + platforms with waving passengers + demo card. That's charming and distinct. 

I also want to add drifting cloud shadows? Skip — scope control.

Let me write the header logo SVG concretely:

```html
<svg class="logo" viewBox="0 0 104 56" width="86" height="46" aria-hidden="true">
  <!-- smoke -->
  <g fill="#c9b9a2" opacity=".8">
    <circle cx="24" cy="10" r="4.5"/>
    <circle cx="33" cy="6" r="3.2"/>
    <circle cx="41" cy="3.5" r="2.2"/>
  </g>
  <!-- chimney -->
  <rect x="20" y="12" width="7" height="12" rx="2" fill="#4a3222"/>
  <!-- cab -->
  <rect x="58" y="10" width="22" height="28" rx="3" fill="#a33a30"/>
  <rect x="55" y="7" width="28" height="6" rx="2.5" fill="#5a2e28"/>
  <rect x="63" y="15" width="12" height="11" rx="2" fill="#cfe8ef"/>
  <!-- boiler -->
  <rect x="22" y="20" width="42" height="17" rx="8" fill="#c0453c"/>
  <rect x="30" y="20" width="4" height="17" fill="#e2b34b" opacity=".9"/>
  <rect x="46" y="20" width="4" height="17" fill="#e2b34b" opacity=".9"/>
  <!-- front cap + cowcatcher -->
  <rect x="61" y="18" width="5" height="21" rx="2" fill="#8f2f28"/>
  <polygon points="66,39 74,30 76,39" fill="#8d8375"/>
  <!-- wheels -->
  <g fill="#3a2a20">
    <circle cx="32" cy="41" r="7"/>
    <circle cx="50" cy="41" r="7"/>
    <circle cx="67" cy="42" r="5"/>
  </g>
  <g fill="#d9a94e">
    <circle cx="32" cy="41" r="2.4"/>
    <circle cx="50" cy="41" r="2.4"/>
  </g>
</svg>
```
Rough but should read as a locomotive side view. Let me refine mentally: viewBox 0 0 104 56, leave space; wheels at bottom y≈41 r7 → bottom 48. Ground shadow ellipse maybe. Front should face right: cowcatcher at right. Boiler from x=22 (behind chimney at 20-27) to 64. Cab at right x58-80? Wait cab at rear — for a locomotive facing right, the cab is at the LEFT and the smokebox/cowcatcher at the RIGHT. Classic steam loco: chimney near the front (right), cab at back (left). Let me flip: cab left (x=4..26), boiler (x=24..66), chimney near right (x=56), cowcatcher right (x=70+). Redraw:

```html
<svg class="logo" viewBox="0 0 100 52" ...>
  smoke circles near (62,8),(70,5),(78,3)
  chimney: rect x=58 y=12 w=7 h=12 rx=2 fill dark
  cab: rect x=6 y=8 w=24 h=28 rx=3 fill #a33a30; roof rect x=3 y=5 w=30 h=6 rx=3 fill #5a2e28; window rect x=11 y=14 w=13 h=11 rx=2 fill #cfe8ef
  boiler: rect x=26 y=19 w=40 h=16 rx=7 fill #c0453c; gold bands rect x=34 y=19 w=4 h=16, x=50 ...
  dome: rect x=44 y=14 w=10 h=7 rx=3 fill #e2b34b (steam dome)
  front: rect x=62 y=17 w=6 h=20 rx=2 fill #8f2f28 (smokebox)
  cowcatcher: polygon points="68,38 78,28 80,38" fill #8d8375? A cowcatcher slopes down-forward: points "68,38 79,29 81,39". fill gray.
  wheels: circles (16,40,r7),(34,40,r7),(50,40,r6),(68,41,r5) dark with gold hubs (16,40,2.4),(34,40,2.4),(50,40,2)
</svg>
```
Good enough; small decorative element.

Also maybe add tiny rail line under wheels: rect x=0 y=47 w=100 h=3 fill #b58a55 rounded; plus a couple ties. Add: rect y=47 h=2.5 fill #c9a269; ties darker every 10px: small rects. Nice finishing.

Now, one important check — keydown handler and focus: "The iframe only receives keyboard events after the user clicks it" — our keydown on window works after focus; overlay says click or press key. Good.

preventDefault on arrows: also space? Space does nothing; maybe pause? Add pause on 'p'? Extra. Skip pause (demo keeps it simple). Actually a pause could be nice for player mode... skip to reduce risk.

Let me also handle the case: player presses key during their own running game that's not a direction (e.g., 'x') → ignore.

Restart from over: keydown → startPlayer(d) — resets and applies direction. pointerdown → startPlayer().

Also: while driver==='player' and running, pointerdown on board does nothing (avoid accidental restarts). Good.

Now let me carefully write doTick food/pickup with pixel positions — need cell known (yes global).

growFx: ghost slab + pickupFx as described.

One more detail: when the head picks up a passenger, ideally the passenger "boards" — the +1 ticket conveys it. Good enough.

Also add small confetti? no.

Check tickMs speed vs AI: 150ms initial, min 80. At 80ms with cell 47px → 590px/s — fast but fine.

Let me now also think: should the demo overlay hide the train? The card sits at bottom center; the board is ~660px tall; card ~90px tall at bottom — train visible. Also semi-transparent bg. OK.

Screenshot at 3s: demo running ~20 ticks in — train has moved, maybe picked up 1-2 passengers. Smoke visible. 

Now write CSS fully:

```css
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
  font-family:"Trebuchet MS","Segoe UI",Verdana,sans-serif;
  color:#4a3222;
  background:
    radial-gradient(rgba(150,110,60,.12) 1.1px, transparent 1.2px) 0 0 / 22px 22px,
    linear-gradient(180deg,#f8eeda 0%,#f0dfbe 100%);
  display:flex;flex-direction:column;align-items:center;
  height:100vh;height:100dvh;
  overflow:hidden;
  -webkit-user-select:none;user-select:none;
}
header.top{...}
```

Details:
```css
header.top{
  width:min(100%,1080px);
  display:flex;align-items:center;justify-content:space-between;
  gap:12px;flex-wrap:wrap;
  padding:10px clamp(12px,3vw,28px) 6px;
}
.brand{display:flex;align-items:center;gap:12px;min-width:0}
.logo{flex:0 0 auto;filter:drop-shadow(0 2px 2px rgba(90,60,20,.25))}
h1{font-size:clamp(19px,3vw,30px);font-weight:900;letter-spacing:.05em;text-transform:uppercase;line-height:1}
h1 .accent{color:#c0453c}
.tagline{font-size:clamp(11px,1.4vw,13px);color:#8a6a45;margin-top:3px;font-style:italic}
.stats{display:flex;gap:8px;flex-wrap:wrap}
.chip{background:#fff7e6;border:2px solid #e0cba2;border-radius:12px;padding:5px 12px 6px;min-width:78px;text-align:center;box-shadow:0 2px 0 #e0cba2}
.chip .lbl{display:block;font-size:9.5px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:#a3865c}
.chip .val{display:block;font-size:clamp(16px,2.2vw,21px);font-weight:900;color:#4a3222}
.chip.pop .val{animation:pop .3s ease}
@keyframes pop{40%{transform:scale(1.35);color:#c0453c}}
.val{display:block} hmm transform on inline? .val is block, transform works on block? transform works on any element generating a box — yes.
```
Stage & board:
```css
#stage{flex:1 1 auto;width:100%;min-height:0;display:flex;align-items:center;justify-content:center;padding:2px clamp(8px,2vw,24px) 10px}
#boardWrap{
  position:relative;
  background:#8a5a33;
  background-image:repeating-linear-gradient(97deg,rgba(0,0,0,.07) 0 6px,rgba(255,255,255,.04) 6px 13px);
  padding:clamp(9px,1.3vw,14px);
  border-radius:22px;
  box-shadow:inset 0 0 0 3px #6b4526, 0 18px 40px rgba(96,64,30,.30), 0 4px 0 #6b4526;
  max-width:100%;
}
canvas{display:block;border-radius:12px;background:#a7c982;touch-action:none}
```
Overlay & chips:
```css
#overlay{position:absolute;inset:0;display:flex;align-items:flex-end;justify-content:center;padding:0 10px calc(clamp(9px,1.3vw,14px) + 12px);pointer-events:none}
#overlay.hidden{display:none}
.card{
  background:rgba(255,247,230,.95);border:2px solid #e0cba2;border-radius:14px;
  padding:10px 18px 11px;text-align:center;max-width:min(92%,430px);
  box-shadow:0 10px 26px rgba(70,45,20,.28);
}
.card .t{font-size:11px;font-weight:800;letter-spacing:.16em;text-transform:uppercase;color:#a3865c}
.card .b{font-size:clamp(14px,2.3vw,18px);font-weight:900;color:#c0453c;margin-top:2px;animation:bob 1.7s ease-in-out infinite}
.card .s{font-size:clamp(10px,1.5vw,12px);color:#8a6a45;margin-top:3px}
@keyframes bob{50%{transform:translateY(-2px)}}
#modeChip{
  position:absolute;top:calc(clamp(9px,1.3vw,14px) + 10px);left:calc(clamp(9px,1.3vw,14px) + 10px);
  background:rgba(58,40,24,.72);color:#ffe9c0;font-size:10.5px;font-weight:800;letter-spacing:.1em;text-transform:uppercase;
  padding:5px 11px;border-radius:999px;pointer-events:none;backdrop-filter:blur(2px);white-space:nowrap;
}
#modeChip.player{background:rgba(192,69,60,.85);color:#fff3dd}
footer.foot{...}
```
Wait — #modeChip must be positioned relative to the canvas area, inside boardWrap. Since boardWrap has padding, top offset accounts. But the chip would sit over the canvas corner — good.

footer:
```css
footer.foot{
  width:100%;display:flex;justify-content:center;align-items:center;gap:8px;flex-wrap:wrap;
  padding:2px 12px 10px;font-size:12px;color:#8a6a45;
}
kbd{
  display:inline-block;background:#fff7e6;border:1.5px solid #d9c39c;border-bottom-width:3px;border-radius:6px;
  padding:0 6px;font-family:inherit;font-size:11px;font-weight:800;color:#6b4d2c;line-height:1.5;
}
.foot .sep{opacity:.5}
@media (max-width:600px){ .foot .long{display:none} .tagline{display:none?} }
```
Hmm hide long hint on small: `<span class="long">…</span>`.

Also on very small screens, stats chips shrink: min-width 64px; fine.

Now assemble the whole JS carefully. Let me write out the final code mentally with attention to bugs:

- `foods.splice` in tick — ok.
- updateStats called on reset, pickup, crash.
- `best` chip shows 0 initially.

AI correctness check with wrap: bfs neighbors wrap — yes.

One AI concern: `if(d.x===-dir.x&&d.y===-dir.y) continue;` — with dir current at decision time. Fine.

Another: when snake is long, `pathLen` uses blockedAfter which includes almost the whole body minus tail — BFS may often be 9000 for all, then sorted by safe/space. OK.

Also add tiny randomness to AI tie-breaks? Not needed.

Potential AI flaw: candidate cell equals current head-adjacent body cells — handled by blockedStrict.

Let me also make demo slightly "human": small chance (2%) to take the second-best candidate? Nah — reliability wins.

Now, initial demo: snake at row 7 (center), x from 8 down to 5, facing right. Foods spawn away ≥4. Good.

Interpolation & first tick: prevCells=cells → first tick cars stationary until first doTick occurs after acc≥tickMs (150ms). At t=0 render shows static train — fine.

Crash handling inside doTick: crash() sets running=false → tick loop breaks (I check `if(!running) break;` in while loop after doTick).

crash():
```js
function crash(){
  running=false;
  crashAt=performance.now();
  const p=headPoint(); // interpolated? just use head cell center
  for(let i=0;i<16;i++) emitSmoke(...) at head cell center with random velocities;
  crashSnd();
  if(driver==='player'){ if(score>best){best=score;} updateStats(); }
  updateOverlayUI();
  if(driver==='demo'){ setTimeout(()=>{ if(!running && driver==='demo') resetGame('demo'); },1600); }
}
```
resetGame sets running=true, state ok.

startPlayer:
```js
function startPlayer(d){
  driver='player';
  resetGame();
  running=true;
  if(d) queuedDirs.push(d);  // resetGame clears queue, so push after
  toot();
  updateOverlayUI();
}
```
Careful resetGame signature: I'll define resetGame() to set driver-independent stuff, and set driver before call since resetGame calls updateOverlayUI which reads driver. Order: driver='player'; resetGame(); then queuedDirs push; toot; overlay already updated inside resetGame. Then extra updateOverlayUI not needed. Let me have resetGame(mode): sets driver=mode. So startPlayer: `resetGame('player'); if(d) queuedDirs.push(d); toot();`

resetGame('demo') similarly.

updateOverlayUI inside resetGame: shows/hides correctly.

Ghost slabs: array ghosts [{x,y,t}] updated in render: t+=dt; filter t<350.

Now render function details — write it:

```js
function render(now, dt){
  ctx.save();
  // shake
  let sx=0, sy=0;
  if(!running){
    const ct=(now-crashAt)/450;
    if(ct<1){ const amp=(1-ct)*cell*0.18; sx=(Math.random()*2-1)*amp; sy=(Math.random()*2-1)*amp; }
  }
  ctx.clearRect(0,0,W,H);
  ctx.translate(sx,sy);
  if(bg) ctx.drawImage(bg, 0,0, W, H);  // bg bitmap is dpr-scaled; drawImage with dest w/h W,H works since ctx transform is dpr — draw at 0,0 W,H CSS units. bg.width = W*dpr pixels; drawImage(bg,0,0,W,H) scales it back — correct.
  
  // ghosts
  for(const g of ghosts){ g.t+=dt; }
  ghosts = ghosts.filter(g=>g.t<380);
  for(const g of ghosts){
    ctx.globalAlpha = 1-g.t/380;
    drawSlab((g.x+0.5)*cell,(g.y+0.5)*cell, false);
    ctx.globalAlpha=1;
  }
  
  drawFoods(now);
  
  // interpolated points
  const tt=running? Math.min(1, acc/tickMs) : 1;
  const chug = tt + Math.sin(tt*Math.PI*2)*0.045;
  const pts=[]; const angs=[];
  for(let i=0;i<cells.length;i++){
    const a=prevCells[i]||cells[i], b=cells[i];
    pts.push(interpPoint(a,b,chug));
    angs.push(carAngle(i,a,b));
  }
  drawTrack(pts);
  // couplings
  ctx.strokeStyle='#4a3222'; ctx.lineWidth=Math.max(2,cell*0.09); ctx.lineCap='round';
  for(let i=0;i<pts.length-1;i++){
    const d=dist(pts[i],pts[i+1]); if(d>cell*1.6) continue;
    ctx.beginPath(); ctx.moveTo(pts[i].x,pts[i].y); ctx.lineTo(pts[i+1].x,pts[i+1].y); ctx.stroke();
  }
  for(let i=cells.length-1;i>=0;i--) drawCar(i, pts[i], angs[i], now);
  
  drawTunnels(pts);
  
  // smoke
  updateAndDrawSmoke(dt);
  // rings, tickets
  ...
  ctx.restore();
}
```
Hmm shake translate before clearRect: clearRect(0,0,W,H) with translate applied — clearing shifted region leaves edges. Better: clearRect(-20,-20,W+40,H+40) after translate, or clear before translate. I'll clearRect before translate with ctx.setTransform reset. Sequence:
```js
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.clearRect(0,0,W,H);
ctx.translate(sx,sy);
```
Since I only use ctx once. Note bg drawn shifted by shake too — fine (shake everything).

drawSlab(px,py): the platform tile used by both foods and ghosts:
```js
function drawSlab(px,py,withEdge=true){
  ctx.save(); ctx.translate(px,py);
  rrFill(-cell*0.47,-cell*0.26,cell*0.94,cell*0.58,cell*0.09,'#cfc6b4');
  ctx.strokeStyle='#b3a78f'; ctx.lineWidth=Math.max(1,cell*0.03);
  rr(ctx,-cell*0.47,-cell*0.26,cell*0.94,cell*0.58,cell*0.09); ctx.stroke();
  // safety stripe
  ctx.fillStyle='#e5b93c';
  ctx.fillRect(-cell*0.41, cell*0.185, cell*0.82, cell*0.06);
  if(withEdge){ // tactile dashes on stripe? skip; sign post
    ctx.fillStyle='#7a5a3a';
    ctx.fillRect(-cell*0.40,-cell*0.62,cell*0.05,cell*0.40);
    rrFill(-cell*0.48,-cell*0.72,cell*0.21,cell*0.20,cell*0.05,'#33547a');
    ctx.fillStyle='#f4ead2';
    ctx.fillRect(-cell*0.42,-cell*0.66,cell*0.09,cell*0.03);
  }
  ctx.restore();
}
```
Hmm the sign might overlap the cell above's content — the sign pokes above the slab within/above cell bounds. The platform cell's neighbor above might hold the track or another platform — the sign is small (~0.2 cell wide, 0.7 cell tall offset) — could visually overlap a track passing above. Minor. Alternatively place the sign flat: a small round sign lying at the corner. Top-down world → sign should be flat-ish. I'll keep the standing sign small and to the left edge; overlap risk is cosmetic only. Actually — wait, my scene has top-down train but front-facing passengers and standing signs: it's a deliberately mixed "diorama" style (like many cute games). Fine.

Also the passenger him/herself pokes above the cell top (head at -0.76*cell) — that's the point (meeple look).

But careful: if the cell above contains the track and the train covers the passenger's head visually — passengers get occluded when train passes above; draw order: foods drawn before track/cars → occluded. That's okay (train in front). If a passenger's head is occluded while train passes the cell above, looks like train in front of the passenger — correct depth cue. 

drawFood: slab + passenger + suitcase. Also a small "waiting" wiggle.

Now drawCar implementation (detailed):

```js
function drawCar(i,p,ang,now){
  const L=cell*0.92, Wd=cell*0.58;
  const isHead=i===0;
  ctx.save();
  ctx.translate(p.x,p.y);
  ctx.rotate(ang);
  // scale-in for newest wagon
  if(i===cells.length-1 && lastBorn>=0){
    const bt=(now-lastBorn)/350;
    if(bt<1){ const s=0.2+0.8*easeOutBack(Math.max(0,bt)); ctx.scale(s,s); }
  }
  // chug bob
  const bob=Math.sin(now*0.011 - i*0.8)*cell*0.018;
  ctx.translate(0,bob);
  // wheels
  ctx.fillStyle='#3a2a20';
  for(const wx of [-L*0.28,L*0.28]) for(const wy of [-Wd*0.52,Wd*0.52]){ ctx.beginPath(); ctx.arc(wx,wy,cell*0.075,0,7); ctx.fill(); }
  if(isHead){
    // extra front wheels
    for(const wx of [L*0.40]) for(const wy of [-Wd*0.5,Wd*0.5]){ ctx.beginPath();ctx.arc(wx,wy,cell*0.065,0,7);ctx.fill(); }
    // cowcatcher
    ctx.fillStyle='#d9d2c2';
    ctx.beginPath();
    ctx.moveTo(L*0.46,-Wd*0.40); ctx.lineTo(L*0.62, -cell*0.03); ctx.lineTo(L*0.62, cell*0.03); ctx.lineTo(L*0.46, Wd*0.40); ctx.closePath(); ctx.fill();
    ctx.strokeStyle='#a99f8a'; ctx.lineWidth=Math.max(1,cell*0.03); ctx.stroke();
    // body
    rrFill(-L/2,-Wd/2,L,Wd,cell*0.14,'#c0453c');
    strokeRr(-L/2,-Wd/2,L,Wd,cell*0.14,'rgba(90,25,20,0.8)? ') use shade('#c0453c',-40) lineWidth cell*0.05
    // boiler band gold
    ctx.fillStyle='#e2b34b';
    ctx.fillRect(L*0.16,-Wd/2+1,cell*0.05,Wd-2);
    ctx.fillRect(L*0.30,-Wd/2+1,cell*0.05,Wd-2);
    // cab
    rrFill(-L*0.50,-Wd*0.60,L*0.40,Wd*1.20,cell*0.10,'#8f2f28');
    // cab roof
    rrFill(-L*0.45,-Wd*0.48,L*0.28,Wd*0.96,cell*0.08,'#5a2e28');
    // vent on cab roof
    ctx.fillStyle='#7a4a3a'; ctx.beginPath(); ctx.arc(-L*0.31,0,cell*0.05,0,7); ctx.fill();
    // chimney
    ctx.fillStyle='#3b2a22'; ctx.beginPath(); ctx.arc(L*0.26,0,cell*0.095,0,7); ctx.fill();
    ctx.strokeStyle='#e2b34b'; ctx.lineWidth=Math.max(1,cell*0.03); ctx.stroke();
    ctx.fillStyle='#1d150e'; ctx.beginPath(); ctx.arc(L*0.26,0,cell*0.05,0,7); ctx.fill();
    // lamp
    ctx.fillStyle='#ffd97a'; ctx.beginPath(); ctx.arc(L*0.47,0,cell*0.055,0,7); ctx.fill();
  } else {
    const color=WAGON_COLORS[(i-1)%WAGON_COLORS.length];
    const kind=(i%5===0)?'tanker':'box';
    if(kind==='box'){
      rrFill(-L/2,-Wd/2,L,Wd,cell*0.12,color);
      strokeRr(..., shade(color,-45), cell*0.045);
      // roof band
      rrFill(-L*0.30,-Wd*0.34,L*0.60,Wd*0.68,cell*0.07,'rgba(255,255,255,0.35)');
      // side sills darker lines:
      ctx.fillStyle='rgba(0,0,0,0.15)';
      ctx.fillRect(-L*0.44,-Wd*0.5+? ...
      // number
      if(cell>=26){
        ctx.fillStyle='rgba(255,255,255,0.9)';
        ctx.font=`900 ${Math.round(cell*0.24)}px "Trebuchet MS",Verdana,sans-serif`;
        ctx.textAlign='center'; ctx.textBaseline='middle';
        ctx.fillText(String(i), 0, cell*0.02);
      }
    } else { // tanker
      ctx.beginPath(); ctx.ellipse(0,0,L*0.44,Wd*0.46,0,0,7); ctx.fillStyle=color; ctx.fill();
      ctx.strokeStyle=shade(color,-45); ctx.lineWidth=cell*0.045; ctx.stroke();
      // dome
      ctx.beginPath(); ctx.arc(0,0,cell*0.10,0,7); ctx.fillStyle=shade(color,-25)? use '#f0e6cf' cream dome: fill '#f4ead2'; stroke shade; 
      // ridge lines
      ctx.strokeStyle='rgba(0,0,0,0.18)'; ctx.lineWidth=Math.max(1,cell*0.03);
      ctx.beginPath(); ctx.moveTo(-L*0.34,0); ctx.lineTo(L*0.34,0); ctx.stroke(); hmm center line
    }
  }
  ctx.restore();
}
```
Also under-car shadow: before rotate: ctx.fillStyle='rgba(70,50,25,0.16)'; ellipse at (p.x, p.y+cell*0.10) rx L*0.55 ry Wd*0.55 — draw before translate/rotate (world coords). Add at start of drawCar before ctx.rotate — actually after translate(p) but before rotate, ellipse at (0, cell*0.10). Good.

carAngle(i,a,b):
```js
function carAngle(i,a,b){
  if(a.x!==b.x||a.y!==b.y) return angleBetween(a,b);
  if(i>0) return angleBetween(cells[i],cells[i-1]);
  return dir.x||dir.y? Math.atan2(dir.y,dir.x):0;
}
```
Wait for head: prev!==curr always after first tick; before first tick prev==curr → use dir. Good.

Note: new tail (i=L) with a==b: angle from cells[i] to cells[i-1]. Good.

angleBetween handles wrap.

drawTrack: implement splitRuns, stroke, ties, rails.

```js
function drawTrack(pts){
  if(pts.length<2) return;
  const runs=[]; let cur=[pts[0]];
  for(let i=1;i<pts.length;i++){
    if(dist(pts[i-1],pts[i])>cell*1.6){ runs.push(cur); cur=[pts[i]]; }
    else cur.push(pts[i]);
  }
  runs.push(cur);
  ctx.lineCap='round'; ctx.lineJoin='round';
  for(const run of runs){
    if(run.length<2) continue;
    // ballast
    pathRun(run); ctx.strokeStyle='#d6c49b'; ctx.lineWidth=cell*0.55; ctx.stroke();
    pathRun(run); ctx.strokeStyle='#cbb98f'? hmm two tones: outer edge darker: first stroke wide dark #b99a6b, then narrower #d8c8a0
```
Let me finalize: stroke1: color '#bd9d6e', width cell*0.56 (edge), stroke2: '#dcc9a0', width cell*0.56 - Math.max(3, cell*0.12) (surface). Then ties color '#96金'? ties '#a1763f'... Actually surface sandy then dark ties on top then rails:

ties: 
```js
ctx.strokeStyle='#8a6a48'; ctx.lineWidth=Math.max(1.5,cell*0.06);
for(let i=1;i<run.length;i++){
  const a=run[i-1], b=run[i];
  const dx=b.x-a.x, dy=b.y-a.y; const len=Math.hypot(dx,dy);
  if(len<1) continue;
  const ux=dx/len, uy=dy/len, nx=-uy, ny=ux;
  const hl=cell*0.27;
  const step=cell*0.38;
  for(let s=cell*0.15; s<len; s+=step){
    const x=a.x+ux*s, y=a.y+uy*s;
    ctx.beginPath(); ctx.moveTo(x+nx*hl,y+ny*hl); ctx.lineTo(x-nx*hl,y-ny*hl); ctx.stroke();
  }
}
```
rails:
```js
const off=cell*0.15, ext=cell*0.18;
ctx.strokeStyle='#5b4a39'; ctx.lineWidth=Math.max(1.5,cell*0.07);
ctx.beginPath();
for(let i=1;i<run.length;i++){
  const a=run[i-1], b=run[i];
  const dx=b.x-a.x, dy=b.y-a.y; const len=Math.hypot(dx,dy); if(len<1) continue;
  const ux=dx/len, uy=dy/len, nx=-uy, ny=ux;
  const ax=a.x-ux*ext + nx*off, ay=a.y-uy*ext + ny*off;
  const bx=b.x+ux*ext + nx*off, by=b.y+uy*ext + ny*off;
  ctx.moveTo(ax,ay); ctx.lineTo(bx,by);
  const ax2=a.x-ux*ext - nx*off, ay2=...;
  ctx.moveTo(ax2,ay2); ctx.lineTo(bx2,by2);
}
ctx.stroke();
```
This draws two offset rails per segment with extensions → corners overlap fine.

pathRun(run): ctx.beginPath(); moveTo run[0]; for rest lineTo; (no stroke).

Order check: ballast edge, ballast surface, ties, rails. Rails over ties — from top view, rails sit on ties. 

Also I should draw the track BELOW couplings and cars — yes step order.

drawTunnels:
```js
function drawTunnels(pts){
  const m=cell*0.8;
  for(const p of pts){
    let bestA=0, bx=0, by=0;
    const cand=[];
    if(p.x<m) cand.push([0,p.y,1-p.x/m]);
    if(W-p.x<m) cand.push([W,p.y,1-(W-p.x)/m]);
    if(p.y<m) cand.push([p.x,0,1-p.y/m]);
    if(H-p.y<m) cand.push([p.x,H,1-(H-p.y)/m]);
    for(const c of cand){ if(c[2]>bestA){bestA=c[2];bx=c[0];by=c[1];} }
    if(bestA<=0) continue;
    const a=Math.min(1,bestA*1.4);
    ctx.globalAlpha=a;
    ctx.fillStyle='#7ba35c'; ctx.beginPath(); ctx.arc(bx,by,cell*0.75,0,7); ctx.fill();
    ctx.fillStyle='#2f2717'; ctx.beginPath(); ctx.arc(bx,by,cell*0.52,0,7); ctx.fill();
    ctx.globalAlpha=a*0.6;
    ctx.strokeStyle='#1d1810'; ctx.lineWidth=Math.max(2,cell*0.08); ctx.beginPath(); ctx.arc(bx,by,cell*0.40,0,7); ctx.stroke();
    ctx.globalAlpha=1;
  }
}
```
Multiple cars near same edge draw repeatedly — same visual. OK. Radius 0.75cell ring might cover neighboring cell content near the border — it's the tunnel hedge; fine.

Hmm wait, there's a subtle problem: the arches are drawn on top of cars — including when the train runs *parallel* near an edge (within 0.8 cell) without crossing. E.g., train at column 0 moving vertically: p.x = 0.5cell → alpha = 1-0.5/0.8 = 0.37 → hedge rings at alpha .37 partially covering the train running in the outermost row. Hmm — that shows tunnel openings along the whole path. Actually that's... arguably fine ("running along the edge looks like entering the hedge"), but it could look odd. Mitigate: draw arches only for cars whose motion direction is heading outward (dot of movement direction with outward normal > 0.3) OR already outside (p beyond bounds). Compute per-car angle (I have angs). Outward for left edge: dirX < -0.5 → moving left → will cross. If moving vertically at col 0, angle=±90°, dot with left = 0 → no arch. 

Implement: pass angs to drawTunnels; for candidate edge with outward normal n=(nx,ny): d = cos(a)*nx + sin(a)*ny; require d > 0.45 OR the car center is outside the board (p.x<0 or p.x>W etc.). For a car at p.x=-0.3cell (exiting): p.x<m holds, and it's outside → draw regardless of direction. Good:

```js
const outside = p.x<0||p.x>W||p.y<0||p.y>H;
for(const c of cand){ 
  const d=Math.cos(ang)*c[3]+Math.sin(c... 
```
I'll store candidate as {x,y,nx,ny,a}. Check `if(!(outside || (Math.cos(ang)*c.nx + Math.sin(ang)*c.ny > 0.45))) continue;`.

But wait: also cars fully inside but crossing... a car crossing left edge: angle=180° → cos=-1 → dot with left normal (-1,0) = 1 → passes. 

Entry side: after wrap, head appears at p.x=-0.4cell moving right (angle 0): candidate left edge: normal (-1,0), dot = cos0*(-1) = -1 <0.45; but outside (p.x<0) → draw. As it moves to p.x=0.5cell, no longer outside, dot<0 → no arch?! But it should still look like it's emerging from a tunnel at the left edge! Hmm. Fix: also draw if the car is within the margin and moving INWARD (dot < -0.45) while still close (p.x < m*0.6?), i.e., treat inward-moving near-edge cars as "emerging". Actually direction check: just draw whenever |dot| > 0.45 (heading out OR coming in), OR outside. Vertical movement parallel excluded. Let me use: `Math.abs(dot) > 0.45`. For a train entering (moving outward), arch fades in as it approaches. For the wrapped head emerging (moving inward), arch shows while within margin then fades as it clears. That's exactly the tunnel illusion. 

But a train just passing parallel (vertical at col 0) still excluded. 

Edge case: train moving horizontally at row 0 near top edge: candidates top edge with normal (0,-1): dot with angle(180°)=cos180*0 + sin180*(-1)=0 → excluded. Only left/right candidates trigger for horizontal motion. Correct.

alpha ramp: as head approaches edge from 0.8cell → alpha 0→1 at edge; after wrap at -0.5cell: 1-(-0.5c)/0.8c>1 → clamp 1. good.

Now smoke update/draw:
```js
function updateAndDrawSmoke(dt,now){
  for(const p of smoke){ p.life+=dt; p.x+=p.vx*dt/1000; p.y+=p.vy*dt/1000; p.r+=cell*0.10*dt/1000; }
  smoke=smoke.filter(p=>p.life<p.maxLife);
  for(const p of smoke){
    const k=p.life/p.maxLife;
    ctx.globalAlpha=(1-k)*0.45;
    ctx.fillStyle='#f5f1e6';
    ctx.beginPath(); ctx.arc(p.x,p.y,p.r,0,7); ctx.fill();
  }
  ctx.globalAlpha=1;
}
```
Emission in render (needs chimney pos): compute headAngle & head point already available (pts[0], angs[0]):
```js
if(running){
  if(now-lastPuff > Math.max(65, tickMs*0.55)){
    lastPuff=now;
    const ox=cell*0.24, oy=0;
    const hx=pts[0].x+Math.cos(angs[0])*ox, hy=pts[0].y+Math.sin(angs[0])*ox;
    emitSmoke(hx,hy);
  }
}
```
But emission in render before drawing smoke — order: compute pts first, emit, then updateAndDraw. Place accordingly. Also on crash the smoke continues (running false stops emission). Good. Note lastPuff initial 0.

Wait: order in render — I need pts before emitting; emit after drawCars but smoke drawn after tunnels; fine: emit right after computing pts (before or after drawing cars doesn't matter, smoke drawn later from array).

Also initial smoke before first tick: pts[0] valid. Good.

Tickets draw:
```js
function drawFx(dt,now){
  for(const r of rings){ r.t+=dt; }
  rings=rings.filter(r=>r.t<500);
  for(const r of rings){
    const k=r.t/500;
    ctx.globalAlpha=(1-k)*0.7;
    ctx.strokeStyle='#fff8e8'; ctx.lineWidth=Math.max(2,cell*0.06);
    ctx.beginPath(); ctx.arc(r.x,r.y,cell*(0.2+0.8*k),0,7); ctx.stroke();
  }
  for(const t of tickets){ t.t+=dt; }
  tickets=tickets.filter(t=>t.t<950);
  for(const t of tickets){
    const k=t.t/950;
    const y=t.y-cell*1.1*easeOutCubic(k);
    ctx.globalAlpha=k<0.7?1:(1-(k-0.7)/0.3);
    const w=cell*0.95,h=cell*0.5;
    rrFill(t.x-w/2,y-h/2,w,h,cell*0.08,'#fff7e2');
    ctx.strokeStyle='#c8a86a'; ctx.lineWidth=Math.max(1,cell*0.035);
    rr(ctx,t.x-w/2,y-h/2,w,h,cell*0.08); ctx.stroke();
    // perforation
    ctx.save(); ctx.setLineDash([Math.max(1,cell*0.05),Math.max(1,cell*0.05)]);
    ctx.strokeStyle='#c8a86a';
    ctx.beginPath(); ctx.moveTo(t.x+w*0.22,y-h/2+2); ctx.lineTo(t.x+w*0.22,y+h/2-2); ctx.stroke();
    ctx.restore();
    ctx.fillStyle='#c0453c';
    ctx.font=`900 ${Math.max(9,Math.round(cell*0.26))}px "Trebuchet MS",Verdana,sans-serif`;
    ctx.textAlign='center'; ctx.textBaseline='middle';
    ctx.fillText('+1', t.x-w*0.14, y+1);
  }
  ctx.globalAlpha=1;
}
```

OK. Now, one more consideration about `acc` growth while `running` false: we skip update; tt=1 renders final positions.

Restart safety: setTimeout for demo restart — if user took over during the timeout (startPlayer resets driver to 'player', running=true) → guard `if(!running && driver==='demo')` prevents. 

Player crash → waiting for input → any keydown or pointerdown → startPlayer(d). Note: keydown handler checks `if(!running){ ensureAudio(); startPlayer(d); return; }` — but also when driver demo & running & key → startPlayer(d) (takeover). Both covered.

Also prevent starting from keys like Shift/F5? Any key starts — including F12? preventDefault on arrows & space only. If user presses F5 the page reloads — whatever. Maybe restrict starting to non-modifier keys: if e.key length===1 or arrow keys or space. I'll allow any key except F-key? Keep: if(['Shift','Control','Alt','Meta'].includes(e.key)) return; plus don't preventDefault F-keys. Minor: accept all except modifiers.

Also — pressing key during player running that maps to direction → queue. Also queue discipline: last queued dir vs current dir must not be reverse of the *last queued or current* dir. Standard:
```js
function queueDir(d){
  const last = queuedDirs.length? queuedDirs[queuedDirs.length-1] : dir;
  if(d.x===-last.x&&d.y===-last.y) return;
  if(d.x===last.x&&d.y===last.y) return;
  if(queuedDirs.length<3) queuedDirs.push(d);
}
```
And doTick applies queuedDirs.shift() via setDir which also guards. OK.

Swipe:
```js
let swipe=null;
boardWrap.addEventListener('pointerdown',e=>{
  ensureAudio();
  if(!running || driver!=='player'){ startPlayer(); }
  else { swipe={x:e.clientX,y:e.clientY}; }
});
window.addEventListener('pointermove',e=>{
  if(!swipe||!running||driver!=='player') return;
  const dx=e.clientX-swipe.x, dy=e.clientY-swipe.y;
  if(Math.hypot(dx,dy)<22) return;
  const d = Math.abs(dx)>Math.abs(dy) ? {x:Math.sign(dx),y:0} : {x:0,y:Math.sign(dy)};
  queueDir(d);
  swipe={x:e.clientX,y:e.clientY};
});
window.addEventListener('pointerup',()=>{swipe=null;});
```
Careful: pointerdown starting the game on tap — good; but tap also then sets swipe? In else branch only when player running. Good.

Note: when player is running and taps the board — nothing (swipe starts). OK.

Hmm — the demo card is pointer-events:none, so taps land on boardWrap. But the card sits within overlay inside boardWrap — pointer-events none passes through. 

Also keydown: preventDefault for arrows and space to stop scrolling (page not scrollable anyway, but iframe might). Do: `if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(e.key)) e.preventDefault();`

Key map including uppercase WASD: use e.key.toLowerCase().

Now updateStats:
```js
function updateStats(pop){
  statScore.textContent=score;
  statCars.textContent=cells.length;
  statBest.textContent=best;
  if(pop){ scoreChip.classList.remove('pop'); void scoreChip.offsetWidth; scoreChip.classList.add('pop'); }
}
```

buildBg decorations: ensure they don't spawn on the center row start area? They're flat decor under everything; fine anywhere. But big bushes under the track could reduce readability slightly — they're low-contrast; acceptable. Keep bushes smallish and semi-transparent.

Also add a pond? Skip.

Vignette: after bg fill, subtle inner shadow: radial? skip; the frame provides depth.

Now — check total heights at 1280×800: header ~ (padding 10+6 + content ~46) ≈ 62; stage: 800-62-footer(~30)-body pad — canvas H = cell*14 with cell≤ floor((stageH-2*pad)/14). Stage gets ~ 800-62-34 ≈ 704 minus its own padding 12 → ~690 avail; pad≈14*2=28 → cell = min(50, floor(662/14)=47) = 47 → H=658. Board total 658+28=686 ≤ 690 ✓. Width: 47*21=987+28=1015 ≤ 1260 ✓.

At 375×667 (iPhone-ish): header wraps: brand (~250px) + stats (3 chips × ~70 = 210 + gaps) → wraps to 2 rows ≈ 100px; foot ~ 46 (wraps 2 lines maybe); stage ≈ 667-100-50 = 517 → cell = floor((517-28)/14)=34? Also width constraint: (375-20-28)/21 = 15.5 → cell=15 → board 315×210+frame. Small but playable & visible. Board width 315 ≤ 375 ✓ no horizontal scroll.

Passenger details at cell=15 are tiny but still readable as blobs; the pop-in + waving still animate. Fine.

Now — the screenshot at ~3s: demo visible with overlay card + chip + train moving + smoke + passengers. 

One more idea to boost first impression: page could include a subtle "clouds" drifting over the meadow — skip, keep perf and code size manageable. The code is already large.

Let me double check the game-over overlay text uses score & best: `${score} passenger(s) · best ${best}`.

Also add demo crash message? In demo crash, overlay currently shows start card (updateOverlayUI shows demo card only if running; if !running shows derailed card — for demo crash it'd show "Derailed! click to start" briefly for 1.6s then restart flips back to demo card. Hmm, during demo crash, better: show a small "conductor-bot derailed — resetting…" message instead of inviting click? Both okay; the invite is actually fine (user can click to take over). But "Derailed!" then instantly back might flicker. Let me handle: if(!running && driver==='demo'): ovTitle='Derailed!', ovBig='Conductor-bot is re-railing the train…', ovSub='Click or press any key to take the wheel'. That reads nicely. 

Also update modeChip during over: keep last value.

Now let me also double-check `interpPoint` for prevCells shorter than cells (growth handled by pushing clone). In doTick growth: prevCells = old (length L); cells length L+1; prevCells[L] undefined → in render `const a=prevCells[i]||cells[i]` → cells[i] (itself) → static. Actually I planned to push the clone; either way `||cells[i]` fallback covers it. I'll still push for clarity? The fallback suffices — but wait, `prevCells[i]||cells[i]` — prevCells[i] is an object always truthy when exists; when undefined fallback. OK, no push needed. But carAngle for that segment: a===b → uses cells[i]→cells[i-1]. 

Also on non-grow tick: prevCells length L === cells length L. Good.

One subtle thing: after growth, at the NEXT tick, old[L]... prev for segment L (index preserved: cells array order stays, new head inserted at 0 and each shifts by one index). Hmm wait — growth: cells = [newHead, ...old] (length L+1). Next tick (no food): cells_new = [newHead2, old2... ] = [h2, cells[0..L-1]] i.e., drops tail (the grown one). Wait no — dropping tail removes the LAST element each non-grow tick. So the grown wagon at index L stays as tail until... it never moves? Let's trace: after growth, cells = [h, c0, c1, ..., c(L-1)] (length L+1). Next tick without food: cells' = [h2, h, c0, ..., c(L-2)] — dropped c(L-1)?? No: we drop the last element: cells'.length = L+1... wait cells' = [newHead].concat(old.slice(0, old.length-1)) = [h2, h, c0..c(L-2)] length L+1-1+1 = L+1? old length = L+1, slice(0, L) keeps L elements + 1 head = L+1. Dropped the last = the newly grown wagon c(L-1)!! That's wrong — the grown wagon would vanish immediately!

Hold on. In classic snake, growth means: don't remove the tail this tick. Sequence: eat at tick k: cells grows to L+1 (tail stays + new head). At tick k+1 (no food): normal move: add head, remove LAST tail element. The last element at tick k+1's "old" is the old tail (the one that was retained)... Let me re-trace concretely. Start: [A,B,C] (A head). Move right onto food at D: new cells [D,A,B,C] (grew; kept C). Now length 4. Next move right to E (no food): new cells = [E,D,A,B] — removed C. So the "growing" segment C stays at the END and gets removed next tick, while the car at position B... hmm visually: the snake's tail extends because C persisted one extra tick; after the next tick, tail is B. Wait that means length stays 4: [E,D,A,B] — yes length 4. Correct: growth persists permanently (length went 3→4 and stays 4).

The rendered "new wagon" is the one that appears when the tail stops advancing: at tick k+1, the car previously at C remains at C... no wait at tick k+1 C is REMOVED and B moves... Each render car i is at cells[i]: tick k: [D,A,B,C] — 4 cars. tick k+1: [E,D,A,B] — 4 cars. The tail car C disappears and B becomes tail. Hmm, so the "new" wagon is actually the car at index L-1 = C during tick k, then it vanishes?!

Wait, that's not how the classic growth works visually. Let me recount. Classic: without food, each tick: unshift head, pop tail → length constant, and each segment "moves forward". With food: unshift head, DON'T pop → length +1. The new length is kept FOREVER because subsequent non-food ticks pop the (now-longer) tail. The extra length is permanent: after k: length 4 [D,A,B,C]. After k+1 (no food): [E,D,A,B] — length 4. ✓ permanent.

Visually: at tick k, the tail C did NOT move (it stayed while A,B advanced... no wait — at tick k, the body cells are [D,A,B,C]: the car that was at A is now at D? The way I render: car i interpolates prevCells[i]→cells[i] where prevCells = old cells (pre-tick) = [A,B,C]. Car 0: A→D. Car 1: B→A. Car 2: C→B. Car 3: prev undefined... prevCells has length 3; cells length 4. Car 3: cells[3]=C, prev = fallback cells[3] = C → stays at C. ✓. So the new car appears at the old tail position C and stays there while cars 0-2 advance. Next tick k+1: old = [D,A,B,C]; cells = [E,D,A,B]. Car 0: D→E; car1: A→D; car2: B→A; car3: C→B ✓ — the new car now moves along. C was never "removed" as a car — the CELL C is vacated but the car (rendered entity at index 3) moved from C to B... wait that's backwards: cars move FORWARD (toward head). Index 3 car: prev C, curr B. B is closer to the head. ✓. 

So all good — my earlier confusion resolved: the growth works and the new wagon persists. The scale-in animation on the last car (born this tick) works; lastBorn updates each growth; if two quick growths, lastBorn resets — last car scales in — fine.

BUT one issue: after growth tick k, the tail car index 3 = the new wagon. On tick k+1, tail car (index 3) moves from C to B and it's the LAST car — my scale-in check `i===cells.length-1 && now-lastBorn<350` — at tick k+1 the last car is still the "new" wagon (since length stayed 4, index 3 is same physical wagon). It'd scale-in... it already scaled in during tick k (350ms might span into k+1 — at 150ms/tick, 350ms covers ticks k and k+1). The animation continues smoothly — the wagon is scaling from 0.2 while moving — acceptable, it reads as "new wagon pops in and joins". 

However, if a LATER growth happens while a previous scale-in is running, lastBorn resets mid-animation for the older wagon causing it to jump to full size? No: the older wagon is no longer the last car (new growth appended). The check `i===cells.length-1` — only last car scales. When new growth occurs, previous last car is now second-to-last → no scale check → renders full size instantly even if it was mid-scale at 0.6 → visual pop. Rare & minor (two pickups within 350ms while both animating). Acceptable. Alternatively track bornAt per segment index... indices shift each tick. Skip — minor.

Now `lastBorn` initial: set to -Infinity (use -1e12).

Also, crash smoke burst uses head cell center: use pts[0] at crash time? crash() called inside doTick before prevCells update — head cell = cells[0] (pre-move head). Use ((cells[0].x+0.5)*cell, ...). Fine.

Also on crash: if driver player & score>best → best=score.

updateOverlayUI on crash — with 0 delay, card appears instantly. Add CSS fade-in: .card{animation: cardIn .3s ease} — re-triggering needs class toggle; since content changes without re-adding element, animation won't re-run. Use overlay.style.display none/flex toggling — when display changes from none to flex, animation restarts? CSS animations restart when element goes from display:none to visible — yes, generally the animation restarts. Set .card animation fade+rise. When overlay hidden during play and shown on over/demo, animation replays. But during demo (visible from load) it plays once. Good.

Now — a tricky detail: overlay display toggles between demo card (at load), none (playing), over card. In updateOverlayUI I set overlay.style.display='flex'/'none'. 

Stat "Cars" = cells.length (loco + wagons) — or show wagons count = length-1? "Cars" includes loco; label it "Cars" with value cells.length. Fine.

Let me also make the header stats update highlight the score chip on pickup (pop). Need chip element ref.

Now the AI's `bfs` uses Int16Array queue — indices up to 294 fit. dist Int16Array fine.

flood/space: count of dist>=0 from the candidate's bfs — that bfs used blockedAfter which includes most of the body; the reachable space measured around the body — good proxy.

aiChoose per tick: 4 candidates × bfs(294) + sorts — trivial.

Edge: foods may be unreachable if snake huge → pathLen 9000 for all → sorted by safe then space. OK.

Another AI subtlety: `blockedStrict` includes tail; when checking candidate nk: allow nk===tailIdx when !eating. But `eating` computed from isFoodCell[nk] — if the tail cell contains food? Food never spawns on snake cells (occupied check includes body), so no. But food could be UNDER the tail if... no, food spawns on free cells; tail is a body cell at spawn time and food stays until eaten. Food cell can't be a snake cell at spawn; but after spawn, the snake could pass over food? Passing over = pickup (head enters cell). Body can't be on food without head having entered it first (which picks it up). Actually the body follows the head, so a food cell with body on it implies head already collected it. Safe.

Wait — actually there's one case: food spawns, then snake head passes → collected. Fine.

Now — collision at tick: `for(let i=0;i<limit;i++)` where limit = L - (grow?0:1). This checks new head against body cells [0..L-1] minus tail if not growing. ✓. Note: if not growing, tail cell vacates, so head can move into tail cell — allowed here ✓ (classic).

Self-collision check must also consider that when not growing, the head can move into the cell of the tail. ✓ handled.

Now, DOM ids: statScore, statCars, statBest, scoreChip wrapper id="chipScore".

Also modeChip.

Fonts in canvas: "Trebuchet MS" may not exist on all platforms; fallback Verdana/sans-serif — fine.

Text on canvas uses ctx.font each time — ok.

Let me also add a tiny "station name" flavor: platforms could have a tiny sign with random station names ("Maple Halt", "Birchford")?? At small cells unreadable. Skip.

Now assemble final HTML. Also meta description? Not needed.

Potential console errors to avoid:
- getContext('2d') fine.
- performance.now fine.
- No localStorage.
- AudioContext guarded.
- Careful with `ctx.arc(...,0,7)` — 7 radians > 2π, draws full circle fine.
- `rr` helper with negative w? Ensure widths positive.
- When cell tiny (15), lineWidth Math.max guards ok.

Also ensure canvas isn't zero-size at first fit: stage exists with flex sizing; getBoundingClientRect should be > 0. Call fit() then also on window 'resize'. Also guard cell minimum 8.

One more: `overlay` inset 0 within boardWrap — matches padded area. Mode chip positions relative to boardWrap (position:relative). ✓

Also the demo start: driver='demo' at load; running=true; overlay shows start card. ✓

Let me now also think about whether to keep arrow glyphs in footer kbd: `<kbd>←</kbd>` etc. Fine.

Also the page <title> and maybe a hidden a11y note. Add aria-hidden on decorative svg. Add role none.

Alright — also small nicety: pressing 'M' toggles mute? skip.

Writing final code now. I'll be careful with every function. Let me write it out completely, then review.

Also: `document.getElementById` for all needed elements; script at end.

Let me count code sections:
1. CSS (~150 lines)
2. HTML (~60)
3. JS (~450)

Total ~700 lines. That's fine.

Detailed JS write-up:

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

// ===== DOM =====
const canvas=document.getElementById('game');
const ctx=canvas.getContext('2d');
const stage=document.getElementById('stage');
const boardWrap=document.getElementById('boardWrap');
const overlay=document.getElementById('overlay');
const ovTitle=document.getElementById('ovTitle');
const ovBig=document.getElementById('ovBig');
const ovSub=document.getElementById('ovSub');
const modeChip=document.getElementById('modeChip');
const statScore=document.getElementById('statScore');
const statCars=document.getElementById('statCars');
const statBest=document.getElementById('statBest');
const chipScore=document.getElementById('chipScore');

// ===== helpers =====
const COLS=21, ROWS=14;
const DIRS=[{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
const TAU=Math.PI*2;
const WAGON_COLORS=['#3d8f7c','#e8913f','#e5c04a','#4d7cc4','#c75d8f','#5ea152','#8d6bc6','#d9705f','#3aa0a8','#b48ec9'];
const SKIN=['#f2c9a1','#e0a878','#c98a5b','#8c5a33','#f7dcb9'];
const SHIRT=['#c75d5d','#4d7cc4','#5ea152','#e0a33c','#8d6bc6','#3aa0a8','#c75d8f','#6b7f36'];
const HAIR=['#4a3222','#2c2c2c','#a3672e','#d9b356','#7a4a22'];

function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296;};}
function clamp(v,a,b){return v<a?a:v>b?b:v;}
function easeOutBack(t){const c1=1.70158,c3=c1+1;return 1+c3*Math.pow(t-1,3)+c1*Math.pow(t-1,2);}
function easeOutCubic(t){return 1-Math.pow(1-t,3);}
function shade(hex,amt){const n=parseInt(hex.slice(1),16);const r=clamp((n>>16)+amt,0,255),g=clamp(((n>>8)&255)+amt,0,255),b=clamp((n&255)+amt,0,255);return`rgb(${r},${g},${b})`;}
function rr(c,x,y,w,h,r){r=Math.min(r,w/2,h/2);c.beginPath();c.moveTo(x+r,y);c.arcTo(x+w,y,x+w,y+h,r);c.arcTo(x+w,y+h,x,y+h,r);c.arcTo(x,y+h,x,y,r);c.arcTo(x,y,x+w,y,r);c.closePath();}
function rrFill(x,y,w,h,r,col){rr(ctx,x,y,w,h,r);ctx.fillStyle=col;ctx.fill();}
```

State vars:
```js
let cell=32,W=0,H=0,dpr=1,bg=null;
let cells=[],prevCells=[],dir={x:1,y:0},queuedDirs=[];
let foods=[],smoke=[],tickets=[],rings=[],ghosts=[];
let driver='demo',running=true;
let score=0,best=0,tickMs=150,acc=0;
let crashAt=-1e9,lastBorn=-1e9,lastPuff=0;
```

fit():
```js
function fit(){
  dpr=Math.min(2,window.devicePixelRatio||1);
  const r=stage.getBoundingClientRect();
  const cs=getComputedStyle(boardWrap);
  const padX=parseFloat(cs.paddingLeft)||12, padY=parseFloat(cs.paddingTop)||12;
  const availW=Math.max(140,r.width-2*padX-4);
  const availH=Math.max(140,r.height-2*padY-4);
```
Hmm wait: stage contains boardWrap; boardWrap size determined by canvas size. If I measure stage and subtract boardWrap's padding — but boardWrap's current size influences stage? stage is flex centered; boardWrap is its content. stage.getBoundingClientRect gives the flex area (full width, full height of the flex slot) — yes since stage is width:100%, flex:1. availH = stage height minus boardWrap padding ×2 minus a couple px border/shadow. Then cell=floor(min(availW/COLS, availH/ROWS, 50)); clamp min 10. W=cell*COLS; H=cell*ROWS. Set canvas style & bitmap, then buildBg().

Wait, one catch: boardWrap box-shadow "0 4px 0" doesn't affect layout. border-radius no. OK.

```js
  cell=clamp(Math.floor(Math.min(availW/COLS,availH/ROWS,50)),9,50);
  W=cell*COLS; H=cell*ROWS;
  canvas.style.width=W+'px'; canvas.style.height=H+'px';
  canvas.width=Math.round(W*dpr); canvas.height=Math.round(H*dpr);
  ctx.setTransform(dpr,0,0,dpr,0,0);
  buildBg();
}
```

buildBg():
```js
function buildBg(){
  bg=document.createElement('canvas');
  bg.width=Math.round(W*dpr); bg.height=Math.round(H*dpr);
  const b=bg.getContext('2d');
  b.setTransform(dpr,0,0,dpr,0,0);
  b.fillStyle='#a7c982'; b.fillRect(0,0,W,H);
  b.fillStyle='rgba(255,255,255,.05)';
  for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++){ if((x+y)%2===0) b.fillRect(x*cell,y*cell,cell,cell); }
  const rnd=mulberry32(20240517);
  // grass tufts
  b.strokeStyle='rgba(96,136,64,.5)'; b.lineWidth=Math.max(1,cell*0.035); b.lineCap='round';
  for(let i=0;i<110;i++){
    const x=rnd()*W, y=rnd()*H, s=cell*0.10;
    b.beginPath();
    b.moveTo(x-s,y+s); b.lineTo(x-s*1.6,y-s);
    b.moveTo(x,y+s); b.lineTo(x,y-s*1.3);
    b.moveTo(x+s,y+s); b.lineTo(x+s*1.6,y-s);
    b.stroke();
  }
  // flowers
  const petals=['#ffffff','#f6c9d4','#f3d77a','#e8b7c8'];
  for(let i=0;i<30;i++){
    const x=cell*0.4+rnd()*(W-cell*0.8), y=cell*0.4+rnd()*(H-cell*0.8);
    const pc=petals[(rnd()*petals.length)|0], pr=Math.max(1.6,cell*0.055);
    b.fillStyle=pc;
    for(let k=0;k<5;k++){const a=k/5*TAU+rnd()*0.5;b.beginPath();b.arc(x+Math.cos(a)*pr*1.4,y+Math.sin(a)*pr*1.4,pr*0.95,0,TAU);b.fill();}
    b.fillStyle='#e2a63c'; b.beginPath(); b.arc(x,y,pr*0.8,0,TAU); b.fill();
  }
  // bushes
  for(let i=0;i<7;i++){
    const x=cell*(1+rnd()*(COLS-2)), y=cell*(1+rnd()*(ROWS-2));
    const g=['#7fae63','#74a55b','#8ab56e'][(rnd()*3)|0];
    b.fillStyle='rgba(70,105,50,.18)';
    b.beginPath(); b.ellipse(x,y+cell*0.16,cell*0.52,cell*0.30,0,0,TAU); b.fill();
    b.fillStyle=g;
    for(let k=0;k<4;k++){const a=rnd()*TAU;const rx=cell*(0.18+rnd()*0.16);b.beginPath();b.arc(x+Math.cos(a)*cell*0.16,y+Math.sin(a)*cell*0.10,rx,0,TAU);b.fill();}
    b.fillStyle='rgba(255,255,255,.14)';
    b.beginPath(); b.arc(x-cell*0.1,y-cell*0.08,cell*0.14,0,TAU); b.fill();
  }
}
```
Bushes with alpha overlaps create blobs — fine.

resetGame:
```js
function resetGame(mode){
  driver=mode; running=true;
  const cy=(ROWS/2)|0;
  cells=[]; for(let i=0;i<4;i++) cells.push({x:6-i,y:cy});
  prevCells=cells.map(c=>({x:c.x,y:c.y}));
  dir={x:1,y:0}; queuedDirs=[];
  foods=[];smoke=[];tickets=[];rings=[];ghosts=[];
  score=0; tickMs=150; acc=0; lastBorn=-1e9; crashAt=-1e9;
  for(let i=0;i<3;i++) spawnFood();
  updateStats();
  updateOverlayUI();
}
```
Note start x=6 with tail at 3 — food spawns ≥4 away from head. OK.

spawnFood — as sketched, using Math.random.

doTick:
```js
function doTick(){
  if(driver==='demo'){ const d=aiChoose(); if(d) dir=d; }
  else if(queuedDirs.length){ dir=queuedDirs.shift(); }
  const head=cells[0];
  const nx=(head.x+dir.x+COLS)%COLS, ny=(head.y+dir.y+ROWS)%ROWS;
  const nk=ny*COLS+nx;
  const fi=foods.findIndex(f=>f.x===nx&&f.y===ny);
  const grow=fi>=0;
  const limit=cells.length-(grow?0:1);
  for(let i=0;i<limit;i++){ if(cells[i].y*COLS+cells[i].x===nk){ crash(); return; } }
  const old=cells;
  cells=[{x:nx,y:ny}].concat(grow?old:old.slice(0,old.length-1));
  prevCells=old;
  if(grow){
    const f=foods.splice(fi,1)[0];
    score++;
    tickMs=Math.max(80,150-score*2.2);
    lastBorn=performance.now();
    ghosts.push({x:f.x,y:f.y,t:0});
    pickupFx(f);
    spawnFood();
    updateStats(true);
  }
}
```
Wait prevCells=old — old is the previous array; cells was replaced by concat creating new array — old remains intact ✓. prevCells[i] for i<old.length valid; for grown i=old.length → undefined → fallback. ✓

But careful: prevCells=old assigns the same array reference that... old === previous cells array; no mutation happens to it later (each tick creates new). ✓ Except: `prevCells` then next tick replaced by that tick's old. ✓

Hmm, one bug risk: `queuedDirs` — player queues {x,y} objects; dir=queuedDirs.shift() directly assigns; queueDir ensures not reverse relative to last queued/current — but between queueing and execution, the executed sequence could still reverse if queue holds [up] then user presses down quickly while current dir is right? queueDir checks against last queued (up) → down is reverse of up → rejected ✓. Good.

But also guard in doTick: applying d directly. If somehow reverse sneaks (e.g., queued while... can't), fine.

crash():
```js
function crash(){
  running=false;
  crashAt=performance.now();
  const hx=(cells[0].x+0.5)*cell, hy=(cells[0].y+0.5)*cell;
  for(let i=0;i<16;i++){
    smoke.push({x:hx+(Math.random()-0.5)*cell*0.6,y:hy+(Math.random()-0.5)*cell*0.6,
      r:cell*(0.10+Math.random()*0.10),life:0,maxLife:900+Math.random()*700,
      vx:(Math.random()-0.5)*30,vy:(Math.random()-0.5)*30});
  }
  crashSnd();
  if(driver==='player'&&score>best){best=score;}
  updateStats();
  updateOverlayUI();
  if(driver==='demo'){ setTimeout(()=>{ if(!running&&driver==='demo') resetGame('demo'); },1700); }
}
```
But crash() called mid-doTick; the while loop in frame checks !running → break. Also acc reset to 0 to avoid instant multi-ticks on restart: resetGame sets acc=0 ✓.

pickupFx:
```js
function pickupFx(f){
  const px=(f.x+0.5)*cell, py=(f.y+0.5)*cell;
  rings.push({x:px,y:py,t:0});
  tickets.push({x:px,y:py-cell*0.1,t:0});
  for(let i=0;i<6;i++) smoke.push({x:px+(Math.random()-0.5)*cell*0.5,y:py+(Math.random()-0.5)*cell*0.5,r:cell*0.09,life:0,maxLife:700+Math.random()*400,vx:(Math.random()-0.5)*20,vy:(Math.random()-0.5)*20});
  ding();
}
```

aiChoose (full, careful):
```js
function aiChoose(){
  const head=cells[0], L=cells.length, tail=cells[L-1], tailK=tail.y*COLS+tail.x;
  const isFoodCell=new Uint8Array(COLS*ROWS);
  for(const f of foods) isFoodCell[f.y*COLS+f.x]=1;
  const bodyAll=new Uint8Array(COLS*ROWS);
  for(let i=0;i<L;i++) bodyAll[cells[i].y*COLS+cells[i].x]=1;
  const cands=[];
  for(const d of DIRS){
    if(d.x===-dir.x&&d.y===-dir.y) continue;
    const nx=(head.x+d.x+COLS)%COLS, ny=(head.y+d.y+ROWS)%ROWS, nk=ny*COLS+nx;
    const eating=isFoodCell[nk]===1;
    if(bodyAll[nk] && !(nk===tailK && !eating)) continue;
    // board after the move
    const keep=eating?L:L-1;
    const blockedAfter=new Uint8Array(COLS*ROWS);
    for(let i=0;i<keep;i++) blockedAfter[cells[i].y*COLS+cells[i].x]=1;
    blockedAfter[nk]=1;
    const newTail=eating?cells[L-1]:cells[L-2];
    const dist=bfs(nx,ny,blockedAfter,newTail.y*COLS+newTail.x);
    let pathLen=9000;
    for(const f of foods){ const fd=dist[f.y*COLS+f.x]; if(fd>=0&&fd<pathLen) pathLen=fd; }
    let space=0; for(let i=0;i<dist.length;i++) if(dist[i]>=0) space++;
    const tailDist=dist[newTail.y*COLS+newTail.x];
    const safe=tailDist>=0?0:1;
    cands.push({d,eating,pathLen,safe,space});
  }
  if(!cands.length) return dir;
  cands.sort((a,b)=>(b.eating-a.eating)||(a.safe-b.safe)||(a.pathLen-b.pathLen)||(b.space-a.space));
  return cands[0].d;
}

function bfs(sx,sy,blocked,freeK){
  const N=COLS*ROWS;
  const dist=new Int16Array(N).fill(-1);
  const q=new Int16Array(N);
  let qh=0,qt=0;
  const s=sy*COLS+sx;
  dist[s]=0; q[qt++]=s;
  while(qh<qt){
    const k=q[qh++];
    const x=k%COLS, y=(k/COLS)|0, nd=dist[k]+1;
    for(let i=0;i<4;i++){
      const nx2=(x+DIRS[i].x+COLS)%COLS, ny2=(y+DIRS[i].y+ROWS)%ROWS, nk2=ny2*COLS+nx2;
      if(dist[nk2]!==-1) continue;
      if(blocked[nk2]&&nk2!==freeK) continue;
      dist[nk2]=nd; q[qt++]=nk2;
    }
  }
  return dist;
}
```
Wait: freeK = newTail cell — treated passable so we can measure "reach the tail". ✓ Also dist to newTail counts a path ending there. Note Int16Array.fill(-1) ✓.

Hmm, subtle: `bfs` start cell s: dist[s]=0 even if blocked — candidate is free by construction ✓.

One more: with `eating` candidate, newTail = cells[L-1] (old tail stays). blockedAfter includes it (keep=L covers 0..L-1) but freeK frees it for reachability. Consistent.

Now — potential issue: when snake length is huge, blockedAfter nearly full; BFS from nk may reach only tail — safe=0 → preferred. Good.

Player takeover edge: startPlayer during running demo → resetGame('player') — everything resets ✓.

updateOverlayUI:
```js
function updateOverlayUI(){
  if(!running){
    if(driver==='player'){
      ovTitle.textContent='Derailed!';
      ovBig.textContent='Click or press any key to try again';
      ovSub.textContent=`${score} passenger${score===1?'':'s'} picked up on this run · best ${best}`;
    } else {
      ovTitle.textContent='Derailed!';
      ovBig.textContent='Re-railing the train…';
      ovSub.textContent='Click or press any key to take the wheel';
    }
    overlay.style.display='flex';
  } else if(driver==='demo'){
    ovTitle.textContent='Demo route · conductor-bot is driving';
    ovBig.textContent='Click or press any key to start';
    ovSub.textContent='Watch the little train, then hop into the driver\'s cab';
    overlay.style.display='flex';
  } else {
    overlay.style.display='none';
  }
  modeChip.textContent=driver==='demo'?'Demo · conductor-bot driving':'You\'re driving';
  modeChip.classList.toggle('player',driver==='player');
}
```

updateStats:
```js
function updateStats(pop){
  statScore.textContent=score;
  statCars.textContent=cells.length;
  statBest.textContent=best;
  if(pop){ chipScore.classList.remove('pop'); void chipScore.offsetWidth; chipScore.classList.add('pop'); }
}
```
Wait `.chip.pop .val{animation}` — class on chip, animation on child. Removing/adding re-triggers ✓.

Input:
```js
const KEYMAP={ArrowUp:{x:0,y:-1},ArrowDown:{x:0,y:1},ArrowLeft:{x:-1,y:0},ArrowRight:{x:1,y:0},w:{x:0,y:-1},s:{x:0,y:1},a:{x:-1,y:0},d:{x:1,y:0}};

window.addEventListener('keydown',e=>{
  if(e.metaKey||e.ctrlKey||e.altKey) return;
  const k=e.key.length===1?e.key.toLowerCase():e.key;
  if(k in KEYMAP||k===' ') e.preventDefault();
  ensureAudio();
  if(!running||driver!=='player'){ const d=KEYMAP[k]; startPlayer(d); return; }
  const d=KEYMAP[k];
  if(d) queueDir(d);
});
```
Careful: `k in KEYMAP` — 'ArrowUp' length>1 keeps as is ✓; ' ' not in map but we preventDefault (space) — space would start the game ✓ (d undefined → startPlayer(undefined) fine).

Modifiers: if user presses Ctrl+R (reload) we return early — good.

But note: pressing 'w' as first input: startPlayer({up}) — fresh snake faces right; queueDir pushes up if valid: queueDir checks reverse vs dir (right) — up ok ✓. In startPlayer I'll call queueDir after reset:

```js
function startPlayer(d){
  resetGame('player');
  if(d) queueDir(d);
  toot();
}
```

Pointer:
```js
let swipe=null;
boardWrap.addEventListener('pointerdown',e=>{
  ensureAudio();
  if(!running||driver!=='player'){ startPlayer(); swipe=null; return; }
  swipe={x:e.clientX,y:e.clientY};
});
window.addEventListener('pointermove',e=>{
  if(!swipe||!running||driver!=='player') return;
  const dx=e.clientX-swipe.x, dy=e.clientY-swipe.y;
  if(Math.abs(dx)<24&&Math.abs(dy)<24) return;
  const d=Math.abs(dx)>Math.abs(dy)?{x:dx>0?1:-1,y:0}:{x:0,y:dy>0?1:-1};
  queueDir(d);
  swipe={x:e.clientX,y:e.clientY};
});
window.addEventListener('pointerup',()=>{swipe=null;});
window.addEventListener('pointercancel',()=>{swipe=null;});
```

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

Render — assemble as planned. Let me also handle `drawFoods` calling drawSlab & drawPassenger.

drawSlab(px,py):
```js
function drawSlab(px,py){
  ctx.save(); ctx.translate(px,py);
  const w=cell*0.94,h=cell*0.56;
  rr(ctx,-w/2,-h*0.10,w,h,cell*0.10);
  ctx.fillStyle='#cfc6b4'; ctx.fill();
  ctx.strokeStyle='rgba(120,105,80,.55)'; ctx.lineWidth=Math.max(1,cell*0.03); ctx.stroke();
  ctx.fillStyle='#e5b93c';
  ctx.fillRect(-w/2+cell*0.06,h*0.52,w-cell*0.12,cell*0.055);
  ctx.restore();
}
```
Slab positioned mostly below center so passenger stands on it: slab center y offset -h*0.10 → occupies y from -0.10h-h/2 to -0.10h+h/2 = roughly -0.38cell..+0.18cell. Passenger feet at y≈+0.1cell? Let me set passenger ground at slab top ~ y=+0.02*cell... I'll define passenger standing with feet at y=0.12*cell (on slab), body above (negative y). Slab spans y ∈ [-0.38c, 0.18c] roughly — feet at 0.12c on slab ✓. Head top ≈ 0.12c - 0.75c = -0.63c — pokes above cell top by 0.13c only if feet at 0.12: head circle center at 0.12c-0.5c? Let me lay out passenger (all in cell units, y negative up):
- feet y=0.10
- legs: from 0.10 up to -0.02 (height 0.12)
- body: from -0.02 up to -0.34 (height 0.32)
- head center at -0.45, r=0.115 → top at -0.565
- hat above: -0.60
So top at -0.6c: within cell (cell top is -0.5c relative to center!). -0.6 < -0.5 → pokes 0.1c above. Fine (diorama).

Wait recompute: center of cell is (0,0). Cell spans -0.5..+0.5. Head top at -0.565 → 0.065c above cell. OK small overhang, good.

Sign post: at left side: x=-0.40c, from y=0.05 up to -0.35; sign board at (-0.5c..-0.28c, -0.5c..-0.32c). Pokes left within cell mostly. OK.

drawPassenger(f, now):
```js
function drawPassenger(f,now){
  const bob=Math.sin(now*0.005+f.phase)*cell*0.015;
  ctx.save();
  ctx.translate(cell*0.06,bob);
  // shadow
  ctx.fillStyle='rgba(70,50,25,.18)';
  ctx.beginPath(); ctx.ellipse(0,cell*0.12,cell*0.15,cell*0.045,0,0,TAU); ctx.fill();
  // suitcase
  if(f.case){
    rrFill(cell*0.20,-cell*0.02,cell*0.20,cell*0.14,cell*0.03,'#8a5a33');
    ctx.strokeStyle='#6d4526'; ctx.lineWidth=Math.max(1,cell*0.02);
    ctx.beginPath(); ctx.arc(cell*0.30,-cell*0.02,cell*0.045,Math.PI,0,false); ctx.stroke();
  }
  // legs
  ctx.fillStyle='#4a3222';
  ctx.fillRect(-cell*0.075,-cell*0.02,cell*0.055,cell*0.12);
  ctx.fillRect(cell*0.02,-cell*0.02,cell*0.055,cell*0.12);
  // static arm (left)
  ctx.fillStyle=f.shirt;
  rr(ctx,-cell*0.20,-cell*0.30,cell*0.07,cell*0.20,cell*0.03); ctx.fill();
  // body
  rr(ctx,-cell*0.14,-cell*0.34,cell*0.28,cell*0.34,cell*0.08);
  ctx.fillStyle=f.shirt; ctx.fill();
  ctx.strokeStyle=shade(f.shirt,-40); ctx.lineWidth=Math.max(1,cell*0.02); ctx.stroke();
  // waving arm (right)
  const wv=Math.sin(now*0.011+f.phase);
  ctx.save();
  ctx.translate(cell*0.13,-cell*0.28);
  ctx.rotate(-2.2+wv*0.45); // arm pivots: rotate around shoulder; arm extends +y after rotate? 
  rr(ctx,-cell*0.033,0,cell*0.066,cell*0.22,cell*0.03); ctx.fillStyle=f.shirt; ctx.fill();
  ctx.fillStyle=f.skin; ctx.beginPath(); ctx.arc(0,cell*0.23,cell*0.045,0,TAU); ctx.fill();
  ctx.restore();
```
Hmm the waving arm rotation: pivot at shoulder; arm drawn extending downward (+y local); rotating around pivot by θ where θ=0 → straight down; θ=-2.2 rad → swings up-left? rotate(-2.2) maps +y direction to... +y rotated by -2.2: (x,y)→(x cosθ - y sinθ, x sinθ + y cosθ). Point (0, L): (−L sinθ, L cosθ) with θ=-2.2: sin(-2.2)≈-0.808 → (0.808L, cos(-2.2)=−0.588 → L*(-0.588)) = (0.81L, -0.59L) → arm points up-right. Waving ±0.45 rad oscillation. So arm raised up-right ✓ nice wave.
```js
  // head
  ctx.fillStyle=f.skin; ctx.beginPath(); ctx.arc(0,-cell*0.45,cell*0.115,0,TAU); ctx.fill();
  // hair
  ctx.fillStyle=f.hair;
  ctx.beginPath(); ctx.arc(0,-cell*0.47,cell*0.115,Math.PI*1.05,Math.PI*1.95); 
```
Arc from ~189° to ~351°? Angles: canvas arc 0=right, PI=left. Top half is from PI to 2PI (going through 3PI/2 = up? canvas y down, so up = -y = angle 3PI/2? cos(3π/2)=0, sin(3π/2)=-1 → point (0,-r) top ✓. So arc(0,0,r,PI,TAU) traces top half. With ctx.fill() it closes chord → fills top cap ✓. Slight raise: arc(0,-0.47c+0.01c?,...). Keep: ctx.arc(0,-cell*0.46,cell*0.118,Math.PI,TAU); fill.
```js
  if(f.hat){
    ctx.fillStyle='#5a4632';
    rrFill(-cell*0.15,-cell*0.60,cell*0.30,cell*0.08,cell*0.035);
    ctx.fillRect(-cell*0.17,-cell*0.545,cell*0.34,cell*0.028);
  }
  // eyes
  ctx.fillStyle='#33241a';
  ctx.beginPath(); ctx.arc(-cell*0.038,-cell*0.46,cell*0.013,0,TAU); ctx.fill();
  ctx.beginPath(); ctx.arc(cell*0.038,-cell*0.46,cell*0.013,0,TAU); ctx.fill();
  ctx.restore();
}
```
Hat only when f.hat && !hair? Hat covers hair anyway. If hat, skip hair? Hair arc under hat partially visible — fine, draw hair only if !f.hat.

drawFood full:
```js
function drawFoods(now){
  for(const f of foods){
    const px=(f.x+0.5)*cell, py=(f.y+0.5)*cell;
    const age=now-f.born;
    const s=age<380?Math.max(0.05,easeOutBack(age/380)):1;
    ctx.save();
    ctx.translate(px,py);
    ctx.scale(s,s);
    drawSlabAt(); // draws at origin — refactor drawSlab to draw at current origin
    drawSign();
    drawPassenger(f,now);
    ctx.restore();
  }
}
```
Refactor: drawSlab() and drawSign() and drawPassenger() all draw relative to current origin (ctx translated). Ghosts: ctx.save/translate/scale alpha; drawSlab only; restore.

For very small cells (<14), eyes/face skip to avoid mush: if(cell>=20) draw face details. Add guards where cheap.

Now sign:
```js
function drawSign(){
  ctx.fillStyle='#7a5a3a';
  ctx.fillRect(-cell*0.42,-cell*0.40,cell*0.045,cell*0.42);
  rrFill(-cell*0.50,-cell*0.545,cell*0.22,cell*0.17,cell*0.04,'#33547a');
  ctx.fillStyle='#f4ead2';
  ctx.fillRect(-cell*0.435,-cell*0.485,cell*0.10,cell*0.032);
  ctx.fillStyle='#e2b34b';
  ctx.fillRect(-cell*0.435,-cell*0.425,cell*0.10,cell*0.032);
}
```
Post from y -0.40 up... fillRect(x, y, w, h): y=-0.40, h=0.42 → spans -0.40..+0.02 (ground). Sign board above at -0.545..-0.375. Board pokes above cell top (-0.545 < -0.5) — slight overhang ok.

Hmm, sign at left: x from -0.50 to -0.28 — inside cell ✓.

render() final assembly — also don't forget ctx.textAlign resets: set each use.

Also fps: fine.

Let me also handle very first frames: foods born at reset → pop-in animation plays at load ✓ nice for screenshot.

Frame loop:
```js
let lastT=performance.now();
function frame(now){
  const dt=Math.min(60,now-lastT); lastT=now;
  if(running){
    acc+=dt;
    let guard=0;
    while(acc>=tickMs&&guard<5){
      doTick(); guard++;
      if(!running){acc=0;break;}
      acc-=tickMs;
    }
  }
  render(now,dt);
  requestAnimationFrame(frame);
}
```
Wait order: doTick may crash (running=false) → break with acc=0.

Init:
```js
function init(){
  fit();
  resetGame('demo');
  requestAnimationFrame(t=>{lastT=t;requestAnimationFrame(frame);});
}
window.addEventListener('resize',fit);
init();
```
Note fit() before resetGame — food pixel positions derive from cell at draw time, fine regardless.

Hmm: fit() reads stage rect — at script run (end of body), layout is done ✓.

One more consideration: the demo AI takes over at each tick including the very first — fine.

Also during demo, smoke emission requires running ✓.

Check: `render` uses `cells`, fine after reset.

Now audio functions defined; ensureAudio on keydown/pointerdown. Note: keydown fires before user gesture requirement satisfied? Keydown IS a user gesture — AudioContext creation allowed ✓.

Let me also double check that no `console.error` from AudioContext creation inside sandboxed iframe: allowed generally. Guarded try/catch anyway.

Canvas font strings with quotes inside template literals — fine.

Edge: `ctx.ellipse` supported ✓.

Potential issue — `Int16Array(N).fill(-1)` ✓.

`new Uint8Array(COLS*ROWS)` per candidate per tick: 4 allocations of 294 bytes ×2 + Int16 dist 294×2B — negligible.

Also ensure aiChoose never returns reverse (it skips reverse) and never null — returns dir fallback.

Also when player just started and immediately crashed? whatever.

Now — is the train visibly a "train"? Head loco with chimney & smoke; colorful wagons on a track with rails/ties; couplings; chug motion. Yes, reads clearly.

One more flourish, cheap: draw a subtle drop shadow under the whole board (CSS box-shadow does it).

Let me also add the station name / "route sign" inside the canvas? Skip.

Title: "SNAKE EXPRESS" — hmm maybe "THE SNAKE EXPRESS". Keep "SNAKE <span>EXPRESS</span>" with accent color on EXPRESS. Tagline: "a little train with a taste for picking up passengers". 

Footer: `<kbd>↑</kbd><kbd>←</kbd><kbd>↓</kbd><kbd>→</kbd> or <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> to steer · swipe on touch` + `<span class="long">· board every waiting passenger · the edges are tunnels — but mind your own wagons!</span>`.

Wait, careful about wording: edges are tunnels (no wall death) — the only death: hitting own wagons. Communicate: "the meadow edges are tunnels — you can roll right through". Good to say since it's a twist on classic snake.

Let me reconsider game difficulty/length: growth per passenger; a long snake on a 21×14 board with wrap — playable.

Double-check drawTrack runs splitting with wrapped points: pts computed with adjusted prev so crossing points are CONTINUOUS? No wait — pts[i] are the interpolated positions of each car. When car i crosses the boundary, its rendered position slides off-canvas (e.g., x from 0.95W to W+0.5cell) — I computed interpPoint with adjusted prev making motion continuous in unwrapped space but the point itself: p = lerp(prevAdj, curr, t) where prevAdj might be -1 (cell coords) and curr 0 → p in [-1,0] — wrapped side! I DON'T re-wrap the rendered point. Let me re-examine interpPoint: ax adjusted to -1 (for left wrap), b.x=0, so p.x spans -1..0 cell coords → pixel -cell..0 → off-canvas LEFT. But the car physically should slide INTO the board from the left (its head pokes in). Hmm wait — which is right?

Scenario: head moving right, wraps from x=COLS-1 to x=0. prevAdj: a.x=COLS-1 → dx=b.x-a.x=0-(COLS-1)=-(COLS-1)<-1 → a.x-=COLS → a.x=-1. So interpolation from -1 → 0: the head slides from off-left into the board. ✓ correct — because it wrapped, it ENTERS from the left. And on the previous tick it slid from COLS-2 → COLS-1, i.e., approaching the right edge, reaching exactly the right edge at t=1 (x pixel W - ... at cell COLS-1 center = W-cell/2). Then next tick it jumps to off-left. So visually the loco touches the right edge then appears at the left edge sliding in — with the tunnel arches at both spots, reads as tunnel. ✓.

But wait — at the moment of wrap, the loco's position jumps from right-edge-center to LEFT off-canvas. Its nose was at ~W-cell/2+0.46cell ≈ W-0.04cell (still on screen) then suddenly it's at -cell..0 — the arch (drawn at the right edge with alpha) fades as the train leaves (no car near right edge anymore once all crossed) — the arch vanishes possibly while the loco is only just crossing. Alpha based on car proximity m=0.8cell: while the loco center is at W-0.5cell..W (approaching), alpha ramps to 1. When it wraps, the loco's exit-side presence is gone, but subsequent cars (wagon 1, 2...) are still approaching the right edge — they keep the arch alive until the tail crosses. ✓ So arch persists through the whole crossing since cars stream behind. 

And the track polyline: pts of crossing cars include off-canvas coords (-cell/2 etc.) and the neighbor points inside → the track visibly runs off the edge ✓. Runs split only if gap > 1.6cell between consecutive car points — during crossing, consecutive cars are adjacent (one at -1..0, previous at COLS-1) → their pixel distance is huge (W-cell) → run split ✓ the track breaks at the boundary — meaning the right-edge track piece ends at the right edge, and a new piece starts off-left ✓ exactly right.

Tunnels: entering side arch appears when head approaches LEFT? The head enters at left edge: its pts.x in [-cell..0] — p.x < m → candidate left edge, alpha 1-(p.x/m): p.x negative → alpha>1 → clamp 1 ✓. And direction: moving right → dot with left normal (-1,0) = -1 → abs>0.45 ✓ (my condition uses abs). And when it's fully inside (p.x>0.8cell) no arch ✓.

Exit side: cars approaching right edge moving right: candidate right edge normal (1,0): dot=+1 → abs ok ✓.

Parallel case excluded ✓.

Now, one more thing about drawTunnels — it uses `angs` per point; pass array. ✓

Smoke at wrap: chimney world pos may be off-canvas → puffs drawn off-canvas, harmless.

Ticket FX at wrap: pickup happens on the wrapped cell — ticket at cell coords (wrapped) → always on-screen ✓ (food cells always in-bounds).

I think the design is solid. Let me also consider: doTick for demo calls aiChoose which uses `dir` — set before movement ✓.

Now — memory: smoke array caps? Crash bursts 16; emission every ~80ms while running with life ~1s → ~15 alive; fine. Cap anyway at 120 (skip if > 120).

Let me write drawCar fully with all details, being careful with coordinates. Also add small "headlight beam"? skip.

One more check — `render` clears with setTransform(dpr...) then translate shake: but I draw bg via drawImage(bg,0,0,W,H): bg bitmap W*dpr px → drawn into W×H CSS units under dpr transform → pixel-perfect ✓.

Shake translate BEFORE drawing bg so everything shakes uniformly ✓. Edges show cream gap during shake? Canvas cleared → transparent → canvas CSS background is green (#a7c982) so gaps show plain green — acceptable.

Now — the scoreboard "Best" only meaningful for player runs ✓.

Also, I realize I should double check `updateOverlayUI` when demo auto-restarts — resetGame calls it → demo card shows again ✓ (display flex with fresh animation since it was flex the whole time — animation won't replay because display never toggled to none. Minor: card animation only plays on load in demo. OK.)

Hmm wait — during demo crash the overlay stays 'flex' with different text (Re-railing…) then back to demo card. Card content swaps, no animation restart. Fine.

Actually let me reconsider: maybe make the demo-start card auto-hide after some seconds and reappear on mouse move? Unnecessary complexity — instructions want the message visible for judging; keep it persistent but compact at bottom.

Also should the demo overlay include a smaller hint so the train is clearly visible? Card at bottom center; height ~86px. Board 658px. Train runs mid-board mostly. Good.

Now let me also make sure: "Games must start in a self-playing demo mode (a simple AI plays) behind a 'Click or press a key to start' message" ✓.

Write everything now. Also final check of HTML structure ids matching JS.

Additional small features I might include if cheap:
- Pause on blur? Skip.
- `document.title` static.

One risk: on some sandboxes, `pointerdown` on boardWrap — clicking the overlay card area (pointer-events:none) passes to boardWrap ✓.

Also text selection prevention: user-select none on body ✓ (also prevents accidental text drag).

Another risk: iframe keyboard — fine.

Let me write the HTML now, complete.

Also I realize the header stat chips: add icons? No icons without a library — fine with text labels only; my taste says no emoji. Labels: "PASSENGERS", "CARS", "BEST". 

Wait, the instructions say icon library for small icons if icons are needed — I don't need DOM icons; canvas art carries it. Inline SVG logo is hand-made. Good.

Let me now write the final file carefully, then re-scan for bugs.

Regarding CSS height chain: body display:flex column; children header (auto), #stage (flex:1 min-height:0), footer (auto). ✓

One more: `#stage` uses `min-height:0` ✓ critical for flex overflow.

Small print — the `.foot` might wrap on narrow screens into 2 lines; fine.

Let me make sure no horizontal overflow: `body{overflow:hidden}` handles stray cases anyway.

Edge case in fit(): stage rect might be huge width on desktop; cell capped at 50 → board 1050+padding ~1078 < 1280 ✓ centered.

On 1280×800: cell=50? availH = stageH - 2*pad. stageH = 800 - header - footer - paddings. Header: padding 10+6 + content height. Brand: logo 46px tall vs title stack ~34px → content ~46 → header ≈ 62. Footer ≈ 34. stage padding: 2 top + 10 bottom = 12. stageH ≈ 800-62-34 = 704 - 12 = 692 inner. availH = 692 - 2*14 = 664 → cell = floor(664/14)=47. Board 987×658 ✓.

Also add slight max-width on body content? Stage is centered flex ✓.

Now writing the code for real. I'll aim for clean, organized, commented code.

Also — textBaseline/Align set inside functions each time used (ticket + wagon numbers) ✓.

drawCar tanker dome colors: dome cream '#f4ead2' stroke shade(color,-40).

Also loco: add subtle boiler highlight: thin lighter rounded stripe along center? add: rrFill(-L*0.42,-Wd*0.10,L*0.80,Wd*0.20,cell*0.06,'rgba(255,255,255,0.14)') over boiler area but under cab? Order: body → highlight → bands → cab → chimney. OK.

Also front "smokebox" darker front cap: rrFill(L*0.34,-Wd/2,L*0.16,Wd,cell*0.10,'#8f2f28')? L/2=0.46L; front cap from 0.34L to 0.5L. Then cowcatcher beyond. OK.

Order in loco draw:
1. wheels (already)
2. cowcatcher
3. body (red)
4. highlight stripe
5. smokebox (dark front band 0.30L..0.46L)
6. gold band rings at 0.16L & maybe skip second
7. cab (wide dark red) + roof + vent
8. chimney + rim
9. lamp

Coordinates sanity: L=0.92cell, Wd=0.58cell. Cab occupies x∈[-0.46L, -0.06L]. Boiler x∈[-0.5L, 0.46L] wait body spans full -L/2..L/2 = -0.46L..0.46L. Cab drawn OVER rear part: from -0.50L (slightly beyond) to -0.10L. Roof from -0.45L to -0.17L. Chimney at +0.26L — between cab and smokebox ✓.

Gold band at x=0.16L vertical strip; smokebox 0.30L..0.46L dark. So visible boiler (red) from -0.10L..0.30L with a gold band at 0.16L ✓.

Chimney at 0.26L — that's INSIDE the smokebox region (0.30L)? 0.26 < 0.30 ✓ just before it. OK, chimney on boiler front area — fine visually.

Hmm — chimney should be near the front: 0.26L of 0.92cell ≈ 0.24cell from center toward front; front is at 0.46cell. Fine.

For smoke emission I used ox=cell*0.24 — matches chimney local x = L*0.26 ≈ cell*0.239 ✓.

Now the wagons: gold? add small coupling hooks at ends: two tiny dark rects at ±L/2 — the coupling line drawn separately covers this. OK.

Number on wagons: only if cell>=26 & box type.

Wagon kind: `const kind = (i%5===0)?'tanker':'box'` → wagons 5,10,15 are tankers. ✓

Also alternate: make the FIRST wagon always box. i=1 → box ✓.

OK. Another detail: couplings drawn for ALL adjacent pairs including head-wagon1 ✓.

Also I want slight shadow under cars: ellipse before rotate — in drawCar after ctx.translate(p.x,p.y) BEFORE rotate:
```js
ctx.fillStyle='rgba(60,42,20,.18)';
ctx.beginPath(); ctx.ellipse(0,cell*0.10,L*0.55,Wd*0.6,0,0,TAU); ctx.fill();
ctx.rotate(ang);
```
Wait ellipse axes should align with car? A world-space shadow slightly offset down-right: ellipse rx along x — but car rotated; shadow as rounded blob is fine unrotated. OK.

But careful: this shadow drawn per car in drawCar — after previous cars drawn (drawing order tail→head) → shadows overlap cars behind? Shadow of car i drawn over car i+1 (already drawn, behind spatially)? Shadows are subtle; but a shadow ON TOP of the car behind looks wrong-ish. Cars are end-to-end, shadow extends ±L*0.55 along its own axis... unrotated ellipse (L*0.55, Wd*0.6) around each car center — could overlap the neighboring car by 0.05L — negligible overlap, alpha low. Fine.

Hmm wait, actually there's a subtle z-order thing: I draw tail first, head last → the loco overlaps wagons at corners ✓ desirable.

Now the "ghost" slab alpha drawing: uses drawSlab at origin — refactor drawSlab to take no coords (draws at origin): ghosts: save→translate→globalAlpha→drawSlab→restore.

Also rings/tickets globalAlpha resets ✓.

Let me also confirm the ticket spawn position: pickup at head's new cell — ticket floats up from the passenger position ✓.

Alright — potential problem: `requestAnimationFrame(frame)` — first call via init: I set lastT inside a RAF to sync. Fine simpler: lastT=performance.now(); requestAnimationFrame(frame).

Also `fit()` on resize rebuilds bg — decorations re-seeded with same seed → same layout ✓ (deterministic across resizes — positions in px though: rnd()*W changes with W → different layout. Meh, fine.)

Actually one improvement: store decoration positions in cell units once, then draw scaled — then resize keeps same layout. Let me do: pregenerate decos array once in cell units at init: {type,x,y,...} with x,y in [0,COLS]/[0,ROWS]. buildBg maps to px. Nice.

```js
const DECOS=(()=>{const rnd=mulberry32(987654); const arr=[];
  for(let i=0;i<110;i++) arr.push({t:'grass',x:rnd()*COLS,y:rnd()*ROWS,r:rnd()});
  for(let i=0;i<30;i++) arr.push({t:'flower',x:0.35+rnd()*(COLS-0.7),y:0.35+rnd()*(ROWS-0.7),c:(rnd()*4)|0,r:rnd()});
  for(let i=0;i<7;i++) arr.push({t:'bush',x:1+rnd()*(COLS-2),y:0.8+rnd()*(ROWS-1.6),c:(rnd()*3)|0,r:rnd()});
  return arr;})();
```
buildBg iterates DECOS with cell scaling ✓.

Grass tuft drawing uses x*cell etc.

OK also — decor under platforms/tracks: fine.

Time to write final code. I'll write it in one pass, being careful.

Also let me pre-verify the arc for hair: ctx.arc(0,-cell*0.46,cell*0.118,Math.PI,TAU) then fill → top half disc ✓.

Eyes at y=-cell*0.46 — same as hair center; eyes slightly lower: -0.44. Let me place: head center (0,-0.45c) r 0.115c. Eyes at (±0.04c, -0.435c). Hair arc center (0,-0.47c). ok.

Also add rosy cheeks? skip.

drawPassenger: I set translate(cell*0.06, bob) — passenger slightly right of center to make room for sign on the left ✓. Suitcase to the right at 0.20c..0.40c — hmm passenger at 0.06 center, body spans ±0.14 → -0.08..0.20; suitcase at 0.20..0.40 ✓ fits (cell half=0.5).

Now, also handle: `f.born` — pop-in scale; also maybe food shouldn't be immediately adjacent to track? fine.

Also — spawnFood avoiding cells right next to the head's current direction? distance≥4 handles.

Let me also think about tickMs acceleration: 150 - score*2.2, min 80. At score 30 → 84ms. ok.

Also display speed somewhere? The "CARS" chip implies growth. fine.

Final check on demo AI tick order: doTick demo branch calls aiChoose() BEFORE movement — aiChoose uses current cells/dir ✓.

Also when player takes over with a direction key: startPlayer(d) → resetGame → queueDir(d) — queuedDirs applied at first tick ✓.

Also `ensureAudio()` inside keydown before startPlayer → toot audible ✓.

What about `ding()` during demo (no audio ctx) → tone() no-op ✓.

Now write it all.

Wait — one more requirement check: "no horizontal scrollbar" at 375px: body overflow hidden ✓ plus content fits.

"adapt gracefully down to 375px" ✓ chips wrap, canvas shrinks.

Let me also set `<meta name="viewport" content="width=device-width, initial-scale=1">` ✓.

Now, final code. Let me also add a tiny CSS for `@media (max-width:520px)`: hide tagline? Keep tagline (it's flavor) — it might wrap; allow. Hide `.long` foot hints. Reduce chip min-width. Reduce logo size via CSS width attr override? SVG has width/height attrs; CSS `.logo{width:64px;height:auto}` on small: media query.

I'll write .logo{width:clamp(58px,7vw,92px);height:auto}.

SVG needs viewBox for height:auto scaling: set viewBox and CSS width; keep attrs width/height for fallback. I'll just set viewBox + CSS.

Let me write the document now — and carefully proofread the JS for typos, since it must run error-free.

One more decision: ROWS=14, COLS=21. On mobile portrait the board is wide-ish (21:14) — at 375 width board is 315×210 — small but fine. Could adapt grid to portrait (e.g., 15×20) but game state depends on fixed grid; adapting means different gameplay per orientation — acceptable? The AI and wrap logic handle any COLS/ROWS. I could choose grid at init based on aspect: if stage aspect < 1 → COLS=15, ROWS=20. But then resize mid-game with different orientation breaks coordinates... lock grid at load only. Hmm, but the screenshot is 1280×800 → landscape 21×14. For 375 wide portrait, 15×20 would give a taller board (cell = min(375-…)/15≈22, board 330×440) — much better use of space! Grid decided at init based on initial aspect; on resize just rescale cell. Rotation mid-session rare; ignore. I'll implement: at init, if stage aspect (w/h) < 0.9 → COLS=15, ROWS=20; else 21×14. But COLS/ROWS are consts used in DECOS generation (in cell units, range-based) — DECOS uses COLS/ROWS at module init... make DECOS lazy: generate after grid chosen. Also WAGON etc unaffected.

Actually — careful: DECOS positions in cell units; generate once after grid selection. Let me restructure: 

```js
let COLS=21, ROWS=14;
function chooseGrid(){
  const r=stage.getBoundingClientRect();
  const portrait=(r.width/Math.max(1,r.height))<0.95;
  COLS=portrait?14:21; ROWS=portrait?20:14;
}
```
Portrait grid 14×20: cell = min((375-28-20)/14≈23, (stageH-28)/20). stageH on 667 tall phone: 667 - header(~110 wrapped) - foot(~60) ≈ 500 → (500-28)/20 ≈ 23 → cell 23 → board 322×460 ✓ nice.

Then DECOS built after chooseGrid. And BFS arrays sized COLS*ROWS at call time ✓ (they're allocated per call — good). KEYMAP unaffected.

But careful: resetGame uses (ROWS/2)|0 etc — dynamic ✓. spawnFood uses COLS/ROWS ✓. All good. Only DECOS must be generated post-grid → wrap in function buildDecos() called from init after chooseGrid.

Also on window resize we do NOT re-choose grid (just fit). Fine.

Hmm — desktop window narrow (e.g., judge resizes to 375 wide on a tall window): initial load is 1280×800 → landscape grid. If they then narrow the window, board shrinks (cell floor min) — cell = min((355-28)/21=15.5, …) → 15px cells. Playable. OK.

Now, DECOS:
```js
let DECOS=[];
function buildDecos(){
  const rnd=mulberry32(987654);
  DECOS=[];
  for(let i=0;i<Math.round(COLS*ROWS*0.38);i++) DECOS.push({t:0,x:rnd()*COLS,y:rnd()*ROWS,r:rnd()});
  for(let i=0;i<Math.round(COLS*ROWS*0.11);i++) DECOS.push({t:1,x:0.3+rnd()*(COLS-0.6),y:0.3+rnd()*(ROWS-0.6),c:(rnd()*4)|0,r:rnd()});
  for(let i=0;i<Math.max(5,Math.round(COLS*ROWS*0.026));i++) DECOS.push({t:2,x:1+rnd()*(COLS-2),y:0.8+rnd()*(ROWS-1.6),c:(rnd()*3)|0,r:rnd()});
}
```

buildBg uses DECOS.

OK, writing final answer now. Length will be substantial (~700-800 lines). Let me write carefully.

Proofread points as I write:
- All getElementById ids exist in HTML.
- No stray references (e.g., `dist` helper defined).
- `ang` param passed to drawTunnels.

Also note: in render I need `angs` array; drawTunnels(pts, angs).

Let me also define dist(a,b)=Math.hypot.

Write out drawTrack with helper pathRun.

Also — canvas rounded corners: canvas has border-radius 12px; with shake translate the content shifts — corners reveal? Fine.

Final structure of script:

1. consts & DOM refs
2. utils
3. state vars
4. grid choose + decos
5. fit/buildBg
6. audio
7. game logic (reset, spawnFood, queueDir, doTick, crash, pickupFx, aiChoose, bfs)
8. UI (updateStats, updateOverlayUI, startPlayer)
9. input handlers
10. drawing helpers (rr, rrFill, shade, drawSlab, drawSign, drawPassenger, drawFoods, drawCar, drawTrack, drawTunnels, smoke/fx draw)
11. render, frame loop, init

Let me be careful about one thing in drawCar: strokeRr helper:
```js
function rrStroke(x,y,w,h,r,col,lw){rr(ctx,x,y,w,h,r);ctx.strokeStyle=col;ctx.lineWidth=lw;ctx.stroke();}
```

Also in drawCar for box wagons, side sills: skip.

Ready. Also double-check the arcTo-based rr for tiny radii — Math.min guard ✓, and r could be negative if w negative — widths always positive.

One thing: `rr` with w or h < 2r handled by min ✓.

Also in drawSlab I use w=cell*0.94, h=cell*0.56, y=-h*0.10.

Let me also reconsider: platform slab drawn at origin; the sign drawn separately (drawSign) — ghosts draw only slab.

Now — putting the passenger feet at y=0.10cell: legs from -0.02.. wait legs fillRect(-0.075c, -0.02c, ...) height 0.12c → spans -0.02..0.10 ✓ feet at 0.10 on slab (slab top at -0.38c+... hmm slab spans y ∈ [-h*0.10 - h/2, -h*0.10 + h/2] = [-0.38c, 0.18c] with h=0.56c → -0.28c-0.10c? compute: h/2=0.28c; top = -0.10c-0.28c = -0.38c; bottom = 0.18c. Slab surface (top) at -0.38c?? The passenger should stand ON the slab → feet near slab top (-0.38c)?? But visually a platform is flat ground; the passenger stands anywhere on it. Feet at +0.10c is on the slab (between -0.38 and +0.18) ✓ reads fine — passenger standing toward the front edge of the platform. The yellow safety stripe at bottom (0.18c area) ✓ like standing behind the yellow line. 

Head top at 0.10-0.565 ≈ -0.47c… wait recompute: head center -0.45c, r=0.115c → top -0.565c; hat to -0.63c. Cell top -0.5c → overhang 0.13c ✓ fine.

Sign spans up to -0.545c — pokes 0.045c ✓.

When food is in the top ROW (y=0), overhang draws over canvas top edge → clipped slightly. Acceptable (canvas clips).

Also spawn safety: food at row 0 gets hat clipped — meh, acceptable. OR shift passenger down slightly... leave.

Now — AI behavior at game start: 3 foods, snake at center heading right. BFS shortest to nearest. Good.

Let me also double check bfs freeK semantics: `if(blocked[nk2]&&nk2!==freeK) continue;` — freeK passable ✓.

In aiChoose, `dist=bfs(nx,ny,blockedAfter,newTailK)`; newTailK passable so tailDist counts reach to the tail cell ✓. safe = tailDist>=0?0:1 ✓. But note: BFS distances measure from candidate head AFTER moving — the tail will also move each tick; heuristic ✓.

Sorting: eating desc, safe asc, pathLen asc, space desc ✓.

Now possible AI pathology: prefers `eating` even if that eating move is unsafe (safe=1) — e.g., food in a dead-end pocket adjacent: eating first priority → grabs it and maybe dies next ticks. Rare; tail-reach check helps in subsequent moves. Acceptable for a demo.

Alright — also player experience: tick 150ms start is comfortable.

Let me also add: when player is at over state, ALSO allow Enter/Space — any key works ✓ (handler: !running → startPlayer).

What about clicking during demo when the player wants to watch? Click starts the game — required behavior ("Click or press a key to start"). ✓

Now, writing. I'll also add a couple of graceful `try{}catch{}` around fit for getComputedStyle weirdness? Not needed.

Also: `window.addEventListener('resize', fit)` — fires on mobile URL bar show/hide; rebuild bg each time; fine.

Also add `orientationchange`? resize covers.

I'm confident. Final code below. I'll do one more mental compile pass while writing.

Additional micro-decision: name in footer/header consistent: "Snake Express".

Also — page background dots: `radial-gradient(rgba(150,110,60,.14) 1.1px, transparent 1.2px) 0 0/22px 22px` — shorthand with position/size: `background-image: radial-gradient(...); background-size:22px 22px;` safer to write separately. I'll write:
```css
background-color:#f2e3c6;
background-image:radial-gradient(rgba(150,110,60,.13) 1.1px, transparent 1.3px);
background-size:22px 22px;
```
plus a soft top-light gradient overlay: add second background-image layer: `linear-gradient(180deg,#f8efdc,#f0dfba)` and radial dots above it (first layer on top). Multiple backgrounds: `background-image: radial-gradient(...), linear-gradient(...);` with background-size applies to both? background-size with two values list: `22px 22px, 100% 100%`. I'll specify lists properly.

Card/overlay fonts consistent.

Also add `@media (prefers-reduced-motion: reduce)`? skip.

Let me also sanity check the keydown preventDefault: `' ' in KEYMAP` false; I check `k===' '` explicitly ✓.

Also, prevent arrow default ALWAYS (even in demo) to avoid iframe scroll ✓ done before state checks.

One subtle thing: `e.key.length===1?e.key.toLowerCase():e.key` — for 'W' → 'w' ✓; ArrowUp stays ✓.

Write the code:

Actually one more check on doTick demo: `const d=aiChoose(); if(d) dir=d;` — aiChoose returns dir fallback if stuck (may be reverse? no—dir itself). If aiChoose returns a reverse... it can't (skips reverse; fallback is current dir). ✓

Also in player mode queuedDirs could contain a dir reverse relative to *new* dir if queue was [left] while dir right... queueDir validated against last queued or dir at queue time; sequence executes over ticks; classic safe.

Hmm, one flaw: queueDir validates vs `last` = last queued OR current dir. Suppose dir=right, queue=[up]. Now player presses down: last=up → down is reverse of up → rejected ✓. Presses right: same as... right vs last=up → allowed, queue=[up,right] ✓ fine.

OK writing final.

For the loco, also add a tiny "coupler" at rear? covered.

Let me also think about drawing order for smoke vs tunnels: smoke after tunnels means puffs over arch — fine.

Also: the "+1" ticket font: `900 ${px}px "Trebuchet MS", Verdana, sans-serif`.

Let me also ensure `ctx.setLineDash` reset after use (I save/restore) ✓.

Potential floating issue: `rr` then `ctx.fill()` uses current fillStyle — set fillStyle before ✓ (rrFill does).

drawFoods: save/translate/scale — drawSlab uses translate? No, draws at origin — but drawSlab itself does save/translate(px,py)? I defined drawSlab(px,py) earlier; simplify: drawSlab() draws at CURRENT origin (no internal translate), so caller translates. Ghosts: translate then drawSlab ✓. Foods: translate+scale then drawSlab ✓.

So drawSlab():
```js
function drawSlab(){
  const w=cell*0.94,h=cell*0.56;
  rr(ctx,-w/2,-h*0.10,w,h,cell*0.10);
  ctx.fillStyle='#cfc6b4';ctx.fill();
  ctx.strokeStyle='rgba(120,104,78,.6)';ctx.lineWidth=Math.max(1,cell*0.03);ctx.stroke();
  ctx.fillStyle='#e5b93c';
  ctx.fillRect(-w/2+cell*0.05,h*0.52- cell*0.055,w-cell*0.10,cell*0.055);
}
```
Stripe at slab bottom: slab bottom edge at -0.10h+h/2 = 0.18c → stripe y from 0.125c height 0.055c → ends 0.18 ✓ inside.

Ghost draw:
```js
for(const g of ghosts){
  const a=1-g.t/380; if(a<=0)continue;
  ctx.save();ctx.translate((g.x+0.5)*cell,(g.y+0.5)*cell);ctx.globalAlpha=a*0.9;drawSlab();ctx.restore();
}
```

Now update ghosts in render before drawing (g.t+=dt).

Alright, final code time. I'll write it completely and check once more at the end.

Also last thought — the demo card overlay text: "Click or press any key to start" MUST be present — ✓ in demo state, and it's the initial state.

Also the game canvas: add `aria-label` maybe; skip.

Counting canvas ops per frame: bg drawImage 1; foods 3×(~20 ops); track ~length×(strokes) — snake length 40 → segments 39 × (2 rails + ~2.5 ties) ≈ 180 line segments; cars 40 × ~15 ops = 600; total < 1500 canvas ops/frame — fine even at 60fps.

Potential perf trap: ctx.shadowBlur — I'm NOT using it ✓ (expensive).

Let me now also double check tunnel alpha edge case when p exactly at edge crossing between frames — fine.

Also — the arch draws when |dot|>0.45 even if the car is merely NEAR the edge while moving inward from far away? E.g., train moving right at column COLS-2 (0.5cell+... p.x = W-1.5cell) — not < m ✓ no arch until within 0.8cell.

Edge columns: train traveling along column COLS-1 (rightmost) vertically: p.x = W-0.5cell < m? m=0.8cell → W-0.5c < W-0.8c? No: W-0.5c > W-0.8c → not near ✓ good, no arch while running along the outermost column vertically.

Traveling along column 0 vertically: p.x=0.5cell < 0.8cell → candidate left, but dot=|cos(±90°)*(−1)|=0 → excluded ✓.

Train horizontal at row 0: p.y=0.5c<0.8c → candidate top, dot=0 → excluded ✓.

Great.

One more: the track drawn to off-canvas points — the run containing the crossing car: pts might be like [offLeft(-0.5cell), inBoard(0.5cell), ...] — consecutive gap = cell ✓ no split; the track draws from off-canvas into board ✓. The previous car still at right side: gap huge → split ✓.

Alright. Also make sure `interpPoint` returns pixel coords: (ax + (b.x-ax)*t + 0.5)*cell ✓.

Edge: both prev and curr identical → returns center ✓.

Now — write!

Actually, also let me reconsider the demo AI tick where `aiChoose` might pick `eating` even if another equally-eating candidate exists — fine.

And a potential rare crash: `cells[L-2]` when L≥2 always ✓ (L starts 4, grows).

Also `foods` never empty (respawn on pickup; if spawnFood fails 200 attempts (board full) — foods count stays lower; fine).

Hmm — one more: `spawnFood` distance check uses head; uses `dx+dy<4 continue` — with 3 foods and small board late-game, could loop 200 times without finding far-enough cell → returns false, no food → AI targets none → pathLen 9000 all — AI wanders by space. If foods array becomes EMPTY (impossible via pickup since spawn fails only if no free cells) — fine.

Actually wait: if all foods eaten and spawn fails, foods.length=0 → game continues without targets until... snake fills board; eventually crash. Edge case whatever.

Let me also make spawnFood fallback: if no cell with dist≥4 found, accept any free cell (second pass with dist≥0):
```js
// second pass relaxed
```
Implement: try strict 60 times, then relaxed 60 times.

Good. Final code:

Also remember: NO text outside the code block in the answer. Just the fenced HTML.

Let me write the complete file now, mentally compiling as I go.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Snake Express — the little train that picks up passengers</title>
<style>
  *{box-sizing:border-box;margin:0;padding:0}
  html,body{height:100%}
  body{
    font-family:"Trebuchet MS","Segoe UI",Verdana,sans-serif;
    color:#4a3222;
    background-color:#f2e3c6;
    background-image:
      radial-gradient(rgba(150,110,60,.14) 1.1px, transparent 1.3px),
      linear-gradient(180deg,#f8efdc 0%,#efe0c0 100%);
    background-size:22px 22px,100% 100%;
    height:100vh;height:100dvh;
    display:flex;flex-direction:column;align-items:center;
    overflow:hidden;
    -webkit-user-select:none;user-select:none;
  }
  header.top{...}
```
etc. Writing it all out in the answer now. During writing, double-check each id: stage, boardWrap, game, overlay, ovTitle, ovBig, ovSub, modeChip, statScore, statCars, statBest, chipScore.

Footer ids: none.

Let me also polish: `.chip .val` uses transform animation — define `.chip.pop .val{animation:valpop .35s ease}` and `@keyframes valpop{40%{transform:scale(1.4);color:#c0453c}}`.

For the modeChip 'player' class: background red.

Also I should double check: overlay has pointer-events:none so clicks fall through to boardWrap ✓; overlay positioned absolute within boardWrap; boardWrap position:relative ✓.

Alright — also the loco drawing when angle is e.g. π (moving left): everything rotated ✓.

Now finalize numbers for loco parts:

L=cell*0.92, Wd=cell*0.58.
- wheels at x=±L*0.28 (also ±L*0.42 small for loco), y=±Wd*0.52, r=cell*0.075.
- cowcatcher: moveTo(L*0.44,-Wd*0.42) lineTo(L*0.64,-cell*0.035) lineTo(L*0.64,cell*0.035) lineTo(L*0.44,Wd*0.42) closePath.
- body rr(-L/2,-Wd/2,L,Wd,cell*0.13) fill #c0453c stroke shade('#c0453c',-45) lw max(1.5,cell*0.045).
- highlight: rrFill(-L*0.42,-Wd*0.14,L*0.82,Wd*0.28,cell*0.07,'rgba(255,235,210,.16)').
- smokebox: rrFill(L*0.28,-Wd/2+1,L*0.18,Wd-2,cell*0.09,'#8f2f28') — hmm x from 0.28L to 0.46L ✓.
- gold band: ctx.fillStyle='#e2b34b'; fillRect(L*0.12,-Wd/2+1,cell*0.05,Wd-2).
- cab: rrFill(-L*0.50,-Wd*0.62,L*0.42,Wd*1.24,cell*0.11,'#8f2f28'); stroke shade('#8f2f28',-30).
- roof: rrFill(-L*0.46,-Wd*0.50,L*0.30,Wd*1.0,cell*0.08,'#5a2e28').
- vent: arc(-L*0.31,0,cell*0.05) fill '#7a4a3a'.
- chimney: arc(L*0.18,0,cell*0.095) fill '#3b2a22', stroke '#e2b34b' lw max(1,cell*0.028); inner arc r cell*0.05 fill '#161009'.

Wait chimney at L*0.18 = 0.166cell from center — between gold band (0.12L=0.11cell) and smokebox (0.28L=0.26cell) ✓.

- lamp: arc(L*0.42,0,cell*0.055) fill '#ffd97a' + stroke dark thin.

Smoke emission offset ox=L*0.18=cell*0.166 ✓ update.

Wagon box:
- body rr(-L/2,-Wd/2,L,Wd,cell*0.11) fill color, stroke shade(color,-45).
- roof: rrFill(-L*0.30,-Wd*0.33,L*0.60,Wd*0.66,cell*0.07,'rgba(255,252,240,.38)').
- ends: small darker caps at both ends: fillStyle shade(color,-30): fillRect(-L/2,-Wd/2, cell*0.05,Wd) and (L/2-cell*0.05,...) — like frame ends. optional, add.
- number if cell>=26 && kind box: fillStyle 'rgba(255,255,255,.92)'; font 900 cell*0.24px; text i.

Tanker:
- ellipse(0,0,L*0.46,Wd*0.48) fill color stroke shade.
- center band: ctx.strokeStyle='rgba(0,0,0,.16)'; lw cell*0.05; line across (-L*0.36,0)-(L*0.36,0)? Actually a lengthwise ridge: draw line along local x ✓.
- dome: arc(0,0,cell*0.10) fill '#f4ead2' stroke shade(color,-30).

Wheels drawn for all cars before body ✓ (peeking from sides: wheels at y=±0.52Wd=±0.30cell; body half width 0.29cell → wheels centers just outside body edge, half visible ✓).

Wagon colors visible on top of roof band. Good.

Coupling color '#43301e'.

OK. One more: bob translate after rotate — cars jitter perpendicular — subtle ✓.

Also for the loco I skip the bob? Keep for all (rattle).

render() — also draw a subtle border darkening INSIDE canvas edges? skip.

Let me also compute smoke vy: puffs should drift "up-left"? Use vx = -cos(ang)*cell*0.25/1000? Let me do velocity in px/sec: opposite of motion: v = -dirVec * (cell*0.35) + random spread + slight world-up (-cell*0.05). Emit at chimney:

```js
function emitPuff(x,y,ang){
  if(smoke.length>110)return;
  const sp=cell*0.5;
  smoke.push({
    x:x+(Math.random()-0.5)*cell*0.08,
    y:y+(Math.random()-0.5)*cell*0.08,
    r:cell*(0.08+Math.random()*0.05),
    life:0,maxLife:800+Math.random()*600,
    vx:-Math.cos(ang)*sp+(Math.random()-0.5)*cell*0.3,
    vy:-Math.sin(ang)*sp+(Math.random()-0.5)*cell*0.3-cell*0.08
  });
}
```
Growth: r += cell*0.12*dt/1000 in update.

Crash burst: velocities random ±cell*1.2, r bigger.

Draw puffs: fill '#f7f3e8' alpha (1-k)*0.5.

Now the tickets position pickup: tickets.push({x:px,y:py-cell*0.2,t:0}) then float up.

Also let me add on pickup: brief headlight flash? skip.

Ready to write the final file. Also double-check `updateStats(true)` only on pickup ✓.

Also — `statCars` updates every pickup & reset ✓. Not every tick (fine).

Potential issue: `void chipScore.offsetWidth` — fine.

Writing the answer now. Also ensure the `script` is after the DOM elements.

One more idea for polish within budget: page corner "ticket" decorations? Skip — board is the hero.

Also — heading `h1` includes span.accent for "Express".

Final check of the frame loop guard: `while(acc>=tickMs&&guard<5)` — with dt clamp 60ms and tickMs≥80, acc grows ≤60 per frame → at most 1 tick per frame typically ✓.

On crash during the while: `if(!running){acc=0;break;}` ✓.

Also after demo auto-restart timeout fires, resetGame('demo') → running=true → next frame ticks resume ✓.

I also need to make sure that when the player crashes and the overlay shows, the train remains visible behind (render continues with running=false → tt=1 static) ✓ and smoke dissipates ✓.

Also `crashAt` used for shake: crashAt set in crash(); render computes ct=(now-crashAt)/450 ✓.

Let me write everything.

Also — subtle: after player crash, clicking anywhere on boardWrap restarts (pointerdown handler: `!running||driver!=='player'` → driver IS 'player' but !running → start ✓).

After demo crash → click → startPlayer ✓ (driver demo → condition driver!=='player' true ✓).

Everything checks. Compose final HTML.

Wait — also ensure `startPlayer` is not triggered by pointermove (only pointerdown) ✓.

And prevent double-start from a single keydown+pointerdown? Not an issue.

Let me write the footer hint text carefully (concise):
"Steer with <kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd> or <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> — swipe works too" + long: "· board every passenger · the meadow's edges are tunnels, but your own wagons will derail you".

Also the tagline: "a pocket-sized railway where the snake is a train".

Title: SNAKE EXPRESS.

Now, one last spec-compliance pass:
- Single fenced code block ✓
- Inline everything ✓ no external requests ✓ (no fonts, no images — canvas + inline SVG only)
- No localStorage/alert/etc ✓
- 1280×800 ✓, 375px ✓
- First paint meaningful ✓ (demo running + overlay + full UI)
- Demo AI + "Click or press a key to start" ✓
- Keyboard hint ✓
- Zero console errors — double-check code carefully during write-out.

Writing now, with extra care on JS syntax.

One thing I want to verify: in drawFoods I do ctx.save(); translate; scale(s,s); then drawSlab (which sets fillStyle etc. — fine), drawSign, drawPassenger(f,now); restore. drawPassenger does its own save/translate/restore — nested fine.

easeOutBack at t<0? age/380 where age≥0 → t∈[0,1] ✓. easeOutBack(0)=1+c3*(-1)+c1*1=1-2.70158+1.70158=0 ✓. At small t could dip negative? easeOutBack overshoots above 1 near t≈0.7, and at t=0 exactly 0; monotonic-ish; Math.max(0.05, ...) guard ✓.

Also food pop scale from ~0 → covers "meaningful within seconds" ✓.

Also: draw order — ghost slabs drawn right after bg (before foods) ✓.

Let me also make sure smoke is drawn ABOVE tunnels but the tunnel arch alpha... smoke after tunnels ✓.

drawFx (rings/tickets) last ✓.

Also overlay text sub for demo mentions passengers ✓.

Final: write the code. I'll now produce the complete file. (Also add a small comment header in the file? Not required; keep a few section comments.)

Also — one more consideration for `fit()` when the stage rect is 0×0 (display issues in some embeds) → clamp avail to min 140 ✓ cell≥9 → board 189×126 minimum. OK.

dpr clamp 2 ✓.

Let me make sure canvas ctx.setTransform called AFTER canvas.width set (setting width resets transform) ✓ order in fit.

And buildBg uses its own ctx `b` ✓.

Also drawImage(bg,0,0,W,H) — while main ctx has dpr transform → correct ✓.

Everything's set. Compose.

Actually, hmm, let me reconsider the initial snake position: cells x=6-i for i=0..3 → x=6,5,4,3, y=7. If portrait grid (14×20): x=6,5,4,3 with COLS=14 → fine (head 6 of 14, room to the right ✓).

Center-ish ✓.

Also chooseGrid thresholds: stage aspect <0.95 → portrait. At 1280×800 aspect ~1.5 → landscape ✓. At 375×667: stage ~ (375×~500) → 0.75 → portrait ✓.

Also DECOS count depends on grid — buildDecos after chooseGrid ✓; call order in init: chooseGrid(); buildDecos(); fit(); resetGame('demo'); loop.

Let me also handle window resize when portrait—cell recomputed with same grid ✓.

Now write.

Also `let COLS=21,ROWS=14;` — used in reset etc. ✓ BFS arrays allocated per call with current COLS/ROWS ✓.

Final answer below.

Let me write the JS in full here mentally once (condensed but complete), checking syntax as I go... I'll write it directly into the answer with high care. A few spots to double check while writing:

1. Template literals with backticks inside the code block — fine.
2. `e.key.length===1?e.key.toLowerCase():e.key` ✓.
3. In keydown, map lookup `KEYMAP[k]` — 'ArrowUp' etc ✓.
4. `if(k in KEYMAP||k===' ')e.preventDefault();` — 'in' works with object literal keys ✓.

5. drawTrack: careful with variable shadowing.

Also careful: `const cand=[]` inside drawTunnels loops.

Write time. I'll also include `text-align` resets.

For safety, wrap the whole script body in try? No — let errors surface in dev; but for the sandbox, better safe: I've reviewed carefully.

One more potential runtime error: `ctx.ellipse` — supported Chrome ✓.

`Array.prototype.fill` on Int16Array ✓ (TypedArray.fill).

`foods.findIndex` ✓.

`queueDir` object `d` may be undefined when called from swipe with computed sign — Math.sign returns ±1 ✓ never 0 since thresholds 24px.

`startPlayer()` without arg → d undefined → `if(d)` skip ✓.

In keydown: `if(!running||driver!=='player'){ startPlayer(KEYMAP[k]); return; }` — pass d ✓.

Also ensure that starting from 'over' as player works: driver='player', running=false → condition true → startPlayer ✓.

Edge: pressing arrow during demo → startPlayer(d): resetGame('player') then queueDir(d) — if d is LEFT (reverse of initial right) → queueDir rejects ✓ snake continues right.

OK. Also one more UI touch: hide the footer "long" hint on small screens via media query ✓.

Also mobile: overlay card width might exceed board (board 322px) — card max-width min(92%,430px) ✓.

Final write-out.

After writing, I'll do a quick error-scan pass over the code (matching braces, etc.) as I compose.

Also note — since the demo must be visible ~3s: at 150ms/tick, 3s = 20 ticks; the loco travels 20 cells with smoke trail, likely a pickup (distance to nearest food ~5-8 → pickup within ~1-2s) → ticket +1 popup may have already faded; ring gone; the platform pop-in of the new food will be visible ✓. Score chip shows 1-2 ✓.

Compose the final file now.

Also — the loco draws last (head on top) — with head drawn after wagons, at corners loco overlaps the first wagon slightly ✓ natural.

Also note `for(let i=cells.length-1;i>=0;i--) drawCar(...)` ✓.

Also the smoke draw loop is after cars; the puff at chimney appears above loco ✓.

Let me also quickly define drawFoods details for the wiggle: passenger wave already animated.

DONE thinking. Write it.

Ah wait, one more important check — the overlay covers the bottom of the canvas area INCLUDING pointer-events none — but it also visually overlaps the bottom rows of the board where the train might pass — semi-opaque card might hide the train when it passes behind. Card is opaque-ish (0.95 alpha) — train passing under the card is hidden briefly. Acceptable (it's like a HUD). Could place card at bottom with translucency 0.9 — fine.

Alternatively position the card at the TOP? The chip is top-left; the card at bottom is standard "press start". Keep bottom.

Also during demo, keep card small: paddings tuned.

Now, truly writing.

One more detail — `#overlay` inset uses the boardWrap padding: inset:0 covers padding area too; card bottom padding uses clamp to sit just above canvas bottom edge... The canvas bottom is at boardWrap padding distance from wrap's bottom. If overlay inset 0 and padding-bottom = 12px, the card would sit 12px above the wrap bottom, i.e., overlapping the frame padding not the canvas — looks off. Set overlay inset to the padding: simpler: overlay inset:0; display flex align-end; padding-bottom: calc(framePad + 10px). framePad = clamp(9px,1.3vw,14px). Use CSS var: `--pad: clamp(9px,1.3vw,14px);` used by #boardWrap padding and overlay padding-bottom: calc(var(--pad) + 10px); and modeChip offsets: top:calc(var(--pad) + 8px); left:calc(var(--pad) + 8px).

JS reads computed padding for fit ✓ (var resolves in computed style).

Great. Write.

Also — set `#boardWrap{padding:var(--pad)}`.

Final file: