Train snakeOne-line
GLM 5.3 Flash
by Z.aiReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 5 min 25 s
- Cost
- $0.013
- Output tokens
- 26,059
- Spent thinking
- 17,979 tokens
- Speed
- 80.3 tok/s
- Console errors
- 0
- First token after
- 535 ms
- First visible text after
- 4 min 6 s
- Prompt tokens
- 363
- Code size
- 22.8 KB
- Finish reason
- stop
- Served by
- Cohere
- Model id
z-ai/glm-5.3-flash- Reasoning sent
{"effort":"low"}- Max output
- 64,000 tokens
- Generated
- Nov 10, 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 · 587 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Little Local — a tiny train snake</title>
<style>
*{box-sizing:border-box}
html,body{margin:0}
body{
background:#f4eee0;color:#2a251d;
font-family:"Trebuchet MS",Verdana,sans-serif;
min-height:100vh;display:flex;align-items:center;justify-content:center;padding:14px;
}
.wrap{width:min(1000px,100%)}
header{display:flex;align-items:flex-end;justify-content:space-between;gap:12px;padding:2px 4px 10px;flex-wrap:wrap}
.brand{display:flex;align-items:center;gap:11px}
.brand svg{flex:none}
h1{font-size:22px;margin:0;letter-spacing:.03em}
.tag{font-size:12px;color:#7a6f5d;margin-top:2px}
.stats{display:flex;gap:26px}
.stat{text-align:right}
.stat .lab{font-size:10px;letter-spacing:.2em;color:#7a6f5d;text-transform:uppercase}
.stat .val{font-size:25px;font-weight:bold;font-variant-numeric:tabular-nums;line-height:1.05;display:inline-block}
.val.bump{animation:bump .25s}
@keyframes bump{40%{transform:scale(1.3)}}
.board{position:relative;border-radius:14px;border:3px solid #3a3128;box-shadow:0 14px 30px rgba(58,49,40,.16);overflow:hidden;background:#8fbf78}
canvas{display:block;width:100%;height:auto;cursor:pointer}
.overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(42,37,29,.36);padding:16px;z-index:2}
.overlay.hidden{display:none}
.card{background:#fbf6ea;border:2px solid #3a3128;border-radius:14px;padding:22px 26px;max-width:440px;text-align:center;box-shadow:0 12px 26px rgba(42,37,29,.28)}
.kicker{font-size:10px;letter-spacing:.26em;color:#c2472f;font-weight:bold;margin-bottom:7px}
.card h2{margin:0 0 9px;font-size:26px;letter-spacing:.01em}
.card p{margin:0 0 14px;font-size:13.5px;line-height:1.55;color:#54483a}
.card p b{color:#2a251d}
button.go{font:inherit;font-weight:bold;font-size:14px;background:#2a251d;color:#f7f1e3;border:none;border-radius:9px;padding:11px 18px;cursor:pointer;letter-spacing:.02em}
button.go:hover{background:#c2472f}
.demo-chip{display:inline-flex;align-items:center;gap:7px;font-size:10.5px;letter-spacing:.14em;text-transform:uppercase;color:#6b5f4d;border:1.5px dashed #b4a68d;border-radius:999px;padding:5px 12px;margin:0 0 14px}
.dot{width:7px;height:7px;border-radius:50%;background:#c2472f;animation:blink 1.1s infinite}
@keyframes blink{50%{opacity:.2}}
.keys{margin-top:13px;font-size:11px;color:#8a7c66}
kbd{border:1px solid #c6b99e;border-bottom-width:2.5px;border-radius:5px;padding:1px 5px;font-family:inherit;font-size:10.5px;background:#fff;margin:0 1px}
footer{padding:10px 4px;font-size:12px;color:#8a7c66;text-align:center}
@media (max-width:560px){
h1{font-size:18px}
.stat .val{font-size:20px}
.stats{gap:16px}
.card{padding:16px 18px}
.card h2{font-size:21px}
}
</style>
</head>
<body>
<main class="wrap">
<header>
<div class="brand">
<svg viewBox="0 0 40 27" width="36" height="25" aria-hidden="true">
<rect x="6" y="4.5" width="3.4" height="6.5" rx="1" fill="#3a3128"/>
<rect x="3" y="9" width="22" height="9.5" rx="2.5" fill="#c2472f"/>
<rect x="21.5" y="3.5" width="9.5" height="15" rx="2" fill="#8e3120"/>
<rect x="24" y="6" width="4.6" height="4.6" rx="1" fill="#f2e3c6"/>
<circle cx="8.5" cy="6" r="2" fill="#332b22"/>
<circle cx="9" cy="20" r="3" fill="#332b22"/><circle cx="16" cy="20" r="3" fill="#332b22"/><circle cx="26.5" cy="20" r="3" fill="#332b22"/>
<path d="M1.5 23.6 h37" stroke="#3a3128" stroke-width="1.8" stroke-linecap="round"/>
</svg>
<div>
<h1>The Little Local</h1>
<div class="tag">a tiny steam-train snake · every passenger becomes a wagon</div>
</div>
</div>
<div class="stats">
<div class="stat"><div class="lab">Passengers</div><div class="val" id="scoreEl">0</div></div>
<div class="stat"><div class="lab">Best today</div><div class="val" id="bestEl">0</div></div>
</div>
</header>
<div class="board">
<canvas id="cv"></canvas>
<div class="overlay" id="overlay"></div>
</div>
<footer>Click the board or press <kbd>P</kbd> to pause · the train lays track behind it as it goes</footer>
</main>
<script>
(()=>{
"use strict";
const COLS=24, ROWS=15, CELL=40, W=COLS*CELL, H=ROWS*CELL;
const cv=document.getElementById('cv'), ctx=cv.getContext('2d');
const overlay=document.getElementById('overlay');
const scoreEl=document.getElementById('scoreEl'), bestEl=document.getElementById('bestEl');
function fit(){
const dpr=Math.min(2, window.devicePixelRatio||1);
cv.width=W*dpr; cv.height=H*dpr;
ctx.setTransform(dpr,0,0,dpr,0,0);
}
fit(); addEventListener('resize',fit);
const PALETTE=['#d1495b','#e3a72f','#4a7fc1','#e07b39','#3aa08c','#9a6fb0'];
const DIRS4=[{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
// ---------- state ----------
let mode='menu'; // menu(demo) | play | pause | over
let demoRun=true;
let snake=[], prevSnake=[], segA=[], wagonColors=[], born=[];
let dir={x:1,y:0}, pending=null;
let passenger=null;
let rails=[], smoke=[], parts=[], floats=[];
let acc=0, stepMs=175, score=0, best=0;
let crashT=0, scatterVec=[], scatterRot=[], smokeT=0;
let posArr=[];
function rng(seed){return function(){seed|=0;seed=seed+0x6D2B79F5|0;let t=Math.imul(seed^seed>>>15,1|seed);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296}}
const R=rng(20240613);
const decor=[];
for(let i=0;i<52;i++) decor.push({x:R()*W,y:R()*H,k:R()<0.78?0:1,s:R()});
function rr(x,y,w,h,r){ctx.beginPath();ctx.roundRect(x,y,w,h,r)}
function shade(hex,f){ // f in [-1,1]
const n=parseInt(hex.slice(1),16);
let r=n>>16&255,g=n>>8&255,b=n&255;
const t=f<0?0:255, p=Math.abs(f);
r=Math.round(r+(t-r)*p); g=Math.round(g+(t-g)*p); b=Math.round(b+(t-b)*p);
return 'rgb('+r+','+g+','+b+')';
}
function angLerp(a,b,t){let d=b-a;while(d>Math.PI)d-=Math.PI*2;while(d<-Math.PI)d+=Math.PI*2;return a+d*t}
function freeCells(minDist){
const occ=new Set(snake.map(c=>c.x+','+c.y));
const h=snake[0], out=[];
for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++){
if(occ.has(x+','+y))continue;
if(Math.abs(x-h.x)+Math.abs(y-h.y)<(minDist||4))continue;
out.push({x,y});
}
return out;
}
function spawnPassenger(){
const cells=freeCells(5);
const c=cells[(Math.random()*cells.length)|0]||freeCells(0)[0];
passenger={x:c.x,y:c.y,color:PALETTE[(Math.random()*PALETTE.length)|0],seed:Math.random()};
}
function resetGame(demo){
snake=[{x:6,y:7},{x:5,y:7},{x:4,y:7}];
prevSnake=snake.map(c=>({...c}));
segA=[0,0,0]; born=[0,0,0];
wagonColors=[PALETTE[(Math.random()*6)|0],PALETTE[(Math.random()*6)|0]];
dir={x:1,y:0}; pending=null;
acc=0; stepMs=175; score=0; smokeT=0;
rails=[]; smoke=[]; parts=[]; floats=[];
scatterVec=[]; scatterRot=[];
spawnPassenger();
demoRun=demo;
mode=demo?'menu':'play';
updateHUD(false);
}
// ---------- audio ----------
let AC=null;
function initAudio(){
try{
if(!AC) AC=new (window.AudioContext||window.webkitAudioContext)();
if(AC&&AC.state==='suspended') AC.resume();
}catch(e){AC=null}
}
function tone(f,dur,type,vol,when){
if(!AC)return;
try{
const o=AC.createOscillator(),g=AC.createGain(),t0=AC.currentTime+(when||0);
o.type=type||'sine'; o.frequency.value=f;
g.gain.setValueAtTime(vol||.12,t0);
g.gain.exponentialRampToValueAtTime(.0008,t0+dur);
o.connect(g); g.connect(AC.destination);
o.start(t0); o.stop(t0+dur+.05);
}catch(e){}
}
function chime(){tone(660,.13,'triangle',.11);tone(990,.16,'triangle',.10,.09)}
function whistle(){tone(620,.42,'triangle',.06);tone(932,.42,'triangle',.05)}
function crashSnd(){
if(!AC)return;
try{
const n=AC.createBufferSource(),b=AC.createBuffer(1,AC.sampleRate*.35|0,AC.sampleRate),d=b.getChannelData(0);
for(let i=0;i<d.length;i++)d[i]=(Math.random()*2-1)*(1-i/d.length);
n.buffer=b;const g=AC.createGain();g.gain.value=.18;
n.connect(g);g.connect(AC.destination);n.start();
}catch(e){}
}
// ---------- HUD ----------
function updateHUD(bump){
scoreEl.textContent=score;
bestEl.textContent=best;
if(bump){scoreEl.classList.remove('bump');void scoreEl.offsetWidth;scoreEl.classList.add('bump');}
}
// ---------- overlays ----------
function showStart(){
overlay.classList.remove('hidden');
overlay.innerHTML=
'<div class="card">'+
'<div class="kicker">NOW BOARDING</div>'+
'<h2>All aboard!</h2>'+
'<p>Steer the little steam train along the line and stop at each platform to pick up a waiting passenger — every rider joins on as a brand-new wagon in their own colour. Just don’t rear-end your own train or overshoot the ends of the line.</p>'+
'<div class="demo-chip"><span class="dot"></span>Autopilot at the throttle — demo running</div><br>'+
'<button class="go">Click or press any key to start</button>'+
'<div class="keys"><kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd> or <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> steer · <kbd>P</kbd> / click board to pause</div>'+
'</div>';
}
function showOver(msg,sub){
overlay.classList.remove('hidden');
const s=score===1?'':'s';
overlay.innerHTML=
'<div class="card">'+
'<div class="kicker">END OF THE LINE</div>'+
'<h2>Derailed!</h2>'+
'<p>'+(msg||('You delivered <b>'+score+' passenger'+s+'</b> this run. Best today: <b>'+best+'</b>.'))+'</p>'+
(sub||'<button class="go">Click or press any key to ride again</button>')+
'</div>';
}
function showPause(){
overlay.classList.remove('hidden');
overlay.innerHTML=
'<div class="card">'+
'<div class="kicker">HELD AT A RED SIGNAL</div>'+
'<h2>Paused</h2>'+
'<button class="go">Click or press any key to continue</button>'+
'</div>';
}
function hideOverlay(){overlay.classList.add('hidden');overlay.innerHTML='';}
// ---------- AI (demo driver) ----------
function aiDir(){
const head=snake[0];
const cands=[dir,{x:dir.y,y:-dir.x},{x:-dir.y,y:dir.x}];
const occ=new Set();
for(let i=0;i<snake.length-1;i++) occ.add(snake[i].x+','+snake[i].y);
let bestD=null,bs=-1e9;
for(const d of cands){
const nx=head.x+d.x, ny=head.y+d.y;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS)continue;
if(occ.has(nx+','+ny))continue;
let s=-(Math.abs(nx-passenger.x)+Math.abs(ny-passenger.y));
let free=0;
for(const dd of DIRS4){
const ax=nx+dd.x, ay=ny+dd.y;
if(ax>=0&&ay>=0&&ax<COLS&&ay<ROWS&&!occ.has(ax+','+ay))free++;
}
s+=free>=3?5:free===2?3:free===1?-4:-20;
s+=Math.random()*0.8;
if(s>bs){bs=s;bestD=d;}
}
return bestD||dir;
}
// ---------- game step ----------
function doStep(){
const old=snake.map(c=>({x:c.x,y:c.y}));
let d=dir;
if(mode==='menu'){ d=aiDir(); }
else if(pending){
if(!(pending.x===-dir.x&&pending.y===-dir.y)&&!(pending.x===dir.x&&pending.y===dir.y)) d=pending;
pending=null;
}
dir=d;
const nx=snake[0].x+d.x, ny=snake[0].y+d.y;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS){ crash(old); return; }
const ate=(nx===passenger.x&&ny===passenger.y);
const occ=new Set();
for(let i=0;i<snake.length-(ate?0:1);i++) occ.add(snake[i].x+','+snake[i].y);
if(occ.has(nx+','+ny)){ crash(old); return; }
rails.push({x:snake[0].x,y:snake[0].y,dx:d.x,dy:d.y,t:performance.now()});
if(rails.length>700) rails.splice(0,rails.length-700);
snake.unshift({x:nx,y:ny});
if(!ate) snake.pop();
prevSnake=old;
if(ate){
score++;
wagonColors.push(passenger.color);
born.push(performance.now());
segA.push(segA[segA.length-1]||0);
stepMs=Math.max(95,stepMs-3.5);
const cx=passenger.x*CELL+CELL/2, cy=passenger.y*CELL+CELL/2;
floats.push({x:cx,y:cy-14,t:performance.now(),txt:'+1'});
for(let i=0;i<10;i++){
const a=Math.random()*Math.PI*2, sp=40+Math.random()*55;
parts.push({x:cx,y:cy,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp,r:2.5+Math.random()*1.6,t:0,life:.5,c:passenger.color});
}
chime();
updateHUD(true);
spawnPassenger();
}
}
function crash(old){
crashT=performance.now();
scatterVec=[]; scatterRot=[];
for(let i=0;i<snake.length;i++){
const a=Math.random()*Math.PI*2, dist=26+Math.random()*42;
scatterVec.push({x:Math.cos(a)*dist,y:Math.sin(a)*dist});
scatterRot.push((Math.random()-.5)*2.4);
}
prevSnake=old; acc=0;
const hp=posArr[0]||{x:snake[0].x*CELL+CELL/2,y:snake[0].y*CELL+CELL/2};
for(let i=0;i<16;i++){
smoke.push({x:hp.x+(Math.random()-.5)*20,y:hp.y+(Math.random()-.5)*20,
vx:(Math.random()-.5)*50,vy:-20-Math.random()*40,r:5+Math.random()*5,t:0,life:.9+Math.random()*.6});
}
smokeT=0;
crashSnd();
if(!demoRun){ best=Math.max(best,score); updateHUD(false); }
mode='over';
}
// ---------- effects ----------
function updateFx(dt,now){
if(mode==='play'||mode==='menu'){
// smoke from the chimney
smokeT-=dt;
if(smokeT<=0&&posArr.length){
smokeT=120+Math.random()*60;
const a=segA[0]||0, fx=Math.cos(a), fy=Math.sin(a);
smoke.push({
x:posArr[0].x+fx*CELL*.30, y:posArr[0].y+fy*CELL*.30,
vx:-fx*10+(Math.random()-.5)*14, vy:-fy*10-16-Math.random()*16,
r:2.6+Math.random()*1.6, t:0, life:1.1+Math.random()*.4
});
}
// smooth car angles
const k=1-Math.exp(-dt/40);
for(let i=0;i<snake.length;i++){
const p=prevSnake[Math.min(i,prevSnake.length-1)], c=snake[i];
if(p.x!==c.x||p.y!==c.y){
segA[i]=angLerp(segA[i],Math.atan2(c.y-p.y,c.x-p.x),k);
}
}
}
for(const s of smoke){s.t+=dt/1000;s.x+=s.vx*dt/1000;s.y+=s.vy*dt/1000;s.vy-=8*dt/1000;}
smoke=smoke.filter(s=>s.t<s.life);
for(const p of parts){p.t+=dt/1000;p.x+=p.vx*dt/1000;p.y+=p.vy*dt/1000;p.vx*=.94;p.vy*=.94;}
parts=parts.filter(p=>p.t<p.life);
floats=floats.filter(f=>now-f.t<950);
}
// ---------- drawing ----------
function drawCar(x,y,a,opt){
const loco=opt.kind==='loco';
const L=loco?CELL*.92:CELL*.78, Wd=CELL*.56;
ctx.save();
ctx.translate(x,y); ctx.rotate(a);
if(opt.pulse!==undefined&&opt.pulse<1){
const s=.4+.6*opt.pulse; ctx.scale(s,s); ctx.globalAlpha=Math.min(1,opt.pulse*2.2);
}
// shadow
ctx.fillStyle='rgba(40,32,20,.20)';
ctx.beginPath();ctx.ellipse(0,3.5,L*.52,Wd*.62,0,0,Math.PI*2);ctx.fill();
// wheels
const wy=Wd/2-1;
const wxs=loco?[-L*.28,L*.05,L*.30]:[-L*.22,L*.22];
for(const wx of wxs)for(const s of[-1,1]){
ctx.fillStyle='#332b22';ctx.beginPath();ctx.arc(wx,s*wy,4.4,0,7);ctx.fill();
ctx.fillStyle='#5a4c3a';ctx.beginPath();ctx.arc(wx,s*wy,1.7,0,7);ctx.fill();
}
if(loco){
// cowcatcher
ctx.fillStyle='#3a3128';
ctx.beginPath();ctx.moveTo(L/2,-Wd*.42);ctx.lineTo(L/2+6.5,0);ctx.lineTo(L/2,Wd*.42);ctx.closePath();ctx.fill();
// boiler
rr(-L/2,-Wd/2,L,Wd,9); ctx.fillStyle='#c2472f'; ctx.fill();
ctx.save(); rr(-L/2,-Wd/2,L,Wd,9); ctx.clip();
ctx.fillStyle='rgba(0,0,0,.13)'; ctx.fillRect(-L/2,Wd/2-6,L,6);
ctx.fillStyle='rgba(255,255,255,.10)'; ctx.fillRect(-L/2,-Wd/2,L,4);
ctx.strokeStyle='#8e3120'; ctx.lineWidth=2;
ctx.beginPath();ctx.moveTo(-L*.10,-Wd/2);ctx.lineTo(-L*.10,Wd/2);ctx.moveTo(L*.15,-Wd/2);ctx.lineTo(L*.15,Wd/2);ctx.stroke();
ctx.restore();
// cab
rr(-L/2,-Wd/2-2.5,14,Wd+5,5); ctx.fillStyle='#8e3120'; ctx.fill();
rr(-L/2+3.5,-Wd/2+1.5,7,Wd-3,3); ctx.fillStyle='#f2e3c6'; ctx.fill();
// dome + chimney
ctx.fillStyle='#e5b954';ctx.beginPath();ctx.arc(L*.02,0,4.2,0,7);ctx.fill();
ctx.fillStyle='#332b22';ctx.beginPath();ctx.arc(L*.24,0,5.6,0,7);ctx.fill();
ctx.fillStyle='#17120d';ctx.beginPath();ctx.arc(L*.24,0,3,0,7);ctx.fill();
// headlamp + beam
const g=ctx.createRadialGradient(L/2,0,2,L/2,0,CELL*.85);
g.addColorStop(0,'rgba(255,232,168,.38)');g.addColorStop(1,'rgba(255,232,168,0)');
ctx.fillStyle=g;ctx.beginPath();ctx.arc(L/2,0,CELL*.85,0,7);ctx.fill();
ctx.fillStyle='#ffe9b0';ctx.beginPath();ctx.arc(L/2-3,0,3,0,7);ctx.fill();
}else{
const c=opt.color;
rr(-L/2,-Wd/2,L,Wd,7); ctx.fillStyle=c; ctx.fill();
ctx.save(); rr(-L/2,-Wd/2,L,Wd,7); ctx.clip();
ctx.fillStyle='rgba(0,0,0,.13)'; ctx.fillRect(-L/2,Wd/2-6,L,6);
ctx.fillStyle=shade(c,.35); rr(-L/2+3,-4.5,L-6,9,4); ctx.fill();
ctx.strokeStyle='rgba(0,0,0,.16)'; ctx.lineWidth=2;
ctx.beginPath();ctx.moveTo(-L*.18,-Wd/2);ctx.lineTo(-L*.18,Wd/2);ctx.moveTo(L*.18,-Wd/2);ctx.lineTo(L*.18,Wd/2);ctx.stroke();
ctx.restore();
}
ctx.restore();
}
function drawRails(now){
for(let i=rails.length-1;i>=0;i--){
const r=rails[i], age=(now-r.t)/1000;
if(age>6){rails.splice(i,1);continue;}
const a=age<2.5?.85:Math.max(0,.85*(1-(age-2.5)/3.5));
const cx=r.x*CELL+CELL/2, cy=r.y*CELL+CELL/2;
ctx.save(); ctx.translate(cx,cy); ctx.rotate(Math.atan2(r.dy,r.dx)); ctx.globalAlpha=a;
ctx.lineCap='round';
ctx.strokeStyle='#8a6a45'; ctx.lineWidth=4;
ctx.beginPath();
for(const s of[-11,0,11]){ctx.moveTo(s,-11);ctx.lineTo(s,11);}
ctx.stroke();
ctx.strokeStyle='#5b4831'; ctx.lineWidth=2.4;
ctx.beginPath();
for(const s of[-6.5,6.5]){ctx.moveTo(-CELL/2+3,s);ctx.lineTo(CELL/2-3,s);}
ctx.stroke();
ctx.restore();
}
}
function drawPassenger(now){
const cx=passenger.x*CELL+CELL/2, cy=passenger.y*CELL+CELL/2;
const bob=Math.sin(now/280+passenger.seed*7)*1.6;
ctx.save(); ctx.translate(cx,cy);
rr(-15,-15,30,30,7); ctx.fillStyle='#d7cec0'; ctx.fill();
ctx.save(); rr(-15,-15,30,30,7); ctx.clip();
ctx.fillStyle='rgba(0,0,0,.08)'; ctx.fillRect(-15,7,30,8);
ctx.restore();
ctx.setLineDash([5,4]); ctx.lineWidth=3.4; ctx.strokeStyle='#dfa63b';
rr(-15,-15,30,30,7); ctx.stroke(); ctx.setLineDash([]);
ctx.fillStyle='rgba(0,0,0,.15)';
ctx.beginPath();ctx.ellipse(0,9,7,2.6,0,0,7);ctx.fill();
ctx.translate(0,bob);
ctx.fillStyle=passenger.color; rr(-5.5,-6,11,13,4); ctx.fill();
ctx.strokeStyle=passenger.color; ctx.lineWidth=3; ctx.lineCap='round';
const wa=Math.sin(now/220+passenger.seed*9)*.55-.95;
ctx.beginPath();ctx.moveTo(4,-4);ctx.lineTo(4+Math.cos(wa)*7.5,-4+Math.sin(wa)*7.5);ctx.stroke();
ctx.fillStyle='#eec49b';ctx.beginPath();ctx.arc(0,-10,4.6,0,7);ctx.fill();
ctx.fillStyle='#4a3a2c';ctx.beginPath();ctx.arc(0,-11,4.6,Math.PI,0);ctx.fill();
ctx.fillStyle='#8a6a45'; rr(9.5,2,7,8,2); ctx.fill();
ctx.strokeStyle='#5b4831';ctx.lineWidth=1.4;
ctx.beginPath();ctx.moveTo(11,2);ctx.lineTo(14.5,2);ctx.stroke();
ctx.restore();
}
function draw(now){
const t=Math.min(1,acc/stepMs);
ctx.save();
if(mode==='over'){
const amp=Math.max(0,1-(now-crashT)/700)*6;
if(amp>.2) ctx.translate((Math.random()-.5)*amp*2,(Math.random()-.5)*amp*2);
}
// grass
ctx.fillStyle='#8fbf78'; ctx.fillRect(-8,-8,W+16,H+16);
ctx.fillStyle='#88b871';
for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++)if((x+y)&1)ctx.fillRect(x*CELL,y*CELL,CELL,CELL);
// decor tufts & flowers
for(const d of decor){
if(d.k===0){
ctx.strokeStyle='#77a55f';ctx.lineWidth=2;ctx.lineCap='round';
ctx.beginPath();
ctx.moveTo(d.x,d.y);ctx.lineTo(d.x-3,d.y-5-d.s*3);
ctx.moveTo(d.x+2,d.y);ctx.lineTo(d.x+2,d.y-6-d.s*3);
ctx.moveTo(d.x+4,d.y);ctx.lineTo(d.x+7,d.y-4-d.s*3);
ctx.stroke();
}else{
ctx.fillStyle='rgba(255,255,255,.85)';
for(const o of[[-2.4,0],[2.4,0],[0,-2.4],[0,2.4]]){ctx.beginPath();ctx.arc(d.x+o[0],d.y+o[1],1.7,0,7);ctx.fill();}
ctx.fillStyle='#e9b44c';ctx.beginPath();ctx.arc(d.x,d.y,1.3,0,7);ctx.fill();
}
}
drawRails(now);
drawPassenger(now);
// couplings
ctx.strokeStyle='#3a3128'; ctx.lineWidth=4; ctx.lineCap='round';
ctx.beginPath();
for(let i=0;i<snake.length-1;i++){
ctx.moveTo(posArr[i].x,posArr[i].y); ctx.lineTo(posArr[i+1].x,posArr[i+1].y);
}
ctx.stroke();
// wagons back-to-front, loco last
for(let i=snake.length-1;i>=1;i--){
const p=born[i]?Math.min(1,(now-born[i])/380):1;
drawCar(posArr[i].x,posArr[i].y,segA[i],{kind:'wag',color:wagonColors[i-1]||PALETTE[0],pulse:p});
}
drawCar(posArr[0].x,posArr[0].y,segA[0],{kind:'loco'});
// smoke
for(const s of smoke){
const a=(1-s.t/s.life)*.55;
ctx.fillStyle='rgba(250,248,242,'+a.toFixed(3)+')';
ctx.beginPath();ctx.arc(s.x,s.y,s.r*(0.6+s.t/s.life*1.7),0,7);ctx.fill();
}
// pickup burst
for(const p of parts){
ctx.globalAlpha=1-p.t/p.life;
ctx.fillStyle=p.c;
ctx.beginPath();ctx.arc(p.x,p.y,p.r,0,7);ctx.fill();
}
ctx.globalAlpha=1;
// floating +1
ctx.font='bold 15px "Trebuchet MS",Verdana,sans-serif';
ctx.textAlign='center';
for(const f of floats){
const k=(now-f.t)/950;
ctx.globalAlpha=1-k;
ctx.fillStyle='#2a251d';
ctx.fillText(f.txt,f.x,f.y-26*k);
}
ctx.globalAlpha=1;
ctx.restore();
}
// ---------- main loop ----------
let lastFrame=performance.now();
function frame(now){
const dt=Math.min(50,now-lastFrame); lastFrame=now;
if(mode==='play'||mode==='menu'){
acc+=dt;
while(acc>=stepMs&&(mode==='play'||mode==='menu')){acc-=stepMs;doStep();}
}
const t=Math.min(1,acc/stepMs);
posArr=snake.map((c,i)=>{
const p=prevSnake[Math.min(i,prevSnake.length-1)];
return {x:(p.x+(c.x-p.x)*t)*CELL+CELL/2, y:(p.y+(c.y-p.y)*t)*CELL+CELL/2};
});
updateFx(dt,now);
if(mode==='over'){
if(demoRun){
if(now-crashT>600&&!overlay.classList.contains('hidden')===false){}
if(now-crashT>600&&overlay.classList.contains('hidden')) showOver('The autopilot came off the rails. A fresh train is being coupled up…','〈restarting demo〉'.replace('〈restarting demo〉',''));
if(now-crashT>2300){resetGame(true);showStart();}
}else if(now-crashT>900&&overlay.classList.contains('hidden')){
showOver();
}
}
draw(now);
requestAnimationFrame(frame);
}
// ---------- input ----------
function startPlay(){
initAudio(); whistle();
resetGame(false); hideOverlay();
}
function togglePause(){
if(mode==='play'){mode='pause';showPause();}
else if(mode==='pause'){mode='play';hideOverlay();lastFrame=performance.now();}
}
addEventListener('keydown',e=>{
const k=e.key;
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(k)) e.preventDefault();
initAudio();
if(mode==='menu'){startPlay();return;}
if(mode==='over'){ if(!demoRun||true){ if(now_ready())startPlay(); } return; }
if(k==='p'||k==='P'||k===' '){togglePause();return;}
if(mode==='pause') return;
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:{x:0,y:-1},S:{x:0,y:1},A:{x:-1,y:0},D:{x:1,y:0}};
if(map[k]) pending=map[k];
});
function now_ready(){return true;}
overlay.addEventListener('click',()=>{
initAudio();
if(mode==='menu') startPlay();
else if(mode==='over') startPlay();
else if(mode==='pause'){mode='play';hideOverlay();lastFrame=performance.now();}
});
cv.addEventListener('click',()=>{ initAudio(); if(mode==='play'||mode==='pause') togglePause(); });
document.addEventListener('visibilitychange',()=>{ if(document.hidden&&mode==='play') togglePause(); });
// boot straight into the autopilot demo so the scene is alive on first paint
resetGame(true);
showStart();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>Reasoning trace (17,979 tokens)
Let me build a snake game where the snake is a train — a locomotive head with colorful wagons following — picking up passengers waiting on a platform (the "food" is a little person on a platform tile). The train moves on a grid, passengers appear at stations.
Key design decisions:
- Visual: I want this to look authored — a warm night/dusk railway scene, not a generic neon snake. Maybe a cozy toy-wooden-train aesthetic: soft cream/paper background, dark green felt-ish board with railway tracks drawn between cells? Actually drawing tracks for every cell could be heavy. Simpler: the grid is a flat field; when the train moves, it leaves "railroad ties"? A nice touch: each visited cell shows a short-lived track segment (ties), giving the impression the train lays track behind it. That's a signature moment.
- Train rendering: locomotive at head (with cab, chimney, smoke puffs), then wagons in rotating palette colors (red, blue, yellow, orange, teal), each wagon a rounded rectangle with wheels and windows. Passengers board the train (they become new wagons). Snake grows when eating a passenger.
- Passengers: little person standing on a small platform (a striped platform square, with a waiting figure). When eaten, passenger boards — spawn a new wagon appended with a little "climb aboard" animation maybe just a pop.
- Movement: grid-based snake, but with smooth interpolation between cells for that train feel — move continuously with easing per step, plus smoke puffs from the chimney periodically.
- Self-collision = crash: screen shake, derailed wagons scatter, game over overlay with restart.
- Score = passengers picked up. Speed increases slightly.
- Walls: wrap or crash? Let's make walls be the edge of the board — crash into buffer = derail. Or wrap-around tunnels? Trains going through tunnels at edges is cute: wrap-around with a tunnel arch drawn at edges? That's complex; simpler: crash at walls = derail, classic. Actually wrap is more forgiving and "tunnel" is thematic. I'll do wrap-around with a quick fade at edges... hmm, teleporting the train rendering with interpolation across the wrap is tricky. I'll keep walls with buffers (tree-lined edges) and derail on collision — classic and simpler rendering.
Wait — self-collision with a long train of wagons: the locomotive hitting its own wagons = derail. Fine.
Demo mode: self-playing AI (simple pathfinding avoiding self/walls, greedy toward passenger with safety check) running behind a "Click or press a key to start" overlay. After start, player controls with arrows/WASD. AI plays visibly in demo.
Rendering: canvas. Grid ~ 24×16 cells. Cell size responsive.
Let me plan the drawing:
Background: warm paper `#f3ead8` page, board is a deep green `#2e4a3a`? Or a railway-y look: grass green field `#7fb069`-ish with subtle texture. Let's do a soft spring green field with slightly darker checker tiles, rounded board corners, trees/bushes decoration on the frame? Keep it clean: field with two-tone green checker, thin grid.
Track behind train: when locomotive leaves a cell, drop a "tie" there: two brown rails + ties drawn across the movement direction, fading over ~ few seconds. Actually persistent track looks great — the train lays permanent track over visited cells, with fade to keep it subtle. I'll store recent cells with timestamps, render rails for cells visited in last N seconds, alpha fading. Memory bounded.
Train segments: each segment has grid position and interpolated pixel position. Movement: step every `stepMs` (starts ~170ms, decreases). Between steps, lerp positions from prev to current with smoothstep. Locomotive drawn at head interpolated pos, rotated toward direction. Wagons follow at their own interpolated positions — because each segment stores prev and cur cell, wagons trail naturally through corners. To make wagons look attached (not cutting corners), interpolation per-segment between its own prev/cur gives nice corner rounding naturally. Good.
Locomotive drawing (top-down view): rounded body in dark red or blue, cab at back, chimney near front, cowcatcher front, headlamp glow, smoke puffs rising (drawn as expanding fading circles drifting up-left, in screen space with slight offset). Smoke particles spawn every step or on timer.
Wagons: rounded rect in color from palette [red #d94f4f, yellow #e8b23a, blue #4a7fd4, orange #e07b39, teal #3aa6a0, pink...], with a lighter roof stripe, small wheels (two dark circles each side), maybe windows. Gap between segments ~ small.
Passengers: at food cell draw a little platform: a rounded square of concrete gray with yellow safety stripe edge, and a person: circle head, body rect in random color, waving? Animate a subtle bob. Also maybe a small sign. When picked: person pops, +1 wagon appended, score++, small floating "+1" text and a little "ding". Speed up slightly.
Crash: on collision with wall or own body (not the tail cell that's about to move? classic: allow moving into tail cell). Crash animation: shake, wagons tilt/scatter (rotate random, offset), dark overlay, "Derailed!" panel with score, best score (in-memory only, no localStorage allowed — so keep best just for session), "Press any key to ride again".
Sound: WebAudio beeps for pickup (little chime two notes) and crash (noise). Keep simple, resume context on first input. Maybe optional; sounds add life. I'll add tiny synth: pickup = two quick sine notes; crash = filtered noise burst. Wrap in try/catch.
AI demo mode: each step, choose direction among [current, left-turn, right-turn] (no reverse) scoring: prefer direction reducing Manhattan distance to passenger, avoid cells occupied by body (excluding tail), avoid walls. Use a lookahead: pick move that is safe (next cell not body/wall). Simple greedy with tie-break random. Also avoid immediate trap: check if chosen cell has at least one free neighbor. This is decent for a demo.
Also demo autopilot after player dies? Keep: after game over, any key/click restarts player mode. The start overlay: "Click or press a key to start" with the AI demo playing behind (dimmed slightly, with "DEMO" tag).
Controls: arrows/WASD, prevent reversing. Also swipe? Not necessary; maybe basic touch d-pad? Skip, keep clean; note keyboard controls. On mobile it's demo only — fine.
HUD: top bar: title "Little Local" or "Puffer & Co."? Title idea: "Tiny Junction" / "The Little Local". Score counter as "Passengers: N", plus speed indicator? Keep HUD minimal: left title with small train icon (draw with CSS or inline SVG — no emoji; I'll draw a tiny inline SVG locomotive icon), right: passengers count and best. Below: canvas board.
Layout: centered card, header row, canvas, footer hint of controls ("Arrows / WASD to steer"). Page bg warm cream `#f5efe3`, ink `#2b2620`, accent red `#c94f3d`. Typography: no external fonts allowed? External fonts are blocked ("No external files, fonts or network requests, except CDN URLs explicitly allowed" — none allowed). So system fonts only; I'll use a font stack with character: `"Avenir Next", "Trebuchet MS", "Segoe UI", sans-serif` — hmm, avoid Inter/Roboto/Arial/system-ui as primary. Trebuchet MS has character for a playful game. Use Georgia for title? A chunky rounded feel: `"Comic Sans MS"`? No. I'll use "Trebuchet MS" for UI and a heavier weight for the title, letter-spaced uppercase small caps for labels. Fine.
Canvas sizing: fixed logical size e.g. 960×600 with 24×15 grid at 40px cell; scale via CSS to container width, handle DPR. Responsive: canvas width 100%, aspect ratio kept via width/height attributes + CSS.
Let me write the code.
Constants:
- COLS=24, ROWS=15, CELL=40 → 960×600.
- Speed: stepMs start 180, min 90, each passenger −4ms.
Data:
- snake: array of {x,y} head first. prevSnake: positions before last step for interpolation. Store as arrays of same length. When growing, new wagon appended: its prev = same as its cur (spawn at tail's previous position) so it doesn't lerp weirdly — set prev equal to cur on grow.
- direction, nextDirection (queued single input).
- progress t = (now - lastStep)/stepMs clamped, eased.
Interpolation: for each segment i, prev[i]→cur[i]. On step: prevSnake = copy of snake (before moving), then move: unshift new head, pop tail (unless eating — then keep tail and grow by appending wagon: actually in snake, eating means don't remove tail; then snake array grows at head. But wagons: locomotive is head, wagons are body segments — new wagon appears at tail end (the caboose grows longer), which matches "passenger boards the last wagon"... but visually the passenger boards the train — new wagon at the back. Standard snake growth at tail = same thing. Good: eat → don't pop tail → length+1, and the new tail segment's prev should equal its cur to avoid pop-in: when we copy prevSnake from old snake (length n), new snake length n+1; index n (new tail) has no prev — set prev[n] = cur[n] (which equals old snake[n-?]...). Let me be careful:
Standard move: newHead added at front; if not eating, tail popped. prev array for interpolation should map: segment i of new snake came from... simplest: prev_i = old position of the same index before the move: for i in 0..n-1, prev[i] = oldSnake[i] if exists else oldSnake[last]. Since newSnake[0]=newHead (moves from oldSnake[0]), newSnake[1]=oldSnake[0], etc. So each segment moves from oldSnake[i] to newSnake[i] — that's exactly adjacent cells (train follows). When eating (no pop), newSnake[i] = oldSnake[i-1] for i≥1 and newSnake[n] = oldSnake[n-1] = old tail which stays — so last segment prev==cur, stationary that step.
Rail tracks: keep array `rails` of {x,y,dir,time}. When head moves from cell A to B, push a rail for cell A with direction of travel. Render each rail: two parallel lines offset perpendicular ±? and cross ties. Fade: alpha = clamp based on age, e.g. full for 4s then fade over 4s, remove after 8s. Cap length ~600 entries. Also draw rail under the train (train covers it anyway).
Passenger spawn: random free cell not on snake, not too close to head (Manhattan ≥ 3). Draw platform: gray rounded rect inset, yellow stripes on the edge facing... just a border stripe at bottom? Simple: platform tile with hazard stripes around border, person standing centered with slight bob, small shadow. Person color random from palette.
Passenger animation: bob via sin(time*3 + seed). On pickup: particle burst: few circles expanding + floating "+1" text rising, fading. Also a chime.
Smoke: particles array {x,y,r,age}. Spawn from locomotive chimney every ~120ms while moving (also in demo). Each puff: rises (y decreases), drifts with wind (-x slightly + direction opposite?), radius grows, alpha fades over ~1.2s. Draw as soft gray circles. Chimney world position: compute from head interpolated position + offset toward front of loco (rotate by heading). Heading angle from movement delta between cur and prev cell (or direction). At corners, angle interpolates? Just use direction vector of current step; snap angle — fine, or lerp angle shortest path. I'll lerp angle smoothly (shortest arc).
Locomotive drawing at (px,py) center, rotated by angle:
- shadow ellipse under.
- body: roundRect length ~0.78*cell, width ~0.6*cell, color deep red `#b5432f`? Palette: locomotive in dark teal-green `#2f6f5e`? Let's do classic: loco body `#3f5d8c` blue? Hmm choose: warm red loco `#c0392b` with dark roof `#8e2b20`, gold boiler band, black chimney, gold dome, headlamp cream circle with glow (radial gradient — glow allowed? I avoid decorative gradient glows as design crutch, but a headlamp light is functional/thematic and small; a soft radial for the lamp is fine and diegetic).
- front cowcatcher: small triangle-ish at front.
- cab at rear: darker rounded square with cream window.
Wheels: 2-3 dark circles along sides, partially outside body? Draw wheels first (dark `#26201a` with lighter hub), then body over? Wheels below body sides visible: draw wheels at y ±(bodyW/2), then body. In top-down view wheels barely visible; still draw small stubs. Keep it readable.
Wagon drawing at center, angle: rounded rect body (its color), slightly narrower than loco, roof stripe lighter along center, 2 wheels each side small, maybe 2 tiny windows (cream squares) — windows on top view = skylights, fine.
Angle handling: draw everything in a rotated context. For body length along movement axis.
Occupied set for collision & spawn: Set of "x,y" from snake (excluding tail when checking head move? Classic allows moving into the cell the tail is vacating this same step — since tail pops simultaneously. Implement: collision check against snake minus last element when not growing.)
Crash sequence: state='crash', crashTime; during crash render wagons scattered: for each segment compute offset rotating outward & random tilt based on time; screen shake via translate random; after ~0.9s show overlay panel (HTML overlay div, absolutely positioned over canvas) with "Derailed!" score, best, prompt. Key/click → reset to playing.
Demo AI: function aiChooseDir(): candidate dirs = [cur, left(cur), right(cur)]. For each, next cell; invalid if out of bounds or occupied by body (excluding tail cell if tail will move — approximate exclude tail). Score: -manhattan distance to passenger; +1 bonus if cell has ≥1 free neighbor (safety), small random tiebreak. Also avoid reversing. If none valid, keep cur (will crash → shows derail, then demo restarts automatically after 2.5s). In demo mode, game over overlay says demo restarts; auto-restart demo after short delay. After user starts (first key/click), demo overlay hides and control passes to user; state playing with fresh snake. If player crashes, overlay with "Press any key" → restart playing.
Also allow player to start by any key or click on overlay. Show controls hint.
HUD elements (HTML above canvas): left: inline SVG loco icon + "The Little Local" title; right: "PASSENGERS 0" and "BEST 0". Use tabular numbers.
Overlay start card: centered, warm panel: title "All aboard!", subtitle "Steer the little train, collect waiting passengers before the line ends.", "Click or press any key to start", small "DEMO — autopilot is driving" badge. Dim canvas behind via overlay bg rgba.
Also a subtle "toot" on start? Fun: on start, play a little whistle (two-tone). Optional; add.
Colors summary:
- page bg `#f2ecdd` cream, ink `#2a251d`.
- board: grass `#8fbf77` / checker alt `#86b66e`, border frame dark wood `#4a3b2c`? Rounded corners on canvas container with border `3px solid #3a3128`, shadow.
- track ties brown `#8a6a45`, rails `#5c4a33`.
- palette wagons: ['#d1495b','#e9b44c','#4d908c','#ef8354','#5f7fbf','#9a6fb8']? keep 6.
- loco red `#c2472f`, details cream `#f2e3c6`, black `#2b2620`.
Trees decoration around board? Could add small bushes drawn on canvas outside grid — skip; instead put a few decorative trees/shrubs ON the field edges? They'd block cells — instead reserve? Simpler: draw faint grass tufts random static spots (non-blocking) for texture. Yes: precompute 40 tuft positions (deterministic random with seed function), draw tiny darker green strokes. Nice texture.
Also a small station building? Maybe draw a tiny station near first passenger spawn? Overkill. Add a fixed little station house in a corner cell area that's decorative (non-blocking), drawn once — cute: at top-left corner outside play? The grid covers whole canvas. I could make border margin: canvas 1000×640 with 20px margin around the 24×15 grid at 40px = 960×600 + margins; draw grass margin with trees and a small station building in margin. Nice framing! Margin 20px: draw wooden fence/trees on margin. Station: small building with "STATION" sign in top margin? 20px too thin for a building. Make margin 28px top/bottom? Let's do canvas 1016×640: grid offset (28,20)? Let me simply: CELL=40, COLS=24, ROWS=15 → 960×600; pad = 24 → canvas 1008×648. Draw pad as darker grass with scattered trees (circles) and a tiny station building at left pad? Left pad only 24 wide. Alternatively put decorative elements sparsely in pad corners. Honestly, simpler and still charming: skip pad; draw board full-bleed with rounded corners via CSS border-radius on canvas, plus a CSS wooden frame (border + inner shadow). Trees: place a handful of non-blocking decorative trees ON the field but the train drives "behind" them? Could look odd. I'll scatter small tufts + a few tiny flowers (3px dots) — subtle texture, safe. And maybe 2-3 bushes at cells that are never used? Not worth collision complexity. Go with tufts/flowers.
Actually one more charming idea: platforms for passengers could alternate orientation, whatever. Keep single style.
DPR handling: set canvas width/height = logical * dpr, ctx.scale(dpr,dpr), CSS width via style 100%.
Keyboard: window keydown: if state==='menu' → startGame(); if 'over' → restart; if playing → map arrows/wasd to dir queue (ignore reverse of current direction). Prevent default for arrows/space to avoid scroll. Also 'p' pause? Add pause with P — nice extra: overlay "Paused". Small effort, include.
Sound: AudioContext lazy init on first user gesture. functions: chime(), whistle(), crashSound(), tick? Keep pickup chime + whistle on start + crash noise. Volume low. Guard try/catch and state.suspended resume.
Game loop: requestAnimationFrame; accumulate time; when t≥stepMs do step (possibly multiple but clamp 1). Update interpolation t = min(1,(now-lastStep)/stepMs) eased = t*t*(3-2t)? For trains, linear looks mechanical-good; slight ease-out at each step gives chug rhythm. Use smoothstep mild: e = t (linear) — I'll use linear for constant train speed, corners handled by per-segment lerp anyway. Actually per-segment linear is fine and smooth.
Passenger pickup check: when head lands on passenger cell → eat. Also check during interpolation? Grid-step check is fine.
Self-collision check at step: newHead equals any segment except tail (when not eating). If eating, tail stays so check includes tail. Standard.
Wall: newHead out of bounds → crash.
Score: passengers count; best tracked in JS var (session only).
Floating texts: array {x,y,text,age} rendered in canvas with bold font, rise & fade 0.9s.
Particles for pickup: 8 small circles in passenger's color, radial burst, fade 0.5s.
Let me also add wagon "windows" glow? no.
Grid draw: for each cell two-tone checker; subtle. Also faint dots at intersections? skip.
Rail rendering: for rail at cell with dir d: ties: 3 short lines perpendicular across cell (spacing along dir), color `#7d5f3f` width 4; rails: two lines parallel to dir offset ±7px, color `#5a4732` width 2.5, drawn with round caps, inset margins. Alpha by age: age<3s → 0.9; then fade to 0 by 7s. Store dir as vector.
Also draw a rail AT the cell head currently occupies? The head's current cell rail gets added when leaving. The segment behind covers previous cell. Fine.
Train length cap: none needed.
Passenger render:
- platform: rounded rect inset 4px, fill `#cfc6b8` concrete, border stripe: draw hazard stripes: clip rounded rect, draw diagonal yellow `#e9c46a`/dark stripes along the border ring? Simple: stroke border with dashed thick yellow line (lineDash [6,5], lineWidth 5, color #e0a43a) — reads as safety edging. Plus small shadow.
- person: bob offset; head circle r4 `#f0c9a6` skin? use neutral `#e8b48c`; body: rounded rect 10×12 in random cloth color; simple arms? keep tiny: head + body + 2 dot feet. Maybe waving arm: a small line rotating. Keep body+head, plus shadow ellipse.
Sign: tiny blue sign post next to platform? skip.
Now the "grows a wagon" pop: when eating, set a flag growPulse: the new tail wagon scales from 0.4→1 over 300ms and passenger color becomes the wagon color — nice narrative: the passenger's coat color becomes their wagon! Store wagonColors array appended with passenger's cloth color. Locomotive separate. So train colors = passenger colors.
Also loco heading: angle = atan2(dy,dx) of (cur-prev) for head; for wagons same per-segment. Smooth angle: keep displayed angle per segment lerped toward target with shortest-arc, factor 1-exp(-dt*12). Store segAngles array parallel to snake. When length grows, push angle = last angle.
Scatter on crash: each segment gets random scatter vector & spin, animated over 0.9s with ease-out; smoke burst big; screen shake decreasing.
Now HTML/CSS structure:
```
<body>
<main class="wrap">
<header> icon svg + h1 "The Little Local" ... stats (PASSENGERS n / BEST n) </header>
<div class="board">
<canvas id="cv"></canvas>
<div class="overlay" id="overlay"> ... dynamic content ... </div>
</div>
<footer class="hint"> arrows/WASD steer · P pause </footer>
</main>
</body>
```
Overlay states: menu (title "All aboard!", "Click or press any key to start", demo badge), paused, over (Derailed! score, best, "Click or press any key to ride again"). Overlay pointer-events auto to capture click (also click anywhere on board). I'll listen click on document/overlay.
Overlay style: centered card, cream bg `#fbf6ea`, border 2px `#3a3128`, border-radius 14px, shadow, padding. Title font bold. Demo badge: small uppercase letter-spaced chip with dot pulsing.
Ensure first paint shows game running: start demo immediately on load (state 'menu', autopilot driving). Overlay visible but not fully opaque — semi transparent dim so train visible: overlay bg rgba(42,37,29,0.35)? But the card itself opaque. Also add slight canvas dim. Good.
Edge case: overlay covering canvas captures clicks — fine, any click starts.
Now code carefully.
Constants & state:
```js
const COLS=24, ROWS=15, CELL=40, W=COLS*CELL, H=ROWS*CELL;
```
state object: mode: 'menu'|'play'|'pause'|'over'; snake array; prevSnake; angles array; dir {x,y}; queue dir pending; passenger {x,y,color,seed}; rails; smoke; particles; floats; score; best; stepMs; lastStep; growPulse timers; crash info; shake.
Functions:
- resetGame(demo)
- step()
- chooseAiDir()
- update(dt, now)
- draw(now)
- drawCell helpers: roundRect path.
Angle lerp helper: `lerpAngle(a,b,t)` shortest.
Passenger spawn: collect free cells (not snake, not within 2 of head maybe), random.
AI:
```js
function aiDir(){
const dirs=[dir, left(dir), right(dir)];
const head=snake[0];
let best=null,bestScore=-1e9;
const occ=new Set(snake.slice(0,-1).map(k)); // tail will move (unless eating; approximate)
for(const d of dirs){
const nx=head.x+d.x, ny=head.y+d.y;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
if(occ.has(nx+','+ny)) continue;
let s = -(Math.abs(nx-passenger.x)+Math.abs(ny-passenger.y));
// safety: count free neighbors
let free=0;
for(const dd of DIRS4){const ax=nx+dd.x,ay=ny+dd.y; if(ax>=0&&ay>=0&&ax<COLS&&ay<ROWS&&!occ.has(ax+','+ay)) free++;}
s += (free>=2?4:free===1?-3:-10);
s += Math.random()*0.6;
if(s>bestScore){bestScore=s;best=d;}
}
return best||dir;
}
```
Also avoid head-on into own next? fine. This keeps demo alive reasonably. Additionally avoid choosing a dir that immediately reverses — left/right of current can't reverse. Good.
One more safety: if passenger is on a cell adjacent such that greedy traps... acceptable for demo; crashes auto-restart demo after 1.8s with fresh run.
Demo auto-restart: in mode 'over' with demo flag, setTimeout restart demo. Use timer in update loop rather than setTimeout (safer): overTime; if demo && now-overTime>1800 → resetGame(true). For player: wait for input.
Input handling:
```js
addEventListener('keydown', e=>{
initAudio();
const k=e.key;
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(k)) e.preventDefault();
if(mode==='menu'){ startPlay(); return;}
if(mode==='over'){ if(!demo) startPlay(); else startPlay(); return;} // any key restarts as player
if(mode==='pause'){ mode='play'; hideOverlay(); return;}
if(k==='p'||k==='P'){ pause(); return;}
map arrows/wasd → queueDir
});
```
Click: if menu/over → startPlay; if pause → resume; if playing → pause? Clicking to pause could be annoying; make click during play do nothing (or pause). I'll make click during play toggle pause? Could confuse. Keep: click only starts/resumes/restarts. Overlay is the click target when visible; when hidden, board click does nothing. Fine.
Queue direction: store pendingDir; applied at next step if not reverse of current dir. Also allow instant reverse check against dir at time of applying.
Update loop:
```js
function frame(now){
const dt = Math.min(50, now-lastFrame); lastFrame=now;
if(mode==='play' || mode==='menu'){ // menu = demo running
acc += dt;
while(acc >= stepMs){ acc -= stepMs; doStep(); }
}
updateFx(dt, now);
draw(now);
requestAnimationFrame(frame);
}
```
Hmm interpolation based on acc/stepMs: t = acc/stepMs.
doStep():
```js
const d = pendingDir && !isReverse(pendingDir, dir) ? pendingDir : dir;
// ai in menu mode:
const nd = (mode==='menu') ? aiDir() : d;
dir = nd;
head new pos; check wall → crash();
check body: occupancy of snake without tail (since tail moves) unless eating; eating = newHead==passenger cell → include tail.
prevSnake = snake.map(copy);
snake.unshift(newHead);
if(ate){ ... score++, wagonColors.push(passenger.color), spawnPassenger, effects, stepMs=max(90,stepMs-4), speedup } else snake.pop();
// rebuild prevSnake mapping: prev for segment i should be oldSnake[i] (position it moves from)
```
Wait ordering: compute oldSnake = snake before move. After move, for interpolation: prev[i] = oldSnake[i] ?? oldSnake[oldSnake.length-1]. Because newSnake[i] = oldSnake[i-1] (i≥1). newSnake[0]=newHead, moves from oldSnake[0]=old head. So prev[i]=oldSnake[i-1]... let me recompute: newSnake[1] = oldSnake[0]; the segment at index1 moved from oldSnake[1]?? The wagon that is now at index1 was previously the head (index0). But visually we treat segments as train cars: car at slot i moves from oldSnake[i] to newSnake[i]=oldSnake[i-1]. Each car advances one cell along the path — correct: prev[i] = oldSnake[i] (if exists) else oldSnake[last]. Yes as I said. And angles similarly: targetAngle[i] = angle from oldSnake[i]→newSnake[i]; if same cell (growth tail), keep previous angle.
Simplify: after computing snake, build prevSnake: for i: prevSnake[i] = oldSnake[Math.min(i, oldSnake.length-1)]. But if not eating, oldSnake.length === snake.length, fine. If eating, snake.length = old+1; last uses old last.
Angle per segment: segAngle[i] = lerp toward angleOf(prev[i]→snake[i]) each frame; if prev==cur keep.
crash(): mode='over' (or demo-over), record scatter params per segment: random dir vector (normalized random angle) * dist(20-70), random rotation; crashT=now; smoke burst; sound; shake amp. Score finalize: best=max(best,score). If demo: schedule auto restart.
Note in demo crash, overlay: show "Derailment! Autopilot restarting…" small — actually keep same overlay card but text adjusts. I'll show overlay for demo crash too briefly with "New run starting…". Simpler: for demo crash show overlay with title "Derailed!" subtitle "The autopilot is coupling up a new train…" and auto-hide on restart. OK.
Draw order:
1. clear, bg grass base + checker.
2. tufts/flowers.
3. rails.
4. passenger (platform + person).
5. train (wagons from tail to head so head on top; actually draw tail-first so head overlaps? draw head last so loco on top. Also smoke above train? smoke drawn after train).
6. smoke particles.
7. particles/floats.
8. shake transform wraps 1-7.
9. vignette? skip.
Also draw subtle border edges? Canvas has CSS border.
Checker: for x,y: fill (x+y)%2 ? '#8db974' : '#95c07c'. Base fill first then only alt cells for speed — fine either way, 360 cells trivial.
Grass tufts: precompute with seeded rand: positions, type (tuft: 3 short strokes darker `#79a763`; flower: tiny white/yellow dot). Avoid cells? They're decorative small, fine anywhere but avoid drawing on passenger cell? negligible.
Passenger draw:
```js
const px=passenger.x*CELL+CELL/2, py=...;
bob = sin(now/300 + seed)*1.5;
platform: roundRect centered w=CELL-10,h=CELL-10,r=8 fill '#d8cfc0', inner shade; stroke dashed '#dca63f' lw4 dash[7,5] r=8.
person at py+bob: shadow ellipse; body roundRect w=12,h=13 color; head circle r=4.5 '#eec39a' with hair arc? just head + tiny cap color? Keep head + body + feet dots.
Also a tiny suitcase beside? cute: small brown rect 6×5 with handle. yes add.
```
Train draw function drawCar(cx,cy,angle,type,color,pulse):
```js
ctx.save(); translate(cx,cy); rotate(angle);
shadow: ellipse offset (2,4) rgba(0,0,0,.18) size L×Wd.
wheels: for type loco: 3 wheels each side at x=-L*0.3,0? along length; radius 4.5 dark.
body loco:
main boiler: roundRect(-L/2, -Wd/2, L, Wd, 8) fill '#c2472f' (loco red);
cab at rear (x from -L/2 to -L/2+12): darker overlay '#a03622' roundRect with window cream roundRect 8×7.
front (x=+L/2): cowcatcher: triangle path fill '#3a3128'.
chimney: at x=+L*0.22: small circle black r=4 with inner r=2 cream? top view chimney = circle. plus gold dome circle at x=-L*0.05 r=4 '#e0b64f'.
headlamp: at x=+L/2 front center small circle cream; glow: radial gradient small alpha (diegetic).
boiler bands: 2 lines across.
wagon: body roundRect fill color; roof stripe lighter (lighten color) roundRect narrower; wheels 2 per side; small coupler? gap covered.
pulse scale for new wagon: scale factor s applied.
```
lighten helper: mix hex with white 30%.
Smoke spawn: every smokeTimer 140ms: pos = head interp pos + rotate offset (x=+L*0.22 along heading) ; particle {x,y, vx:-dirx*10 + rand, vy:-diry*10 - 20 rising? Screen-space rising: vy negative (up), plus inherit a bit of reverse heading. r start 3→ grows to 10; life 1.3s; alpha .5→0; color '#ece6da' on grass? white-ish smoke `rgba(245,242,235,α)` with slight gray. On crash: burst 14 puffs bigger.
Chimney offset: forward vector f=(cos a, sin a); pos = head + f*(CELL*0.28).
Floats: {x,y,vy:-30,age}.
Now the HUD stats update: DOM spans updated when score changes.
Fonts: title in header: uppercase, letter-spacing .12em, weight 700, `Trebuchet MS`. Numbers tabular via font-variant-numeric.
Inline SVG loco icon (side view tiny train): simple path: body rect, cab, chimney, 2 wheels. I'll hand-write:
```svg
<svg viewBox="0 0 40 26" width="34">
<rect x="3" y="9" width="22" height="9" rx="2.5" fill="#c2472f"/>
<rect x="22" y="4" width="9" height="14" rx="2" fill="#a03622"/>
<rect x="24.5" y="6.5" width="4" height="4" rx="1" fill="#f2e3c6"/>
<rect x="6" y="4" width="3.4" height="6" rx="1" fill="#3a3128"/>
<circle cx="9" cy="19.5" r="3" fill="#3a3128"/>
<circle cx="16" cy="19.5" r="3" fill="#3a3128"/>
<circle cx="26.5" cy="19.5" r="3" fill="#3a3128"/>
<path d="M1 22 h34" stroke="#3a3128" stroke-width="1.6"/>
</svg>
```
Something like that; refine.
Responsive: `.wrap{max-width:1020px; margin:auto; padding:16px}` canvas display block width:100% height auto. Header flex wrap. At 375px, stats wrap under title.
Prevent page scroll on arrows handled.
Pause overlay content: "Paused — press P to resume".
Let me also add subtle speed display? skip; show "PASSENGERS" count big in HUD and best.
Board CSS: `.board{position:relative; border-radius:16px; border:3px solid #3a3128; overflow:hidden; box-shadow:0 10px 24px rgba(58,49,40,.18), inset 0 0 0 2px rgba(255,255,255,.15)}` canvas display block.
Page bg: cream `#f4eee0` with subtle pattern? Add a very subtle repeating radial? Avoid gradients as decoration; flat cream fine, maybe faint dotted texture via CSS `background-image: radial-gradient(...)`? I'd rather keep flat. Add a thin top accent bar? Keep clean: cream bg, ink text.
Header: baseline row: icon + title "The Little Local", tagline small "a tiny train snake" ; right stats: two stat blocks (label small caps, value big).
Footer hint: "Arrow keys / WASD to steer · P to pause · wagons = passengers picked up".
Demo badge in overlay: "AUTOPILOT DEMO — click or press any key to take the throttle".
Overlay start card content:
```
<div class="card">
<div class="kicker">NOW BOARDING</div>
<h2>All aboard!</h2>
<p>Steer the little steam train along the line. Stop at each platform to pick up waiting passengers — every rider becomes a new wagon on your train. Don't rear-end yourself or overshoot the ends of the line.</p>
<div class="demo-chip"><span class="dot"></span>Autopilot driving — watch the demo</div>
<button>Click or press any key to start</button>
<div class="keys">↑ ↓ ← → or WASD · P pauses</div>
</div>
```
Button styled solid ink with cream text; hover press effect. Clicks on button/overlay trigger start.
Game over card: kicker "END OF THE LINE", h2 "Derailed!", stats "You delivered N passengers" + "Best this session: M", button "Click or press any key to ride again".
Pause card: h2 "Held at a red signal", p "Press P or click to continue".
Now write the full code. Watch out for correctness.
Also DPR:
```js
function resize(){
const dpr=Math.min(2, devicePixelRatio||1);
cv.width=W*dpr; cv.height=H*dpr; ctx.setTransform(dpr,0,0,dpr,0,0);
}
```
CSS canvas width 100%, aspect-ratio W/H.
Angle helper:
```js
function angLerp(a,b,t){let d=b-a; while(d>Math.PI)d-=2*Math.PI; while(d<-Math.PI)d+=2*Math.PI; return a+d*t;}
```
Per-frame: for each segment i: target = atan2(cur-prev) if differs; segAngle[i]=angLerp(segAngle[i],target, 1-Math.exp(-dt/60))... use k = 1-exp(-dt*0.02)? dt ms: k=1-Math.exp(-dt/40). Also on new segment push angle of previous last.
Positions: for i: prev cell pc, cur cell c: px = lerp(pc.x,c.x,t)*CELL+CELL/2 etc.
But careful: segAngle initial on reset: dir angle.
Crash scatter rendering: in 'over' mode, for each segment: base interp pos frozen at crash (t frozen — store frozen positions? simply stop stepping; t stays at last acc/stepMs value; acc stops accumulating since mode!=='play'). For scatter: age=(now-crashT)/1000; e=easeOut(age/0.9 clamped); offset = scatterVec[i]*e; rot = scatterRot[i]*e; draw car with translate(pos+offset) rotate(angle+rot), maybe alpha fade slightly? keep full, add tilt via scale? Fine.
Shake: amp = max(0, 1 - age*1.4) * 7; translate random each frame.
In 'over' player mode overlay appears after 800ms delay (so scatter visible first): handle in update: if mode==='over' && !overlayShown && now-crashT>800 → showOverlay('over'). Demo: show immediately? show after 500ms, restart at 2200ms.
Smoke continues in over? no new spawns except burst.
Passenger shouldn't spawn on snake; also ensure reachable-ish. Fine.
Also don't spawn passenger under… ok.
Occupancy check for spawn: build set from snake.
Let me now also handle the "pendingDir" for player, and reverse prevention: isReverse(d, dir) means d.x===-dir.x && d.y===-dir.y. Also ignore same dir. At doStep for player: if pendingDir valid (not reverse, not equal) dir=pendingDir; clear pendingDir.
Multiple key presses between steps: only last kept — fine.
Start: snake initial: length 3 (loco + 2 wagons) at center-left heading right: cells (5,7),(4,7),(3,7); wagonColors pre-picked 2 colors. Actually give starting train 1 wagon? Loco + 2 wagons looks better on first paint. Yes 2.
Demo also same start.
stepMs=175.
Score HUD update via function.
Audio:
```js
let AC=null;
function initAudio(){ if(!AC){try{AC=new (window.AudioContext||window.webkitAudioContext)()}catch(e){}} if(AC&&AC.state==='suspended')AC.resume(); }
function tone(f,dur,type='sine',vol=.15,when=0){ if(!AC)return; try{const o=AC.createOscillator(),g=AC.createGain();o.type=type;o.frequency.value=f;g.gain.setValueAtTime(vol,AC.currentTime+when);g.gain.exponentialRampToValueAtTime(.001,AC.currentTime+when+dur);o.connect(g).connect(AC.destination);o.start(AC.currentTime+when);o.stop(AC.currentTime+when+dur+.05);}catch(e){}}
pickup: tone(660,.12,'triangle',.12); tone(880,.14,'triangle',.12,.09);
whistle: tone(540,.35,'sine? use sawtooth soft'...): two detuned: tone(620,.4,'triangle',.08); tone(740,.4,'triangle',.08) with slight vibrato — keep simple: play 620 & 930 together .45s.
crash: noise: create buffer white noise 0.4s through gain ramp — do simple:
function crashSnd(){ if(!AC)return; try{const n=AC.createBufferSource();const b=AC.createBuffer(1,AC.sampleRate*0.35,AC.sampleRate);const d=b.getChannelData(0);for(let i=0;i<d.length;i++)d[i]=(Math.random()*2-1)*(1-i/d.length);n.buffer=b;const g=AC.createGain();g.gain.value=.2;n.connect(g).connect(AC.destination);n.start();}catch(e){}}
```
Now write everything. Also the demo runs before any click — audio not initialized until gesture, so silent demo. Fine.
Rail drawing detail: rail segment for cell with direction d (unit). perpendicular p=(-d.y,d.x). rails at ±6 along p, spanning from -CELL/2+4 to +CELL/2-4 along d (so rails connect across cells visually). ties: 3 lines perpendicular at s=-10,0,10 length 22, width 4? Ties under rails: draw ties first color '#8a6a45' alpha*, then rails '#5b4831' width 2.5. round caps.
Alpha: age s=(now-r.t)/1000; a = s<2.5?0.85 : max(0, .85*(1-(s-2.5)/3.5)); remove when s>6.
Cap rails array length: shift when > 800.
The train covers current cells so tracks appear behind — good.
One subtlety: initial cells have no rails until train leaves them — fine.
Let me also give the field a mowed-stripe look via checker — yes already.
Write drawCar:
```js
function drawCar(x,y,a,opt){ // opt: {kind:'loco'|'wag', color, pulse, alpha}
const L = opt.kind==='loco'? CELL*0.92 : CELL*0.78;
const Wd = CELL*0.56;
ctx.save();
ctx.translate(x,y);
ctx.rotate(a);
if(opt.pulse!=null){const s=0.4+0.6*opt.pulse; ctx.scale(s,s); ctx.globalAlpha=Math.min(1,opt.pulse*2);}
// shadow
ctx.fillStyle='rgba(40,32,20,.20)';
rounded ellipse: ctx.beginPath(); ctx.ellipse(0,3,L*0.52,Wd*0.62,0,0,Math.PI*2); ctx.fill();
// wheels
ctx.fillStyle='#332b22';
const wy=Wd/2-1;
const wxs = opt.kind==='loco'? [-L*0.28, L*0.05, L*0.3] : [-L*0.22, L*0.22];
for(const wx of wxs){ for(const s of [-1,1]){ ctx.beginPath(); ctx.arc(wx, s*wy, 4.4,0,7); ctx.fill(); } }
// hubs
... small lighter circles r1.8 '#5a4c3a'
if(opt.kind==='loco'){
// cowcatcher
ctx.fillStyle='#3a3128';
ctx.beginPath(); ctx.moveTo(L/2, -Wd*0.42); ctx.lineTo(L/2+6, 0); ctx.lineTo(L/2, Wd*0.42); ctx.closePath(); ctx.fill();
// body
rr(-L/2, -Wd/2, L, Wd, 9); fill '#c2472f';
// boiler shading stripe bottom
ctx.fillStyle='rgba(0,0,0,.12)'; rr(-L/2, Wd/2-7, L, 7, {bl:9,br:9}) hmm roundrect partial corners; simpler: clip body then fill rect bottom.
// cab (rear)
ctx.fillStyle='#8e3120'; rr(-L/2, -Wd/2-2, 14, Wd+4, 5) fill; // cab slightly taller
// cab window
ctx.fillStyle='#f2e3c6'; rr(-L/2+3.5, -Wd/2+1.5, 7, Wd-3, 3) fill;
// chimney
ctx.fillStyle='#332b22'; circle at (L*0.24,0) r 5.5; inner circle '#1f1a14' r 3;
// steam dome
ctx.fillStyle='#e5b954'; circle at (L*0.02,0) r 4.2;
// lamp
circle at (L/2-3,0) r 3 '#ffe9b0'; glow radial r 10 alpha .35.
// bands
strokes '#a03622' lw2 lines across at x=-L*0.12, L*0.14? small vertical lines.
} else {
rr(-L/2,-Wd/2,L,Wd,7) fill color;
// roof stripe
lighter = mix(color,#fff,.35); rr(-L/2+3,-4.5,L-6,9,4) fill lighter; // center plank
// outline shade bottom: rgba(0,0,0,.10) rect bottom 6 within clip.
// tiny skylights: two cream rr 6×5 at ±L*0.18? on stripe? maybe two darker straps instead. Keep stripes + 2 thin dark straps across.
}
restore
}
```
rr helper building path with roundRect (ctx.roundRect available in Chrome). Use ctx.beginPath(); ctx.roundRect(...); ctx.fill(); Chrome latest supports roundRect — yes.
For bottom shading clip: after filling body, ctx.save(); clip same path; fillRect(-L/2, Wd/2-6, L,6) rgba(0,0,0,.12); restore. Do similar for loco.
Coupler between cars? Cars nearly touching due to follow spacing (adjacent cells, centers 40px apart, car length ~31-37 → gaps ~5-9px). Draw a small dark link: line between consecutive car centers when drawing? Simple: before cars, draw for each consecutive pair a short dark line (coupling) width 4 color '#3a3128' from pos[i] to pos[i+1]. Yes! Nice detail.
Order: couplings → wagons (tail→index1) → loco last.
Headlamp glow: ctx.createRadialGradient at front, radius ~CELL*0.9, from rgba(255,235,170,.35) to transparent — diegetic light, ok.
Draw order note: glow should render above grass but under? draw as part of loco (after body) — will glow over rails ahead. Nice.
Also loco slight chug: body bounce? subtle scale pulse with step phase: s=1+0.03*sin(t*2π). Optional — add tiny bob offset perpendicular: y-offset in local space = sin(t*2π*?)... keep simple: no.
Floats draw: ctx.font='bold 16px Trebuchet MS'; fill '#2a251d' with alpha; text '+1 passenger'? just '+1'.
Pickup particles color = passenger color.
Score display maybe animate bump: CSS class pulse on change: stats value transition transform scale via re-adding class. Simple: set textContent, add 'bump' class, remove after 250ms (with reflow). ok.
Overlay show/hide functions manipulating innerHTML? Use three prebuilt cards toggled by hidden class — cleaner. I'll build overlay container with dynamic innerHTML via template strings for simplicity.
Now AI: also in menu mode player keys shouldn't do anything besides start. Good.
Time to write final code. Also make sure no console errors: guard roundRect existence (Chrome has it). devicePixelRatio fine.
Grid occupancy Set keys `x+','+y`.
Passenger spawn initial after reset.
Edge: aiDir uses passenger — ensure exists.
Write CSS:
```css
*{box-sizing:border-box}
html,body{margin:0}
body{background:#f4eee0;color:#2a251d;font-family:"Trebuchet MS","Verdana",sans-serif; min-height:100vh; display:flex; align-items:center; justify-content:center; padding:14px;}
.wrap{width:min(1000px,100%);}
header{display:flex; align-items:flex-end; justify-content:space-between; gap:12px; padding:2px 4px 10px; flex-wrap:wrap;}
.brand{display:flex; align-items:center; gap:10px;}
h1{font-size:22px; margin:0; letter-spacing:.04em;}
.tag{font-size:12px; color:#7a6f5d; margin-top:2px; letter-spacing:.02em}
.stats{display:flex; gap:22px;}
.stat .lab{font-size:10px; letter-spacing:.18em; color:#7a6f5d; text-transform:uppercase}
.stat .val{font-size:24px; font-weight:bold; font-variant-numeric:tabular-nums; line-height:1.05}
.board{position:relative; border-radius:14px; border:3px solid #3a3128; box-shadow:0 14px 30px rgba(58,49,40,.16); overflow:hidden; background:#8db974;}
canvas{display:block; width:100%; height:auto;}
.overlay{position:absolute; inset:0; display:flex; align-items:center; justify-content:center; background:rgba(42,37,29,.34); padding:16px;}
.overlay.hidden{display:none}
.card{background:#fbf6ea; border:2px solid #3a3128; border-radius:14px; padding:22px 26px; max-width:430px; text-align:center; box-shadow:0 12px 26px rgba(42,37,29,.25);}
.kicker{font-size:10px; letter-spacing:.24em; color:#c2472f; font-weight:bold; margin-bottom:6px}
.card h2{margin:0 0 8px; font-size:26px}
.card p{margin:0 0 14px; font-size:13.5px; line-height:1.55; color:#54483a}
button.go{font:inherit; font-weight:bold; background:#2a251d; color:#f7f1e3; border:none; border-radius:9px; padding:11px 18px; cursor:pointer; letter-spacing:.02em}
button.go:hover{background:#c2472f}
.demo-chip{display:inline-flex; align-items:center; gap:7px; font-size:10.5px; letter-spacing:.14em; text-transform:uppercase; color:#6b5f4d; border:1.5px dashed #b4a68d; border-radius:999px; padding:5px 12px; margin-bottom:14px}
.dot{width:7px;height:7px;border-radius:50%;background:#c2472f; animation:blink 1.1s infinite}
@keyframes blink{50%{opacity:.25}}
.keys{margin-top:12px; font-size:11px; color:#8a7c66}
kbd{border:1px solid #c6b99e; border-bottom-width:2.5px; border-radius:5px; padding:1px 5px; font-family:inherit; font-size:10.5px; background:#fff; }
footer{padding:10px 4px; font-size:12px; color:#8a7c66; text-align:center}
.val.bump{animation:bump .25s}
@keyframes bump{40%{transform:scale(1.25)}}
.stat .val{display:inline-block}
```
Now JS full. Careful with pulse for the new wagon: track growAt = {index: snake.length-1, t:now} — simpler: store growTime and growIndex; pulse = clamp((now-growTime)/350). When another grow happens later index changes; only one new wagon at a time realistically. Use per-segment bornAt array: born[i] timestamps; default 0 (no pulse). On grow, born.push(now). pulse for segment i if born[i]: p=min(1,(now-born[i])/350) → scale .4+.6p. Once p>=1, treat none.
prevSnake & born arrays align with snake length each frame: when building prev from oldSnake, born for existing indices keep; new index gets now.
Actually born array: on step, born length adjusts: when eating, born.push(now); when not eating (no length change), born unchanged. But note indices shift meaning? newSnake[i] corresponds to oldSnake[i-1] as the same car... The car identities: head is loco (always index0 = loco). Wagons: car k at index k. When moving, car at index i was previously at oldSnake[i]—wait no: the car identities stay with indices since head new cell becomes index0 (the loco moves there), and car formerly at index i-1 is now at index i? Hmm: newSnake[i] = oldSnake[i-1] means the cell that car i now occupies is where car i-1 was. Car i (identity) moved from oldSnake[i] to oldSnake[i-1]. So identity = index. born[i] belongs to car i. When growing, a new car appears at index len-1 (new tail) — born.push(now). Correct.
Colors: wagonColors[0] unused (loco). wagonColors[i] for i≥1; length = snake.length-1. On grow push passenger color.
Angles array segA per index; on grow push current tail angle.
prevSnake per index; on step set prevSnake = oldSnake padded.
Let me write step():
```js
function doStep(){
const old = snake.map(c=>({x:c.x,y:c.y}));
let d=dir;
if(mode==='menu'){ d = aiDir(); }
else if(pending){ if(!(pending.x===-dir.x&&pending.y===-dir.y) && !(pending.x===dir.x&&pending.y===dir.y)) d=pending; pending=null; }
dir=d;
const nx=snake[0].x+d.x, ny=snake[0].y+d.y;
// wall
if(nx<0||ny<0||nx>=COLS||ny>=ROWS){ crash(old,false); return; }
const ate = (nx===passenger.x && ny===passenger.y);
// body collision: cells occupied excluding tail (tail vacates) unless ate
const occ=new Set();
for(let i=0;i<snake.length-(ate?0:1);i++) occ.add(snake[i].x+','+snake[i].y);
if(occ.has(nx+','+ny)){ crash(old, false); return; }
// rail at old head cell
rails.push({x:snake[0].x,y:snake[0].y,dx:d.x,dy:d.y,t:performance.now()});
if(rails.length>700) rails.splice(0,rails.length-700);
snake.unshift({x:nx,y:ny});
if(!ate) snake.pop();
// prev mapping
prevSnake = old; // prev[i]=old[min(i,old.length-1)] handled in draw via clamp
if(ate){ ... }
}
```
prev mapping with clamp index in draw: `const p = prevSnake[Math.min(i, prevSnake.length-1)]`. When ate: snake.length = old.length+1; index last: prev = old[last] = its cur → stationary. Wait snake[last] after unshift-no-pop = old[old.length-1]?? newSnake[last] = old[last-1]? Let's verify: newSnake = [newHead, old0, old1, ..., oldLast] (length old+1). index i≥1 → old[i-1]. prev for index i should be old[i] (car i moves from old[i] to old[i-1]) — for i up to old.length-1. For last index i=old.length: car is the NEW car; it should appear at position old[old.length-1]?? newSnake[old.length] = old[old.length-1] = old tail. And prev = clamp → old[old.length-1] = same → stationary.
But wait — new car spawns at old tail position which is where the old caboose was... and old caboose car index old.length-1 moved to old[old.length-2]. So the new car appears exactly where the last wagon was — train grows from the back seamlessly. But visually two cars at same spot at t=0 then separate — new car stationary, caboose ahead moves forward. Good, and new car pulses in.
Non-eat: newSnake=[newHead, old0..old_{n-2}] length n. index i: cur=old[i-1], prev=old[i] (clamp fine since prevSnake length n, index n-1: prev=old[n-1], cur=old[n-2]) — caboose moves forward. Head: prev=old[0]... wait prev[0] should be old head pos = old[0], cur=newHead. clamp gives old[0]. ✓.
Draw loop positions: t = mode playing/menu ? acc/stepMs : (frozen? during 'over' use lastT). Store lastT = acc/stepMs at crash moment... acc stops updating in over mode; compute t=acc/stepMs anyway (acc frozen). But acc could be ≥ stepMs? we clamp while loop before crash... crash exits doStep leaving acc maybe partially. t=min(1,acc/stepMs). Fine.
For angles: target angle for segment i = atan2(cy-py, cx-px) if movement else keep. Update each frame with smoothing k=1-exp(-dt/45).
Draw wagons from last to 1, loco idx0. Couplings first.
Now updateFx: smoke spawn timer only when mode play/menu and not crashed: smokeT-=dt; if<=0 spawn & reset 130ms + jitter. Particles update, floats update, rails prune, angles update, HUD? HUD updated on events.
Crash also spawns burst smoke and sets shake.
Draw smoke: for each p: alpha=(1-p.age/p.life)*0.55; radius p.r*(0.5+p.age/p.life*1.6)? draw circle at p.x,p.y fill `rgba(250,248,242,α)`. Also darker core? single tone ok with slight two circles. Keep one.
Smoke drift: vx = -dirx*8 + rand(-6,6), vy = -diry*8 - rand(14,26) (rising on screen = up). Update x+=vx*dt/1000 etc. r grows.
Passenger pick effect: particles burst 10 dirs random speed 40-90, life .5, radius 3, color passenger color; float '+1' at cell top.
Also chime.
Also maybe speed lines? no.
spawnPassenger(): choose random free cell with manhattan dist from head ≥4; color = palette random (avoid same as last? fine random); seed=rand.
Palette: ['#d1495b','#e9b44c','#3f7fbf'? choose: red #d1495b, mustard #e3a72f, blue #4a7fc1, orange #e07b39, teal #3aa08c, plum #9a6fb0].
Wagon car colors constant.
Now the AI needs DIRS4 for free-neighbor check.
Potential issue: AI greedy may oscillate when passenger unreachable-ish; fine.
Also demo restart: in frame loop: if mode==='over' && demoRun && now-crashT>2200 → resetGame(true) (stay menu). Player: overlay after 900ms shows card.
Also after startPlay (from menu), demoRun=false.
resetGame(demo): sets snake, dir {x:1,y:0}, pending null, acc=0, stepMs=175, score=0, rails=[], smoke=[], particles=[], floats=[], wagonColors=[pick2], born=[0,0,0], segA=[0,0,0], prevSnake=copy, spawnPassenger, mode = demo?'menu':'play', overlay: if demo → show start card; else hide.
startPlay(): initAudio(); whistle(); resetGame(false); hideOverlay().
Hmm whistle on every start; ok.
Pause: mode='pause'; show pause card. Resume sets mode='play'. Note acc keeps? while paused skip stepping: frame only steps in play/menu. dt for fx also frozen? Keep fx running (smoke fades) — fine, or freeze. Simpler: fx update only in play/menu; draw continues (static). Passenger bob uses now — continues animating, ok charming.
But angles smoothing uses dt — skip when paused, fine.
Keydown 'p' toggles pause only in play/pause.
Click handling: overlay click → if menu → startPlay; over → startPlay; pause → resume. Attach to overlay element. Also keydown anywhere.
Prevent arrows scroll: preventDefault when handled.
visibilitychange: pause when hidden? optional; if playing → pause. Add for polish? if(document.hidden && mode==='play') pause(). ok.
Now HUD best: bestScore var; on crash: best=max(best,score); update DOM.
DOM refs: scoreEl, bestEl.
Also show current passengers in overlay over card? no.
Let me write the drawing of platform carefully to look like a tiny station platform:
```js
function drawPassenger(now){
const cx=passenger.x*CELL+CELL/2, cy=passenger.y*CELL+CELL/2;
const bob=Math.sin(now/280+passenger.seed*6)*1.6;
ctx.save(); ctx.translate(cx,cy);
// platform base
rr(-15,-15,30,30,7); ctx.fillStyle='#d7cec0'; ctx.fill();
ctx.save(); rr(-15,-15,30,30,7); ctx.clip();
ctx.fillStyle='rgba(0,0,0,.08)'; ctx.fillRect(-15,6,30,9);
ctx.restore();
// safety edge dashed
ctx.setLineDash([5,4]); ctx.lineWidth=3.4; ctx.strokeStyle='#dfa63b';
rr(-15,-15,30,30,7); ctx.stroke(); ctx.setLineDash([]);
// shadow of person
ctx.fillStyle='rgba(0,0,0,.15)'; ctx.beginPath(); ctx.ellipse(0,9,7,2.6,0,0,7); ctx.fill();
ctx.translate(0,bob*0.4? ) hmm bob person only.
// body
ctx.fillStyle=passenger.color; rr(-5.5,-6,11,13,4); ctx.fill();
// head
ctx.fillStyle='#eec49b'; ctx.beginPath(); ctx.arc(0,-10,4.6,0,7); ctx.fill();
// cap
ctx.fillStyle=shade(passenger.color,-25)? maybe hair: arc top darker: ctx.beginPath(); ctx.arc(0,-11,4.6,Math.PI,0); fill '#4a3a2c';
// waving arm: line from shoulder rotating
const wa=Math.sin(now/220+passenger.seed*9)*0.5-0.9;
ctx.strokeStyle=passenger.color; ctx.lineWidth=3; ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(4,-4); ctx.lineTo(4+Math.cos(wa)*7, -4+Math.sin(wa)*7); ctx.stroke();
// suitcase
ctx.fillStyle='#8a6a45'; rr(9,2,7,8,2) fill; handle small.
ctx.restore();
}
```
Waving arm rotating around shoulder — cute "wave" animation signals "pick me". Good signature-ish.
shade helper: hex adjust.
rr helper:
```js
function rr(x,y,w,h,r){ ctx.beginPath(); ctx.roundRect(x,y,w,h,r); }
```
Checker: fill base '#8fbf78' full, alt cells '#88b871'.
Tufts: seeded pseudo random: use mulberry32 with fixed seed; 46 items {x,y,type,c} within margins; draw each frame (cheap). Tuft: 3 strokes; flower: dot + petals? just 4 tiny petals white + yellow center.
Also faint cell grid? skip.
Track under train head cell: draw rails for cells visited; the very cell head is on hasn't a rail yet but wagons behind cover previous. OK.
Now floating +1: font bold 15px, stroke? fill '#2a251d', alpha.
Let me make sure canvas text uses font family Trebuchet MS.
Overlay content templates:
```js
function showStart(){
overlay.classList.remove('hidden');
overlay.innerHTML = `
<div class="card">
<div class="kicker">NOW BOARDING</div>
<h2>All aboard!</h2>
<p>Steer the little steam train along the line and stop at each platform to pick up a waiting passenger — every rider joins as a brand-new wagon. Just don't rear-end your own train or overshoot the ends of the line.</p>
<div class="demo-chip"><span class="dot"></span>Autopilot at the throttle — demo running</div><br>
<button class="go">Click or press any key to start</button>
<div class="keys"><kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd> or <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> steer · <kbd>P</kbd> pause</div>
</div>`;
}
```
Similar over card:
```js
`<div class="card">
<div class="kicker">END OF THE LINE</div>
<h2>Derailed!</h2>
<p>You delivered <b>${score} passenger${s}</b> this run${score>=?} ... Best today: ${best}.</p>
<button class="go">Click or press any key to ride again</button>
</div>`
```
Pause card small.
Demo crash card: title "Derailed!" p "The autopilot is coupling up a fresh train…" auto continues.
Note: demo crash shouldn't update best? best only for player; fine either.
Now assemble code fully. Also click on overlay button triggers same via overlay click listener (event bubbles).
Also add touch? Not required.
One more check: "Something meaningful visible on first paint" — the demo train moving with smoke, passenger platform, HUD. Yes.
Potential perf fine.
Write the mulberry32:
```js
function rng(seed){return function(){seed|=0;seed=seed+0x6D2B79F5|0;let t=Math.imul(seed^seed>>>15,1|seed);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296}}
```
Decor = rng(7).
Now finalize numbers: initial snake at row 7 (ROWS=15 → mid 7): cells (6,7),(5,7),(4,7). dir right.
Passenger spawn min dist from head 5.
stepMs: 175; per eat -3.5 min 95.
AI free-neighbor bonus tuning as above.
Also AI should not pick cell occupied by passenger? Passenger cell is free (not snake) — landing there = eat, fine.
AI when no valid: return dir (will crash) — ok.
Crash scatter data: scatterVec[i] = random angle, dist 26+rand*40; scatterRot = (rand-0.5)*2.4.
During over draw: use frozen positions p (t frozen), pos + vec*e, angle+rot*e, alpha maybe 1.
Smoke burst at crash: 16 puffs from head pos.
Shake: in draw: if mode==='over': amp=max(0,1-(now-crashT)/700)*6; ctx.translate((Math.random()-0.5)*amp*2, ...). Random per frame ok.
Also on crash set dir={0,0}? Angles freeze since prev==cur? prev vs cur differ still (t<1) fine.
Angles during over: skip smoothing updates (mode check) — keep last.
Edge: when crash happens head didn't move — snake unchanged, prev=old (same). positions fine.
Also for wall crash maybe head should bump visually — skip.
HUD update calls: updateHUD() sets scoreEl.textContent=score; bump.
Let me also add small "chuff" sound each step? Could be annoying; skip.
Write final HTML now. Be careful about template literal usage inside script — fine.
Also ensure `ctx.roundRect` polyfill? Chrome ≥99 has it. Assume fine.
Height: H=600 canvas; page fits 800 viewport with header/footer: 600 + ~110 = ok at width ≤1000. At narrower widths canvas scales down. Good.
Let me write out the whole file mentally consistent, then output.
Smoke spawn position needs heading vector: use segA[0] current angle: fx=Math.cos(segA[0]), fy=Math.sin(segA[0]); pos = headPx + f*CELL*0.30. headPx computed from interpolation — compute in updateFx? We can compute draw positions in a function used by both: computePos(i,t) returns {x,y}. Cache per frame: posCache computed in frame before draw. I'll compute in update step of frame: `positions = snake.map((c,i)=>...)` store globally `posArr`. Then updateFx uses posArr[0].
Order in frame(): step logic; compute t; compute posArr & angles smoothing; fx update (spawn smoke using posArr[0]); draw.
Angles smoothing function:
```js
for(let i=0;i<snake.length;i++){
const p=prevSnake[Math.min(i,prevSnake.length-1)], c=snake[i];
if(p.x!==c.x||p.y!==c.y){ const ta=Math.atan2(c.y-p.y,c.x-p.x); segA[i]=angLerp(segA[i],ta,1-Math.exp(-dt/40)); }
}
```
On reset segA all = 0 (heading right).
When eating, new tail segA push segA[last existing] → segA.push(segA[segA.length-1])? At eat time (in doStep) we don't yet have arrays aligned... manage arrays at eat: wagonColors.push(color); born.push(now); segA.push(segA[segA.length-1]||0). But segA indexed same as snake; before push, segA length = old snake length; after unshift w/o pop, snake length +1; push keeps aligned. ✓. Similarly born for non-eat unchanged. But wait non-eat: indices shift? snake.pop removes tail cell but identity-wise index stays car. born/segA/wagonColors unchanged length. ✓.
prevSnake=old length may be one less than snake when ate; clamp handles.
passenger eaten → respawn immediately (with new color).
Floats: push {x:cellpx, y:cellpy, t:now}.
OK also cap train: none.
HUD "BEST": show best (session).
I think ready. Also add slight vignette on canvas edges? skip.
Write it. Also remember: single code block, no text before/after.
One more consideration: click during play does nothing (overlay hidden, clicks on canvas do nothing) — add canvas click → pause? Might be nice for mouse users: click board toggles pause. I'll add: clicking canvas toggles pause when playing. And when paused, click resumes. Document it in footer hint.
Actually careful: overlay covers canvas when visible; when hidden clicks hit canvas. Add listener on canvas: if mode play→pause, pause→resume.
Also keydown space = pause too? map Space to pause. preventDefault space. ok add.
Final code writing.
For the loco glow gradient: create per frame small radial gradient — fine perf.
Double-check angLerp usage with k.
Let me write draw():
```js
function draw(now){
const t = Math.min(1, acc/stepMs);
ctx.clearRect... fill base.
// shake
ctx.save();
if(mode==='over'){ const amp=Math.max(0,1-(now-crashT)/700)*6; if(amp>0.2) ctx.translate((Math.random()-.5)*amp*2,(Math.random()-.5)*amp*2); }
// grass
ctx.fillStyle='#8fbf78'; ctx.fillRect(0,0,W,H);
ctx.fillStyle='#88b871';
for(y...) for(x...) if((x+y)&1) fillRect(x*CELL,y*CELL,CELL,CELL);
// decor
drawDecor();
// rails
drawRails(now);
// passenger (skip if over? keep drawing platform even after crash — fine keep)
if(mode!=='over'||true) drawPassenger(now);
// couplings + cars
const n=snake.length;
ctx.strokeStyle='#3a3128'; ctx.lineWidth=4; ctx.lineCap='round';
ctx.beginPath();
for(let i=0;i<n-1;i++){ ctx.moveTo(posArr[i].x,posArr[i].y); ctx.lineTo(posArr[i+1].x,posArr[i+1].y); }
ctx.stroke();
for(let i=n-1;i>=1;i--) drawCar(posArr[i].x,posArr[i].y,segA[i],{kind:'wag',color:wagonColors[i-1],pulse:born[i]?Math.min(1,(now-born[i])/380):1});
drawCar(posArr[0].x,posArr[0].y,segA[0],{kind:'loco'});
// smoke
...
// particles, floats
ctx.restore();
}
```
pulse: when born==0 pulse=1 (no scale). If pulse<1: scale.
But note born array: initialize [0,0,0].
Wait: with pulse scaling at <1, alpha also. ok.
drawCar wag uses color wagonColors[i-1].
posArr computed with t & clamp prev.
Also during 'pause' t frozen (acc unchanged) fine; smoke not updated but drawn (existing) — draw uses stored particles; they'd be static — fine.
Compute posArr in frame before fx/draw:
```js
posArr = snake.map((c,i)=>{ const p=prevSnake[Math.min(i,prevSnake.length-1)]; return {x:(p.x+(c.x-p.x)*t)*CELL+CELL/2, y:(p.y+(c.y-p.y)*t)*CELL+CELL/2}; });
```
Angles updated only when mode play/menu.
Crash positions frozen: t computed from acc which froze — but acc might exceed stepMs? While loop consumes acc>=stepMs by stepping; crash exits loop (return from doStep; but while continues! After crash mode='over', while condition acc>=stepMs && (mode==='play'||mode==='menu') — add mode check in while). Then t=acc/stepMs could be ≥1 → clamp min(1,...). But clamp 1 means head at new cell which it never reached (crash before moving) — actually on crash we didn't unshift, snake unchanged, prev=old (same values) → positions same regardless of t. ✓ since prev==cur.
Set on crash: acc=0? then t=0, prev==cur anyway. fine.
Now write everything for real. Estimate ~450 lines. Go.
Also include favicon? no need.
Ensure no `alert` etc. No localStorage. Good.
Let me write decor generation:
```js
const R=rng(20240613);
const decor=[];
for(let i=0;i<50;i++){decor.push({x:R()*W,y:R()*H,k:R()<0.8?0:1,s:R()});}
```
Avoid drawing decor under passenger cell? minor overlap acceptable, but tuft on platform looks odd — skip cells where passenger currently is? passenger moves; just draw decor before passenger; platform covers it. fine.
drawDecor: tuft: strokes:
```js
ctx.strokeStyle='#77a55f'; lw 2; three arcs/lines small.
```
Simple: for each tuft: lines from (x,y) up-left, up, up-right length 5-7. flower: white 4 petals dots r1.5 around center + yellow center r1.2.
Ok, writing final answer now.
One more: heading of segA smoothing uses dt in ms: k=1-Math.exp(-dt/40) — dt~16 → k≈0.33, quick. good.
AI details final:
```js
function aiDir(){
const head=snake[0];
const L={x:dir.y,y:-dir.x}, Rt={x:-dir.y,y:dir.x};
const cands=[dir,L,Rt];
const occ=new Set(); for(let i=0;i<snake.length-1;i++) occ.add(snake[i].x+','+snake[i].y);
let best=null,bs=-1e9;
for(const d of cands){
const nx=head.x+d.x,ny=head.y+d.y;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS)continue;
if(occ.has(nx+','+ny))continue;
let s=-(Math.abs(nx-passenger.x)+Math.abs(ny-passenger.y))*1.0;
let free=0;
for(const dd of [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}]){const ax=nx+dd.x,ay=ny+dd.y; if(ax>=0&&ay>=0&&ax<COLS&&ay<ROWS&&!occ.has(ax+','+ay))free++;}
s+= free>=3?5: free===2?3 : free===1?-4 : -20;
s+=R2()*0.8; // Math.random fine
if(s>bs){bs=s;best=d;}
}
return best||dir;
}
```
Note: candidate equal to dir when reverse not possible anyway.
Potential trap: greedy into dead-end pocket with free=1 but leads on... acceptable.
Also demo might loop forever without eating if unlucky; ok.
Write it.