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 23 s
- Cost
- $0.020
- Output tokens
- 39,000
- Spent thinking
- 30,387 tokens
- Speed
- 121 tok/s
- Console errors
- 0
- First token after
- 417 ms
- First visible text after
- 4 min 17 s
- Prompt tokens
- 363
- Code size
- 23.6 KB
- Finish reason
- stop
- Served by
- Crusoe
- Model id
z-ai/glm-5.3-flash- Reasoning sent
{"effort":"max"}- Max output
- 64,000 tokens
- Generated
- Nov 10, 2026
What it was asked
The prompt
“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 · 585 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Little Express — a tiny train snake</title>
<style>
:root{
--paper:#f4ecdc; --ink:#2b2a26; --green:#1f3d2b; --green-d:#16301f;
--gold:#e9b44c; --red:#d1495b; --cream:#f7f1e1;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
background:radial-gradient(1100px 520px at 50% -10%, #fbf6ea, rgba(251,246,234,0) 60%), var(--paper);
color:var(--ink);
font-family:ui-monospace,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;
min-height:100vh;display:flex;align-items:flex-start;justify-content:center;
padding:14px;overflow-x:hidden;
}
.wrap{width:100%;max-width:984px;display:flex;flex-direction:column;align-items:center;gap:8px}
.sign{display:flex;align-items:center;gap:14px;background:var(--green);color:var(--cream);
padding:9px 22px;border-radius:10px;border:2px solid var(--green-d);
box-shadow:inset 0 0 0 2px var(--gold),0 4px 0 rgba(0,0,0,.10)}
.sign svg{flex:none}
.sign h1{font-size:clamp(19px,3vw,27px);letter-spacing:.2em;text-transform:uppercase;line-height:1.05}
.sign .sub{font-size:10px;letter-spacing:.1em;color:#cfe0c8;margin-top:3px;text-transform:uppercase}
.hud{display:flex;gap:8px;flex-wrap:wrap;justify-content:center}
.chip{background:var(--green);color:var(--cream);border:1px solid var(--green-d);border-radius:8px;
padding:5px 14px 6px;min-width:104px;text-align:center;box-shadow:0 2px 0 rgba(0,0,0,.12)}
.chip .lbl{display:block;font-size:9px;letter-spacing:.24em;text-transform:uppercase;color:var(--gold)}
.chip .val{font-size:17px;font-weight:700}
.stage{position:relative;width:100%;
max-width:min(952px, max(300px, calc((100vh - 235px) * 1.5556)));
border-radius:12px;overflow:hidden;cursor:pointer;
box-shadow:0 0 0 3px #35573c,0 0 0 7px rgba(31,61,43,.35),0 16px 34px rgba(84,62,25,.28);
touch-action:none;user-select:none;-webkit-user-select:none}
canvas{display:block;width:100%;height:auto}
#overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(18,32,22,.30)}
#overlay.hidden{display:none}
.card{background:var(--cream);border:2px solid var(--green);border-radius:10px;padding:18px 28px;
text-align:center;max-width:min(430px,86%);transform:rotate(-1.4deg);
box-shadow:0 14px 26px rgba(0,0,0,.28),inset 0 0 0 4px rgba(233,180,76,.4)}
.badge{display:inline-block;font-size:9px;letter-spacing:.32em;text-transform:uppercase;
background:var(--green);color:var(--gold);padding:3px 10px;border-radius:3px;margin-bottom:9px}
.card h2{font-size:23px;letter-spacing:.14em;text-transform:uppercase;margin-bottom:7px}
.card p{font-size:12px;line-height:1.55;color:#4c463a}
.cta{margin-top:11px;font-weight:700;color:#7c2d3a;letter-spacing:.05em;animation:pulse 1.25s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
#toast{position:absolute;left:50%;bottom:12px;transform:translate(-50%,8px);
background:rgba(31,61,43,.94);color:var(--cream);padding:6px 16px;border-radius:20px;
font-size:11px;letter-spacing:.06em;opacity:0;transition:opacity .3s,transform .3s;
pointer-events:none;white-space:nowrap}
#toast.show{opacity:1;transform:translate(-50%,0)}
.hints{font-size:11px;color:#6b6353;text-align:center}
kbd{background:#fff;border:1px solid #c9bfa8;border-bottom-width:2px;border-radius:4px;padding:1px 5px;font:inherit;font-size:10px}
@media (max-width:700px){
body{padding:10px}
.sign{padding:8px 14px;gap:10px}
.chip{min-width:78px;padding:4px 10px 5px}
.chip .val{font-size:14px}
.card{padding:14px 18px}
.card h2{font-size:18px}
}
</style>
</head>
<body>
<main class="wrap">
<header class="sign">
<svg viewBox="0 0 64 40" width="52" height="34" aria-hidden="true">
<circle cx="42" cy="5" r="3.4" fill="rgba(246,243,234,.85)"/>
<circle cx="47" cy="2" r="2.4" fill="rgba(246,243,234,.6)"/>
<rect x="2" y="10" width="13" height="22" rx="3" fill="#b23a48" stroke="#7e2836" stroke-width="2"/>
<rect x="5" y="14" width="6" height="8" rx="2" fill="#f7e9c4"/>
<rect x="15" y="16" width="28" height="15" rx="6" fill="#d1495b" stroke="#8f2f3e" stroke-width="2"/>
<rect x="24" y="16" width="3" height="15" fill="#e9b44c"/>
<rect x="33" y="16" width="3" height="15" fill="#e9b44c"/>
<rect x="36" y="7" width="7" height="10" rx="2" fill="#3a332c"/>
<rect x="34.5" y="5.5" width="10" height="3" rx="1.5" fill="#3a332c"/>
<polygon points="43,26 50,31 43,31" fill="#4a3b33"/>
<circle cx="9" cy="33" r="3.4" fill="#2b2a26"/>
<circle cx="21" cy="33" r="3.4" fill="#2b2a26"/>
<circle cx="31" cy="33" r="3.4" fill="#2b2a26"/>
<circle cx="40" cy="33" r="3" fill="#2b2a26"/>
</svg>
<div class="titles">
<h1>Little Express</h1>
<p class="sub">every passenger becomes a new coach</p>
</div>
</header>
<div class="hud">
<div class="chip"><span class="lbl">Passengers</span><span class="val" id="score">0</span></div>
<div class="chip"><span class="lbl">Coaches</span><span class="val" id="coaches">3</span></div>
<div class="chip"><span class="lbl">Top score</span><span class="val" id="best">0</span></div>
<div class="chip"><span class="lbl">Speed</span><span class="val" id="speed">13 km/h</span></div>
</div>
<div class="stage" id="stage">
<canvas id="game" width="952" height="612"></canvas>
<div id="overlay">
<div class="card">
<div class="badge">Central Station</div>
<h2 id="ov-title">All aboard!</h2>
<p id="ov-line1">The automatic pilot is giving a demo ride — watch the little express collect passengers.</p>
<p class="cta" id="ov-line2">Click or press a key to start</p>
</div>
</div>
<div id="toast"></div>
</div>
<p class="hints">Steer with <kbd>←</kbd><kbd>↑</kbd><kbd>↓</kbd><kbd>→</kbd> or <kbd>WASD</kbd> · swipe on touch · each pickup couples a new coach</p>
</main>
<script>
(function(){
'use strict';
var COLS=28, ROWS=18, CELL=34, W=COLS*CELL, H=ROWS*CELL;
var cv=document.getElementById('game'), ctx=cv.getContext('2d');
var dpr=Math.min(2, window.devicePixelRatio||1);
cv.width=W*dpr; cv.height=H*dpr;
var PALETTE=['#e9b44c','#7fae6b','#5b9aa0','#c97b9a','#e08e45','#7aa5c4','#8fb3a9'];
var DIRS4=[[1,0],[-1,0],[0,1],[0,-1]];
var mode='demo', dead=false;
var snake=[], dir={x:1,y:0}, pendingDirs=[], coachColors=[];
var platforms=[], score=0, best=0, stepMs=150;
var smokes=[], sparkles=[], pops=[], boarders=[];
var shake=0, acc=0, last=performance.now(), tNow=last, tickCount=0, deadAt=0, overAt=0;
var elScore=document.getElementById('score'), elCo=document.getElementById('coaches'),
elBest=document.getElementById('best'), elSpeed=document.getElementById('speed'),
ov=document.getElementById('overlay'), ovTitle=document.getElementById('ov-title'),
ovL1=document.getElementById('ov-line1'), ovL2=document.getElementById('ov-line2'),
toastEl=document.getElementById('toast'), stage=document.getElementById('stage');
/* ---------- pre-rendered grass ---------- */
var bg=document.createElement('canvas');
bg.width=W*dpr; bg.height=H*dpr;
(function(){
var b=bg.getContext('2d'); b.setTransform(dpr,0,0,dpr,0,0);
for(var y=0;y<ROWS;y++)for(var x=0;x<COLS;x++){
b.fillStyle=((x+y)%2)?'#527a4f':'#587f55';
b.fillRect(x*CELL,y*CELL,CELL,CELL);
}
var s=12345; function rnd(){ s=(s*16807)%2147483647; return s/2147483647; }
for(var i=0;i<130;i++){
var px=rnd()*W, py=rnd()*H, r=rnd();
if(r<0.45){ b.fillStyle='rgba(28,48,28,0.20)'; b.fillRect(px,py,3,1.6); b.fillRect(px+2,py-2,3,1.6); }
else if(r<0.75){ b.fillStyle='rgba(245,240,220,0.55)'; b.beginPath(); b.arc(px,py,1.5,0,7); b.fill();
b.fillStyle='#e9b44c'; b.fillRect(px-0.7,py-0.7,1.4,1.4); }
else { b.fillStyle='rgba(222,236,212,0.35)'; b.fillRect(px,py,2.5,2.5); }
}
var g=b.createRadialGradient(W/2,H/2,H*0.35,W/2,H/2,H*0.95);
g.addColorStop(0,'rgba(0,0,0,0)'); g.addColorStop(1,'rgba(20,35,20,0.22)');
b.fillStyle=g; b.fillRect(0,0,W,H);
})();
/* ---------- helpers ---------- */
function rr(c,x,y,w,h,r){
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 circle(x,y,r){ ctx.beginPath(); ctx.arc(x,y,r,0,7); ctx.fill(); }
function center(s){ return [s.x*CELL+CELL/2, s.y*CELL+CELL/2]; }
/* ---------- audio (created lazily on first gesture) ---------- */
var AC=null;
function ensureAudio(){
if(AC) return;
try{ var C=window.AudioContext||window.webkitAudioContext; if(C) AC=new C(); }catch(e){ AC=null; }
}
function beep(f,dur,type,vol,when){
if(!AC) return;
try{
if(AC.state==='suspended') AC.resume().catch(function(){});
var t0=AC.currentTime+(when||0), o=AC.createOscillator(), g=AC.createGain();
o.type=type||'sine'; o.frequency.setValueAtTime(f,t0);
g.gain.setValueAtTime(0.0001,t0);
g.gain.exponentialRampToValueAtTime(vol||0.15,t0+0.02);
g.gain.exponentialRampToValueAtTime(0.0001,t0+dur);
o.connect(g); g.connect(AC.destination);
o.start(t0); o.stop(t0+dur+0.05);
}catch(e){}
}
function sfxPickup(){ beep(659,0.09,'triangle',0.16); beep(988,0.14,'triangle',0.13,0.07); }
function sfxWhistle(){ beep(740,0.20,'square',0.05); beep(932,0.30,'square',0.05,0.14); }
function sfxCrash(){ beep(150,0.30,'sawtooth',0.18); beep(92,0.40,'sawtooth',0.14,0.06); }
/* ---------- game state ---------- */
function resetGame(){
var cy=ROWS>>1;
snake=[{x:9,y:cy},{x:8,y:cy},{x:7,y:cy},{x:6,y:cy}];
dir={x:1,y:0}; pendingDirs=[]; dead=false;
stepMs=150; score=0; platforms=[]; smokes=[]; sparkles=[]; pops=[]; boarders=[];
shake=0; acc=0; tickCount=0;
coachColors=[PALETTE[1],PALETTE[2],PALETTE[3]];
spawnPlatform();
updateHUD();
}
function spawnPlatform(){
for(var i=0;i<400;i++){
var x=(Math.random()*COLS)|0, y=(Math.random()*ROWS)|0;
var bad=false;
for(var j=0;j<snake.length;j++) if(snake[j].x===x&&snake[j].y===y){ bad=true; break; }
if(bad) continue;
for(var k=0;k<platforms.length;k++) if(platforms[k].x===x&&platforms[k].y===y){ bad=true; break; }
if(bad) continue;
if(Math.abs(x-snake[0].x)+Math.abs(y-snake[0].y)<6) continue;
var col;
do{ col=PALETTE[(Math.random()*PALETTE.length)|0]; }
while(col===coachColors[coachColors.length-1]);
platforms.push({x:x,y:y,color:col,seed:Math.random()*6.28,born:tNow});
return true;
}
return false;
}
function ensurePlatforms(){
var want=snake.length>=10?2:1;
while(platforms.length<want){ if(!spawnPlatform()) break; }
}
function eat(p){
platforms=platforms.filter(function(q){ return q!==p; });
score+=10; if(score>best) best=score;
stepMs=Math.max(82,150-score*0.9);
coachColors.push(p.color);
boarders.push({fx:p.x*CELL+CELL/2, fy:p.y*CELL+CELL/2, t0:tNow, dur:520, color:p.color});
pops.push({x:p.x*CELL+CELL/2, y:p.y*CELL+CELL/2, t0:tNow, txt:'+10'});
ensurePlatforms(); updateHUD(); sfxPickup();
if(score>0 && score%50===0) toast(score/10+' passengers delivered!');
}
function queueDir(d){
var lastD=pendingDirs.length?pendingDirs[pendingDirs.length-1]:dir;
if(d.x===lastD.x&&d.y===lastD.y) return;
if(d.x===-lastD.x&&d.y===-lastD.y) return;
if(pendingDirs.length<2) pendingDirs.push(d);
}
function tick(){
if(mode==='demo'){ var ad=aiDir(); if(ad) dir=ad; }
else if(pendingDirs.length) dir=pendingDirs.shift();
var h=snake[0], nx=h.x+dir.x, ny=h.y+dir.y;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS){ crash(); return; }
var pi=-1;
for(var i=0;i<platforms.length;i++) if(platforms[i].x===nx&&platforms[i].y===ny){ pi=i; break; }
var grow=pi>=0;
var lim=grow?snake.length:snake.length-1;
for(var j=0;j<lim;j++){ if(snake[j].x===nx&&snake[j].y===ny){ crash(); return; } }
snake.unshift({x:nx,y:ny});
if(grow) eat(platforms[pi]); else snake.pop();
if(tickCount%2===0) puff();
tickCount++;
}
function crash(){
dead=true; shake=1; steamBurst(); sfxCrash();
if(mode==='demo'){ deadAt=tNow+1100; }
else { mode='over'; overAt=tNow; showOverlay('over'); }
}
/* ---------- autopilot ---------- */
function flood(sx,sy,occ){
var seen=new Uint8Array(COLS*ROWS), q=[sx+sy*COLS];
seen[q[0]]=1; var n=0;
while(q.length){
var c=q.pop(); n++;
var x=c%COLS, y=(c-x)/COLS;
for(var k=0;k<4;k++){
var X=x+DIRS4[k][0], Y=y+DIRS4[k][1];
if(X<0||Y<0||X>=COLS||Y>=ROWS) continue;
var id=X+Y*COLS;
if(seen[id]||occ.has(X+','+Y)) continue;
seen[id]=1; q.push(id);
}
}
return n;
}
function aiDir(){
var h=snake[0], target=null, bd=1e9;
for(var i=0;i<platforms.length;i++){
var p=platforms[i], d=Math.abs(p.x-h.x)+Math.abs(p.y-h.y);
if(d<bd){ bd=d; target=p; }
}
var cands=[dir,{x:dir.y,y:-dir.x},{x:-dir.y,y:dir.x}];
var occ=new Set();
for(var j=0;j<snake.length-1;j++) occ.add(snake[j].x+','+snake[j].y);
var bestD=null, bestS=-Infinity;
for(var c=0;c<3;c++){
var d2=cands[c], nx=h.x+d2.x, ny=h.y+d2.y, s;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS||occ.has(nx+','+ny)) s=-1e9;
else{
var area=flood(nx,ny,occ);
var dist=target?Math.abs(nx-target.x)+Math.abs(ny-target.y):0;
s=(area>=snake.length+1?5000:area*2)-dist*4+(c===0?1.5:0);
}
if(s>bestS){ bestS=s; bestD=d2; }
}
return bestD;
}
/* ---------- smoke ---------- */
function puff(){
var h=snake[0], hx=h.x*CELL+CELL/2+dir.x*14, hy=h.y*CELL+CELL/2+dir.y*14;
smokes.push({x:hx,y:hy,r:2.5+Math.random()*2,
vx:-dir.x*16+(Math.random()*12-6), vy:-30-Math.random()*12,
life:0, max:800+Math.random()*300});
}
function steamBurst(){
var h=snake[0], hx=h.x*CELL+CELL/2, hy=h.y*CELL+CELL/2;
for(var i=0;i<16;i++){
var a=Math.random()*6.28, sp=20+Math.random()*50;
smokes.push({x:hx,y:hy,r:4+Math.random()*4,
vx:Math.cos(a)*sp, vy:Math.sin(a)*sp-20,
life:0, max:700+Math.random()*400});
}
}
/* ---------- fx updates ---------- */
function updateFx(dt){
var k=dt/1000, i, s;
for(i=smokes.length-1;i>=0;i--){
s=smokes[i]; s.life+=dt; s.x+=s.vx*k; s.y+=s.vy*k;
s.vy-=6*k; s.r+=7*k;
if(s.life>=s.max) smokes.splice(i,1);
}
for(i=sparkles.length-1;i>=0;i--){
s=sparkles[i]; s.life+=dt; s.x+=s.vx*k; s.y+=s.vy*k; s.vy+=160*k;
if(s.life>=s.max) sparkles.splice(i,1);
}
for(i=boarders.length-1;i>=0;i--){
var b=boarders[i];
if(tNow-b.t0>=b.dur){
boarders.splice(i,1);
var tail=snake[snake.length-1], t2=center(tail);
for(var j2=0;j2<7;j2++){
var a=Math.random()*6.28, sp2=30+Math.random()*50;
sparkles.push({x:t2[0],y:t2[1],vx:Math.cos(a)*sp2,vy:Math.sin(a)*sp2-40,
r:1.5+Math.random()*1.8,life:0,max:480+Math.random()*260});
}
}
}
pops=pops.filter(function(p){ return tNow-p.t0<800; });
shake=Math.max(0,shake-dt/350);
}
/* ---------- drawing ---------- */
function segAngle(i){
if(i===0) return Math.atan2(dir.y,dir.x);
var a=snake[i], b=snake[i-1];
return Math.atan2(b.y-a.y,b.x-a.x);
}
function drawTrack(){
ctx.lineCap='butt';
for(var i=0;i<snake.length;i++){
var s=snake[i], x=s.x*CELL, y=s.y*CELL, horiz, b;
if(i===0) horiz=dir.y===0;
else { b=snake[i-1]; horiz=(s.y===b.y); }
var cx=x+CELL/2, cy=y+CELL/2, o;
ctx.fillStyle='rgba(203,184,142,0.95)';
if(horiz) ctx.fillRect(x-1,cy-11,CELL+2,22); else ctx.fillRect(cx-11,y-1,22,CELL+2);
ctx.fillStyle='#7d5a3c';
if(horiz){ for(o=-9;o<=9;o+=9) ctx.fillRect(cx+o-1.5,y+7,3,20); }
else { for(o=-9;o<=9;o+=9) ctx.fillRect(x+7,cy+o-1.5,20,3); }
ctx.strokeStyle='#4c4038'; ctx.lineWidth=3;
ctx.beginPath();
if(horiz){
ctx.moveTo(x-3,cy-6); ctx.lineTo(x+CELL+3,cy-6);
ctx.moveTo(x-3,cy+6); ctx.lineTo(x+CELL+3,cy+6);
} else {
ctx.moveTo(cx-6,y-3); ctx.lineTo(cx-6,y+CELL+3);
ctx.moveTo(cx+6,y-3); ctx.lineTo(cx+6,y+CELL+3);
}
ctx.stroke();
}
}
function drawPassenger(cx,cy,color,t,seed,sc,alpha){
var hop=Math.abs(Math.sin(t*0.004+seed))*2;
ctx.save();
ctx.translate(cx,cy-hop*sc); ctx.scale(sc,sc); ctx.globalAlpha=alpha;
ctx.fillStyle='#8a6b46'; rr(ctx,-11,1,6,7,1.5); ctx.fill();
ctx.strokeStyle='rgba(0,0,0,.35)'; ctx.lineWidth=1; ctx.stroke();
ctx.fillStyle=color; rr(ctx,-5,-4,10,11,4); ctx.fill(); ctx.stroke();
ctx.fillStyle='#f0c290'; ctx.beginPath(); ctx.arc(0,-8,4.2,0,7); ctx.fill(); ctx.stroke();
ctx.fillStyle='#2f5d3a'; ctx.beginPath(); ctx.arc(0,-9,4.4,Math.PI,0); ctx.fill();
var a=-2.2+Math.sin(t*0.012+seed)*0.45;
ctx.strokeStyle='#f0c290'; ctx.lineWidth=2.4; ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(4,-2);
ctx.lineTo(4+Math.cos(a)*7,-2+Math.sin(a)*7); ctx.stroke();
ctx.restore();
}
function drawPlatforms(t){
for(var i=0;i<platforms.length;i++){
var p=platforms[i], cx=p.x*CELL+CELL/2, cy=p.y*CELL+CELL/2;
var ap=Math.min(1,(tNow-p.born)/260), sc=0.6+0.4*(1-Math.pow(1-ap,3));
ctx.save(); ctx.translate(cx,cy); ctx.scale(sc,sc);
ctx.fillStyle='#e3d9bd'; rr(ctx,-15,-12,30,25,4); ctx.fill();
ctx.strokeStyle='#b8ab8b'; ctx.lineWidth=1.5; ctx.stroke();
ctx.fillStyle='rgba(255,255,255,.35)'; ctx.fillRect(-13,-10,26,3);
ctx.fillStyle='#e9b44c'; ctx.fillRect(-13,7,26,2.5);
ctx.restore();
drawPassenger(cx,cy-2,p.color,t,p.seed,1,1);
}
}
function drawGlow(){
var h=snake[0], hx=h.x*CELL+CELL/2+dir.x*CELL*0.8, hy=h.y*CELL+CELL/2+dir.y*CELL*0.8;
var g=ctx.createRadialGradient(hx,hy,2,hx,hy,26);
g.addColorStop(0,'rgba(255,214,120,.35)'); g.addColorStop(1,'rgba(255,214,120,0)');
ctx.fillStyle=g; ctx.fillRect(hx-26,hy-26,52,52);
}
function drawWagon(i,t){
var c=center(snake[i]), cx=c[0], cy=c[1];
ctx.save(); ctx.translate(cx,cy); ctx.rotate(segAngle(i));
ctx.translate(0,Math.sin(t*0.006+i*0.9)*1.4);
var col=coachColors[i-1]||PALETTE[(i-1)%PALETTE.length];
ctx.fillStyle='#3a332c';
circle(-9,-12,3.2); circle(-9,12,3.2); circle(9,-12,3.2); circle(9,12,3.2);
ctx.fillStyle=col; rr(ctx,-15,-11,30,22,5); ctx.fill();
ctx.lineWidth=2; ctx.strokeStyle='rgba(43,42,38,0.5)'; ctx.stroke();
ctx.fillStyle='rgba(255,255,255,0.16)'; rr(ctx,-12,-8,24,16,3); ctx.fill();
ctx.fillStyle='rgba(247,241,225,0.9)';
rr(ctx,-10,-3.5,6,7,2); ctx.fill(); rr(ctx,3,-3.5,6,7,2); ctx.fill();
ctx.restore();
}
function drawLoco(t){
var c=center(snake[0]), cx=c[0], cy=c[1];
ctx.save(); ctx.translate(cx,cy); ctx.rotate(segAngle(0));
ctx.translate(0,Math.sin(t*0.006)*1.1);
ctx.fillStyle='#3a332c';
circle(-11,-12,3.4); circle(-11,12,3.4); circle(0,-12,3.4); circle(0,12,3.4);
circle(11,-11,3.2); circle(11,11,3.2);
ctx.fillStyle='#4a3b33';
ctx.beginPath(); ctx.moveTo(14,-9); ctx.lineTo(21,0); ctx.lineTo(14,9); ctx.closePath(); ctx.fill();
ctx.fillStyle='#d1495b'; rr(ctx,-6,-10,22,20,7); ctx.fill();
ctx.lineWidth=2; ctx.strokeStyle='#8f2f3e'; ctx.stroke();
ctx.fillStyle='#e9b44c'; ctx.fillRect(1,-10,2.5,20); ctx.fillRect(9,-10,2.5,20);
ctx.fillStyle='#e9b44c'; circle(5,0,3.4);
ctx.strokeStyle='#a97f2a'; ctx.lineWidth=1.2;
ctx.beginPath(); ctx.arc(5,0,3.4,0,7); ctx.stroke();
ctx.fillStyle='#b23a48'; rr(ctx,-17,-12,12,24,4); ctx.fill();
ctx.strokeStyle='#7e2836'; ctx.stroke();
ctx.fillStyle='#f7e9c4'; rr(ctx,-14,-7,6,14,2); ctx.fill();
ctx.fillStyle='#3a332c'; circle(14,0,4.6);
ctx.fillStyle='#171310'; circle(14,0,2.6);
ctx.strokeStyle='#e9b44c'; ctx.lineWidth=1.5;
ctx.beginPath(); ctx.arc(14,0,4.6,0,7); ctx.stroke();
ctx.fillStyle='#ffe9a8'; circle(19.5,0,2.4);
ctx.restore();
}
function drawTrain(t){
var i, a, b, mx, my, ang;
ctx.fillStyle='#3c332c';
for(i=1;i<snake.length;i++){
a=center(snake[i]); b=center(snake[i-1]);
mx=(a[0]+b[0])/2; my=(a[1]+b[1])/2;
ang=Math.atan2(b[1]-a[1],b[0]-a[0]);
ctx.save(); ctx.translate(mx,my); ctx.rotate(ang);
ctx.fillRect(-6,-2.5,12,5); ctx.restore();
}
for(i=snake.length-1;i>=1;i--) drawWagon(i,t);
drawLoco(t);
}
function drawSmoke(){
for(var i=0;i<smokes.length;i++){
var s=smokes[i], a=Math.max(0,1-s.life/s.max);
ctx.globalAlpha=a*0.55; ctx.fillStyle='#f6f3ea';
circle(s.x,s.y,s.r);
}
ctx.globalAlpha=1;
}
function drawFx(t){
var i;
for(i=0;i<boarders.length;i++){
var b=boarders[i], p=Math.min(1,(tNow-b.t0)/b.dur), e=1-Math.pow(1-p,3);
var tail=snake[snake.length-1], t2=center(tail);
var x=b.fx+(t2[0]-b.fx)*e, y=b.fy+(t2[1]-b.fy)*e-Math.sin(Math.PI*p)*14;
drawPassenger(x,y,b.color,t,0,0.95,1);
}
for(i=0;i<sparkles.length;i++){
var s=sparkles[i];
ctx.globalAlpha=Math.max(0,1-s.life/s.max);
ctx.fillStyle='#f4c95d'; circle(s.x,s.y,s.r);
}
ctx.globalAlpha=1;
ctx.font='bold 14px ui-monospace, Menlo, Consolas, monospace';
ctx.textAlign='center';
for(i=0;i<pops.length;i++){
var pp=pops[i], q=(tNow-pp.t0)/800;
if(q>=1) continue;
ctx.globalAlpha=1-q;
var yy=pp.y-10-q*20;
ctx.lineWidth=3; ctx.strokeStyle='rgba(25,40,25,.75)'; ctx.strokeText(pp.txt,pp.x,yy);
ctx.fillStyle='#fdf7e7'; ctx.fillText(pp.txt,pp.x,yy);
}
ctx.globalAlpha=1;
}
function render(t){
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.drawImage(bg,0,0,W,H);
ctx.save();
if(shake>0) ctx.translate((Math.random()*2-1)*5*shake,(Math.random()*2-1)*5*shake);
drawTrack();
drawPlatforms(t);
if(!dead) drawGlow();
drawTrain(t);
drawSmoke();
drawFx(t);
ctx.restore();
}
/* ---------- HUD / overlay / toast ---------- */
function updateHUD(){
elScore.textContent=score;
elCo.textContent=snake.length-1;
elBest.textContent=best;
elSpeed.textContent=Math.max(10,Math.round(2000/stepMs))+' km/h';
}
function showOverlay(kind){
ov.classList.remove('hidden');
if(kind==='demo'){
ovTitle.textContent='All aboard!';
ovL1.textContent='The automatic pilot is giving a demo ride — watch the little express collect passengers.';
ovL2.textContent='Click or press a key to start';
} else {
var n=score/10;
ovTitle.textContent='End of the line';
ovL1.textContent='You delivered '+n+' passenger'+(n===1?'':'s')+' and grew to '+(snake.length-1)+' coaches. Best this visit: '+best+'.';
ovL2.textContent='Click or press a key to ride again';
}
}
function hideOverlay(){ ov.classList.add('hidden'); }
var toastT=0;
function toast(msg){
toastEl.textContent=msg; toastEl.classList.add('show');
clearTimeout(toastT);
toastT=setTimeout(function(){ toastEl.classList.remove('show'); },1900);
}
/* ---------- input ---------- */
function startPlay(d){
ensureAudio();
resetGame(); mode='play'; hideOverlay();
toast('All aboard!'); sfxWhistle();
if(d) queueDir(d);
}
var KEYMAP={ArrowUp:[0,-1],KeyW:[0,-1],ArrowDown:[0,1],KeyS:[0,1],
ArrowLeft:[-1,0],KeyA:[-1,0],ArrowRight:[1,0],KeyD:[1,0]};
window.addEventListener('keydown',function(e){
var kd=KEYMAP[e.code];
if(kd) e.preventDefault();
if(mode==='demo'||mode==='over'){ startPlay(kd?{x:kd[0],y:kd[1]}:null); }
else if(kd&&!dead){ queueDir({x:kd[0],y:kd[1]}); }
});
document.addEventListener('pointerdown',function(){
if(mode==='demo'||mode==='over') startPlay(null); else ensureAudio();
});
var tS=null;
stage.addEventListener('pointerdown',function(e){ tS={x:e.clientX,y:e.clientY}; });
stage.addEventListener('pointerup',function(e){
if(!tS) return;
var dx=e.clientX-tS.x, dy=e.clientY-tS.y; tS=null;
if(Math.hypot(dx,dy)<24) return;
var d=Math.abs(dx)>Math.abs(dy)?{x:dx>0?1:-1,y:0}:{x:0,y:dy>0?1:-1};
if(mode==='play'&&!dead) queueDir(d);
});
/* ---------- main loop ---------- */
function frame(t){
tNow=t;
var dt=Math.min(100,t-last); last=t;
if(!dead){
acc+=dt;
while(acc>=stepMs&&!dead){ acc-=stepMs; tick(); }
} else if(mode==='demo'&&t>=deadAt){ resetGame(); }
if(mode==='over'&&tNow-overAt>12000){ mode='demo'; resetGame(); showOverlay('demo'); }
updateFx(dt);
render(t);
requestAnimationFrame(frame);
}
resetGame();
showOverlay('demo');
requestAnimationFrame(function(t){ last=t; frame(t); });
})();
</script>
</body>
</html>Reasoning trace (30,387 tokens)
Let me think about this task: a snake game where the snake is a little train — locomotive head, then colorful wagons following — and instead of apples, there are passengers waiting on platforms. When the train passes a platform, the passenger boards (gets attached as a new wagon, or adds length).
Requirements:
- Single self-contained HTML file, inline CSS/JS.
- Sandbox: no localStorage, no alert, etc.
- Must look good at 1280×800 and adapt to 375px.
- Something meaningful visible on first paint; screenshot ~3s after load.
- Games must start in self-playing demo mode (AI plays) behind a "Click or press a key to start" message. So I need an AI autopilot playing the game in demo mode, with an overlay saying "Click or press a key to start". When the user clicks/presses a key, the game resets and the user plays.
- Zero console errors.
Design plan:
- Canvas-based game. Grid, e.g., 24×18 cells or similar. Train moves cell to cell on a timer.
- Render: track (rails) drawn behind the train — nice touch: draw rails along the path the train has traveled (like the track follows the train). Actually a classic charming idea: the train lays track behind it. Draw a persistent track segment for each body segment (ties + rails), with the track fading/curving. Simpler: draw rails under each body cell oriented along movement direction, plus ties. That looks like a railway.
- Locomotive: rounded rectangle with chimney, cab, funnel with smoke puffs (animated smoke particles — nice signature moment), headlight beam.
- Wagons: colorful rounded rects with little windows, connected by couplings. Colors cycle through a palette (red, orange, yellow, green, blue, purple...). Wagons alternate slight color shades.
- Passengers: little figures waiting on a platform — a platform is a small paved rectangle (a few cells? or one cell with a platform strip) with 1-3 waiting passengers. Actually simpler: each "food" is a platform tile: a gray platform with a waiting person (circle head, body). When train head reaches it, passenger boards: a little animation of the passenger hopping onto the last wagon? Simpler: passenger disappears, score +, new wagon added, maybe a "+1" floating text and a toast "Passenger boarded!".
- Maybe multiple passengers per platform: platform has N waiting passengers shown as dots; each pass picks one? That complicates. Keep: one platform spawns at a time (or up to 2-3 concurrent), each is one passenger.
Let me design the mechanic:
- Grid 28×20? Canvas scaled. Cell size ~ let's compute: canvas 960×640 for game area, cell 32px → 30×20 grid. At 1280 viewport width, fine. On small screens, scale canvas via CSS with aspect ratio preserved.
- Actually I want the whole page: header with title, score, speed; canvas centered; footer hints. Use a wrapper that scales.
Game loop:
- tick every N ms (starts ~140ms, speeds up slightly with score, cap).
- Direction changes queued (buffer of 2) via arrow keys / WASD / swipe.
- Autopilot AI: BFS/greedy path to nearest passenger avoiding body. Simple approach: each tick choose direction minimizing Manhattan distance to target among safe moves (not reversing, not hitting body/wall); with lookahead or flood-fill safety check to avoid trapping itself. A decent simple AI: prefer moves that keep flood-fill reachable area >= snake length; else max free space. That's robust enough for demo.
Collision: walls kill (or wrap?). Let's make walls solid — crash causes steam explosion and "crash" state; in demo mode, AI avoids crashing; if AI dies, auto-restart demo after brief moment. In user mode, show game over overlay with score and "press key to restart".
Scoring: +10 per passenger; every 5 passengers maybe new "coach added" milestone. Speed increases slightly.
Visual polish:
- Background: grass? Let's go with a stylized night? Hmm. "Little train picking up passengers on platform" suggests a cheerful daytime countryside. Palette: warm cream paper background, deep green field with subtle pattern, sandy track bed. Let me pick a distinctive palette avoiding default blue/purple:
- Page background: warm paper #F3ECDD or deep slate? Let me do a warm cream background with the game board as a deep green (#2E5B3E-ish) field. Accents: tomato red locomotive, brass/gold details.
- Locomotive: red with gold boiler bands, dark chimney emitting puffs of smoke (light gray circles fading, drifting up-left opposite of motion... simpler: drift upward with slight randomness).
- Wagons: palette [sunflower #F2B233, teal #3FA7A0? hmm keep warm-ish]. Let's use: #E4572E (orange-red), #F2B233 (amber), #7FB069 (leaf green), #5B8E7D (sea green), #C86FA4? Actually a candy set: crimson, amber, green, teal, plum, sky. Fine — colorful wagons is the brief.
- Passenger: small figure with round head (skin tone), body (bright color), waving hand animation (little arm wiggling). Cute.
- Platform: light stone tile with edge, maybe a small "STOP" sign or lamp. Draw platform as a cell-sized rectangle with lighter top, plus the passenger standing on it.
- Track: draw for each occupied body cell a pair of rails (two parallel lines perpendicular to travel direction... actually rails run along direction of travel) and wooden ties. To make it look continuous, also draw track under the head's previous cell. Simplest: maintain a `trail` of visited cells (the body covers them anyway). Body cells = train; draw track beneath each body cell oriented by the direction of travel through that cell. I can store for each segment the direction it entered/left. Since body follows head path, each segment i moves from its previous position; direction of track at segment i = direction from segment i to segment i-1 (toward head) — rails run along that axis, ties perpendicular. At corners it'll be axis-aligned pieces meeting at corner — acceptable, looks like a track with joints. Alternatively draw track as connected path with rounded corners — could use the polyline through segment centers and stroke two offset lines... complex. Axis-aligned per-cell with a small overlap is fine and reads as railway.
- Smoke: particles emitted from chimney every few ticks, drift up, grow, fade.
- Boarding effect: when passenger picked: floating "+10", a small heart or sparkle, passenger briefly appears to hop onto train (animate a little figure moving from platform to last wagon over ~300ms then fades). That's a nice signature touch — I'll do a simple "boarding" tween: passenger sprite lerps to the tail wagon position then vanishes with sparkle.
- Headlight cone in front of locomotive (subtle translucent triangle) — cute at "dusk"? For daytime keep a soft yellow glow ellipse ahead.
- Level/station bell "ding" via WebAudio — allowed (no network). Small oscillator beep on pickup, low thud on crash. Must not error before user gesture — AudioContext may need resume on gesture; in demo (autoplay) creating AudioContext may warn/suspend but not error. I'll create audio lazily on first user gesture, and in demo mode keep silent (or attempt resume — fine). To be safe: only init audio on first pointer/keydown; demo silent. Actually the screenshot is at ~3s without interaction; audio isn't needed for screenshot.
Demo mode specifics:
- Overlay: semi-transparent panel "🚂..." no emoji! Use text + maybe a drawn train icon via CSS/SVG inline. "LITTLE LINE — Click or press a key to start". Behind overlay the AI plays. The overlay should not block view too much — maybe a banner at bottom/center that says "Click or press a key to start — watch the demo". The AI demo keeps running visibly (game rendered, autopilot). That satisfies "self-playing demo mode behind a 'Click or press a key to start' message".
- On first click/keydown: start real game (reset board, human control).
- In user mode, on death: overlay "Game over — score X, wagons Y. Press any key to ride again." Also maybe return to demo after 8s idle? Not necessary; keep simple: game over overlay, key restarts. Maybe after 10s idle on game over, resume demo. Nice touch but optional; I'll add it — cheap.
HUD: Score (passengers), wagons count, speed indicator, best score in-memory (no localStorage allowed — keep session best in JS var).
Title: "The Little Line" or "Choo-Choo Express" / "Petit Train". Let's call it "Choochoo Line" or "The Little Express". I'll go "LITTLE EXPRESS — all aboard!". Typography: use a characterful font stack without external fonts (no network requests except allowed CDNs — task says none unless specified; the system prompt says no external fonts). So use system fonts but styled: "Georgia"? For a toy-train vibe, a rounded font would be nice but not available. I can use font-family stack like "Trebuchet MS", "Verdana"... Or draw the title on canvas? Keep HTML header with letter-spacing, maybe use a stencil vibe via CSS. Use `font-family: 'Courier New'` monospace for a ticket/signage vibe? A railway departure-board vibe: monospace uppercase with letter-spacing feels like an old station board — nice and characterful. Title in a "railway sign" style: white text on deep green board with gold border. Good.
Layout:
- Page bg: warm cream (#f2ead8) with subtle diagonal texture? Keep simple: cream with faint dot pattern via CSS gradient.
- Center: station sign header: small caps "LITTLE EXPRESS", subtitle "a tiny train & its passengers".
- HUD row: "PASSENGERS 0 · WAGONS 3 · BEST 0" in departure-board style chips.
- Canvas with wooden frame? A subtle dark green board with rounded corners and inner shadow.
- Footer: controls hint "Arrows / WASD to steer — swipe on touch".
Canvas sizing: fixed internal resolution (e.g., 30×20 cells at 32px = 960×640) and CSS scale to fit container width, maintaining aspect. For 375px viewport, scale to ~355 wide → height ~237, fine. Use `width:100%; max-width:960px; aspect-ratio` handled by height auto.
DPR sharpness: set canvas width to cols*cell * devicePixelRatio? Simpler: fixed internal 960×640 and CSS scaling; may be slightly blurry on hidpi but acceptable. Better: render at DPR: canvas.width = 960*dpr etc., ctx.scale(dpr,dpr). I'll do that.
Grid: 30×20 might be large for a "little" train; 26×18 at 34px = 884×612. Let's do cols=26, rows=18, cell=34 → 884×612. Hmm nice. Or 28×18, cell 34 → 952×612. I'll pick cols=28, rows=18, cell=34 → 952×612 canvas. Wrapper max-width 952.
Train rendering details per cell (size 34):
- Body cell: draw track first (for all body cells), then wagons on top.
- Locomotive (head): draw in direction of travel: boiler (rounded rect across cell along direction), cab at back, chimney near front, cowcatcher at front, headlight. Rotate context by direction angle to draw one orientation.
- Wagons: rounded rect along direction, with roof stripe and 2 windows, wheels (two small dark circles), coupling link between segments (short dark bar). Colors from palette cycling by index.
- Slight bobbing: y offset = sin(time*speed + i)*1.5 per wagon for lively feel. Nice.
Track per cell: rails along movement axis: two lines offset ±7px from center, spanning full cell (extend 3px beyond to overlap neighbors), color dark brown/rust #7a5a3a with lighter top? Ties: 3 short perpendicular dark slats spaced along the cell. Also a bed: slightly darker green strip under rails (ballast) — draw a rounded strip 22px wide along axis, color sandy #d9c49a at low alpha? On green grass, ballast gray-tan looks right.
Passengers/platform rendering:
- Platform: rounded square 26×26 light stone (#e8e0cc) with border, sitting on grass; a small shadow.
- Passenger: head circle (r 4, #f1c27d), body rounded rect (bright color e.g. #d64550 or varied per spawn), tiny waving arm: line from shoulder rotating with sin(t). Also a small suitcase? maybe a dot beside. Keep it readable at 34px.
- Spawn: keep up to 2 platforms concurrently after score grows? Start with 1; every 6 passengers, allow 2 concurrent. Platform placement: random free cell not adjacent to head/front, not on body.
- Passenger "waiting" animation: gentle hop (y offset sin), wave arm.
Boarding animation: on pickup, create effect object {x,y (platform), color, t}. Animate over 400ms: passenger figure lerps from platform cell center toward the current tail wagon center (recompute each frame toward tail), fading; then spawn sparkles "+10". Simple: just tween toward tail position at time of pickup (tail moves, but close enough; recompute tail each frame is easy — I have body array).
Crash effect: puff of steam circles at head, screen shake tiny (translate canvas by decaying random offsets), head flashes. In user mode show overlay after 600ms.
AI (demo autopilot):
- Each tick before moving, compute direction: candidates = [current, left-turn, right-turn] (no reverse). Score each: after simulating one step, compute flood-fill free area from that cell (bounded, counting cells not occupied by body); want area >= length (safe), among safe pick min distance to nearest platform; tie-break: straight preferred. If none safe, pick max area. Also avoid moving into cell adjacent-to-head? Standard.
- Flood fill each tick on 28×18=504 cells — trivial cost.
- Additional: lookahead 2? Not needed; flood fill safety usually keeps it alive long. Also treat platforms as free targets.
- If AI dies anyway (rare), auto-restart demo after 900ms with steam puff.
Also demo should show off: speed moderate.
Controls: keydown arrows/WASD; prevent default for arrows/space to avoid scrolling. Touch: swipe detection on canvas (touchstart/touchend delta). Also on-screen? Swipe is enough; mention hint. Also clicking canvas acts as start when in demo/attract; when playing, taps do nothing (or maybe tap quarters to steer? keep swipe only; also add simple 4-arrow buttons below canvas on small screens? Could clutter. I'll add swipe plus optional D-pad visible only on coarse pointers via media query (pointer: coarse). Let me include a compact D-pad under canvas shown on touch devices — safe addition. Actually keep scope controlled: swipe + keys, hint text. Hmm, mobile usability: swipes work fine. I'll implement touch swipe with threshold 24px.
State machine: mode = 'demo' | 'play' | 'over'(within play) . Demo overlay element visible in demo; hidden in play. In 'over', overlay shows result; pressing key/click restarts play (or returns to demo after timeout).
Message overlay design: centered card, cream paper with green border, headline "ALL ABOARD!", body "Click or press a key to take the controls", small "watching the little automatic pilot…" tag. For game over: "END OF THE LINE", score, "Press any key to ride again". Overlay pointer-events: it should capture clicks to start — attach click on whole document. Keyboard: window keydown.
Ensure first paint has everything: demo running immediately, HUD populated, overlay visible. Screenshot at 3s shows train mid-ride with wagons and a platform. Good.
Pause? Add "P" to pause maybe — optional; skip to keep code tight but pause is easy. I'll skip pause; not required.
Edge cases:
- Direction queue: store pendingDir; on tick, if pendingDir not reverse of current, apply. Buffer up to 2 keys.
- Reverse prevention uses comparison against current dir.
- Self collision: head moves into any body cell except the tail cell if tail will move (classic: moving into tail is OK since tail vacates). I'll allow moving into the current tail cell (since it vacates) — implement: occupied set built from body[0..len-1); check head against body excluding last element, when not growing. If growing (just ate), tail doesn't vacate — but growth appends after move; standard approach: compute newHead; if growing, tail stays; collision check vs all body. Let me implement:
- newHead; collision if out of bounds or in bodyCells (excluding tail when not growing).
- Eat: if newHead == platform cell → grow (don't pop tail this tick), score+, effects, schedule new platform.
- Growth: push newHead to front; if not eating, pop tail.
- Speed: base 150ms; each passenger -3ms, min 80ms. Demo uses base-ish 130ms constant? Let demo speed also ramp mildly for show.
Smoke particles: array {x,y,r,vy,life}; spawn from chimney world position every tick (or every other tick) when moving; update in rAF with dt; render as soft circles rgba(240,240,235,alpha). Chimney position: compute from head center + offset toward front*10, perpendicular 0; puff drifts up (screen up always) with slight back-drift opposite direction. Cute.
Headlight: soft radial gradient ellipse ahead of loco — draw with ctx gradient each frame; subtle.
Rails drawing details:
For a body cell with axis (horizontal or vertical):
- ballast: fillRect along axis width 26 centered, length cell+2, color '#cbb98f' alpha .9? On grass it stands out — good, track bed.
- ties: perpendicular slats: for horizontal track, ties are vertical bars width 3 height 20, at x offsets -10, 0, +10 (3 per cell) color '#8a6b46'.
- rails: two horizontal lines at y = cy±7, 3px tall, color '#5b4a3a' with highlight line? Use stroke lineWidth 3, '#6b5643', plus 1px lighter '#a08a6a' on top edge? Keep single stroke plus round caps extending 4px past cell so corners connect.
Actually simpler continuous look: draw rails as a path through consecutive body cell centers? The body is a connected orthogonal path (snake). I could stroke the polyline of all segment centers with two parallel strokes offset perpendicular... complexity with corners. Per-cell approach with overshoot gives connected appearance since consecutive cells share edge and rails overlap into neighbor by 4px. At corners, a horizontal rail piece and vertical rail piece will cross at the corner cell? Each cell has ONE axis (the direction through it). Corner cell: direction into it differs from out. The rail drawn along "to previous segment" direction... For corner cell, rails along direction toward head-neighbor; the neighbor's rails go its own axis; they'll meet at shared edge with an L-junction — looks like a turnout, acceptable and charming for a toy train.
Choose axis for segment i: direction from segment i to segment i-1 (the one closer to head), except head uses current dir, tail uses direction to segment i+1? For tail cell, use direction from tail to its next segment (toward head). Fine: for i>0 axis = dir(body[i] -> body[i-1]); for i==0 axis = current move dir.
Wagon rendering (in rotated frame where +x is forward):
- Local coords: cell 34, center 0,0. Wagon body: from x=-14..+14, y=-11..+11 rounded rect r=5, fill color, stroke darker shade.
- Roof: darker strip along top? Since top-down view, "roof" is just the top face; windows: two lighter rounded squares at x=-7 and +5, size 6×10? Windows along sides? Top-down: draw a lighter inner rect (roof panel) and two dark skylights. Wheels: 4 small dark circles at corners (±10, ±11) drawn under body? Draw wheels first (black circles r3 at x=±9, y=±12), then body over. Coupling: before drawing each wagon (except loco), draw dark rect from x=+14..+19 (toward previous segment)? Coupling between segment i and i-1: draw at the boundary. Easiest: after drawing all bodies in world space with per-segment rotation, draw coupling as thick short line between centers of i and i-1: from center of i toward i-1, a 6px wide dark line from radius 12 to 16... Let me just draw a small dark rect at the midpoint between the two cell centers, oriented along the axis, size 10×5. Compute midpoint, angle. Good.
Locomotive (local frame, forward +x):
- Wheels: 3 circles r3.5 at x=-10,0,+10? y=±12, dark.
- Boiler: rounded rect from x=-4..+16, y=-10..+10, fill red #d1495b, stroke #8f2f3e, with 2 gold bands (vertical lines at x=2, x=9, color #e9b44c width 2).
- Cab: rect from x=-16..-4, y=-12..+12, fill #b23a48 slightly darker, gold trim line, small window (skylight) #f4e6c1 at x=-10, 6×8.
- Chimney: circle/cylinder at x=+11: dark circle r4 with inner #333, ring gold.
- Cowcatcher: triangle at front from x=16 to x=20 widening y -8..+8, fill #4a3b33.
- Headlight dot at x=+15, small yellow circle; glow drawn in world frame before rotation (ellipse ahead).
- Head cell uses angle of current dir.
Bobbing: apply translate(0, sin(t*0.02 + i*0.9) * 1.6) in rotated frame before drawing? Bobbing perpendicular looks like sway; fine.
Passenger boarding tween: store {fx, fy (from), t0, dur:450, color}. Each frame compute target = center of body[last]. Progress p; pos = lerp(from, target, easeOut(p)); draw mini passenger with slight arc (add -10*sin(pi*p) to y). At end, spawn sparkle particles at tail. Also floating "+10" text at platform rising & fading 700ms.
Sparkles: small star particles (draw 4-point star or just circles) in gold, gravity slight, life 500ms.
Score popup: canvas text bold 14px monospace, color #2e4027 with white stroke? On green field, use cream text with dark outline.
Platform occupancy: ensure spawn cell not on body, not within 3 cells ahead of head, not adjacent to another platform. Try random 200 attempts.
Also draw grass texture: base fill #476b45? Let's craft palette:
- page bg: #f4ecdc (cream)
- board field: #4e7a52? A slightly desaturated green #567d55. Add subtle checker of two greens (like lawn stripes): alternate cells (x+y)%2 with two greens #5a8257 / #557a52 — gives mowed-lawn feel, nice and readable.
- ballast: #cdbb95; ties #7d5a3c; rails #4e4038.
- HUD board: deep green #1f3d2b with cream text, gold accents #e9b44c.
- Loco red #d1495b; wagons palette: ['#e9b44c','#7fae6b','#5b9aa0','#c97b9a','#e08e45','#9a7fc7']? avoid purple-ish maybe fine for a wagon. Keep 6: amber, leaf, teal, rose, tangerine, sky(#6fa8c9). OK.
Title area: "LITTLE EXPRESS" with a small SVG train glyph? I can inline a tiny SVG locomotive icon in the header — nice. Draw simple: rounded rect + circles. Or skip icon; use text-only with gold rules. I'll add a minimal inline SVG loco icon (black/gold) — cheap and characterful.
Font stack: `ui-monospace, 'Cascadia Mono', 'JetBrains Mono', Menlo, Consolas, monospace` for HUD/tickets; headline maybe same monospace, bold, letter-spacing .12em, uppercase. This gives station-board character without external fonts.
Structure:
```
<body>
<main class="wrap">
<header> sign: title + subtitle </header>
<section class="board">
<div class="hud"> chips: PASSENGERS / WAGONS / BEST / SPEED </div>
<div class="stage"> <canvas> <overlay> </div>
<footer hints + dpad? >
</section>
</main>
<script>...</script>
```
Overlay inside stage absolutely positioned.
HUD chips styled like split-flap: dark green bg, cream monospace, gold label small caps. Update via JS.
Let me also add a subtle "station clock"? Overkill. Keep tidy.
Now code carefully.
Constants:
```
const COLS=28, ROWS=18, CELL=34, W=COLS*CELL, H=ROWS*CELL;
```
Canvas DPR:
```
const dpr = Math.min(2, devicePixelRatio||1);
canvas.width=W*dpr; canvas.height=H*dpr; canvas.style... CSS width 100% via class; ctx.setTransform(dpr,0,0,dpr,0,0);
```
Game state:
```
let snake=[{x,y}...], dir={x:1,y:0}, pending=[], grow=0, platforms=[], score, wagonsBought...
```
Actually wagon count = snake length - 1 (locomotive + wagons). Start length 4 (loco + 3 wagons). Each passenger +1 wagon (length). Score += 10 * combo? Keep +10; maybe +5 per existing wagon bonus? Keep simple: +10.
Timer: use accumulator in rAF: acc += dt; while(acc>=stepMs){tick(); acc-=stepMs}. Cap dt 100ms.
Tick:
1. If pending dir valid, dir = shift pending.
2. newHead = head+dir.
3. Check bounds → crash.
4. Check body: occupied = set of "x,y" for snake; if growing (growPending>0?) hmm growth occurs when eating this tick. Standard:
- willGrow = platformAt(newHead) exists.
- bodyToCheck = willGrow ? snake : snake.slice(0,-1).
- if bodyToCheck contains newHead → crash.
5. Move: snake.unshift(newHead); if !willGrow snake.pop(); else { eat(newHead); }
6. effects, spawn logic.
Crash: mode stays but state='dead'; steam burst; if demo → schedule reset; if play → overlay gameover.
Platforms array items: {x,y,color,seed}. Rendering uses time for wave/wave-arm.
Spawn platform: choose random free cell (not on snake, not on existing platform, manhattan distance from head > 4). Start with 1 platform; when score crosses thresholds add second (maxConcurrent = score>=60?2:1... maybe 2 when wagons>=8). Actually nicer: always exactly 2 platforms once length >= 8, else 1. Also platform refresh: each platform has 1 passenger; after boarding, new platform spawns elsewhere (immediately). Keep count = (snake.length>=9?2:1).
Autopilot:
```
function aiDir(){
const head=snake[0];
const target = nearest platform (min manhattan);
const dirs=[dir, leftOf(dir), rightOf(dir)];
score each candidate cell c:
if blocked (wall or body minus tail) → score -Inf;
else area = flood(c); safe = area >= snake.length (approx);
dist = manhattan(c, target);
rank: safe first, then smaller dist, then straight bonus (== dir) tiny.
choose best; if all blocked choose dir (crash) — but flood handles.
}
```
Flood: BFS from c over free cells (not body, not wall). Grid small; use Uint8Array visited, queue array. Count up to early-exit at threshold (snake.length+2) to save time — but need max area if unsafe; just full count, 504 cells fine.
One more AI nicety: avoid hugging walls into dead ends — flood fill covers it. Also prefer moves that keep tail reachable? Skip; flood area >= length is a decent heuristic. Also with 2 platforms, target nearest.
To reduce AI oscillation, add tie-break preferring previous dir.
Also AI should not pick a cell adjacent to head's current body... covered by body check (body includes head's neighbors occupied as body). Candidate cell = head+dir; if that cell is body (except tail) → blocked.
Demo restart on AI death: setTimeout(()=>{ if(mode==='demo') resetGame(); }, 1000).
User death: state 'over'; overlay with results; keydown/click → resetGame(); mode='play'. Also idle timer: after 12s in 'over', go back to demo. Implement via timestamp check in rAF.
Start interaction: any keydown or pointerdown on document → if mode==='demo' → start play (reset). Note: the first keydown both starts and could be a direction — nice: capture the key as first steering input (if arrow). Implement: on start, if key is arrow/wasd set pending accordingly.
Audio: lazy AudioContext on first gesture. Sounds:
- pickup: two quick sine blips (E6→A6) 80ms, gain envelope.
- crash: noise? simple low square descending 200ms.
- whistle on game start: two-tone (A5+E5) triangle 300ms — charming "toot toot".
Keep functions guarded with try/catch.
Rendering order per frame:
1. clear; draw grass checker.
2. draw track for all body cells (so wagons sit on rails).
3. draw platforms + waiting passengers (under train? beside; platforms are separate cells so no overlap).
4. draw headlight glow (before train? glow under train near head front) — draw after track, before train.
5. draw smoke (some behind train? smoke above everything looks fine — draw after train for visibility). Draw smoke after train.
6. draw couplings then wagons back-to-front so front overlaps: draw from tail to head: for i=len-1..1 wagon; then loco. Wait couplings: draw all couplings first, then wagons tail→head, then loco last (front-most). Overlap order: later drawn on top; loco on top good; wagons: front wagon over rear one slight overlap due to bobbing — drawing tail→head means head-side drawn last, on top. Good.
7. effects: boarders, sparkles, popups.
8. screen shake transform applied at start (translate random*shake).
Also draw subtle board border/vignette inside canvas? The stage has CSS border/rounded corners + shadow; canvas corners rounded via CSS border-radius + overflow hidden.
HUD updates each tick / on events: score, wagons (len-1), best (session), speed as "mph" fun: mph = Math.round( (150-step) ... ) or just level. Show "PACE" as e.g. "LEISURELY/brisk/express" based on stepMs? Fun: speed chip shows `${Math.round(1000/stepMs*3.6)} km/h` scaled — playful. I'll show `SPEED ${Math.round(60000/stepMs/10)/1...}`. Simpler: show `v${level}` where level increases every 5 passengers. Chips: PASSENGERS n · COACHES m · BEST b · SPEED L1.. Let me do SPEED as km/h = Math.round(1000/stepMs * 2). Eh — I'll compute kmh = (1000/stepMs)*2.2 rounded. Whatever, playful.
Milestones toast: at every 5 passengers show banner text on canvas center-top "New coach added!"? The +1 wagon itself communicates. Add small toast div bottom of stage: "All aboard! · Coach added · Full steam!" messages. Keep one toast element with fade class. Fine.
Now write the code with care for zero errors. Let me draft.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Little Express — a tiny train snake</title>
<style>
:root{
--paper:#f4ecdc; --ink:#2b2a26; --green:#1f3d2b; --green2:#2c5238;
--gold:#e9b44c; --red:#d1495b; --cream:#f7f1e1;
}
*{box-sizing:border-box; margin:0; padding:0}
html,body{height:100%}
body{
background:
radial-gradient(1200px 600px at 50% -10%, #fbf6ea 0%, transparent 60%),
var(--paper);
color:var(--ink);
font-family: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
display:flex; align-items:center; justify-content:center;
min-height:100vh; padding:16px;
}
...
</style>
```
Wait: body height with min-height 100vh and content maybe taller on 375 width — allow scroll vertical is fine (no horizontal scrollbar requirement only). Keep vertical scroll okay.
Header sign:
```
<header class="sign">
<div class="sign-inner">
<svg class="loco-icon" ...>...</svg>
<div>
<h1>Little Express</h1>
<p class="sub">a tiny railway where every passenger becomes a coach</p>
</div>
</div>
</header>
```
Style: dark green board with double gold border (border + outline offset), cream text.
HUD chips: flex row wrap, gap.
Stage: position relative; canvas display block width 100%; height auto (aspect preserved automatically since canvas has intrinsic ratio via width/height attributes? With CSS width:100% and height:auto, intrinsic aspect from attributes (after dpr scaling it's same ratio) — yes height auto preserves ratio of canvas width/height attributes which are W*dpr, H*dpr — ratio same. Good.)
Overlay: absolute inset 0, display flex center; card. In demo: card with "ALL ABOARD!" etc. states.
Footer hints: small text; also keycaps styled `<kbd>`.
Now JS.
```js
(() => {
const COLS=28, ROWS=18, CELL=34, W=COLS*CELL, H=ROWS*CELL;
const cv=document.getElementById('game'), ctx=cv.getContext('2d');
const dpr=Math.min(2, window.devicePixelRatio||1);
cv.width=W*dpr; cv.height=H*dpr; ctx.setTransform(dpr,0,0,dpr,0,0);
const PALETTE=['#e9b44c','#7fae6b','#5b9aa0','#c97b9a','#e08e45','#8fb3a9','#d6a2b0'];
...
```
Colors for wagons — ensure enough variety: amber #e9b44c, leaf #7fae6b, teal #5b9aa0, rose #c97b9a, tangerine #e08e45, sage #8fb3a9, sky #7aa5c4. Good.
State:
```js
let mode='demo'; // demo | play | over
let snake, dir, pendingDirs, stepMs, score, best=0, platforms, overAt=0;
let effects=[], smokes=[], sparkles=[], shake=0, deadTimer=0;
let acc=0, last=performance.now();
```
reset():
```js
function resetGame(){
const cy=Math.floor(ROWS/2);
snake=[{x:8,y:cy},{x:7,y:cy},{x:6,y:cy},{x:5,y:cy}];
dir={x:1,y:0}; pendingDirs=[];
stepMs=150; score=0; platforms=[]; effects=[]; smokes=[]; sparkles=[]; shake=0;
spawnPlatform(); if(snake... ) spawn maybe 1
updateHUD();
}
```
spawnPlatform(count target handled separately): I'll have `ensurePlatforms()` called after each eat & reset:
```js
function freeCell(minDistHead=5){
for(let i=0;i<300;i++){
const x=(Math.random()*COLS)|0, y=(Math.random()*ROWS)|0;
if(snake.some(s=>s.x===x&&s.y===y)) continue;
if(platforms.some(p=>p.x===x&&p.y===y)) continue;
if(Math.abs(x-snake[0].x)+Math.abs(y-snake[0].y) < minDistHead) continue;
// avoid spawning directly in line right in front? minDist handles mostly
return {x,y};
}
return null;
}
function ensurePlatforms(){
const want = snake.length>=9 ? 2 : 1;
while(platforms.length<want){ const c=freeCell(); if(c) platforms.push({x:c.x,y:c.y,color:PALETTE[(Math.random()*PALETTE.length)|0],seed:Math.random()*7}); else break; }
}
```
eat:
```js
function eat(p){
platforms=platforms.filter(q=>q!==p);
score+=10; if(score>best) best=score;
stepMs=Math.max(82, 150-score*0.9);
effects.push({type:'board', fx:p.x, fy:p.y, t0:now, dur:480, color:p.color});
effects.push({type:'pop', x:p.x, y:p.y, t0:now, txt:'+10'});
ensurePlatforms();
updateHUD(); sfxPickup();
if(score%50===0) toast('Coach added — full steam!');
}
```
Hmm effects use performance.now-based `now` updated in rAF. Use global `tNow`.
tick():
```js
function tick(){
if(mode==='demo') { const d=aiDir(); if(d) dir=d; }
else if(pendingDirs.length){
const d=pendingDirs.shift();
if(!(d.x===-dir.x&&d.y===-dir.y)) dir=d; // note: also could re-push? just skip
}
const head=snake[0], nx=head.x+dir.x, ny=head.y+dir.y;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS){ crash(); return; }
const idx=platforms.findIndex(p=>p.x===nx&&p.y===ny);
const willGrow=idx>=0;
const lim=willGrow?snake.length:snake.length-1;
for(let i=0;i<lim;i++){ if(snake[i].x===nx&&snake[i].y===ny){ crash(); return; } }
snake.unshift({x:nx,y:ny});
if(willGrow) eat(platforms[idx]); else snake.pop();
// smoke
if(tickCount%2===0) puff();
tickCount++;
}
```
Wait pendingDirs apply: better to loop shifting until a valid non-reverse found, applying at most... Standard: shift one; if reverse, ignore (drop) — but then a quick double-turn (two keys between ticks) gets half-applied. Buffer: apply first valid; keep rest for next tick? I shift only one per tick; if it's reverse, drop it and try next next tick — to keep responsiveness, on keydown I can validate against last queued dir before pushing:
```js
function queueDir(d){
const lastD = pendingDirs.length? pendingDirs[pendingDirs.length-1] : dir;
if(d.x===lastD.x&&d.y===lastD.y) return;
if(d.x===-lastD.x&&d.y===-lastD.y) return;
if(pendingDirs.length<2) pendingDirs.push(d);
}
```
Then in tick: if(pendingDirs.length) dir=pendingDirs.shift(); — always valid vs the dir at enqueue time; since we validate against chain, applying sequentially is safe. Good, cleaner.
crash():
```js
function crash(){
state.dead=true; shake=1; steamBurst(snake[0]); sfxCrash();
if(mode==='demo'){ deadTimer=tNow+1100; }
else { mode='over'; overAt=tNow; showOver(); }
}
```
Hmm keep flags: use `dead` boolean; when dead, tick() returns early; in demo, when tNow>deadTimer → reset (still demo). In 'over', overlay shown; input restarts.
Actually manage with mode values: 'demo' (with possible dead substate), 'play', 'over'. Use `dead` flag + mode. When demo dead & time passed → resetGame(); dead=false.
Main loop:
```js
function frame(t){
tNow=t; const dt=Math.min(100, t-last); last=t;
if(!dead){
acc+=dt;
const step = mode==='demo'? Math.max(96, 150-score*0.6) : stepMs;
while(acc>=step && !dead){ acc-=step; tick(); }
} else {
if(mode==='demo' && t>deadTimer){ dead=false; resetGame(); }
}
// effects update
updateFx(dt);
render(t);
requestAnimationFrame(frame);
}
```
Careful: stepMs for play already updates in eat; demo uses its own formula — just use stepMs for both and update stepMs in eat regardless of mode. Simpler: eat always sets stepMs; demo initial 150 same. OK use stepMs both.
Dead in play: keep rendering scene (frozen with smoke drifting). Overlay shows.
updateFx: smokes (life, pos), sparkles, boarders (they need current tail), popups; shake decays.
Render:
```js
function render(t){
ctx.save();
ctx.clearRect? fill grass full:
// grass
for cells fill two greens.
// shake
if(shake>0){ ctx.translate((Math.random()-0.5)*6*shake,(Math.random()-0.5)*6*shake); }
drawTrack(); drawPlatforms(t); drawGlow(); drawTrain(t); drawSmoke(); drawFx(t);
ctx.restore();
}
```
Shake should apply to whole scene; do translate before drawing grass too? Grass moving looks odd but shake is brief; apply translate before grass fill — grass is full-cover fill; if translated, edges show background. Fill grass without translate covering extra margin: fill rect -8..W+8. I'll fill grass first without shake, then translate for the rest. Fine.
Grass: two greens checker per cell + subtle darker border rows? Also small tufts? skip tufts for perf; maybe pre-render grass to offscreen canvas once (static) — good perf and lets me add tufts/dots cheaply. I'll pre-render background (grass + occasional lighter blades + tiny flowers dots) into offscreen canvas at dpr once.
Flowers: little 3px dots cream/white scattered deterministic (seeded random simple LCG). Nice.
drawTrack:
```js
function drawTrack(){
for(let i=0;i<snake.length;i++){
const a=snake[i];
const b = i===0? {x:a.x+dir.x, y:a.y+dir.y} : snake[i-1];
const horiz = a.y===b.y; // axis of travel through this cell toward head
// ballast
ctx.fillStyle='rgba(196,178,138,0.95)';
if(horiz) ctx.fillRect(a.x*CELL-1, a.y*CELL+CELL/2-11, CELL+2, 22);
else ctx.fillRect(a.x*CELL+CELL/2-11, a.y*CELL-1, 22, CELL+2);
// ties
ctx.fillStyle='#7d5a3c';
if(horiz){ for(const o of [-9,0,9]) ctx.fillRect(a.x*CELL+CELL/2+o-1.5, a.y*CELL+5, 3, 24); }
else { for(const o of [-9,0,9]) ctx.fillRect(a.x*CELL+5, a.y*CELL+CELL/2+o-1.5, 24, 3); }
// rails
ctx.strokeStyle='#4c4038'; ctx.lineWidth=3; ctx.lineCap='butt';
if(horiz){
line(a.x*CELL-3, a.y*CELL+CELL/2-6, a.x*CELL+CELL+3, a.y*CELL+CELL/2-6);
line(... +6 ...);
} else vertical similarly.
}
}
```
Hmm rails centered at cy±6, ties spanning 24 tall crossing both rails. Ties drawn under rails: order ballast, ties, rails.
Corner junction: neighbor's rails will overlap into this cell by 3px; visually acceptable.
Rail highlight: draw 1px lighter line atop? skip, keep clean.
drawPlatforms(t):
```js
for p of platforms:
cx=p.x*CELL+CELL/2, cy=...
// shadow
roundRect(cx-14,cy-10,28,24,5) fill '#ded4b8' stroke '#b8a888'?
Actually platform should read as a tiny platform slab: draw a rect 30×22 with edge highlight top.
Then passenger standing: hop = Math.abs(Math.sin(t*0.004+p.seed))*2;
head circle r4 at (cx, cy-6-hop), body roundRect(cx-4, cy-3-hop, 8,9,3) fill p.color,
waving arm: from (cx+4, cy-1-hop) to angle wave: draw line to (cx+8, cy-5-hop + sin(t*0.012)*2), stroke skin color width 2, lineCap round.
tiny suitcase: rect(cx-10, cy+2, 5,6) brown.
```
Simplify: passenger = head + body + wave arm + suitcase. At 34px cell this reads well.
Also draw a small lamp post? skip.
drawGlow: ellipse gradient ahead of head:
```js
const hx=head center + dir*CELL*0.9;
radial gradient centered hx, radius 26, rgba(255,220,120,0.25)→0.
```
drawTrain(t):
```js
// couplings first
for i=1..len-1: midpoint between centers of snake[i], snake[i-1]; angle=atan2; fillRect rotated 12×5 color '#3c332c'.
// wagons tail→head
for(let i=snake.length-1;i>=1;i--) drawWagon(i,t);
drawLoco(t);
```
Each drawWagon: compute center (x*CELL+17, y*CELL+17), angle from dir to snake[i-1] (toward head). rotate; bob offset; body.
```js
function segAngle(i){
const a=snake[i], b= i===0? null : snake[i-1];
if(i===0) return Math.atan2(dir.y,dir.x);
return Math.atan2(b.y-a.y, b.x-a.x);
}
```
But intermediate segments' angle: from cell to next-toward-head cell — adjacent orthogonal so angle ∈ multiples of 90°. Good.
drawWagon(i,t):
```js
const ang=segAngle(i); const cx=..., cy=...;
ctx.save(); ctx.translate(cx,cy); ctx.rotate(ang);
const bob=Math.sin(t*0.006 + i*0.9)*1.4; ctx.translate(0,bob);
const col=PALETTE[(i-1)%PALETTE.length];
// wheels
ctx.fillStyle='#3a332c';
circle(-9,-12,3); circle(-9,12,3); circle(9,-12,3); circle(9,12,3);
// body
roundRect(-15,-11,30,22,5); fill col; stroke darken(col)? Use fixed dark stroke 'rgba(43,42,38,.55)' width 2.
// roof stripe: lighter inner rect
roundRect(-12,-8,24,16,3); fill 'rgba(255,255,255,0.18)';
// skylights: two dark rounded rects at x=-7,+3 w5 h12? along center line? On top view skylights run across width: two vertical dark strips centered: at x=-8 and x=+3, size 6×14, fill 'rgba(43,42,38,0.35)'. Hmm windows on a top-down wagon: draw as lighter rectangles (sky reflecting) — do cream 'rgba(247,241,225,0.85)' rounded 2, at (-9,-4,6,8) and (3,-4,6,8). That reads as windows/ventilators. ok.
ctx.restore();
```
drawLoco(t):
```js
translate head center; rotate segAngle(0); translate(0,bob tiny);
wheels: circles at (-9,-12),(-9,12),(2,-12),(2,12),(12,-11),(12,11) r3 dark;
cowcatcher: path triangle from (14,-9)→(20,0)→(14,9) fill '#4a3b33' — actually front is +x: points (15,-8),(21,0),(15,8).
boiler: roundRect(-6,-10,21,20,6) fill '#d1495b', stroke '#8f2f3e' w2.
gold bands: fillRect(x=0 w2 full height 20..-10?) vertical bands at x=1 and x=8: fillRect(1,-10,2.5,20) '#e9b44c'; fillRect(8,-10,2.5,20).
cab: roundRect(-16,-12,11,24,4) fill '#b23a48'; stroke; window: roundRect(-13,-7,6,14,2) fill '#f7e9c4'? A cab skylight: cream.
chimney: at x=13: fill circle r4.5 '#3a332c'; inner circle r2.5 '#1d1916'; gold ring stroke circle r4.5 '#e9b44c' w1.5.
dome: small circle at x=6 r3 gold? boiler already has bands; add brass dome circle at (5,0) r3 '#e9b44c' stroke dark — cute.
headlamp: circle at (15.5,0) r2.5 '#ffe9a8' with glow.
```
Order: wheels, cowcatcher, boiler, bands, dome, cab, window, chimney, lamp.
Smoke puff origin in world coords: chimney local (13,0) rotated by head angle → world = head + rotate(13,0) = head + dir*13. So puff at headCenter + dir*13... wait chimney near front x=+13 yes. Puff world pos px=hx+dir.x*13, py=hy+dir.y*13. Then particle rises (py decreases) and drifts opposite dir slightly: vx=-dir.x*12 etc.
puff():
```js
smokes.push({x:hx+dir.x*14, y:hy+dir.y*14, r:3+Math.random()*2, vx:-dir.x*14+(Math.random()*10-5), vy:-26-Math.random()*10, life:0, max:900+Math.random()*300});
```
update: life+=dt; x+=vx*dt/1000; y+=vy*dt/1000; vy decays toward -8? keep linear; alpha=1-life/max; r grows +6*dt/1000.
Render smoke: for s: ctx.globalAlpha=a*0.5; fill '#f3efe6' circle r; also darker core? fine. Reset globalAlpha.
Steam burst on crash: 14 puffs bigger at head.
Boarder effect update: p = (tNow-t0)/dur; target = center of snake[snake.length-1] (tail). But when new wagon added, tail is new segment at eat time? Growth appends head; tail unchanged during growth tick — the new coach effectively appears at head and shifts... Hmm: in snake-grow, new head added; the "new wagon" logically is the loco becoming... In train metaphor, passenger boards the last coach. New coach appears at tail. Since we draw wagon colors by index (i-1)%palette, when length grows all indices shift → all wagons change color! That's bad. Fix: assign fixed color per segment index from head: index 0 loco; wagon colors should stay with the wagon. Since body is FIFO (head unshift, tail pop), segment k relative to head changes identity over time... Actually each *position from head* is a stable "slot": slot1 = segment right behind loco. When train grows at tail, slots don't shift; the new tail slot is new. So color by position-from-head: wagon j (1-based from front) gets PALETTE[(j-1)%len]. Positions from head are stable! segment i (0=head) → slot i. When a new tail appears, it becomes slot len-1 with next palette color. Existing wagons keep their slot colors.
But wait — snake grows by unshifting head, so a given physical cell moves from slot i to slot i+1... the "wagon" as a drawn object is re-defined each frame by slot anyway (we draw per slot at the cell currently in that slot). Since cells shift slots, the drawn wagon at slot j is at different cell each tick — that's how snake rendering works; the "train" is slots moving forward along the track. Color by slot is stable visually.
So boarding tween target: the tail slot cell — the newly added coach appears at tail... when growing, snake.length increases; the new slot (last) this tick holds the old tail cell (since we didn't pop). So new coach appears at old tail position then travels forward. Boarding target = tail cell center at each frame → tween toward moving tail, nice: passenger appears to run along and hop on the back as it grows.
drawFx: boarders: p in [0,1]; pos lerp from platform center to tail center (recompute tail each frame); ease = 1-(1-p)^3; y arc: -12*Math.sin(Math.PI*min(1,p)); draw mini passenger (head+body) scale .9, alpha 1 (maybe fade last 15%). At p>=1: remove & spawn 6 sparkles at tail.
popups: y rises 18px over 800ms, alpha fade; font bold 13px; text '+10'; stroke dark outline.
sparkles: {x,y,vx,vy,life,max,color gold}; small circles r2 fading.
updateHUD(): set textContent of spans. Elements: #score,#coaches,#best,#speed.
Speed display: kmh = Math.round((1000/stepMs)*2.0); at 150ms → 13 km/h… little train indeed. Cute: show `${kmh} km/h`.
Toast: div#toast; function toast(msg){ set text, add .show; clearTimeout; timeout remove 1800ms }.
Overlays:
- #overlay div with .card; content set by function showOverlay(kind). Modes:
- demo: title "ALL ABOARD!", line1 "The little automatic pilot is giving a demo ride.", line2 "Click or press a key to take the controls."
- over: title "END OF THE LINE", line `You delivered ${score/10} passengers · ${coaches} coaches`, line2 "Click or press a key to ride again."
- Hide overlay when playing.
Also a small persistent hint bar under canvas: "← ↑ ↓ → / WASD steer · swipe on touch · any key starts". In demo mode it says "Watch the demo — click or press a key to start". Keep footer static with both hints; overlay handles start. Fine.
Input:
```js
const KEYMAP={ArrowUp:[0,-1],KeyW:[0,-1],ArrowDown:[0,1],KeyS:[0,1],ArrowLeft:[-1,0],KeyA:[-1,0],ArrowRight:[1,0],KeyD:[1,0]};
window.addEventListener('keydown', e=>{
if(e.repeat && !KEYMAP[e.code]) return;
const d=KEYMAP[e.code];
userStart();
if(d){ e.preventDefault(); if(mode==='play'&&!dead) queueDir({x:d[0],y:d[1]}); }
}, {passive:false});
```
userStart(): initAudio(); if(mode==='demo'||mode==='over'){ startPlay(); } — but if the key is an arrow and mode demo, also queue it after reset: startPlay(dir?) → resetGame(); mode='play'; hideOverlay(); if arrow key queue it. For over→restart same. Implement:
```js
function userStart(d){
ensureAudio();
if(mode==='demo' || mode==='over'){
resetGame(); dead=false; mode='play'; hideOverlay(); toast('All aboard!'); sfxWhistle();
if(d) queueDir(d);
}
}
```
But keydown handler calls userStart() then separately queues if playing — after userStart mode is 'play', so queue works. But careful: the keydown's queue happens after startPlay — order in handler: call userStart(d) which itself queues; don't double-queue. I'll make handler:
```js
const d=KEYMAP[e.code]; if(d) e.preventDefault();
if(mode==='demo'||mode==='over'){ startPlay(d); }
else if(d && mode==='play' && !dead){ queueDir(d); }
```
pointerdown on document: same but d=null. Also prevent double-firing with touch → use 'pointerdown' only.
But pointerdown on document also fires when clicking... fine anywhere starts.
Also prevent context menu? not needed.
Touch swipe: on canvas (or stage) pointerdown records start; pointermove/pointerup compute delta; if |d|>24 → queueDir dominant axis. Use Pointer events with setPointerCapture? Simple: touchstart/touchend on stage. But pointerdown also triggers userStart (starting game). For swipe after game started: pointerdown store, pointerup compute swipe and queue. Note: on mobile the first tap starts game; subsequent swipes steer. Implement:
```js
let tS=null;
stage.addEventListener('pointerdown',e=>{ tS={x:e.clientX,y:e.clientY}; });
stage.addEventListener('pointerup',e=>{ if(!tS) return; const dx=e.clientX-tS.x, dy=e.clientY-tS.y; tS=null;
if(Math.hypot(dx,dy)<24) return;
const d = Math.abs(dx)>Math.abs(dy)? {x:Math.sign(dx),y:0}:{x:0,y:Math.sign(dy)};
if(mode==='play'&&!dead) queueDir(d);
});
```
Also prevent page scroll on touchmove within stage: css touch-action:none on stage. And preventDefault on touchmove? touch-action: none handles it.
Audio:
```js
let AC=null;
function ensureAudio(){ if(AC) return; try{ AC=new (window.AudioContext||window.webkitAudioContext)(); }catch(e){} }
function beep(f,dur,type='sine',vol=0.15,when=0){
if(!AC) return; if(AC.state==='suspended') AC.resume().catch(()=>{});
try{
const t0=AC.currentTime+when, o=AC.createOscillator(), g=AC.createGain();
o.type=type; o.frequency.value=f; g.gain.setValueAtTime(0.0001,t0);
g.gain.exponentialRampToValueAtTime(vol,t0+0.02);
g.gain.exponentialRampToValueAtTime(0.0001,t0+dur);
o.connect(g).connect(AC.destination); o.start(t0); o.stop(t0+dur+0.05);
}catch(e){}
}
sfxPickup(){ beep(660,0.09,'triangle',0.18); beep(990,0.12,'triangle',0.14,0.07); }
sfxWhistle(){ beep(740,0.22,'square',0.06); beep(988,0.3,'square',0.05,0.16); } // two-tone whistle
sfxCrash(){ beep(140,0.35,'sawtooth',0.2); beep(90,0.4,'sawtooth',0.15,0.05); }
```
AC.resume() returns promise; catch to avoid unhandled rejection. Calling resume without gesture while suspended — in demo we never call (audio only after gesture since ensureAudio on gesture). Good.
Prerender background:
```js
const bg=document.createElement('canvas'); bg.width=W*dpr; bg.height=H*dpr;
const b=bg.getContext('2d'); b.setTransform(dpr,0,0,dpr,0,0);
for cells: fill '#5a8257' or '#54794f' checker.
// mow stripes alt rows? checker fine.
// scatter tufts: LCG
let s=7; const rnd=()=>((s=s*16807%2147483647)/2147483647);
for(let i=0;i<90;i++){ const x=rnd()*W,y=rnd()*H; b.fillStyle= rnd()>0.5?'rgba(255,255,255,0.14)':'rgba(30,50,30,0.18)'; b.fillRect(x,y,2,2)? tuft: draw 3 short lines? Keep dots (flowers) and darker speckles:
dots cream r1.5 (flowers), plus darker grass speckle rects 3×1.
}
```
Also subtle inner vignette: radial gradient dark edges alpha .12.
Then render: ctx.drawImage(bg,0,0,W,H).
roundRect helper: use ctx.roundRect if available (Chrome yes) else manual path. I'll write helper rr(x,y,w,h,r) building path manually to be safe (still fine). Actually ctx.roundRect exists in Chrome 99+; safe to use but I'll implement helper for portability:
```js
function rr(c,x,y,w,h,r){ 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(); }
```
line helper: function ln(x1,y1,x2,y2){ctx.beginPath();ctx.moveTo..;ctx.stroke();}
HUD ids and update.
Game over overlay content: passengers delivered = score/10. coaches = snake.length-1.
Best: session var; update on eat; show over overlay includes best.
Idle return to demo: in 'over', if tNow-overAt>12000 → mode='demo'; showOverlay('demo'); resetGame(). OK.
Also demo start: on load resetGame(); mode='demo'; showOverlay('demo'); rAF.
One more detail — dead in demo: the train freezes; steam puffs continue via fx update; after 1.1s reset. In 'over' the scene freezes similarly.
tickCount variable init.
Also demo AI: platforms list find nearest:
```js
function aiDir(){
const head=snake[0];
let target=null, bd=1e9;
for(const p of platforms){ const d=Math.abs(p.x-head.x)+Math.abs(p.y-head.y); if(d<bd){bd=d;target=p;} }
const cands=[dir,{x:dir.y,y:-dir.x},{x:-dir.y,y:dir.x}]; // right/left turns
let bestD=null,bestS=-1e9;
for(const d of cands){
const nx=head.x+d.x, ny=head.y+d.y;
let s;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS) s=-1e9;
else{
const occ=new Set(); const lim=snake.length-1; // tail vacates unless eating; approximate: exclude tail
for(let i=0;i<lim;i++) occ.add(snake[i].x+','+snake[i].y);
// hmm if next cell is platform we grow, tail won't vacate — but tail cell only conflicts if head adjacent... ignore nuance; use lim=snake.length-1 excluding tail, plus if the candidate equals tail cell and we might grow... fine.
if(occ.has(nx+','+ny)) s=-1e9;
else{
const area=flood(nx,ny,occ);
const dist=target? Math.abs(nx-target.x)+Math.abs(ny-target.y):0;
s = (area>=snake.length?1000:area) - dist*3 - (d===dir?0:1.5) ...
```
Wait d===dir reference compare works since cands includes dir object itself; use index: straight = (d===cands[0]).
```
s = (area>=snake.length+1 ? 5000 : area*2) - dist*4 + (i===0?2:0);
```
Hmm make sure safe >> unsafe: safe base 5000; unsafe = area*2 (max 2*504=1008) so safe always wins. Among safe, minimize dist*4 + turnPenalty. Good.
if(s>bestS){bestS=s;bestD=d;}
}
return bestD||dir;
}
function flood(sx,sy,occ){
const seen=new Uint8Array(COLS*ROWS); const q=[sx+sy*COLS]; seen[q[0]]=1; let n=0;
while(q.length){ const c=q.pop(); n++;
const x=c%COLS, y=(c/COLS)|0;
for(const [dx,dy] of [[1,0],[-1,0],[0,1],[0,-1]]){
const X=x+dx,Y=y+dy; if(X<0||Y<0||X>=COLS||Y>=ROWS) continue;
const id=X+Y*COLS; if(seen[id]) continue;
if(occ.has(X+','+Y)) continue;
seen[id]=1; q.push(id);
}
if(n>600) break;
}
return n;
}
```
Allocating Set each candidate each tick: 3 sets of ~len; fine.
Note: `for(const [dx,dy] of ...)` creates arrays each iteration — 504*4 allocs per flood *3 floods per tick * ~7 ticks/sec ≈ 42k allocs/s — fine for JS, but let me unroll to static arrays to be tidy:
```js
const DIRS4=[[1,0],[-1,0],[0,1],[0,-1]];
```
outside; loop with index. Fine.
Edge: occ uses string keys — ok perf.
Also AI: when no platforms (shouldn't happen), wander straight.
Now overlays HTML:
```html
<div id="overlay">
<div class="card">
<div class="badge">CENTRAL STATION</div>
<h2 id="ov-title">All aboard!</h2>
<p id="ov-line1">The automatic pilot is giving a demo ride.</p>
<p id="ov-line2" class="cta">Click or press a key to take the throttle</p>
</div>
</div>
```
Style card: cream paper, 2px solid green border + inner gold line via box-shadow inset; slight rotate(-1deg) for ticket charm? Ticket-style with perforated edge? Keep simple tilted card, shadow.
CTA pulse animation (opacity).
Overlay click: pointerdown listener on document already covers; but overlay should not block canvas pointer for swipe... it covers stage; pointerdown still fires on document (bubbles) so start works; swipe after start: overlay hidden then. But tS capture listener is on stage; when overlay covers stage, pointerdown target overlay bubbles to stage? Overlay is child of stage → yes bubbles to stage. tS recorded; pointerup after overlay hidden — pointerup fires on element under pointer? Pointer events target element under pointer at event time; pointerup after overlay hidden (hidden during down? overlay hides on pointerdown via document listener which fires before? Document listener in bubbling phase runs after stage listener... Both run on same event: stage (target path) then document. Order: capture? I use bubble listeners: stage gets event first (target = overlay, bubbles through stage then document). So tS set, then userStart hides overlay, then pointerup on canvas — tS still set, dx small → ignored. OK fine.
Also `user-select:none` on stage; `cursor:pointer` maybe.
HUD chips markup:
```html
<div class="hud">
<div class="chip"><span class="lbl">Passengers</span><span class="val" id="score">0</span></div>
<div class="chip"><span class="lbl">Coaches</span><span class="val" id="coaches">3</span></div>
<div class="chip"><span class="lbl">Top score</span><span class="val" id="best">0</span></div>
<div class="chip"><span class="lbl">Speed</span><span class="val" id="speed">13 km/h</span></div>
</div>
```
Footer: `<p class="hints">Steer with <kbd>←↑↓→</kbd> or <kbd>WASD</kbd> · swipe on touch</p>` — arrows as text characters ok (not emoji).
Small print: "Every passenger you pick up joins the train as a new coach."
Now the sign header with inline SVG loco icon:
```html
<svg viewBox="0 0 48 32" width="44" height="30" aria-hidden="true">
<rect x="4" y="14" width="26" height="12" rx="4" fill="#d1495b"/>
<rect x="30" y="10" width="10" height="16" rx="3" fill="#b23a48"/>
<rect x="36" y="4" width="4" height="7" fill="#3a332c"/>
<circle cx="12" cy="27" r="3" fill="#3a332c"/><circle cx="22" cy="27" r="3" fill="#3a332c"/><circle cx="34" cy="27" r="3" fill="#3a332c"/>
<rect x="8" y="18" width="6" height="4" rx="1" fill="#e9b44c"/>
</svg>
```
Eh — top-down vs side view inconsistency; side-view icon is fine for a logo. Slight tune later.
Now, will the demo AI look good within 3 seconds of screenshot? At 150ms/step, in 3s ~20 steps → train moved 20 cells with 4+ segments, likely picked 1-2 passengers, track visible, wagons colorful. Overlay visible.
One concern: overlay covers center; train behind visible? Card ~ small; make overlay background transparent-ish (no full dim, or light dim rgba(31,61,43,0.25)). Card centered. Train visible around. Good.
Also I should make sure canvas isn't too tall for 800px viewport: header ~70 + hud ~50 + canvas 612 scaled to width... At 1280 wide viewport, container max 960 → canvas 952 wide? Let's set stage max-width 960px; canvas CSS width 100% of stage → 952px, height 612. Total ~ 16+70+50+612+40+16 ≈ 800. Tight but ok; reduce paddings. Let me set header compact (padding 10), hud margin 8. If slightly over, page scrolls vertically — acceptable but better to fit. Set body padding 12, header ~64px, hud ~46, footer ~28 → 12+64+8+46+8+612+8+28+12 = 798.
Media query ≤ 700px: shrink header text, chips smaller, canvas full width.
Compute width at 375: body padding 16*2=32 → stage 343 → canvas height 343*612/952 ≈ 220. Total fine.
Now writing all code carefully. Watch pitfalls:
- `performance.now()` used as tNow via rAF param; effects t0 use tNow — first frame tNow set before any effects (reset before rAF start; tNow init = performance.now()). ok.
- dead while demo: in frame loop, skip acc accumulation but still render & fx. When reset, acc=0.
- In play mode with dead: mode becomes 'over'; frame: if(!dead) ... but mode 'over' has dead=true until restart. So condition: if(!dead){...} else { if(mode==='demo'&&t>deadTimer) reset... }. In over: stays frozen. Good.
- When starting play from over: resetGame sets dead=false (do in resetGame: dead=false).
HUD update on reset & eat & crash(no change).
showOverlay(kind): sets text and overlay style display flex; hide sets display none. Also for over: include score. Use ids.
Toast element positioned bottom center of stage.
Let me also add "little platform count": with 2 platforms ensure spacing.
Potential issue: eat() called during tick inside loop while iterating — fine.
`ensurePlatforms` after eat; also at reset ensure 1.
Also crash when head hits platform? Platforms are targets, not obstacles — passing over picks up. Good.
Let me also handle the "board" effect when tail moves during tween — target recomputed each render: store effect, in drawFx compute tail=snake[snake.length-1].
If snake dead/frozen, tail static, tween finishes. ok.
Write drawPassenger helper used by platform render and boarder fx (param scale).
Passenger colors: p.color from PALETTE — but wagons use same palette; passenger color预示 coach color — nice coherence: the passenger's shirt matches the coach they become! Lovely detail: when boarding, new coach color = passenger color. Implement: wagon color function: slot i → use a stored colors array: when eat, push p.color onto `coachColors` array; drawWagon(i) uses coachColors[i-1]. coachColors length = snake.length-1. On reset: coachColors = [3 colors from palette start]. Ensure palette distinct sequence. When passenger boards, coachColors.push(p.color) — could duplicate adjacent colors; acceptable (random spawn color). To reduce dupes: choose spawn color != last coachColors? minor; skip or quick pick: filter palette excluding last used. I'll pick randomly excluding coachColors last.
Now passengers' colors distinct from each other on field — fine.
drawWagon(i): color = coachColors[i-1] (guard undefined → PALETTE fallback).
Let me now also think about the grass colors & rails contrast: green #587f55/#51754e; ballast tan; red loco pops. Headlight subtle.
Text popups: '+10' cream with dark stroke.
Crash overlay 'END OF THE LINE'. Also show "best" maybe.
Also add: when eating, tiny "chime" — done.
Edge: queueDir when dead → ignored (handler checks !dead). In demo, arrow keys shouldn't queue (mode demo startPlay consumes as initial dir). ok.
Now write final code. Also include `<meta name="color-scheme" content="light">`? optional.
Font sizes: h1 clamp(20px, 3.4vw, 30px).
Let me write the whole file mentally checking syntax.
Also remember: no console errors. Guard getContext null? fine.
One more consideration: `ctx.setTransform(dpr,0,0,dpr,0,0)` then shake translate via ctx.translate after save — need save/restore around render with base transform preserved: at render start: ctx.setTransform(dpr,0,0,dpr,0,0) each frame then save/translate/restore — simplest: at frame start do ctx.setTransform(dpr,0,0,dpr,0,0) to reset.
Render sequence in code:
```js
function render(t){
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.drawImage(bg,0,0,W,H);
if(shake>0){ shake=Math.max(0,shake-dt...)}
```
shake decay in updateFx(dt): shake=Math.max(0,shake-dt/300).
```js
ctx.save();
if(shake>0) ctx.translate((Math.random()*2-1)*5*shake,(Math.random()*2-1)*5*shake);
drawTrack(); drawPlatforms(t); drawGlow(); drawTrain(t); drawSmoke(); drawFx(t);
ctx.restore();
}
```
drawGlow uses head & dir; if dead, no glow? keep small.
Smoke drawn after train but smoke rises from chimney — over train ok.
Boarder fx drawn above train — passenger hopping onto roof — draw after train. yes in drawFx.
Also popups above.
Detail: when dead, stop puffing smoke; existing drift.
Platform spawn animation: scale-in over 250ms: store born:tNow; scale=easeOutBack(min(1,(tNow-born)/250)). Nice touch.
Passenger head skin '#f0c290'; body color p.color; outline dark thin.
Wave arm: shoulder at body top-right; hand position rotates: angle = -0.9 + Math.sin(t*0.012+p.seed)*0.5; draw line shoulder→shoulder+(cos,sin)*6 upward. Since top-down view, a waving arm sideways reads odd but it's stylized — cute anyway. Alternatively two little arms wiggling both sides. Keep one arm up-wave (drawn as small line up-right with hand dot).
Time to write it all out. I'll be careful with syntax.
Also: `const now` naming conflicts nothing.
HUD "SPEED": update in eat and reset: kmh=Math.max(10,Math.round(2000/stepMs)); 150→13, 82→24. show `${kmh} km/h`.
Toast text ideas: start "All aboard!", milestone every 50 points: "New coaches on order…" eh: at score%50===0 && score>0 → toast(`${score/10} passengers happy!`). Keep.
Let me now also double-check demo overlay text per requirement: must display "Click or press a key to start". I'll write exactly: "Click or press a key to take the throttle" — requirement says display a clear message; including the words click or press a key + start. I'll include: "Click or press a key to start". Put that as the cta line verbatim plus flavor above. Good.
Also the game-over overlay: "Click or press a key to ride again."
Write CSS:
```css
.wrap{width:100%; max-width:984px; display:flex; flex-direction:column; gap:10px; align-items:center}
.sign{background:var(--green); color:var(--cream); border:2px solid #16301f; border-radius:10px;
box-shadow: inset 0 0 0 2px #2c5238, inset 0 0 0 4px var(--gold)?
```
Double trim: box-shadow: 0 3px 0 #16301f, inset 0 0 0 3px rgba(233,180,76,0)? Simplify: border:3px double var(--gold)? Use: border:2px solid #142a1c; box-shadow: inset 0 0 0 2px var(--gold), 0 4px 0 rgba(0,0,0,.08).
.sign-inner{display:flex; align-items:center; gap:14px; padding:10px 22px}
h1{font-size:clamp(19px,3vw,27px); letter-spacing:.18em; text-transform:uppercase; font-weight:700}
.sub{font-size:11px; letter-spacing:.08em; color:#cfe0c8; margin-top:2px}
.hud{display:flex; gap:8px; flex-wrap:wrap; justify-content:center}
.chip{background:var(--green); color:var(--cream); border-radius:8px; padding:6px 14px; min-width:96px; text-align:center; box-shadow:0 2px 0 rgba(0,0,0,.12); border:1px solid #16301f}
.lbl{display:block; font-size:9px; letter-spacing:.22em; text-transform:uppercase; color:#e9b44c}
.val{font-size:17px; font-weight:700}
.stage{position:relative; width:100%; border-radius:12px; overflow:hidden; box-shadow:0 10px 30px rgba(60,45,20,.25), 0 0 0 3px #2b4a33, 0 0 0 6px #1c3524? }
```
Frame: 3px solid #23422e + outer ring. Use box-shadow: 0 0 0 3px #35573c, 0 0 0 7px rgba(31,61,43,.35), 0 14px 30px rgba(80,60,20,.25). ok.
canvas{display:block; width:100%; height:auto; touch-action:none}
#overlay{position:absolute; inset:0; display:flex; align-items:center; justify-content:center; background:rgba(20,34,24,.28); }
.card{background:var(--cream); color:var(--ink); border:2px solid var(--green); border-radius:10px; padding:18px 26px; text-align:center; max-width:min(420px,84%); transform:rotate(-1.2deg); box-shadow:0 12px 24px rgba(0,0,0,.25), inset 0 0 0 4px rgba(233,180,76,.35)}
.badge{display:inline-block; font-size:9px; letter-spacing:.3em; background:var(--green); color:var(--gold); padding:3px 10px; border-radius:3px; margin-bottom:8px}
.card h2{font-size:24px; letter-spacing:.12em; text-transform:uppercase; margin-bottom:6px}
.card p{font-size:12px; line-height:1.5}
.cta{margin-top:10px; font-weight:700; color:#7c2d3a; animation:pulse 1.2s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.45}}
#toast{position:absolute; left:50%; bottom:12px; transform:translateX(-50%) translateY(6px); background:rgba(31,61,43,.92); color:var(--cream); padding:6px 14px; border-radius:20px; font-size:11px; letter-spacing:.06em; opacity:0; transition:.3s; pointer-events:none}
#toast.show{opacity:1; transform:translateX(-50%) translateY(0)}
.hints{font-size:11px; color:#6b6353; text-align:center}
kbd{background:#fff; border:1px solid #c9bfa8; border-bottom-width:2px; border-radius:4px; padding:1px 5px; font-size:10px}
```
Now assemble. Also overlay hidden class: #overlay.hidden{display:none}.
Let me count demo overlay content — also mention controls there.
Also: mobile viewport height: fine.
Write JS in full now (mental draft then final). Also define circle helper.
Potential subtle bug: In tick, after crash return, snake unchanged; dead=true. In demo frame: while loop `while(acc>=step && !dead)` — tick sets dead → loop exits. good.
pendingDirs validated at enqueue against last queued/current; between enqueue and application dir may have changed by tick applying earlier queue — chain validation ensures consistency: at enqueue, lastD = current dir or last pending; we compare candidate to lastD; when applied in order, each applied dir was valid vs its predecessor. But tick applies ONE per tick; if user presses two keys quickly both queued; tick1 applies first, tick2 applies second — second was validated vs first. Correct.
But reverse check must be against dir that will be current when applied — the chain handles it. Also equal-dir filter prevents 180 via double-press? e.g., current right; press up (queued), press left quickly: lastD=up; left vs up: not reverse (reverse of up is down) → queued. tick1: dir=up. tick2: dir=left. Valid sequence. Good.
One more: while dead in play mode, queueDir not called (handler checks mode==='play' && !dead). During 'over', keys restart. Good.
AI: ensure it also accounts platform-under-head? not needed.
Also when snake length grows large, AI flood threshold area>=snake.length+1 might rarely be satisfiable near walls — heuristic still ok, may eventually die; demo auto-restarts. Fine.
Render platform before train so train passes over platform? Platform is on its own cell; when head enters platform cell, pickup happens same tick, so train never overlaps platform visually except the boarding fx. Order irrelevant mostly.
Draw order note: drawTrack under everything after grass. Platforms after track (platforms never on track cells anyway).
Now, drawFx boarders: passenger mini figure — reuse drawPassengerShape(cx,cy,color,scale,alpha).
Let me define:
```js
function drawPassenger(cx,cy,color,t,seed,sc,alpha){
const hop=Math.abs(Math.sin(t*0.004+(seed||0)))*2;
ctx.save(); ctx.translate(cx,cy-hop*sc); ctx.scale(sc,sc); ctx.globalAlpha=alpha??1;
// suitcase
ctx.fillStyle='#8a6b46'; rr(ctx,-11,1,6,7,1.5); ctx.fill(); ctx.strokeStyle='rgba(0,0,0,.35)'; ctx.lineWidth=1; ctx.stroke();
// body
ctx.fillStyle=color; rr(ctx,-5,-4,10,11,4); ctx.fill(); ctx.stroke();
// head
ctx.fillStyle='#f0c290'; ctx.beginPath(); ctx.arc(0,-8,4.2,0,7); ctx.fill(); ctx.stroke();
// cap? little hat: arc top fill '#3a6ea5'? avoid blue default... hat green #2f5d3a: arc(0,-9,4.2, PI, 0) fill. cute conductor cap.
// waving arm
const a=-2.2+Math.sin(t*0.012+(seed||0))*0.45;
ctx.strokeStyle='#f0c290'; ctx.lineWidth=2.4; ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(4,-2); ctx.lineTo(4+Math.cos(a)*7, -2+Math.sin(a)*7); ctx.stroke();
ctx.restore(); ctx.globalAlpha=1;
}
```
Note arc(0,-8,4.2,0,7) — 7 > 2π ok.
Hat: ctx.fillStyle='#2f5d3a'; ctx.beginPath(); ctx.arc(0,-9,4.4,Math.PI,0); ctx.fill(); — half circle top. Then brim line? skip.
Boarder fx uses drawPassenger at interpolated pos with fixed hop=0 — pass t such that hop ~0? Just draw with t but it's fine (waving while flying is cute).
Scale passenger relative to cell 34: body 10 wide → good.
Platform drawing:
```js
function drawPlatforms(t){
for(const p of platforms){
const cx=p.x*CELL+CELL/2, cy=p.y*CELL+CELL/2;
const ap=Math.min(1,(tNow-p.born)/260); const s=0.6+0.4*(1-Math.pow(1-ap,3)); // ease
ctx.save(); ctx.translate(cx,cy); ctx.scale(s,s);
// slab
ctx.fillStyle='#e3d9bd'; rr(ctx,-15,-12,30,25,4); ctx.fill();
ctx.strokeStyle='#b8ab8b'; ctx.lineWidth=1.5; ctx.stroke();
ctx.fillStyle='rgba(255,255,255,.35)'; ctx.fillRect(-13,-10,26,3); // edge highlight
// yellow safety line
ctx.fillStyle='#e9b44c'; ctx.fillRect(-13,8,26,2.5);
ctx.restore();
drawPassenger(cx,cy-6,p.color,t,p.seed,1,1);
}
}
```
Passenger standing slightly above slab center — set passenger at cy-4. Slab 30×25 at cell 34: nearly fills; ok looks like platform tile. Yellow tactile strip at front edge. Good.
drawGlow:
```js
const h=snake[0], hx=h.x*CELL+CELL/2+dir.x*CELL*0.8, hy=...;
const g=ctx.createRadialGradient(hx,hy,2,hx,hy,26);
g.addColorStop(0,'rgba(255,214,120,.35)'); g.addColorStop(1,'rgba(255,214,120,0)');
ctx.fillStyle=g; ctx.fillRect(hx-26,hy-26,52,52);
```
Smoke draw:
```js
for(const s of smokes){ const a=1-s.life/s.max; ctx.globalAlpha=Math.max(0,a*0.55); ctx.fillStyle='#f5f2ea'; circle(s.x,s.y,s.r); }
ctx.globalAlpha=1;
```
Popup:
```js
const p=(tNow-e.t0)/800; if p>1 remove; y=e.y*CELL+8-p*20; alpha=1-p;
ctx.font='bold 13px ui-monospace, Menlo, monospace'; textAlign center; strokeStyle 'rgba(30,40,25,.8)' lineWidth 3 strokeText; fillStyle '#f7f1e1' fillText(e.txt, x, y);
```
Sparkles: gold circles r 1.5-2.5, vy up then gravity? simple: vx,vy random radial, life 500, alpha fade.
steamBurst: 16 smokes with bigger r, random vel.
Now HUD element refs.
Everything seems coherent. Let me also handle resize? Canvas CSS scales automatically; nothing needed.
Let me make sure no `alert` etc. none. No network. No storage.
Time to write final file. Also `<html lang="en">` since user request in English.
Also make sure first paint: initial resetGame runs before first rAF; render called each frame — canvas painted immediately. Overlay demo visible. HUD set.
Write the final code now, carefully balancing braces.
Also details: `ctx.lineJoin='round'` default fine.
Let me write drawTrack with helper:
```js
function rail(hx, hy, horiz){...}
```
Inline simpler.
Full code:
```js
(function(){
'use strict';
const COLS=28, ROWS=18, CELL=34, W=COLS*CELL, H=ROWS*CELL;
const cv=document.getElementById('game');
const ctx=cv.getContext('2d');
const dpr=Math.min(2, window.devicePixelRatio||1);
cv.width=W*dpr; cv.height=H*dpr;
const PALETTE=['#e9b44c','#7fae6b','#5b9aa0','#c97b9a','#e08e45','#7aa5c4','#8fb3a9'];
const DIRS4=[[1,0],[-1,0],[0,1],[0,-1]];
let mode='demo', dead=false;
let snake=[], dir={x:1,y:0}, pendingDirs=[], coachColors=[];
let platforms=[], score=0, best=0, stepMs=150;
let smokes=[], sparkles=[], pops=[], boarders=[];
let shake=0, acc=0, last=performance.now(), tNow=last, tickCount=0, deadAt=0, overAt=0;
// DOM
const elScore=document.getElementById('score'), elCo=document.getElementById('coaches'),
elBest=document.getElementById('best'), elSpeed=document.getElementById('speed'),
ov=document.getElementById('overlay'), ovTitle=document.getElementById('ov-title'),
ovL1=document.getElementById('ov-line1'), ovL2=document.getElementById('ov-line2'),
toastEl=document.getElementById('toast'), stage=document.getElementById('stage');
...
})();
```
Background prerender with LCG:
```js
const bg=document.createElement('canvas');
bg.width=W*dpr; bg.height=H*dpr;
(function paintBG(){
const b=bg.getContext('2d'); b.setTransform(dpr,0,0,dpr,0,0);
for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++){
b.fillStyle=(x+y)%2? '#527a4f':'#587f55';
b.fillRect(x*CELL,y*CELL,CELL,CELL);
}
let s=12345; const rnd=()=> (s=(s*16807)%2147483647)/2147483647;
for(let i=0;i<130;i++){
const x=rnd()*W, y=rnd()*H, r=rnd();
if(r<0.5){ b.fillStyle='rgba(28,48,28,0.20)'; b.fillRect(x,y,3,1.6); b.fillRect(x+2,y-2,3,1.6); }
else if(r<0.8){ b.fillStyle='rgba(245,240,220,0.5)'; b.beginPath(); b.arc(x,y,1.4,0,7); b.fill(); b.fillStyle='#e9b44c'; b.fillRect(x-0.7,y-0.7,1.4,1.4);}
else { b.fillStyle='rgba(220,235,210,0.35)'; b.fillRect(x,y,2,2); }
}
// vignette
const g=b.createRadialGradient(W/2,H/2,H*0.35,W/2,H/2,H*0.95);
g.addColorStop(0,'rgba(0,0,0,0)'); g.addColorStop(1,'rgba(20,35,20,0.22)');
b.fillStyle=g; b.fillRect(0,0,W,H);
})();
```
Helpers:
```js
function rr(c,x,y,w,h,r){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 circle(x,y,r){ctx.beginPath();ctx.arc(x,y,r,0,7);ctx.fill();}
```
resetGame:
```js
function resetGame(){
const cy=ROWS>>1;
snake=[{x:9,y:cy},{x:8,y:cy},{x:7,y:cy},{x:6,y:cy}];
dir={x:1,y:0}; pendingDirs=[]; dead=false;
stepMs=150; score=0; platforms=[]; smokes=[]; sparkles=[]; pops=[]; boarders=[]; shake=0; acc=0;
coachColors=[PALETTE[1],PALETTE[2],PALETTE[3]];
spawnPlatform();
updateHUD();
}
```
spawnPlatform:
```js
function spawnPlatform(){
for(let i=0;i<400;i++){
const x=(Math.random()*COLS)|0, y=(Math.random()*ROWS)|0;
if(snake.some(s=>s.x===x&&s.y===y)) continue;
if(platforms.some(p=>p.x===x&&p.y===y)) continue;
if(Math.abs(x-snake[0].x)+Math.abs(y-snake[0].y)<6) continue;
let col; do{ col=PALETTE[(Math.random()*PALETTE.length)|0]; }while(col===coachColors[coachColors.length-1]);
platforms.push({x,y,color:col,seed:Math.random()*6.28,born:tNow});
return;
}
}
```
ensurePlatforms:
```js
function ensurePlatforms(){ const want=snake.length>=10?2:1; while(platforms.length<want) spawnPlatform(); }
```
careful infinite loop if spawnPlatform fails (returns without push) — spawnPlatform returns void; if it failed, while loops forever. Make spawnPlatform return boolean:
```js
while(platforms.length<want){ if(!spawnPlatform()) break; }
```
eat:
```js
function eat(p){
platforms=platforms.filter(q=>q!==p);
score+=10; if(score>best) best=score;
stepMs=Math.max(82,150-score*0.9);
coachColors.push(p.color);
boarders.push({fx:p.x*CELL+CELL/2, fy:p.y*CELL+CELL/2, t0:tNow, dur:520, color:p.color});
pops.push({x:p.x*CELL+CELL/2, y:p.y*CELL+CELL/2, t0:tNow, txt:'+10'});
ensurePlatforms(); updateHUD(); sfxPickup();
if(score>0 && score%50===0) toast(score/10+' passengers delivered!');
}
```
tick:
```js
function tick(){
if(mode==='demo'){ const d=aiDir(); if(d) dir=d; }
else if(pendingDirs.length) dir=pendingDirs.shift();
const h=snake[0], nx=h.x+dir.x, ny=h.y+dir.y;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS){ crash(); return; }
const pi=platforms.findIndex(p=>p.x===nx&&p.y===ny);
const grow=pi>=0;
const lim=grow?snake.length:snake.length-1;
for(let i=0;i<lim;i++){ if(snake[i].x===nx&&snake[i].y===ny){ crash(); return; } }
snake.unshift({x:nx,y:ny});
if(grow) eat(platforms[pi]); else snake.pop();
if(tickCount%2===0) puff();
tickCount++;
}
```
Note: eat filters platforms AFTER we captured pi — findIndex before unshift; eat(platforms[pi]) — pi still valid since we filtered inside eat after call... eat receives p reference; fine.
But careful: crash check when grow: lim=snake.length includes tail — correct since tail won't vacate.
crash:
```js
function crash(){
dead=true; shake=1; steamBurst(); sfxCrash();
if(mode==='demo'){ deadAt=tNow+1100; }
else { mode='over'; overAt=tNow; showOver(); }
}
```
steamBurst:
```js
function steamBurst(){
const h=snake[0], hx=h.x*CELL+CELL/2, hy=h.y*CELL+CELL/2;
for(let i=0;i<16;i++){
const a=Math.random()*6.28, sp=20+Math.random()*50;
smokes.push({x:hx,y:hy,r:4+Math.random()*4,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp-20,life:0,max:700+Math.random()*400});
}
}
function puff(){
const h=snake[0], hx=h.x*CELL+CELL/2+dir.x*13, hy=h.y*CELL+CELL/2+dir.y*13;
smokes.push({x:hx,y:hy,r:2.5+Math.random()*2,vx:-dir.x*16+(Math.random()*12-6),vy:-30-Math.random()*12,life:0,max:800+Math.random()*300});
}
```
updateFx(dt):
```js
const k=dt/1000;
for(let i=smokes.length-1;i>=0;i--){const s=smokes[i]; s.life+=dt; s.x+=s.vx*k; s.y+=s.vy*k; s.vy-=6*k; s.r+=7*k; if(s.life>=s.max) smokes.splice(i,1);}
sparkles similar with vy+=160*k gravity.
pops: remove when (tNow-p.t0)>800.
boarders: remove when (tNow-b.t0)>b.dur → on removal spawn sparkles at tail.
shake=Math.max(0,shake-dt/350);
```
drawTrain(t):
```js
function segAngle(i){
if(i===0) return Math.atan2(dir.y,dir.x);
const a=snake[i], b=snake[i-1];
return Math.atan2(b.y-a.y,b.x-a.x);
}
function center(s){return [s.x*CELL+CELL/2, s.y*CELL+CELL/2];}
// couplings
ctx.fillStyle='#3c332c';
for(let i=1;i<snake.length;i++){
const [ax,ay]=center(snake[i]), [bx,by]=center(snake[i-1]);
const mx=(ax+bx)/2, my=(ay+by)/2, ang=Math.atan2(by-ay,bx-ax);
ctx.save(); ctx.translate(mx,my); ctx.rotate(ang); ctx.fillRect(-6,-2.5,12,5); ctx.restore();
}
// wagons tail→head
for(let i=snake.length-1;i>=1;i--) drawWagon(i,t);
drawLoco(t);
```
drawWagon(i,t):
```js
const [cx,cy]=center(snake[i]);
ctx.save(); ctx.translate(cx,cy); ctx.rotate(segAngle(i));
ctx.translate(0,Math.sin(t*0.006+i*0.9)*1.4);
const col=coachColors[i-1]||PALETTE[(i-1)%PALETTE.length];
ctx.fillStyle='#3a332c';
circle(-9,-12,3.2); circle(-9,12,3.2); circle(9,-12,3.2); circle(9,12,3.2);
ctx.fillStyle=col; rr(ctx,-15,-11,30,22,5); ctx.fill();
ctx.lineWidth=2; ctx.strokeStyle='rgba(43,42,38,0.5)'; ctx.stroke();
ctx.fillStyle='rgba(255,255,255,0.16)'; rr(ctx,-12,-8,24,16,3); ctx.fill();
ctx.fillStyle='rgba(247,241,225,0.9)';
rr(ctx,-10,-3.5,6,7,2); ctx.fill(); rr(ctx,3,-3.5,6,7,2); ctx.fill();
ctx.restore();
```
drawLoco(t):
```js
const [cx,cy]=center(snake[0]);
ctx.save(); ctx.translate(cx,cy); ctx.rotate(segAngle(0));
ctx.translate(0,Math.sin(t*0.006)*1.1);
ctx.fillStyle='#3a332c';
circle(-11,-12,3.4); circle(-11,12,3.4); circle(0,-12,3.4); circle(0,12,3.4); circle(11,-11,3.2); circle(11,11,3.2);
// cowcatcher
ctx.fillStyle='#4a3b33'; ctx.beginPath(); ctx.moveTo(14,-9); ctx.lineTo(21,0); ctx.lineTo(14,9); ctx.closePath(); ctx.fill();
// boiler
ctx.fillStyle='#d1495b'; rr(ctx,-6,-10,22,20,7); ctx.fill();
ctx.lineWidth=2; ctx.strokeStyle='#8f2f3e'; ctx.stroke();
// gold bands
ctx.fillStyle='#e9b44c'; ctx.fillRect(1,-10,2.5,20); ctx.fillRect(9,-10,2.5,20);
// brass dome
ctx.fillStyle='#e9b44c'; circle(5,0,3.4); ctx.strokeStyle='#a97f2a'; ctx.lineWidth=1.2; ctx.beginPath(); ctx.arc(5,0,3.4,0,7); ctx.stroke();
// cab
ctx.fillStyle='#b23a48'; rr(ctx,-17,-12,12,24,4); ctx.fill(); ctx.strokeStyle='#7e2836'; ctx.stroke();
ctx.fillStyle='#f7e9c4'; rr(ctx,-14,-7,6,14,2); ctx.fill();
// chimney
ctx.fillStyle='#3a332c'; circle(14,0,4.6);
ctx.fillStyle='#171310'; circle(14,0,2.6);
ctx.strokeStyle='#e9b44c'; ctx.lineWidth=1.5; ctx.beginPath(); ctx.arc(14,0,4.6,0,7); ctx.stroke();
// headlamp
ctx.fillStyle='#ffe9a8'; circle(19.5,0,2.4);
ctx.restore();
```
Note boiler spans -6..16 (w22). Chimney at 14 sits on boiler front. Cowcatcher 14→21 beyond cell (cell half=17) — slight overflow into next cell is fine/cute.
Boiler roundRect radius 7 with w22,h20 fine.
Smoke origin dir*13 matches chimney world pos approx (rotate(14,0)→dir*14). ok.
drawFx:
```js
// boarders
for(const b of boarders){
const p=Math.min(1,(tNow-b.t0)/b.dur), e=1-Math.pow(1-p,3);
const tail=snake[snake.length-1], [tx,ty]=center(tail);
const x=b.fx+(tx-b.fx)*e, y=b.fy+(ty-b.fy)*e - Math.sin(Math.PI*p)*14;
drawPassenger(x,y,b.color,tNow,1,0.95,1);
}
// sparkles
for(const s of sparkles){ const a=1-s.life/s.max; ctx.globalAlpha=Math.max(0,a); ctx.fillStyle='#f4c95d'; circle(s.x,s.y,s.r); }
ctx.globalAlpha=1;
// pops
ctx.font='bold 14px ui-monospace, Menlo, Consolas, monospace'; ctx.textAlign='center';
for(const p of pops){ const q=(tNow-p.t0)/800; if(q>=1) continue; ctx.globalAlpha=1-q;
const y=p.y-10-q*20; ctx.lineWidth=3; ctx.strokeStyle='rgba(25,40,25,.75)'; ctx.strokeText(p.txt,p.x,y); ctx.fillStyle='#fdf7e7'; ctx.fillText(p.txt,p.x,y); }
ctx.globalAlpha=1;
```
Popups removal in updateFx (filter). Boarders removal spawns sparkles:
```js
for(let i=boarders.length-1;i>=0;i--){
const b=boarders[i];
if(tNow-b.t0>=b.dur){ boarders.splice(i,1);
const tail=snake[snake.length-1], [tx,ty]=center(tail);
for(let j=0;j<7;j++){ const a=Math.random()*6.28, sp=30+Math.random()*50;
sparkles.push({x:tx,y:ty,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp-40,r:1.5+Math.random()*1.8,life:0,max:480+Math.random()*260}); }
}
}
```
AI functions as drafted. Let me finalize aiDir:
```js
function aiDir(){
const h=snake[0];
let target=null,bd=1e9;
for(const p of platforms){const d=Math.abs(p.x-h.x)+Math.abs(p.y-h.y); if(d<bd){bd=d;target=p;}}
const cands=[dir,{x:dir.y,y:-dir.x},{x:-dir.y,y:dir.x}];
let bestD=null,bestS=-Infinity;
const occ=new Set();
for(let i=0;i<snake.length-1;i++) occ.add(snake[i].x+','+snake[i].y);
for(let ci=0;ci<3;ci++){
const d=cands[ci], nx=h.x+d.x, ny=h.y+d.y;
let s;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS||occ.has(nx+','+ny)) s=-1e9;
else{
const area=flood(nx,ny,occ);
const dist=target?Math.abs(nx-target.x)+Math.abs(ny-target.y):0;
s=(area>=snake.length+1?5000:area*2)-dist*4+(ci===0?1.5:0);
}
if(s>bestS){bestS=s;bestD=d;}
}
return bestD;
}
```
occ includes head (i=0) — candidate cells adjacent to head are never head itself. Body excluding tail — but if next move eats (grow), tail doesn't vacate; risk rare. Also head currently occupies its cell; next head cell can't be current head. fine.
Note: occ should exclude... also note the cell behind the head (snake[1]) — moving there = reverse, prevented by candidate set. ok.
flood with early exit? full count fine.
Also flood should treat platform cells as free — they are (not in occ).
Input handling & overlay:
```js
function showOverlay(kind){
ov.classList.remove('hidden');
if(kind==='demo'){
ovTitle.textContent='All aboard!';
ovL1.textContent='The automatic pilot is giving a demo ride — watch the little express pick up passengers.';
ovL2.textContent='Click or press a key to start';
} else {
ovTitle.textContent='End of the line';
ovL1.textContent='You delivered '+(score/10)+' passenger'+(score===10?'':'s')+' and grew to '+(snake.length-1)+' coaches. Best this visit: '+best+'.';
ovL2.textContent='Click or press a key to ride again';
}
}
function hideOverlay(){ ov.classList.add('hidden'); }
```
startPlay(d):
```js
function startPlay(d){
ensureAudio();
resetGame(); mode='play'; hideOverlay(); toast('All aboard!'); sfxWhistle();
if(d) queueDir(d);
}
```
Input:
```js
const KEYMAP={ArrowUp:[0,-1],KeyW:[0,-1],ArrowDown:[0,1],KeyS:[0,1],ArrowLeft:[-1,0],KeyA:[-1,0],ArrowRight:[1,0],KeyD:[1,0]};
window.addEventListener('keydown',e=>{
const kd=KEYMAP[e.code];
if(kd) e.preventDefault();
if(mode==='demo'||mode==='over'){ startPlay(kd?{x:kd[0],y:kd[1]}:null); }
else if(kd&&!dead){ queueDir({x:kd[0],y:kd[1]}); }
});
document.addEventListener('pointerdown',()=>{ if(mode==='demo'||mode==='over') startPlay(null); else ensureAudio(); });
```
Note ensureAudio also in startPlay.
Swipe on stage (pointerdown/up). Also `stage.style.touchAction='none'` via CSS.
Over→demo idle: in frame:
```js
if(mode==='over' && tNow-overAt>12000){ mode='demo'; resetGame(); showOverlay('demo'); }
```
Toast:
```js
let toastT=0;
function toast(msg){ toastEl.textContent=msg; toastEl.classList.add('show'); clearTimeout(toastT); toastT=setTimeout(()=>toastEl.classList.remove('show'),1900); }
```
updateHUD:
```js
elScore.textContent=score; elCo.textContent=snake.length-1; elBest.textContent=best;
elSpeed.textContent=Math.max(10,Math.round(2000/stepMs))+' km/h';
```
2000/150≈13, 2000/82≈24. cute.
Audio:
```js
let AC=null;
function ensureAudio(){ if(AC) return; try{ const C=window.AudioContext||window.webkitAudioContext; if(C) AC=new C(); }catch(e){AC=null;} }
function beep(f,dur,type,vol,when){
if(!AC) return;
try{
if(AC.state==='suspended') AC.resume().catch(()=>{});
const t0=AC.currentTime+(when||0), o=AC.createOscillator(), g=AC.createGain();
o.type=type||'sine'; o.frequency.setValueAtTime(f,t0);
g.gain.setValueAtTime(0.0001,t0);
g.gain.exponentialRampToValueAtTime(vol||0.15,t0+0.02);
g.gain.exponentialRampToValueAtTime(0.0001,t0+dur);
o.connect(g); g.connect(AC.destination);
o.start(t0); o.stop(t0+dur+0.05);
}catch(e){}
}
function sfxPickup(){ beep(659,0.09,'triangle',0.16); beep(988,0.14,'triangle',0.13,0.07); }
function sfxWhistle(){ beep(740,0.20,'square',0.05); beep(932,0.30,'square',0.05,0.14); }
function sfxCrash(){ beep(150,0.30,'sawtooth',0.18); beep(92,0.4,'sawtooth',0.14,0.06); }
```
Frame loop:
```js
function frame(t){
tNow=t; const dt=Math.min(100,t-last); last=t;
if(!dead){
acc+=dt;
while(acc>=stepMs&&!dead){ acc-=stepMs; tick(); }
} else if(mode==='demo'&&t>=deadAt){ resetGame(); }
if(mode==='over'&&tNow-overAt>12000){ mode='demo'; resetGame(); showOverlay('demo'); }
updateFx(dt);
render(t);
requestAnimationFrame(frame);
}
resetGame(); showOverlay('demo'); requestAnimationFrame(t=>{last=t; frame(t);});
```
Hmm resetGame sets acc=0 good.
Wait: while(!dead) with tick() that can set dead — exit. But if eat sets stepMs smaller mid-loop, fine.
Also: `if(mode==='over' ...)` placement fine.
One more check: In demo after crash auto-reset, showOverlay('demo') still shown (never hidden) — overlay persists whole demo. Good.
In 'over' mode overlay shown with results. When idle 12s → demo overlay. Good.
render(t):
```js
function render(t){
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.drawImage(bg,0,0,W,H);
ctx.save();
if(shake>0) ctx.translate((Math.random()*2-1)*5*shake,(Math.random()*2-1)*5*shake);
drawTrack();
drawPlatforms(t);
if(!dead) drawGlow();
drawTrain(t);
drawSmoke();
drawFx(t);
ctx.restore();
}
```
drawTrack:
```js
function drawTrack(){
ctx.lineCap='butt';
for(let i=0;i<snake.length;i++){
const s=snake[i], x=s.x*CELL, y=s.y*CELL;
let horiz;
if(i===0) horiz=dir.y===0; else { const b=snake[i-1]; horiz=s.y===b.y; }
const cx=x+CELL/2, cy=y+CELL/2;
// ballast
ctx.fillStyle='rgba(203,184,142,0.95)';
if(horiz) ctx.fillRect(x-1,cy-11,CELL+2,22); else ctx.fillRect(cx-11,y-1,22,CELL+2);
// ties
ctx.fillStyle='#7d5a3c';
if(horiz){ for(const o of[-9,0,9]) ctx.fillRect(cx+o-1.5,y+5,3,24); }
else { for(const o of[-9,0,9]) ctx.fillRect(x+5,cy+o-1.5,24,3); }
// rails
ctx.strokeStyle='#4c4038'; ctx.lineWidth=3;
ctx.beginPath();
if(horiz){ ctx.moveTo(x-3,cy-6); ctx.lineTo(x+CELL+3,cy-6); ctx.moveTo(x-3,cy+6); ctx.lineTo(x+CELL+3,cy+6); }
else { ctx.moveTo(cx-6,y-3); ctx.lineTo(cx-6,y+CELL+3); ctx.moveTo(cx+6,y-3); ctx.lineTo(cx+6,y+CELL+3); }
ctx.stroke();
}
}
```
Hmm ties length 24 (y+5..y+29) vs ballast 22 tall — ties slightly wider than ballast; make ties within: y+6 h 22? rails at cy±6 = y+11,y+23; ties should span beyond rails: y+7 to y+27 (h20). Set ties: y+7, h20; horizontal case x+7..? for horiz ties vertical: fillRect(cx+o-1.5, y+7, 3, 20). And ballast 22 (cy-11..cy+11 = y+6..y+28). ties y+7..27 inside. good. Vertical case ties horizontal: fillRect(x+7, cy+o-1.5, 20, 3).
Also rails at ±6 with lineWidth 3 → occupy ±7.5; ballast half-width 11 fine.
For head cell, dir axis; for segments angle b=snake[i-1] toward head; horizontal if same y. Since consecutive cells always orthogonal-adjacent, s.y===b.y implies horizontal movement. good.
drawSmoke:
```js
function drawSmoke(){
for(const s of smokes){
const a=Math.max(0,1-s.life/s.max);
ctx.globalAlpha=a*0.55;
ctx.fillStyle='#f6f3ea';
circle(s.x,s.y,s.r);
}
ctx.globalAlpha=1;
}
```
Also give smoke slight dark under-shadow? skip.
Now HTML body:
```html
<main class="wrap">
<header class="sign">
<svg ...loco icon...></svg>
<div class="titles">
<h1>Little Express</h1>
<p class="sub">every passenger becomes a new coach</p>
</div>
</header>
<div class="hud"> ... </div>
<div class="stage" id="stage">
<canvas id="game" width="952" height="612"></canvas>
<div id="overlay"> card </div>
<div id="toast"></div>
</div>
<p class="hints">Steer with <kbd>←</kbd><kbd>↑</kbd><kbd>↓</kbd><kbd>→</kbd> or <kbd>WASD</kbd> · swipe on touch · pickups add coaches</p>
</main>
```
canvas width/height attributes will be overwritten by JS dpr scaling; initial attrs give intrinsic ratio for CSS before JS? JS runs immediately; set attributes correct anyway (952×612). After JS sets width=W*dpr, CSS width:100% + height:auto preserves displayed ratio (height auto uses intrinsic ratio from attributes) — attributes 1904/1224 ratio same 952/612. Good.
Overlay card include small note in demo: also mention "Watch out for the buffer stops!" eh skip.
Also add `aria` minimal.
Media query:
```css
@media (max-width:700px){
body{padding:10px}
.sign-inner{padding:8px 14px; gap:10px}
.chip{min-width:74px; padding:5px 10px}
.val{font-size:14px}
.card{padding:14px 18px}
.card h2{font-size:18px}
}
```
The stage box-shadow rings okay.
Also to be safe about overlay pointer capturing: overlay has no pointer-events issue (default auto) — click anywhere handled by document listener anyway.
Double-check no emoji anywhere. Arrows ←↑↓→ are fine (symbols not emoji).
Fonts: monospace stack; headline uppercase spaced — looks like station signage.
Potential rendering nit: `ctx.font` with monospace stack string containing spaces — quote families: `"ui-monospace, Menlo, Consolas, monospace"` fine as CSS font shorthand: `bold 14px ui-monospace, Menlo, Consolas, monospace` valid.
Now demo visual at screenshot: train near start moving right, one platform ahead with waving passenger, track behind, smoke trail. Lovely.
Let me also add subtle rails shadow? skip.
One more mechanic check: platform spawn min distance 6 from head — with grid 28 wide fine.
AI turning penalty & distance: at spawn, platform maybe far right; train goes right, collects. Flood safety with len 4 trivial. As it grows, still fine. The AI may loop when platform unreachable-safely? flood handles. Occasionally AI might oscillate around a platform if dist heuristic conflicts with safety — safe branch dominates (5000 vs ≤1008), so within safe moves picks min dist → reaches. Good.
Edge: candidate cell occupied check uses occ excluding tail; if candidate == tail cell and snake would grow... rare false-pass causing crash into tail-on-grow. Acceptable.
Also `flood` counts the start cell even if it's a platform — fine.
Now let me also handle document visibility (rAF pauses automatically; dt capped 100 — fine).
Write final HTML. Also title of page. Also make sure `hidden` class defined: `.hidden{display:none!important}`.
Toast initial empty.
Also HUD 'Speed' initial set by updateHUD on reset.
Check `circle` helper uses ctx closure — defined inside IIFE, fine.
drawPassenger signature: (cx,cy,color,t,seed,sc,alpha). In platform call I pass (cx,cy-6? ) Actually platform slab occupies cell; passenger should stand ON slab: draw passenger centered at (cx, cy-2). Slab drawn scaled; passenger not scaled by slab scale (fine).
Suitcase at (-11,1) may overlap slab edge; fine.
Boarder draw: drawPassenger(x,y,color,tNow,0,0.9,1) with waving arm animating — cute.
Also while boarding, draw a dashed "hop" arc? skip.
Let me finalize sizes: loco icon svg — refine:
```html
<svg class="loco" viewBox="0 0 64 40" width="52" height="34" aria-hidden="true">
<rect x="2" y="16" width="34" height="16" rx="6" fill="#d1495b" stroke="#8f2f3e" stroke-width="2"/>
<rect x="36" y="10" width="14" height="22" rx="4" fill="#b23a48" stroke="#7e2836" stroke-width="2"/>
<rect x="40" y="15" width="6" height="9" rx="2" fill="#f7e9c4"/>
<rect x="10" y="5" width="7" height="12" rx="2" fill="#3a332c"/>
<rect x="12" y="2" width="3" height="4" fill="#3a332c"/>?
```
Simplify chimney: rect x=24? Loco faces right: boiler front right with chimney near front: chimney at x=26: `<rect x="26" y="6" width="7" height="11" rx="2" fill="#3a332c"/>`, gold band `<rect x="18" y="16" width="4" height="16" fill="#e9b44c"/>`, wheels circles cx=10,22,43 cy=34 r=4 fill #2b2a26 stroke none, cowcatcher polygon "36,32 46,32"? cab at right? Hmm typical loco: cab at back(left?) For right-facing loco: cab at right? No—cab at back = left side? Locomotive: cab at rear, chimney at front. Facing right: front=right: chimney right, cab left. Rework:
- cab left: rect x=2 y=10 w=13 h=22 rx=3 fill #b23a48; window x=5 y=14 w=6 h=8 cream.
- boiler: x=15 y=16 w=28 h=15 rx=6 fill #d1495b; bands x=24,x=33 w=3 gold.
- chimney: x=36 y=7 w=7 h=10 fill #3a332c, top lip rect x=34.5 y=6 w=10 h=3.
- cowcatcher: polygon points "43,31 50,31 46,25"? front bottom right: points "43,26 50,31 43,31".
- wheels: cx 10? under cab 8, under boiler 20,30,40 cy=33 r=3.5.
- smoke puff circle at (41,3) r3 fill rgba? add two gray circles.
Fine — decorative icon, tune numbers plausibly.
Also dome circle at (28,14)? skip.
Header svg small; visual roughness acceptable.
Now also ensure the whole thing compiles: I'll mentally trace braces when writing.
Also note: `best` shows 0 until first point — chip "Top score".
Potential issue: on touch devices pointerdown + pointerup — after starting game via tap, that same tap's pointerup has tS set and delta<24 → ignored. good.
Also prevent scrolling with arrow keys — preventDefault done. Space? not used; ignore.
Also keydown repeat: holding arrow — queueDir dedupes same dir (returns if equal lastD). fine.
Let me write out the final answer file now. Keep code tidy and commented lightly.
Additional flourish: little "buffers/water tower" decorative in corners? skip — enough.
Also add subtle animated signal? enough.
Final check on requirement "Everything that makes the demo interesting must be visible within the first seconds" — smoke, boarding fx occur as AI plays; at 3s maybe boarding happened (first pickup likely within ~2-4s). Even if not, train + track + platform + waving passenger visible. Good.
Write it.
One more thing: the stage max width: `.wrap{max-width:984px}` canvas 952. On 1280 viewport, fits with side margins. Good.
Vertical fit 800: body padding 16*2 + sign(~58) + gap10 + hud(~48) + gap10 + canvas 612 + gap10 + hints(~16) ≈ 796. Close; reduce body padding to 14 and gaps 8: ~788. OK.
Actually compute sign height: padding 10*2 + content ~34 = 54. hud: chip padding 6*2+lbl9+val17+gaps → ~44. total: 28+54+8+44+8+612+8+18 ≈ 780. Good.
If viewport shorter (e.g., 1280×720 laptop), vertical scroll appears — acceptable ("no horizontal scrollbar" is the requirement). Could also scale canvas by max-height via CSS: `max-height:calc(100vh - 210px)` with width auto? That complicates ratio (canvas element with both constraints: set CSS `width:auto`? Use `object-fit`? For canvas, CSS width/height both settable; to preserve ratio use aspect-ratio property: canvas{width:100%; height:auto; aspect-ratio:952/612} — height auto already keeps ratio. To also cap by height: `.stage{max-width:min(952px, calc((100vh - 200px) * 1.5556))}` where 952/612=1.5556. Nice: stage width capped so height fits. Add that: max-width:min(984px... let stage max-width: min(952px, calc((100vh - 230px)*1.5556)); with fallback? calc with vh fine. If 100vh small (mobile landscape), shrinks. Also min floor: max(320px, ...) — use `max(300px, calc(...))` inside min: min(952px, max(300px, calc((100vh - 230px)*1.5556))). Good touch.
Hmm but hud/footer heights vary with wrap; 230px estimate fine.
Write final.
Also one more safety: `requestAnimationFrame(t=>{last=t;frame(t);})` — inside frame we re-request; fine. Actually simpler: last=performance.now(); requestAnimationFrame(frame); first dt small.
Let me write everything now, then double-check for syntax errors mentally.
Also remember `coachColors[coachColors.length-1]` when coachColors empty (never—reset sets 3). ok.
drawPassenger 'alpha' param uses ctx.globalAlpha then restore resets — I use save/restore, and set globalAlpha inside save. In code above I set globalAlpha after scale within save; restore resets. Remove trailing `ctx.globalAlpha=1`.
For sparkles draw loop sets globalAlpha then resets after loop.
pops: continue when q>=1 — but removal handled in updateFx; drawing guards anyway.
updateFx pops: `pops=pops.filter(p=>tNow-p.t0<800);` reassign — pops declared let. yes.
boarders loop with splice as above.
OK writing.
Also drawGlow before train so glow under train? Glow ahead of train on empty grass — order irrelevant; keep before train.
Also small detail: draw a tiny station bell / sign next to platform? Platform + passenger + suitcase reads fine.
Also maybe add the passenger counts down? no.
Let me write the final file.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Little Express — a tiny train snake</title>
<style>...</style>
</head>
<body>
<main class="wrap">
...
</main>
<script> ... </script>
</body>
</html>
```
CSS full:
```css
:root{--paper:#f4ecdc;--ink:#2b2a26;--green:#1f3d2b;--green-d:#16301f;--gold:#e9b44c;--red:#d1495b;--cream:#f7f1e1}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{background:radial-gradient(1100px 520px at 50% -10%,#fbf6ea,rgba(251,246,234,0) 60%),var(--paper);color:var(--ink);font-family:ui-monospace,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;min-height:100vh;display:flex;align-items:flex-start;justify-content:center;padding:14px;overflow-x:hidden}
.wrap{width:100%;max-width:984px;display:flex;flex-direction:column;align-items:center;gap:8px}
.sign{display:flex;align-items:center;gap:14px;background:var(--green);color:var(--cream);padding:9px 22px;border-radius:10px;border:2px solid var(--green-d);box-shadow:inset 0 0 0 2px var(--gold),0 4px 0 rgba(0,0,0,.10)}
.sign h1{font-size:clamp(19px,3vw,27px);letter-spacing:.2em;text-transform:uppercase;line-height:1.05}
.sign .sub{font-size:10px;letter-spacing:.1em;color:#cfe0c8;margin-top:3px;text-transform:uppercase}
.hud{display:flex;gap:8px;flex-wrap:wrap;justify-content:center}
.chip{background:var(--green);color:var(--cream);border:1px solid var(--green-d);border-radius:8px;padding:5px 14px 6px;min-width:104px;text-align:center;box-shadow:0 2px 0 rgba(0,0,0,.12)}
.chip .lbl{display:block;font-size:9px;letter-spacing:.24em;text-transform:uppercase;color:var(--gold)}
.chip .val{font-size:17px;font-weight:700}
.stage{position:relative;width:100%;max-width:min(952px,max(300px,calc((100vh - 235px)*1.5556)));border-radius:12px;overflow:hidden;box-shadow:0 0 0 3px #35573c,0 0 0 7px rgba(31,61,43,.35),0 16px 34px rgba(84,62,25,.28);touch-action:none;user-select:none;-webkit-user-select:none;cursor:pointer}
canvas{display:block;width:100%;height:auto}
#overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(18,32,22,.30)}
#overlay.hidden{display:none}
.card{background:var(--cream);border:2px solid var(--green);border-radius:10px;padding:18px 28px;text-align:center;max-width:min(430px,86%);transform:rotate(-1.4deg);box-shadow:0 14px 26px rgba(0,0,0,.28),inset 0 0 0 4px rgba(233,180,76,.4)}
.badge{display:inline-block;font-size:9px;letter-spacing:.32em;text-transform:uppercase;background:var(--green);color:var(--gold);padding:3px 10px;border-radius:3px;margin-bottom:9px}
.card h2{font-size:23px;letter-spacing:.14em;text-transform:uppercase;margin-bottom:7px}
.card p{font-size:12px;line-height:1.55;color:#4c463a}
.cta{margin-top:11px;font-weight:700;color:#7c2d3a;letter-spacing:.05em;animation:pulse 1.25s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
#toast{position:absolute;left:50%;bottom:12px;transform:translate(-50%,8px);background:rgba(31,61,43,.94);color:var(--cream);padding:6px 16px;border-radius:20px;font-size:11px;letter-spacing:.06em;opacity:0;transition:opacity .3s,transform .3s;pointer-events:none;white-space:nowrap}
#toast.show{opacity:1;transform:translate(-50%,0)}
.hints{font-size:11px;color:#6b6353;text-align:center}
kbd{background:#fff;border:1px solid #c9bfa8;border-bottom-width:2px;border-radius:4px;padding:1px 5px;font:inherit;font-size:10px}
@media(max-width:700px){body{padding:10px}.sign{padding:8px 14px;gap:10px}.chip{min-width:78px;padding:4px 10px 5px}.chip .val{font-size:14px}.card{padding:14px 18px}.card h2{font-size:18px}}
```
The `.sign` svg: add class. `svg.loco{flex:none}`.
Now JS final. I'll write it in one go carefully.
Also note: the requirement about `pointerdown` starting game — also on mobile, tap starts. Good.
Potential subtle problem: `document.addEventListener('pointerdown', ...)` triggers even when clicking during play → ensureAudio() only; fine.
Alright — also confirm `arc(...,0,7)` 7 radians > 2π (6.283) → full circle. ok.
Time to output. Also double-check that in `tick`, when mode==='over' we never call tick since dead=true. And in demo, pendingDirs always empty (queueDir only called in play). ok.
Also: `elSpeed` shows km/h; fun.
Also add `best` update display on eat only (done).
Final: write answer with exactly one fenced code block, no surrounding text.
Let me carefully assemble the JS ensuring every function defined before use at runtime (function declarations hoisted within IIFE — fine).
One concern: `showOver()` — I named showOverlay('over'); use that.
In crash(): `showOverlay('over')`.
Also, in demo, dead overlay unchanged; also maybe flash the card? skip.
Write final code:
Also careful: In `drawFx` boarders target uses snake tail — snake exists always.
Also `puff()` uses dir — fine.
Also in frame, when mode==='over' and dead, render frozen scene each frame (fx update continues: smoke drifts). ok.
Let me count braces mentally as I write.
Additionally, small thing: `cv` attributes in HTML `width="952" height="612"`.
Write now.