Train snakeOne-line
Mistral Large 4
by MistralReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 6 min 39 s
- Cost
- $0.070
- Output tokens
- 33,246
- Spent thinking
- 25,426 tokens
- Speed
- 83.7 tok/s
- Console errors
- 0
- First token after
- 1.9 s
- First visible text after
- 5 min 43 s
- Prompt tokens
- 375
- Code size
- 20.4 KB
- Finish reason
- stop
- Served by
- Mistral
- Model id
mistralai/mistral-large-4-0- Reasoning sent
{"effort":"high"}- Max output
- 262,144 tokens
- Generated
- Nov 12, 2026
What it was asked
The prompt
“make me a snake game where the snake is a little train with a locomotive and colorful wagons, picking up passengers waiting on the platform”
System prompt, sent with every prompt
You are generating a self-contained web demo. It will be rendered automatically in a sandboxed iframe and compared side by side with the output of other AI models given the exact same task. Output rules: - Reply with exactly ONE fenced code block containing the complete file: ```html (or ```svg when the task asks for an SVG). No text before or after the code block. - Everything must be inline: CSS in <style>, JavaScript in <script>. No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task. - Do not use localStorage, sessionStorage, cookies, IndexedDB, alert(), confirm(), prompt() or window.open(): they are blocked in the sandbox and will throw. - The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar. - Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction. - Everything that makes the demo interesting must be visible within the first seconds: nothing hidden behind a high score, a later level or a long play session. - Games must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, so the game can be judged in action without playing. - The iframe only receives keyboard events after the user clicks it: if the demo uses the keyboard, display a clear "Click or press a key to start" message. - The code must run with zero errors in the console of the latest Chrome.
Code run-1.html · 553 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>🚂 Choo-Choo Express — Snake Train</title>
<link rel="icon" href="data:,">
<style>
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden}
body{
display:flex;flex-direction:column;
background:radial-gradient(1100px 620px at 50% -12%, #22345c 0%, #131b2e 45%, #0a0e17 100%);
color:#e8ecf4;font-family:'Trebuchet MS','Segoe UI',system-ui,sans-serif;
-webkit-user-select:none;user-select:none;
}
header{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:10px 16px;
background:linear-gradient(#161f33,#10172a);border-bottom:2px solid #2b3a5e}
h1{font-size:20px;letter-spacing:.5px;color:#ffd23f;text-shadow:0 2px 0 rgba(0,0,0,.5);white-space:nowrap}
.stats{display:flex;gap:10px;margin-left:auto;flex-wrap:wrap}
.stat{background:#1a2440;border:1px solid #31436b;border-radius:10px;padding:3px 12px 5px;
font-size:11px;color:#93a6cc;text-align:center;min-width:64px}
.stat b{display:block;font-size:17px;color:#fff;font-variant-numeric:tabular-nums;line-height:1.15}
main{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;padding:10px}
#stage{position:relative;line-height:0}
#stage::after{content:'';position:absolute;inset:0;border-radius:14px;pointer-events:none;z-index:1;
box-shadow:inset 0 0 70px rgba(0,0,0,.55)}
canvas{display:block;background:#121826;border-radius:14px;
box-shadow:0 0 0 4px #2b3a5e,0 20px 60px rgba(0,0,0,.65)}
#overlay{position:absolute;inset:0;z-index:2;display:flex;align-items:center;justify-content:center;
pointer-events:none;transition:opacity .25s ease}
#overlay.hidden{opacity:0}
.card{text-align:center;background:rgba(8,12,20,.74);border:1px solid rgba(255,255,255,.16);
border-radius:18px;padding:16px 30px;line-height:1.35;backdrop-filter:blur(4px);
box-shadow:0 12px 44px rgba(0,0,0,.55)}
#ovTitle{font-size:26px;font-weight:700;color:#ffd23f;text-shadow:0 2px 0 rgba(0,0,0,.6)}
#ovSub{font-size:14px;color:#c9d5ee;margin-top:6px}
footer{padding:6px 12px 10px;text-align:center;font-size:12px;color:#7e8fb4}
@media (max-width:560px){
header{gap:8px;padding:8px 10px}
h1{font-size:15px}
.stats{gap:6px}
.stat{min-width:52px;padding:2px 8px 4px;font-size:10px}
.stat b{font-size:14px}
footer{font-size:10px}
#ovTitle{font-size:19px}
#ovSub{font-size:12px}
.card{padding:12px 18px}
}
</style>
</head>
<body>
<header id="hdr">
<h1>🚂 Choo-Choo Express</h1>
<div class="stats">
<div class="stat">Score<b id="stScore">0</b></div>
<div class="stat">🧍 Rescued<b id="stRescued">0</b></div>
<div class="stat">🚃 Cars<b id="stLen">4</b></div>
<div class="stat">🏆 Best<b id="stBest">0</b></div>
</div>
</header>
<main>
<div id="stage">
<canvas id="game"></canvas>
<div id="overlay">
<div class="card">
<div id="ovTitle">🚂 Choo-Choo Express</div>
<div id="ovSub">Click or press any key to start</div>
</div>
</div>
</div>
</main>
<footer id="ftr">Steer with Arrow keys or WASD • Space to pause • Pick up the passengers waiting on the platform — every passenger adds a colorful car!</footer>
<script>
(() => {
'use strict';
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const overlay = document.getElementById('overlay');
const ovTitle = document.getElementById('ovTitle');
const ovSub = document.getElementById('ovSub');
const DIRS = [[1,0],[0,1],[-1,0],[0,-1]];
const WAGON_COLORS = ['#e74c3c','#e67e22','#f1c40f','#2ecc71','#3498db','#9b59b6','#e84393','#16a085'];
const SHIRT_COLORS = ['#ff6b6b','#feca57','#48dbfb','#1dd1a1','#f368e0','#ff9f43','#a29bfe'];
const LOCO = '#d64545';
let cols=26, rows=17, cell=24, boardW=0, boardH=0, dpr=1;
let snake=[], prevPos=[], dir=[1,0], nextDir=[1,0];
let passengers=[], track=new Map(), particles=[];
let score=0, rescued=0, best=0, tickMs=145, acc=0, colorIdx=0;
let mode='demo', shake=0, crashFlash=0;
/* ---------- helpers ---------- */
function rr(x,y,w,h,r){
r = Math.min(r, w/2, h/2);
ctx.beginPath();
ctx.moveTo(x+r,y);
ctx.arcTo(x+w,y,x+w,y+h,r);
ctx.arcTo(x+w,y+h,x,y+h,r);
ctx.arcTo(x,y+h,x,y,r);
ctx.arcTo(x,y,x+w,y,r);
ctx.closePath();
}
function key(x,y){ return y*cols+x; }
function nextWagonColor(){ return WAGON_COLORS[colorIdx++ % WAGON_COLORS.length]; }
/* ---------- audio (lazy, gesture-unlocked) ---------- */
let actx=null;
function unlockAudio(){
if(!actx){ try{ actx = new (window.AudioContext||window.webkitAudioContext)(); }catch(e){ actx=null; } }
if(actx && actx.state==='suspended'){ actx.resume().catch(()=>{}); }
}
function tone(f,d,type,v,when){
if(!actx) return;
try{
const t0=actx.currentTime+(when||0);
const o=actx.createOscillator(), g=actx.createGain();
o.type=type||'sine'; o.frequency.value=f;
g.gain.setValueAtTime(v||0.15,t0);
g.gain.exponentialRampToValueAtTime(0.001,t0+d);
o.connect(g); g.connect(actx.destination);
o.start(t0); o.stop(t0+d+0.05);
}catch(e){}
}
function sndPickup(golden){ tone(golden?880:660,0.12,'triangle',0.18); tone(golden?1320:990,0.2,'triangle',0.14,0.08); }
function sndCrash(){ tone(160,0.35,'sawtooth',0.2); tone(85,0.45,'square',0.12,0.06); }
/* ---------- layout ---------- */
function resize(){
const hdr=document.getElementById('hdr'), ftr=document.getElementById('ftr');
const availW=window.innerWidth-20;
const availH=window.innerHeight-hdr.offsetHeight-ftr.offsetHeight-20;
cols = availW<560 ? 20 : 26;
rows = availH<460 ? 13 : 17;
cell = Math.max(10, Math.floor(Math.min(availW/cols, availH/rows)));
boardW=cols*cell; boardH=rows*cell;
dpr=Math.min(window.devicePixelRatio||1,2);
canvas.style.width=boardW+'px'; canvas.style.height=boardH+'px';
canvas.width=Math.round(boardW*dpr); canvas.height=Math.round(boardH*dpr);
ctx.setTransform(dpr,0,0,dpr,0,0);
}
window.addEventListener('resize', ()=>{
resize();
if(snake.length && snake.some(s=>s.x<0||s.y<0||s.x>=cols||s.y>=rows)){
let m = mode==='over' ? 'player' : (mode==='demoCrash' ? 'demo' : mode);
if(m==='paused') m='player';
reset(m);
}
});
/* ---------- HUD / overlay ---------- */
function updateHUD(){
document.getElementById('stScore').textContent=score;
document.getElementById('stRescued').textContent=rescued;
document.getElementById('stLen').textContent=snake.length;
document.getElementById('stBest').textContent=best;
}
function setOverlay(){
let show=true, t='', s='';
if(mode==='demo'){ t='🚂 Choo-Choo Express'; s='Self-driving demo — click or press any key to start!'; }
else if(mode==='demoCrash'){ t='💥 Crash!'; s='Restarting demo…'; }
else if(mode==='over'){ t='💥 Crash!'; s='Score '+score+' • Best '+best+' — click or press any key to ride again'; }
else if(mode==='paused'){ t='⏸ Paused'; s='Press any key to keep rolling'; }
else show=false;
ovTitle.textContent=t; ovSub.textContent=s;
overlay.classList.toggle('hidden', !show);
}
/* ---------- game state ---------- */
function spawnPassenger(){
const head=snake[0]||{x:-99,y:-99};
for(let tries=0; tries<300; tries++){
const edge=(Math.random()*4)|0;
let x,y;
if(edge===0){ x=(Math.random()*cols)|0; y=0; }
else if(edge===1){ x=(Math.random()*cols)|0; y=rows-1; }
else if(edge===2){ x=0; y=(Math.random()*rows)|0; }
else { x=cols-1; y=(Math.random()*rows)|0; }
if(Math.abs(x-head.x)+Math.abs(y-head.y) < 5) continue;
if(snake.some(s=>s.x===x&&s.y===y)) continue;
if(passengers.some(p=>p.x===x&&p.y===y)) continue;
passengers.push({x,y, color:SHIRT_COLORS[(Math.random()*SHIRT_COLORS.length)|0], golden:Math.random()<0.12});
return;
}
}
function collect(nh){
const i=passengers.findIndex(p=>p.x===nh.x&&p.y===nh.y);
if(i<0) return;
const p=passengers[i]; passengers.splice(i,1);
score += p.golden?50:10;
rescued++;
tickMs = Math.max(70, 145 - rescued*3);
const cx=(nh.x+0.5)*cell, cy=(nh.y+0.5)*cell;
for(let k=0;k<16;k++){
particles.push({x:cx,y:cy,vx:(Math.random()-0.5)*220,vy:-Math.random()*220-40,g:320,
life:1,size:3+Math.random()*4,color:WAGON_COLORS[k%WAGON_COLORS.length],smoke:false});
}
spawnPassenger();
sndPickup(p.golden);
updateHUD();
}
function die(){
best=Math.max(best,score); updateHUD();
shake=12; crashFlash=1; sndCrash();
if(mode==='demo'){
mode='demoCrash'; setOverlay();
setTimeout(()=>{ if(mode==='demoCrash') reset('demo'); },1500);
} else if(mode==='player'){
mode='over'; setOverlay();
}
}
function reset(m){
mode=m;
const cx=(cols/2)|0, cy=(rows/2)|0;
snake=[]; colorIdx=0;
for(let i=0;i<4;i++){
snake.push({x:cx-i, y:cy, color: i===0 ? LOCO : nextWagonColor()});
}
dir=[1,0]; nextDir=[1,0];
prevPos=snake.map(s=>({x:s.x,y:s.y}));
passengers=[]; track.clear(); particles=[];
score=0; rescued=0; tickMs=145; acc=0; shake=0; crashFlash=0;
for(let i=0;i<4;i++) track.set((cy*cols+cx-i), {dx:1,dy:0,a:0.55});
for(let i=0;i<3;i++) spawnPassenger();
updateHUD(); setOverlay();
}
/* ---------- demo AI ---------- */
function bfsFirst(sx,sy,tx,ty,blocked){
if(sx===tx&&sy===ty) return sy*cols+sx;
const W=cols,H=rows;
const prev=new Int32Array(W*H).fill(-1);
const start=sy*W+sx; prev[start]=start;
const q=[start];
for(let qi=0; qi<q.length; qi++){
const c=q[qi], cx=c%W, cy=(c/W)|0;
for(const d of DIRS){
const nx=cx+d[0], ny=cy+d[1];
if(nx<0||ny<0||nx>=W||ny>=H) continue;
const ni=ny*W+nx;
if(prev[ni]!==-1||blocked.has(ni)) continue;
prev[ni]=c;
if(nx===tx&&ny===ty){
let cur=ni;
while(prev[cur]!==start) cur=prev[cur];
return cur;
}
q.push(ni);
}
}
return -1;
}
function spaceAt(x,y,blocked){
const seen=new Set([key(x,y)]);
const q=[[x,y]]; let count=0;
while(q.length && count<80){
const c=q.pop(); count++;
for(const d of DIRS){
const nx=c[0]+d[0], ny=c[1]+d[1];
if(nx<0||ny<0||nx>=cols||ny>=rows) continue;
const k=key(nx,ny);
if(seen.has(k)||blocked.has(k)) continue;
seen.add(k); q.push([nx,ny]);
}
}
return count;
}
function aiDir(){
const head=snake[0];
const blocked=new Set();
for(let i=1;i<snake.length-1;i++) blocked.add(key(snake[i].x,snake[i].y));
const targets=[...passengers].sort((a,b)=>
(Math.abs(a.x-head.x)+Math.abs(a.y-head.y)) - (Math.abs(b.x-head.x)+Math.abs(b.y-head.y)));
for(const t of targets){
const step=bfsFirst(head.x,head.y,t.x,t.y,blocked);
if(step>=0){ return [step%cols-head.x, ((step/cols)|0)-head.y]; }
}
const opts=[];
for(const d of DIRS){
if(d[0]===-dir[0]&&d[1]===-dir[1]) continue;
const nx=head.x+d[0], ny=head.y+d[1];
if(nx<0||ny<0||nx>=cols||ny>=rows) continue;
if(blocked.has(key(nx,ny))) continue;
opts.push(d);
}
if(!opts.length) return dir;
let bestD=opts[0], bestS=-1;
for(const d of opts){
const s=spaceAt(head.x+d[0], head.y+d[1], blocked);
if(s>bestS){ bestS=s; bestD=d; }
}
return bestD;
}
/* ---------- tick ---------- */
function spawnSmoke(){
const head=snake[0], ang=Math.atan2(dir[1],dir[0]);
const lx=cell*0.36, ly=-cell*0.58;
const wx=(head.x+0.5)*cell + Math.cos(ang)*lx - Math.sin(ang)*ly;
const wy=(head.y+0.5)*cell + Math.sin(ang)*lx + Math.cos(ang)*ly;
particles.push({x:wx,y:wy,vx:dir[0]*10+(Math.random()-0.5)*8, vy:dir[1]*10-22-Math.random()*10,
g:0, life:1, size:cell*0.10+Math.random()*cell*0.06, color:'#c9d2dc', smoke:true});
}
function tick(){
if(mode==='demo') nextDir=aiDir();
if(!(nextDir[0]===-dir[0]&&nextDir[1]===-dir[1]) && !(nextDir[0]===dir[0]&&nextDir[1]===dir[1])) dir=nextDir;
const head=snake[0];
const nx=head.x+dir[0], ny=head.y+dir[1];
if(nx<0||ny<0||nx>=cols||ny>=rows){ die(); return; }
const willGrow = passengers.some(p=>p.x===nx&&p.y===ny);
const limit = willGrow ? snake.length : snake.length-1;
for(let i=1;i<limit;i++){ if(snake[i].x===nx&&snake[i].y===ny){ die(); return; } }
const old=snake;
const prev=old.map(s=>({x:s.x,y:s.y}));
const nh={x:nx,y:ny,color:LOCO};
snake=[nh,...old];
if(willGrow){ snake[snake.length-1].color=nextWagonColor(); }
else snake.pop();
prevPos=[prev[0], ...prev.slice(0, snake.length-1)];
track.set(ny*cols+nx, {dx:dir[0], dy:dir[1], a:0.55});
if(willGrow) collect(nh);
spawnSmoke();
}
/* ---------- input ---------- */
const KEYMAP={ArrowUp:[0,-1],ArrowDown:[0,1],ArrowLeft:[-1,0],ArrowRight:[1,0],
w:[0,-1],s:[0,1],a:[-1,0],d:[1,0],W:[0,-1],S:[0,1],A:[-1,0],D:[1,0]};
function startPlayer(){ reset('player'); }
window.addEventListener('keydown', e=>{
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(e.key)) e.preventDefault();
unlockAudio();
if(mode==='demo'||mode==='demoCrash'||mode==='over'){ startPlayer(); return; }
if(mode==='paused'){
mode='player';
const d=KEYMAP[e.key]||KEYMAP[e.key.toLowerCase()];
if(d) nextDir=d;
setOverlay(); return;
}
if(mode==='player'){
if(e.key===' '){ mode='paused'; setOverlay(); return; }
const d=KEYMAP[e.key]||KEYMAP[e.key.toLowerCase()];
if(d && !(d[0]===-nextDir[0]&&d[1]===-nextDir[1]) && !(d[0]===nextDir[0]&&d[1]===nextDir[1])) nextDir=d;
}
});
window.addEventListener('pointerdown', ()=>{
unlockAudio();
if(mode==='demo'||mode==='demoCrash'||mode==='over') startPlayer();
});
/* ---------- drawing ---------- */
function drawFloor(){
ctx.fillStyle='#121826';
ctx.fillRect(0,0,boardW,boardH);
ctx.fillStyle='rgba(255,255,255,0.028)';
for(let y=0;y<rows;y++) for(let x=0;x<cols;x++)
if((x+y)%2===0) ctx.fillRect(x*cell,y*cell,cell,cell);
for(let y=0;y<rows;y++) for(let x=0;x<cols;x++){
if(x!==0&&y!==0&&x!==cols-1&&y!==rows-1) continue;
ctx.fillStyle='#4d3b27'; ctx.fillRect(x*cell,y*cell,cell,cell);
ctx.fillStyle='rgba(0,0,0,0.28)'; ctx.fillRect(x*cell,(y+1)*cell-2,cell,2);
ctx.fillStyle='rgba(255,255,255,0.05)'; ctx.fillRect(x*cell,y*cell,cell,2);
}
ctx.strokeStyle='#f5c542'; ctx.lineWidth=3; ctx.setLineDash([cell*0.6,cell*0.4]);
ctx.strokeRect(cell-1.5, cell-1.5, (cols-2)*cell+3, (rows-2)*cell+3);
ctx.setLineDash([]);
ctx.fillStyle='rgba(255,255,255,0.16)';
ctx.font='bold '+Math.max(9,cell*0.36)+'px sans-serif';
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText('P L A T F O R M', boardW/2, cell*0.5);
ctx.fillText('P L A T F O R M', boardW/2, boardH-cell*0.5);
}
function drawRails(){
track.forEach((v,k)=>{
const x=k%cols, y=(k/cols)|0;
ctx.save();
ctx.translate((x+0.5)*cell,(y+0.5)*cell);
ctx.rotate(Math.atan2(v.dy,v.dx));
ctx.globalAlpha=Math.max(0,v.a);
ctx.fillStyle='#4a3826';
for(let t=-cell/2; t<cell/2; t+=cell/2.6) ctx.fillRect(t,-cell*0.44,cell*0.22,cell*0.88);
ctx.fillStyle='#aab2bd';
ctx.fillRect(-cell/2,-cell*0.30-1.5,cell,3);
ctx.fillRect(-cell/2, cell*0.30-1.5,cell,3);
ctx.restore();
});
ctx.globalAlpha=1;
}
function drawPassengers(now){
const c=cell;
for(const p of passengers){
const cx=(p.x+0.5)*c, bob=Math.sin(now/260+p.x*1.3+p.y*0.7)*c*0.05;
const cy=(p.y+0.55)*c+bob;
ctx.save();
if(p.golden){ ctx.shadowColor='#ffd700'; ctx.shadowBlur=c*0.6; }
ctx.fillStyle='#232838';
ctx.fillRect(cx-c*0.11, cy+c*0.08, c*0.09, c*0.16);
ctx.fillRect(cx+c*0.02, cy+c*0.08, c*0.09, c*0.16);
ctx.fillStyle=p.color;
rr(cx-c*0.16, cy-c*0.14, c*0.32, c*0.26, c*0.07); ctx.fill();
ctx.fillStyle='#f1c9a5';
ctx.beginPath(); ctx.arc(cx, cy-c*0.24, c*0.115, 0, 7); ctx.fill();
ctx.fillStyle='#4a3222';
ctx.beginPath(); ctx.arc(cx, cy-c*0.27, c*0.115, Math.PI, 0); ctx.fill();
if(p.golden){
ctx.fillStyle='#ffd700';
ctx.font=Math.max(9,c*0.32)+'px sans-serif';
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText('★', cx+c*0.18, cy-c*0.36);
}
ctx.restore();
}
}
function drawWagon(x,y,ang,color){
const c=cell;
ctx.save(); ctx.translate(x,y); ctx.rotate(ang);
ctx.fillStyle='#15181e';
for(const wx of [-c*0.26, c*0.02, c*0.30]){ ctx.beginPath(); ctx.arc(wx,c*0.30,c*0.115,0,7); ctx.fill(); }
ctx.fillStyle='#7f8794';
for(const wx of [-c*0.26, c*0.02, c*0.30]){ ctx.beginPath(); ctx.arc(wx,c*0.30,c*0.045,0,7); ctx.fill(); }
ctx.fillStyle=color;
rr(-c*0.44,-c*0.30,c*0.88,c*0.56,c*0.12); ctx.fill();
ctx.strokeStyle='rgba(0,0,0,0.35)'; ctx.lineWidth=Math.max(1,c*0.05); ctx.stroke();
ctx.fillStyle='rgba(255,255,255,0.25)';
rr(-c*0.44,-c*0.30,c*0.88,c*0.12,c*0.06); ctx.fill();
ctx.fillStyle='rgba(230,245,255,0.9)';
rr(-c*0.30,-c*0.16,c*0.18,c*0.18,c*0.04); ctx.fill();
rr( c*0.06,-c*0.16,c*0.18,c*0.18,c*0.04); ctx.fill();
ctx.fillStyle='rgba(0,0,0,0.22)';
ctx.fillRect(-c*0.44,c*0.10,c*0.88,c*0.08);
ctx.restore();
}
function drawLoco(x,y,ang){
const c=cell;
ctx.save(); ctx.translate(x,y); ctx.rotate(ang);
ctx.fillStyle='#8f2f2f';
ctx.beginPath(); ctx.moveTo(c*0.55,c*0.06); ctx.lineTo(c*0.30,-c*0.26); ctx.lineTo(c*0.30,c*0.26); ctx.closePath(); ctx.fill();
ctx.fillStyle='#15181e';
for(const wx of [-c*0.34,-c*0.02,c*0.28]){ ctx.beginPath(); ctx.arc(wx,c*0.28,c*0.13,0,7); ctx.fill(); }
ctx.fillStyle='#8b93a1';
for(const wx of [-c*0.34,-c*0.02,c*0.28]){ ctx.beginPath(); ctx.arc(wx,c*0.28,c*0.05,0,7); ctx.fill(); }
ctx.fillStyle='#20242c';
rr(-c*0.46,-c*0.14,c*1.0,c*0.30,c*0.07); ctx.fill();
ctx.fillStyle=LOCO;
rr(-c*0.12,-c*0.34,c*0.62,c*0.26,c*0.10); ctx.fill();
ctx.fillStyle='rgba(0,0,0,0.18)';
ctx.fillRect(-c*0.12,c*0.02,c*0.62,c*0.06);
ctx.fillStyle='rgba(255,255,255,0.25)';
ctx.fillRect(c*0.06,-c*0.34,c*0.05,c*0.26);
ctx.fillRect(c*0.28,-c*0.34,c*0.05,c*0.26);
ctx.fillStyle='#a83232';
ctx.beginPath(); ctx.arc(c*0.50,-c*0.21,c*0.13,0,7); ctx.fill();
ctx.save();
ctx.shadowColor='#ffe08a'; ctx.shadowBlur=c*0.5;
ctx.fillStyle='#ffefb0';
ctx.beginPath(); ctx.arc(c*0.56,-c*0.21,c*0.06,0,7); ctx.fill();
ctx.restore();
ctx.fillStyle='#7e2a2a';
rr(c*0.30,-c*0.50,c*0.13,c*0.18,c*0.03); ctx.fill();
rr(c*0.26,-c*0.55,c*0.21,c*0.07,c*0.03); ctx.fill();
ctx.fillStyle='#b03a3a';
rr(-c*0.46,-c*0.44,c*0.36,c*0.32,c*0.06); ctx.fill();
ctx.fillStyle='#cfeaff';
rr(-c*0.41,-c*0.39,c*0.17,c*0.17,c*0.03); ctx.fill();
ctx.fillStyle='#5c1f1f';
rr(-c*0.49,-c*0.49,c*0.42,c*0.07,c*0.03); ctx.fill();
ctx.restore();
}
function drawTrain(){
const t=(mode==='demo'||mode==='player') ? Math.min(1,acc/tickMs) : 1;
const pts=[];
for(let i=0;i<snake.length;i++){
const s=snake[i], pr=prevPos[i]||s;
pts.push({x:(pr.x+(s.x-pr.x)*t+0.5)*cell, y:(pr.y+(s.y-pr.y)*t+0.5)*cell, s});
}
ctx.strokeStyle='#20242c'; ctx.lineWidth=Math.max(2,cell*0.14); ctx.lineCap='round';
ctx.beginPath();
for(let i=pts.length-1;i>0;i--){ ctx.moveTo(pts[i].x,pts[i].y); ctx.lineTo(pts[i-1].x,pts[i-1].y); }
ctx.stroke();
for(let i=pts.length-1;i>=1;i--){
const s=snake[i], pr=prevPos[i]||s;
let ang=Math.atan2(s.y-pr.y, s.x-pr.x);
if(s.x===pr.x&&s.y===pr.y) ang=Math.atan2(dir[1],dir[0]);
drawWagon(pts[i].x,pts[i].y,ang,s.color);
}
drawLoco(pts[0].x,pts[0].y,Math.atan2(dir[1],dir[0]));
}
function drawParticles(){
for(const p of particles){
ctx.globalAlpha=Math.max(0,Math.min(1,p.life))*(p.smoke?0.45:0.9);
ctx.fillStyle=p.color;
ctx.beginPath();
ctx.arc(p.x,p.y,p.size*(p.smoke?1+(1-p.life)*1.6:1),0,7);
ctx.fill();
}
ctx.globalAlpha=1;
}
function render(now){
ctx.clearRect(0,0,boardW,boardH);
ctx.save();
if(shake>0) ctx.translate((Math.random()-0.5)*shake,(Math.random()-0.5)*shake);
drawFloor();
drawRails();
drawPassengers(now);
drawTrain();
drawParticles();
ctx.restore();
if(crashFlash>0){
ctx.fillStyle='rgba(255,40,40,'+(crashFlash*0.25)+')';
ctx.fillRect(0,0,boardW,boardH);
}
}
/* ---------- main loop ---------- */
function updateParticles(dt){
const s=dt/1000;
for(let i=particles.length-1;i>=0;i--){
const p=particles[i];
p.x+=p.vx*s; p.y+=p.vy*s;
if(p.smoke){ p.vy-=14*s; p.size+=8*s; } else { p.vy+=p.g*s; }
p.life-=dt/(p.smoke?900:1100);
if(p.life<=0) particles.splice(i,1);
}
track.forEach((v,k)=>{ v.a-=dt*0.012; if(v.a<=0.03) track.delete(k); });
if(shake>0) shake=Math.max(0,shake-dt*0.03);
if(crashFlash>0) crashFlash=Math.max(0,crashFlash-dt*0.0012);
}
let last=performance.now();
function frame(now){
const dt=Math.min(50,now-last); last=now;
if(mode==='demo'||mode==='player'){
acc+=dt;
let n=0;
while(acc>=tickMs && n<5){
tick(); acc-=tickMs; n++;
if(mode!=='demo'&&mode!=='player'){ acc=0; break; }
}
if(n===5) acc=0;
}
updateParticles(dt);
render(now);
requestAnimationFrame(frame);
}
/* ---------- boot ---------- */
resize();
reset('demo');
requestAnimationFrame(frame);
})();
</script>
</body>
</html>Reasoning trace (25,426 tokens)
The user wants a snake game with a train theme: locomotive + colorful wagons, picking up passengers waiting on the platform. Need self-playing demo mode behind "Click or press a key to start" message. Must be a single HTML file, inline CSS/JS, no external resources. Must work in 1280×800 and adapt to 375px. No horizontal scrollbar. Visible content on first paint. Zero console errors.
Let me design:
- Canvas-based game, grid-based snake. Train: head is locomotive (drawn with emoji or custom shapes), wagons are colored squares/circles trailing behind. Passengers appear on "platform" tiles (maybe edges of the board styled as platforms, or special tiles). When the train head passes over a passenger tile, passenger boards (attaches a wagon? or just score). Let's make: passengers spawn on platform tiles at edges; when head eats a passenger, score increases and a new wagon is added (train grows) — or passenger sits in last wagon. Maybe collect passengers to fill wagons: each wagon has capacity? Keep simple: each passenger collected adds score + grows train by 1 wagon, and a new passenger spawns elsewhere on platform. Occasionally a "golden passenger" or special.
Also add obstacles? Maybe keep it simple: walls kill, or wrap around? Let's do: hitting wall or self = game over, with restart. Self-playing AI: simple pathfinding (BFS) toward passenger avoiding body. Demo mode runs automatically; overlay "Click or press a key to start" — clicking starts player control (arrow keys/WASD). In demo mode, AI plays. After game over in player mode, show game over and restart.
Visuals:
- Background: dark, with a station theme. Platform tiles along edges rendered as wooden planks / yellow safety line. Passengers: little people (circle head + body) standing on platform tiles.
- Train: locomotive drawn nicely: red engine with chimney, cowcatcher, wheels; wagons: colored rounded rectangles with wheels, each different color (rainbow). Draw on canvas with grid cells, maybe slightly inset with rounded corners and connecting couplings.
- Track: maybe draw subtle track lines under the path? Could draw the train's traveled path as rails. Nice touch: draw rail ties along cells the snake has occupied (fading). That sells "train" theme. Let's keep a set of visited cells with fading alpha drawn as track (two lines + ties).
- HUD: score (passengers rescued), length, speed, high score (in-memory only, no localStorage). Message overlay.
- Sound? Could use WebAudio for chuff sounds — allowed (no external). Optional; keep small: a little "ding" when picking passenger, using oscillator. Audio requires user gesture; in demo mode no gesture, so guard with try/catch and only init on first interaction. Actually AudioContext creation before gesture just stays suspended; calling resume on click. Safe: create on first key/click. Keep simple: play sound only if audio unlocked.
Grid: choose cols/rows based on canvas size. Canvas sized to fit container, cell size computed. Let's do fixed logical grid, e.g., 28 cols × 18 rows, cell ~ min(w/28, h/18). Canvas 1280x800 area: game area maybe full window minus HUD. Simpler: canvas fills window below a top HUD bar (HTML). Compute cell size from available space.
Responsive: canvas resizes on window resize; keep grid constant (e.g., 26x17), cell = floor(min(cw/cols, ch/rows)), center the board. At 375px wide, cell = floor(375/26)=14px, board height = 17*14=238 — fine. Maybe reduce grid on small screens: cols = width<520 ? 20 : 26, rows = height<... keep 16? Let's compute grid dynamically: cols = clamp based on width: width<480?18:26 etc. Simpler: keep fixed 24x16 and compute cell; at 375 wide cell=15, height=240, plus HUD. Fine, looks okay.
Actually let's make it nicer: board area = viewport minus header (~64px) and maybe footer. Use devicePixelRatio scaling for crispness.
AI: BFS from head to passenger target avoiding body (treat body except tail as obstacles, since tail moves). If no path, chase tail or move to any safe move. Also avoid moving into cells that would trap (simple: pick safe move that maximizes distance?). Keep BFS + fallback: choose move that doesn't collide and is safe (simulate). If none safe, pick any non-colliding; else game over. Add slight randomness to look natural, and maybe target nearest passenger if multiple passengers exist. Spawn 3 passengers at a time on platform tiles (edge cells). Passenger tiles: edges rows/cols. But if train wraps? No wrap — walls are station walls? Let's make outer ring = platform where passengers wait; train can drive onto platform (it's open), walls are beyond platform (the board border). So the entire grid is drivable, but visually the outer ring looks like a platform with a yellow line. Passengers stand on random edge cells. That keeps gameplay simple (no insta-death at edges) and thematic: platform around the station.
Hmm, but then "walls" — hitting board edge = crash into the station wall. The outer ring is platform (drivable), the border beyond is wall. Good.
Passenger pickup: when head enters a cell with a passenger, passenger boards: score++, train grows by 1 (add wagon), passenger removed, new passenger spawns on random free edge cell. Maybe every 5th passenger is a "VIP" worth 5? Keep simple: normal passengers + occasionally golden. Also add "coal" maybe as food to grow? The passengers themselves grow the train. Speed increases slightly with score.
Wagons: each segment gets a color from a palette cycling. When growing, the new tail segment gets next color. Draw each segment as a wagon: rounded rect with two wheels and a little window; locomotive: bigger drawing with cabin, chimney with smoke puffs (particles!), cowcatcher. Smoke particles add life. Also headlight beam? Maybe subtle.
Direction: grid-based, moves on tick (e.g., every 120ms, decreasing to ~70ms). Smooth animation: interpolate segment positions between ticks for smooth movement — that's a nice touch. Implement render with interpolation factor t between ticks; positions as float. Head moves along direction; body follows. Simpler approach: store each segment's previous and current grid pos; render at lerp. Turning: when direction changes, body segments follow path — with per-segment prev pos, lerping works fine visually.
Controls: Arrow keys / WASD; prevent reverse. Space to pause. Click to start player mode from demo. In demo, "AI" controls. Pressing any key/click switches to player mode (restart). Overlay message: "🚂 Click or press any key to take the wheel!" in demo; hidden in player mode; "Game Over — click to ride again" on death in player mode; in demo mode, auto-restart after a pause (so demo keeps running forever) — but if user clicked, they're in player mode. Also allow pressing "D" to go back to demo? Not needed.
Edge case: iframe receives keyboard only after click — the message says click or press a key to start; demo plays behind it. Good.
Let me structure:
HTML:
- header HUD: title "🚂 Choo-Choo Express", score, passengers, length, speed, best.
- canvas container.
- overlay div centered with message.
- small footer hint: "Arrows / WASD to steer • Space to pause".
CSS: dark gradient background, station-y. Use system fonts (no external fonts). Maybe Google fonts not allowed? "No external files, fonts or network requests, except CDN URLs explicitly allowed" — none allowed, so system fonts only. Use a fun stack: "Trebuchet MS", "Comic Sans MS"? Use `font-family: 'Segoe UI', system-ui, sans-serif` and maybe monospace for numbers.
JS structure:
```js
const canvas, ctx;
let cols, rows, cell, offsetX, offsetY, dpr;
function resize(){ ... compute board ... }
```
Grid: let's pick cols=26, rows=17. Board pixel size = cols*cell. cell = floor(min((W)/cols, (H)/rows)). Center.
State:
```js
let snake; // array of {x,y} head first; also prev positions array
let dir, nextDir;
let passengers = []; // {x,y,golden}
let score, best=0, speed (ms per tick), tick accumulator
let mode: 'demo' | 'player' | 'over' | 'paused'
let particles = []; // smoke
let track = Map "x,y" -> alpha for rails
```
Game loop with requestAnimationFrame, accumulator for ticks.
Tick logic:
- dir = nextDir (player) or AI decides each tick (in demo compute at tick).
- newHead = head + dir; check collision with wall (out of bounds) or body (excluding tail tip if not growing). If collision: die().
- move: unshift newHead, pop tail unless growing.
- update prev positions for render interpolation: keep prevSnake = old snake copy before move.
- if passenger at newHead: collect.
- occasionally spawn passenger to maintain count (3).
Rendering:
- clear, draw station floor: checkered subtle tiles or dark with grid lines.
- draw outer ring as platform: wooden planks color with yellow safety line at inner edge. Draw per edge cell.
- draw rails on visited cells (track map): draw two horizontal/vertical lines depending on direction of travel... simpler: draw ties (small perpendicular lines) and two lines along travel direction. We can store for each visited cell the direction of travel; draw rails accordingly. When reversing? Can't reverse. Store dir per cell when head passes. Fade over time (alpha decays). Nice.
- draw passengers: little person: body (rounded rect), head circle, maybe hat; golden passenger glows.
- draw wagons: iterate from tail to head, lerp positions; each wagon colored; draw couplings (line between consecutive segment centers); wheels (two dark circles bottom); wagon body rounded rect inset; maybe a tiny window. Locomotive: draw at head with direction rotation: body, cabin, chimney, smoke particles emitted at chimney, cowcatcher triangle at front, headlight glow.
Rotating drawing: use ctx.save/translate/rotate based on dir.
Smoke particles: spawn each tick at chimney position, rise and fade.
AI (demo):
```js
function aiChooseDir(){
const head = snake[0];
// target nearest passenger
// BFS over grid avoiding body cells (all except tail) and walls
// if path found, return first step direction
// else try to move to safe cell (not colliding) preferring one with most freedom (flood fill count)
// else return current dir
}
```
BFS each tick on 26x17=442 cells — trivial.
Safety: after choosing path, also ensure the move doesn't immediately die; BFS already avoids body. Also avoid cells adjacent to body that would trap? Keep simple; occasionally dying in demo is fine but we want demo to look good — auto restart on death in demo mode after showing "crash" flash for ~1.5s.
Collision check must consider that tail moves: if not growing, the tail cell will be free next tick, so exclude tail from obstacles in AI and in collision (when checking self-collision, ignore the last segment if it will move). Standard: collision with body segments [1..n-1] excluding last when not growing.
Growing: when passenger collected, grow: don't pop tail for `growPending` ticks. Implement growPending counter: on collect, growPending += 1 (wagon per passenger). Actually each passenger = 1 new wagon. Also maybe passengers could be worth more. Fine.
Also add "station bell" — skip.
Speed: base 140ms, minus 2ms per passenger, min 70ms. In demo same.
Score: +10 per passenger, +50 golden. HUD update.
Overlay messages:
- demo: big "🚂 All aboard!" + "Click or press any key to take control". Show/hide.
- player: hidden.
- paused: "Paused — Space to resume".
- over (player): "💥 Crash! Score X — click or press a key to ride again".
- demo crash: show "Crash! Restarting…" briefly then auto restart.
Input: keydown: if mode demo → switch to player (startGame('player')). If over → restart. If player → set nextDir (arrows/wasd), space pause toggle. Click on canvas/overlay: same. Also prevent default on arrows/space to avoid scrolling.
Also handle visibility of "Click or press a key to start" — required by instructions.
First paint: game starts in demo mode immediately, rendering train moving. Good — screenshot at 3s shows action with overlay message.
Let me also add subtle background: gradient + some stars? Station theme: dark blue night, maybe hanging lamps. Keep canvas-drawn background: floor tiles with slight vignette. Header HTML with title.
Now details for drawing:
Cell size maybe 26px at 1280 wide: board width 26*cell. Available width ~1280, height 800-64 header-? Let's compute: cell = floor(min((innerWidth-24)/cols, (innerHeight-90)/rows)). At 1280x800: min(1256/26=48.3, 710/17=41.7) → 41. Board = 1066×697. Centered with margins. Good. At 375x667 (mobile): min(351/26=13.5, 577/17=33.9) → 13. Board 338×221. Small but playable; maybe increase cols smaller on narrow: if width<600 use cols=22, rows=15 → cell = min(351/22=15.9,...) → 15. Board 330×225. Eh similar. Fine either way. Let's do dynamic: cols = W<560 ? 22 : 26; rows = H<560 ? 14 : 17. On 1280x800 same as before. On 375x667: cols22 rows? H=667-90=577 → rows=17? H<560 false so rows=17, cell=min(351/22=15.95, 577/17=33.9)=15, board=330x255. okay.
Hmm, actually maybe make rows fixed 16 for balance. Whatever — fine.
Resize: recompute cell and offsets; snake positions remain in grid coords so fine. Canvas CSS size = board size; use dpr scaling: canvas.width = boardW*dpr etc., ctx.setTransform(dpr,0,0,dpr,0,0). Draw in board pixel coordinates; cell positions * cell + offset within canvas.
Draw functions:
drawBoard():
- floor: fill dark (#141a26?). tiles: alternate slight shade per cell, or draw subtle grid lines every cell: stroke rgba(255,255,255,0.03).
- platform ring: for cells on outer ring: fill #3a2f23 (wood) or concrete #4a4a55 with plank lines; plus yellow line on inner edge of ring: draw a thick yellow-ish line along the rectangle at ring inner boundary with dashed white? Simpler: draw platform cells with a distinct fill and draw an inner border line (stroke rect at (cell,cell) to (cols-1,rows-1)*cell) in yellow with dashes. Also small "PLATFORM" text? skip.
Actually nicer: platform = outer ring cells filled with warm brown planks (horizontal lines), and inner edge has yellow safety stripe: draw line at x=cell..(cols-1)*cell border. Let's draw: for ring cells fill '#5b4632' with plank separators; then stroke inner rect border with '#ffd23f' width 3 dashed [10,6]. Looks like platform edge.
Passengers stand on ring cells: draw them centered in cell, small: legs? Just: body rounded rect (shirt color random from palette), head circle (skin tone), maybe a hat. Bobbing animation (sin time). Golden: draw with glow (shadowBlur) and star.
Rails (track): maintain Map key `${x},${y}` -> {dirx,diry, alpha}. On each tick after moving, set track for previous head cell? Actually set for each new head cell with travel dir. Cap size: if map size > cols*rows, clear old? Just decay alpha each frame by dt*0.02 and delete when <0.02. Draw per cell: translate to cell center, rotate to dir, draw two lines at ±cell*0.28 across cell, gray #8a8f98 width 2, plus ties: perpendicular short lines every ~cell/3 dark brown. Simpler: draw ties as small rects across. Let's draw: along direction axis, two rails; ties perpendicular every 8px. Looks like track. Alpha from map.
Train drawing with interpolation: for segment i, pos = lerp(prev[i], curr[i], t). Need prev array aligned: before moving, prev = snake.map(copy). After move, snake = new array; ensure lengths equal: if growing, we keep tail (don't pop) so new length = old+1; prev needs an extra entry = old tail (duplicate) so the new tail lerps in place. Handle: when growing, prev = old.slice(); prev.push({...old tail})? Actually new snake = [newHead, ...old]; prev should be [oldHead, ...old, oldTailDuplicate]... Let's do: prev = snake.map(s=>({x:s.x,y:s.y})); then compute newSnake; if newSnake.length > prev.length, prev.push({...prev[prev.length-1]}) — wait prev corresponds position-wise: prev[i] should be where segment i was before. newSnake[0]=newHead was "at" old head? Not exactly but visually fine: prev[0] = old head. newSnake[i] = old snake[i-1]; its prev = old snake[i-1] = prev[i-1]. So prev for new array = [oldHead, ...oldSnake] shifted... Let me just: prevNew = [ {...old[0]} ]; for i in 1..newSnake.length-1: prevNew[i] = {...old[i-1]} — which equals prev[i-1] if prev=old. So prevNew = [prev[0], ...prev] truncated/padded to newSnake.length: prevNew = [prev[0], ...prev.slice(0, newSnake.length-1)]. If growing, newSnake.length = prev.length+1, slice(0, prev.length) = all prev, so prevNew = [prev0, ...prev] length+1, last = prev[last] = old tail → new tail appears at old tail pos, then lerps... wait new tail = old tail, prev = old tail too → static, correct-ish (tail stays until next move). Hmm when growing we don't pop, so newSnake = [newHead, ...oldSnake], tail unchanged; prev for tail = old tail = same → no movement that tick, fine.
If not growing: newSnake = [newHead, ...old.slice(0, -1)]; prevNew = [prev0, ...prev.slice(0, len-1)] = prev.slice(0,len) basically = prev (same length). Good.
So: prevPos = snake.map(...); after computing newSnake: prevPos = [prevPos[0], ...prevPos.slice(0, newSnake.length-1)]; then snake = newSnake. Wait need prevPos computed from old snake before reassign. Fine.
t = clamp(accum / tickMs, 0, 1). Draw each segment i at lerp(prevPos[i] ?? snake[i], snake[i], t). If lengths mismatch (shouldn't), fallback.
Direction for rotation: use dir (current movement dir). Head rotates by dir angle; wagons maybe slight angle from movement from prev to curr segment (smooth turning): angle = atan2(curr-prev) if moved. Nice: each segment oriented by its own movement vector. Compute per segment: dx = curr.x-prev.x etc., if nonzero use angle.
Draw wagon (segment i>0): color palette[i % len]. Body: rounded rect inset 12% of cell, height ~70%, centered; wheels: two circles at bottom (dark with lighter hub); window: small light rect; coupling: draw line from this segment center to previous segment center before bodies (draw couplings first, then bodies). Draw order: couplings (lines), then wagons tail→head, then locomotive last.
Locomotive drawing (facing angle θ):
- translate to head center, rotate θ.
- cowcatcher: triangle at front (x = +cell*0.5).
- main body: rounded rect from -0.45c to +0.35c, height 0.6c, color crimson #d63c3c with darker stripe.
- cabin at rear top: rect with window (light blue).
- chimney at front top: small rect; smoke particles spawn here (world coords: compute chimney world pos = head + rotated offset).
- wheels: 3 circles along bottom.
- headlight: at front, glow circle yellow with shadowBlur.
- roof, etc.
Keep it charming but not overly complex.
Smoke: particles array {x,y,vx,vy,life,size}. Each tick spawn 1-2 at chimney. Update in render loop with dt: y -= vy, alpha = life. Draw gray circles.
Also add "choo" sound? Optional; implement tiny WebAudio blips guarded: create AudioContext lazily on first user gesture; functions playDing(), playCrash(). Wrap in try/catch. Keep minimal.
Passenger spawn: choose random ring cell not occupied by snake/passengers, not too close to head (manhattan > 5). Keep count = 3 (or 3 + floor(score/100)? keep 3, plus golden chance 15%).
Collect: score += golden? 50:10; growPending++ (or directly grow: don't pop tail this tick → implement via grow counter consumed in move). Also spawn confetti particles at cell (colored). Remove passenger, spawn new one (maybe after short delay — just immediately).
Also passengers could "wait" with a little patience? Skip.
HUD: score (passengers count = score/10? just show "Passengers: N" count and "Score"), length = snake.length, best. Update DOM each tick or on change.
Game over: mode='over' if player; flash red vignette; overlay message. Demo: brief pause then reset to demo.
reset(): snake starts length 4 at center moving right: positions (cx-i, cy). dir=(1,0). passengers spawn 3. track map cleared (or keep? clear). score=0, speed reset, particles cleared, growPending=0, tickAcc=0.
Pause: only in player mode; space toggles; overlay "Paused".
Loop:
```js
let last=performance.now();
function frame(now){ dt=now-last; last=now; if(mode!=='paused'){ acc+=dt; while(acc>=tickMs){ tick(); acc-=tickMs; } } render(now, dt); requestAnimationFrame(frame); }
```
In demo mode tick uses AI; cap acc to avoid spiral.
tick():
- if mode demo: nextDir = aiDir();
- apply dir if not reverse: if (nextDir not opposite of dir) dir=nextDir.
- compute newHead; collisions: wall if out of bounds; body: segments snake[0..len-1 - (growPending>0?0:1)]... check newHead equals any snake[i] for i in 1..(growPending>0 ? len-1 : len-2)? If growing this tick (passenger at newHead) then tail stays; but we check before knowing pickup. Standard approach: obstacle set = all segments except tail (tail will move) unless growing. We don't know growth until we check passenger at newHead. Do: willGrow = passengerAt(newHead); bodyToCheck = willGrow ? snake.slice(1) : snake.slice(1, -1); if newHead in bodyToCheck → die. Fine.
- move, handle pickup, speed update, track record, smoke spawn, particles.
AI details:
```js
function aiDir(){
const head=snake[0];
const occupied = new Set(snake.slice(0, -1).map(key)); // tail will move; but if willGrow... ignore
// include head? head moves away, fine.
// targets: passengers sorted by dist
for each passenger target:
path = bfs(head, target, occupied)
if path: return dir of first step
// fallback: safe moves
const moves = dirs.filter(d => !opposite(d,dir) && inBounds(head+d) && !occupied.has(head+d))
// choose move with max flood-fill space
pick best by space(head+d)
return best or dir
}
```
BFS with obstacles; also avoid stepping onto head? head moves. Also treat cells adjacent to walls maybe fine.
flood fill count capped at ~80 for speed.
Also AI should prefer not to trap: the flood fill fallback helps.
Edge: passengers on ring; BFS fine.
Prevent 180° turns: player can't; AI dir filter excludes opposite.
Now overlay: absolutely positioned div over canvas container, pointer-events none (clicks handled on window/canvas). Text changes. Style: semi-transparent dark pill with big text, backdrop blur maybe.
Click handling: window.addEventListener('pointerdown', ...) → if demo/over → start player mode (reset). If player mode → ignore (or could be pause? no). Keyboard: any key: if demo/over → start player. In player: arrows/wasd set nextDir (preventDefault), space pause, preventDefault for arrows/space.
Note: "Click or press a key to start" — pressing any key starts. Good.
Also ensure first paint has content: render called immediately; start demo at load.
Sounds:
```js
let actx=null; function audio(){ if(!actx){ try{ actx=new (window.AudioContext||window.webkitAudioContext)(); }catch(e){} } if(actx&&actx.state==='suspended') actx.resume(); return actx; }
function blip(freq,dur,type,vol){ const a=audio(); if(!a) return; try{ const o=a.createOscillator(), g=a.createGain(); ... }catch(e){} }
```
Call audio() only inside user-gesture-started contexts? Creating AudioContext on load logs a warning in console but not an error. "Zero errors in console" — warnings okay but let's avoid: create lazily only on pointerdown/keydown (user gesture). Demo mode plays silent until user interacts. Good: in startPlayer (called from gesture) call audio() to unlock. Sounds: pickup ding (two tones), crash (noise/low), maybe chuff each tick too much — skip chuff.
HUD best: keep in-memory variable.
Let me also add a subtle CSS: body background gradient, header with title and stats, canvas with border-radius and box-shadow, maybe a wooden frame? Keep clean: header dark with accent.
Layout:
```html
<div id="app">
<header> title + stats </header>
<main><div id="stage"><canvas id="game"></canvas><div id="overlay">...</div></div></main>
<footer>hints</footer>
</div>
```
Stage flex center; canvas sized via JS to board dims; set canvas.style.width/height.
Prevent horizontal scrollbar: body overflow hidden? If content fits, fine; set overflow:hidden on body to be safe? Might clip on small heights — but we size canvas to fit, so ok. Use `overflow:hidden` on html,body.
Vignette: CSS radial gradient overlay on stage via ::after pointer-events none. Or draw in canvas. CSS simpler.
Now write the code carefully.
Constants:
```js
const PALETTE = ['#e74c3c','#e67e22','#f1c40f','#2ecc71','#3498db','#9b59b6','#e84393','#1abc9c'];
```
Wagon color by index: palette[(i*?)...] — assign per segment stable color: when segment created, store color? Simpler: color = PALETTE[(snake.length - i) % 8]? That shifts colors as train moves... Each segment keeps index; as train moves, segment indices shift (head index 0 always, tail grows). Colors would shift each tick — bad. Store color with segment: snake as objects {x,y,c} where c = color assigned when appended (tail). On move, new head inherits color of old head? Head is locomotive (red), wagons colored. Let's store segments as {x,y,role:'loco'|'wagon', color}. On unshift newHead with color = old head color (loco red) and shift others' colors along? When moving, newSnake = [newHead, ...old] — colors move with segments naturally since objects carry color. New head color: same as old head (loco red) — set explicitly. Tail pop removes last. When growing, tail kept → its color stays; new wagon effectively is the kept tail? Hmm: growth means length increases by 1 with no pop; the "new wagon" is at the tail (the kept tail segment), but its color was assigned earlier. To make new wagon get a fresh color, on grow set tail segment color = next palette color. Implement growCounter: when pickup, growPending++; in move, if growPending>0 { growPending--; don't pop; and set snake[last].color = nextColor() } else pop. Also passengers add exactly one wagon each — good.
nextColor: counter++ % palette.length, but avoid same as last: fine simple.
Segment object: {x,y,color}. prevPos separate array of {x,y}.
Rendering wagons: index i from 1..len-1.
Coupling: for i from len-1 down to 1: line between center(i) and center(i-1), stroke dark, width cell*0.12.
Locomotive at index 0.
Passenger draw: at cell center + bob:
```js
function drawPassenger(p, time){ const {x,y}=p; const cx=(x+0.5)*cell, cy=(y+0.5)*cell + Math.sin(time/300 + x)*1.5;
// legs
// body: rounded rect w=cell*0.4 h=cell*0.45 color p.color at cy
// head: circle r=cell*0.16 at top, skin #f2c89b
// hat? golden: star
}
```
Golden passenger: draw glow: ctx.shadowColor gold, shadowBlur.
Confetti on pickup: particles colored.
Crash: screen shake? add shake var decays; translate canvas randomly. Nice juice. Implement shake=8 on death; in render ctx.translate(rand) if shake>0, decay.
Flash red overlay on death: draw semi-transparent red rect alpha decays.
Now the AI BFS:
```js
function bfsPath(sx,sy,tx,ty,blocked){
const W=cols,H=rows;
const prev=new Int16Array(W*H).fill(-1);
const q=[sy*W+sx]; prev[sy*W+sx]=sy*W+sx;
while(q.length){ const c=q.shift(); const cx=c%W, cy=(c/W)|0;
if(cx===tx&&cy===ty){ // reconstruct first step
let cur=c; while(prev[cur]!==cur && prev[cur]!==sy*W+sx){ cur=prev[cur]; }
// first step from start: walk to the node whose prev is start
return cur; // cell adjacent to start (or start if same)
}
for(const [dx,dy] of DIRS){ const nx=cx+dx, ny=cy+dy; if(nx<0||ny<0||nx>=W||ny>=H) continue; const ni=ny*W+nx; if(prev[ni]!==-1||blocked.has(ni)) continue; prev[ni]=c; q.push(ni); }
}
return -1;
}
```
Then first step cell → direction.
Blocked set for AI: body except tail: snake.slice(0,-1).map(key) — but if willGrow unknown; use slice(1,-1) plus head? head cell will be vacated; exclude head. So blocked = indices 1..len-2 (exclude head 0 and tail last). Actually including head as blocked is wrong (head moves away). Exclude index 0 and last. Also exclude current passenger cells? No, target is passenger.
Also AI should avoid running into the cell the tail currently occupies? Tail moves away next tick, so it's free — exclude. OK.
Fallback safe moves & flood fill:
```js
function safeMoves(head, blocked){
return DIRS.filter(([dx,dy])=>{ const nx=head.x+dx, ny=head.y+dy; if(nx<0||ny<0||nx>=cols||ny>=rows) return false; if(blocked.has(ny*cols+nx)) return false; if(dx===-dir[0]&&dy===-dir[1]) return false; return true; });
}
function spaceAt(x,y,blocked){ flood fill count cap 60 }
```
AI choose:
```js
function aiDir(){
const head=snake[0];
const blocked=new Set(); for(let i=1;i<snake.length-1;i++) blocked.add(snake[i].y*cols+snake[i].x);
// sort passengers by manhattan
const targets=[...passengers].sort((a,b)=>dist(head,a)-dist(head,b));
for(const t of targets){ const step=bfsFirst(head,t,blocked); if(step>=0){ const sx=step%cols, sy=(step/cols)|0; return [sx-head.x, sy-head.y]; } }
const moves=safeMoves(head,blocked);
if(!moves.length) return dir;
let best=moves[0], bestScore=-1;
for(const m of moves){ const nx=head.x+m[0], ny=head.y+m[1]; const s=spaceAt(nx,ny,blocked); if(s>bestScore){bestScore=s;best=m;} }
return best;
}
```
Note: blocked excludes tail so path through tail cell allowed — matches movement.
One more nuance: after moving, if the chosen path's first step leads into a cell that becomes blocked because tail doesn't move (growing)... rare, ignore.
Player input: keydown map: ArrowUp/w → (0,-1) etc. if not opposite of dir → nextDir.
tick uses dir (already validated). For player set nextDir; in tick: if nextDir not opposite and not same → dir=nextDir.
Now sizes: header height ~ 60px. main padding. Compute:
```js
function resize(){
const headerH = document.querySelector('header').offsetHeight;
const footerH = document.querySelector('footer').offsetHeight;
const availW = window.innerWidth - 24;
const availH = window.innerHeight - headerH - footerH - 24;
cols = availW < 560 ? 20 : 26;
rows = availH < 480 ? 13 : 17;
cell = Math.max(10, Math.floor(Math.min(availW/cols, availH/rows)));
const bw = cols*cell, bh = rows*cell;
dpr = Math.min(window.devicePixelRatio||1, 2);
canvas.style.width=bw+'px'; canvas.style.height=bh+'px';
canvas.width=bw*dpr; canvas.height=bh*dpr;
ctx.setTransform(dpr,0,0,dpr,0,0);
boardW=bw; boardH=bh;
}
```
Reset snake on resize? Grid dims change could put snake out of bounds. Handle: on resize, clamp/rebuild snake: if out of bounds, reset game (same mode). Simpler: keep mode, call reset(mode) if any segment out of bounds. Or clamp positions. I'll just reset if out of bounds.
Render details — let me write draw code.
```js
function render(now, dt){
ctx.clearRect(0,0,boardW,boardH);
// shake
ctx.save();
if(shake>0){ ctx.translate((Math.random()-0.5)*shake, (Math.random()-0.5)*shake); shake*=Math.pow(0.001, dt/1000)? }
```
Simpler: shake = max(0, shake - dt*0.02).
Floor:
```js
ctx.fillStyle='#10151f'; ctx.fillRect(-20,-20,boardW+40,boardH+40);
for y,x: alternate: if((x+y)%2===0) fillStyle 'rgba(255,255,255,0.02)' fillRect.
```
Platform ring:
```js
const ring = (x,y)=> x===0||y===0||x===cols-1||y===rows-1;
for cells on ring: fill '#4b3b2a'; plank lines: stroke horizontal lines every cell/2? Draw per cell: fillRect, then line at bottom rgba(0,0,0,0.25).
// yellow safety line on inner edge:
ctx.strokeStyle='#f7c948'; ctx.lineWidth=3; ctx.setLineDash([12,8]); ctx.strokeRect(cell*1 - 1.5, cell*1 -1.5, (cols-2)*cell+3, (rows-2)*cell+3); setLineDash([]);
```
Hmm strokeRect inset by cell → the line sits at inner boundary of ring. Good.
Rails from track map:
```js
track.forEach((v,key)=>{ const [x,y]=key.split(',').map(Number); ... })
```
Decay: iterate map, v.a -= dt*0.015; if <=0 delete. Drawing: center cx,cy; angle from v.dx,v.dy; save translate rotate; draw ties: for t=-cell/2..cell/2 step cell/3: fillRect(t-1.5, -cell*0.42, 3, cell*0.84) color '#5a4632' alpha a; rails: two lines y=±cell*0.3: fillRect(-cell/2, -cell*0.3-1.5, cell, 3) color '#9aa3ad' alpha a... rotate handles direction. Use globalAlpha = a.
Simplify: set ctx.globalAlpha=v.a; draw; reset.
Passengers:
```js
for(const p of passengers) drawPassenger(p, now);
```
Train:
```js
const t = Math.min(1, acc/tickMs);
// positions
const pts = snake.map((s,i)=>{ const pr = prevPos[i] || s; return { x:(pr.x+(s.x-pr.x)*t + 0.5)*cell, y:(pr.y+(s.y-pr.y)*t + 0.5)*cell, s, i }; });
// couplings
ctx.strokeStyle='#2c3138'; lineWidth=cell*0.14; lineCap round;
for(let i=pts.length-1;i>0;i--){ line pts[i] to pts[i-1] }
// wagons tail->head
for(let i=pts.length-1;i>=1;i--) drawWagon(pts[i], angleOf(pts[i], i));
// loco
drawLoco(pts[0], angleOf(pts[0],0));
```
angleOf: use dir vector of segment: curr - prev (grid) → if zero use dir. Compute angle = atan2(dy, dx). For head use dir.
drawWagon(p, angle):
```js
ctx.save(); ctx.translate(p.x,p.y); ctx.rotate(angle);
const c=cell;
// wheels
ctx.fillStyle='#1b1e24';
wheel at (-c*0.26, c*0.30) r c*0.11; (c*0.26, c*0.30) r c*0.11; maybe middle for long? two fine.
// body
roundRect(-c*0.42, -c*0.30, c*0.84, c*0.52, c*0.12) fill p.s.color; darker stroke.
// roof highlight
// window
ctx.fillStyle='rgba(255,255,255,0.85)'; roundRect(-c*0.28,-c*0.18,c*0.2,c*0.16,3) fill? Actually window on side: since rotated along travel, side is vertical; window as small rect near top: fine.
// stripe
ctx.fillStyle='rgba(0,0,0,0.18)'; fillRect(-c*0.42, c*0.08, c*0.84, c*0.12);
ctx.restore();
```
roundRect: use ctx.roundRect if available (Chrome yes) with fallback helper. I'll write helper rr(x,y,w,h,r).
drawLoco(p, angle):
```js
ctx.save(); translate rotate;
const c=cell;
// cowcatcher
ctx.fillStyle='#8a2f2f'; triangle from (c*0.5, c*0.1) to (c*0.28,-c*0.28) to (c*0.28, c*0.28) → fill.
// chassis
rr(-c*0.45, -c*0.16, c*0.95, c*0.34, c*0.08) fill '#22262e';
// boiler
rr(-c*0.45, -c*0.34, c*0.72, c*0.22, c*0.1) fill '#d64545';
// cabin
rr(c*0.02? rear...)
```
Wait orientation: angle 0 = facing +x (right). Front = +x. Boiler front at +x. Cabin at rear (x negative). Chimney near front top.
Let me define: front = +0.5c.
- cowcatcher at front bottom: triangle points (c*0.52, c*0.05),(c*0.30,-c*0.26),(c*0.30,c*0.26) fill '#b03a3a'.
- boiler: rr(-c*0.10, -c*0.32, c*0.58, c*0.26, c*0.09) fill '#d64545' (from -0.10 to 0.48).
- boiler front cap: circle at (c*0.46,-c*0.19) r c*0.13 fill '#a93232'? and headlight: circle at (c*0.50, -c*0.19) r c*0.07 fill '#ffe9a3' with shadowBlur glow.
- chimney: at (c*0.34, -c*0.40): rr(c*0.28, -c*0.46, c*0.12, c*0.16, c*0.03) fill '#7e2a2a'; top flare rr(c*0.24,-c*0.50,c*0.20,c*0.06,c*0.03).
- cabin: rr(-c*0.46, -c*0.42, c*0.38, c*0.30, c*0.06) fill '#b03a3a'; window rr(-c*0.40,-c*0.38,c*0.16,c*0.16,c*0.03) fill '#bfe6ff'.
- roof: rr(-c*0.48, -c*0.46, c*0.42, c*0.06, c*0.03) fill '#5c1f1f'? optional.
- wheels: three: x=-0.34, 0.0, 0.30 at y=c*0.22 r c*0.12 fill '#14171c', hub circle r c*0.05 fill '#8b93a1'. Wheels below chassis: chassis bottom at c*0.18; wheels center y c*0.24.
- stripe: fillRect along boiler bottom rgba(0,0,0,0.2).
Smoke spawn point world: rotate offset (c*0.34, -c*0.52) by angle + head pos.
Store loco front direction for smoke: compute in tick.
Particles update in render with dt.
drawPassenger(p, now):
```js
const c=cell; const cx=(p.x+0.5)*c; const bob=Math.sin(now/280 + p.x*1.7 + p.y)*c*0.04; const cy=(p.y+0.55)*c + bob;
if(p.golden){ ctx.save(); ctx.shadowColor='#ffd700'; ctx.shadowBlur=12; }
// legs
ctx.fillStyle='#2c3140'; fillRect(cx-c*0.10, cy+c*0.10, c*0.08, c*0.14); fillRect(cx+c*0.02, cy+c*0.10, c*0.08, c*0.14);
// body
ctx.fillStyle=p.color; rr(cx-c*0.15, cy-c*0.12, c*0.30, c*0.26, c*0.06) fill;
// head
ctx.fillStyle='#f0c8a0'; circle(cx, cy-c*0.22, c*0.11) fill;
// hair/hat
ctx.fillStyle='#5a3b2a'; arc top half? simple: fillRect(cx-c*0.11, cy-c*0.33, c*0.22, c*0.06) as hair.
if golden: draw star on top: small star path or just a ✦ circle: ctx.fillStyle='#ffd700'; circle(cx+c*0.12, cy-c*0.30, c*0.06).
restore if golden.
```
Overlay HTML:
```html
<div id="overlay"><div class="card"><div id="ovTitle">🚂 All aboard!</div><div id="ovSub">Click or press any key to take the wheel</div></div></div>
```
Show/hide via class. In player mode hide (opacity 0, pointer-events none always).
Set text per mode:
- demo: title "🚂 Choo-Choo Express", sub "Self-driving demo — click or press any key to take control"
- over: title "💥 Crash!", sub `Score ${score} • Best ${best} — click or press any key to ride again`
- demo crash: title "💥 Crash!" sub "Restarting demo…" then auto reset after 1200ms.
- paused: "⏸ Paused — press Space to resume"
HUD elements: Score, Passengers (count collected), Length, Best, Speed maybe. Keep: Score, 🧍 rescued count, 🚃 length, Best.
Track record on tick: track.set(key of newHead? or old head?) Rails should appear under the path traveled — set for the new head cell with travel dir; but at start, also set initial cells. Do: after computing newHead, track.set(`${newHead.x},${newHead.y}`, {dx:dir[0],dy:dir[1],a:0.5}). Also initially seed the starting row cells. Max alpha 0.55 so subtle.
Actually also the cells behind (body) — head cell covers as it moves; every tick head cell gets rails; body follows same path so covered. Good.
Crash handling:
```js
function die(){
best=Math.max(best,score);
crashFlash=1; shake=10;
playCrash();
if(mode==='demo'){ mode='demoCrash'; overlay show crash/restarting; setTimeout(()=>{ if(mode==='demoCrash'){ reset('demo'); } }, 1400); }
else { mode='over'; show overlay game over; }
}
```
setTimeout fine.
startPlayer(): mode='player'; reset('player'); hide overlay; unlock audio.
keydown:
```js
window.addEventListener('keydown', e=>{
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(e.key)) e.preventDefault();
if(mode==='demo'||mode==='demoCrash'||mode==='over'){ startPlayer(); return; }
if(mode==='player'){
const k=e.key.toLowerCase();
if(k===' '){ togglePause(); return; }
const d = KEYMAP[e.key] || KEYMAP[k]; if(d) setDir(d);
} else if(mode==='paused'){ if(e.key===' ') togglePause(); }
});
```
Hmm if paused and other key pressed — ignore. Also if mode==='paused' and key is arrow, resume? Let's: any key while paused except space resumes? Keep: space toggles; also any key resumes? Simpler: space or Enter toggles pause; arrows while paused do nothing (stay paused). Actually let any key resume: if paused → unpause. Fine.
pointerdown on window: same start logic; also unlock audio.
KEYMAP: ArrowUp:(0,-1), ArrowDown:(0,1), ArrowLeft:(-1,0), ArrowRight:(1,0), w/a/s/d similarly (use e.key lower).
setDir(d): if d is opposite of dir or equal → ignore; else nextDir=d. But if two quick key presses within one tick, second could reverse relative to nextDir. Check against nextDir: if opposite of nextDir ignore. Use: if((d[0]===-nextDir[0]&&d[1]===-nextDir[1])||(d[0]===nextDir[0]&&d[1]===nextDir[1])) return; nextDir=d.
tick:
```js
function tick(){
if(mode==='demo') nextDir = aiDir();
if(!(nextDir[0]===-dir[0]&&nextDir[1]===-dir[1])) dir=nextDir;
const head=snake[0];
const nh={x:head.x+dir[0], y:head.y+dir[1], color:'#d64545'};
// wall
if(nh.x<0||nh.y<0||nh.x>=cols||nh.y>=rows){ die(); return; }
const willGrow = passengers.some(p=>p.x===nh.x&&p.y===nh.y);
// body collision
const limit = willGrow? snake.length : snake.length-1;
for(let i=1;i<limit;i++){ if(snake[i].x===nh.x&&snake[i].y===nh.y){ die(); return; } }
prevPos = snake.map(s=>({x:s.x,y:s.y}));
snake.unshift(nh);
if(growPending>0){ growPending--; const tail=snake[snake.length-1]; tail.color = nextWagonColor(); }
else snake.pop();
prevPos = [prevPos[0], ...prevPos.slice(0, snake.length-1)];
// hmm prevPos length must equal snake.length: snake.length = old+1 (grow) or old (no grow). prevPos old length = old. After unshift without pop (grow): snake.length=old+1 → prevPos needs length old+1: [prevPos[0], ...prevPos] → but my slice(0, snake.length-1) = slice(0, old) = all → length old+1. ✓. No grow: snake.length=old → slice(0, old-1) → length old ✓.
track.set(nh.x+','+nh.y, {dx:dir[0], dy:dir[1], a:0.55});
if(willGrow){ collect(nh); }
spawnSmoke();
tickCount++;
tickMs = Math.max(70, 140 - score/10*1.5); // recompute
}
```
Wait growPending set in collect; order: collect sets growPending++ but we already decided pop based on growPending before collect. So passenger pickup grows next tick. Alternative: handle pickup before move? Simpler: in collect, directly grow by not popping — but pop already happened. Fix: compute willGrow first (we do), then if willGrow treat as grow:
```js
if(willGrow){ const tail=snake[snake.length-1]; tail.color=nextWagonColor(); } else snake.pop();
```
And drop growPending.
collect(nh): find passenger index; score += golden?50:10; rescued++; particles confetti at cell; remove; spawnPassenger(); update HUD; blip sound; speed update: tickMs = max(70, 145 - rescued*3).
spawnPassenger(): pick random ring cell free (not in snake, not in passengers, manhattan from head > 4). Try 200 times; golden if Math.random()<0.12 (and rescued>0 maybe). p={x,y,color: pick from shirt palette, golden}.
Init reset(mode):
```js
function reset(m){
mode=m;
const cx=Math.floor(cols/2), cy=Math.floor(rows/2);
snake=[]; for(let i=0;i<4;i++) snake.push({x:cx-i, y:cy, color: i===0?'#d64545':'#888'});
```
Colors: initial wagons palette colors: assign via nextWagonColor for i>0.
```js
dir=[1,0]; nextDir=[1,0]; prevPos=snake.map(s=>({...s}));
passengers=[]; for(let i=0;i<3;i++) spawnPassenger();
score=0; rescued=0; tickMs=145; acc=0; track.clear(); particles=[]; shake=0; crashFlash=0;
updateHUD(); setOverlay();
}
```
Note: track seed: set cells of initial snake row: for i 0..3 track.set(...). Fine.
setOverlay(): per mode set texts and visibility:
- demo: visible.
- demoCrash: visible with crash text.
- player: hidden.
- paused: visible "Paused".
- over: visible crash text.
togglePause: if mode==='player' → 'paused' (overlay), resume → 'player'.
Render loop always running; when mode==='demoCrash' we still render (frozen snake, flash). acc not advanced (mode not player/demo) — condition: advance acc only if mode==='demo'||mode==='player'.
Particles update always (smoke drifts even on crash) fine.
Smoke spawn each tick at chimney world pos: compute from head pos + dir angle:
```js
function spawnSmoke(){ const head=snake[0]; const ang=Math.atan2(dir[1],dir[0]); const c=cell; const ox=Math.cos(ang)*c*0.34, oy=Math.sin(ang)*c*0.34 - c*0.5; hmm chimney is "up" in local coords which after rotation is -y local → world offset = rotate((0.34c, -0.5c), ang).
const wx=(head.x+0.5)*c + Math.cos(ang)*c*0.34 - Math.sin(ang)*(-c*0.5)...
```
Rotation: world = (cos* lx - sin* ly, sin* lx + cos* ly). local (0.34c, -0.52c). Compute. push particle {x:wx, y:wy, vx:dir[0]*8 + rand, vy:-20 - rand*10, life:1, size:c*0.12+rand}. Update: p.x+=vx*dt/1000 etc., vy decreases (rises) — vy negative; p.life -= dt/600; size grows. Draw: globalAlpha=life*0.5, fill '#cfd6dd'.
Confetti: on collect: 14 particles from cell center random velocities, gravity, life 1, colors palette. Update similar with vy+= 300*dt/1000.
Combine particle types with flag {smoke:boolean}.
HUD update function sets textContent.
Now write CSS:
```css
*{box-sizing:border-box; margin:0; padding:0}
html,body{height:100%; overflow:hidden}
body{ background: radial-gradient(1200px 700px at 50% -10%, #1d2a44, #0b0f18 60%); color:#e8ecf4; font-family: 'Trebuchet MS','Segoe UI', system-ui, sans-serif; display:flex; flex-direction:column; }
header{ display:flex; align-items:center; gap:18px; padding:10px 18px; flex-wrap:wrap; background:linear-gradient(#141b2b,#10151f); border-bottom:2px solid #2a3550; }
h1{font-size:20px; letter-spacing:1px; color:#ffd23f; text-shadow:0 2px 0 #7a4a00;}
.stats{display:flex; gap:14px; margin-left:auto; flex-wrap:wrap}
.stat{ background:#1a2334; border:1px solid #2c3a58; border-radius:10px; padding:4px 12px; font-size:13px; color:#9fb0cc; }
.stat b{display:block; font-size:17px; color:#fff; font-variant-numeric:tabular-nums}
main{flex:1; display:flex; align-items:center; justify-content:center; position:relative; min-height:0; padding:10px}
#stage{position:relative; line-height:0}
canvas{ background:#10151f; border-radius:14px; box-shadow:0 0 0 4px #2a3550, 0 18px 50px rgba(0,0,0,.6); }
#overlay{position:absolute; inset:0; display:flex; align-items:center; justify-content:center; pointer-events:none; transition:opacity .25s;}
#overlay.hidden{opacity:0}
.card{ text-align:center; background:rgba(10,14,22,.72); border:1px solid rgba(255,255,255,.14); padding:18px 28px; border-radius:16px; backdrop-filter: blur(4px); line-height:1.4; box-shadow:0 10px 40px rgba(0,0,0,.5)}
#ovTitle{font-size:26px; font-weight:bold; color:#ffd23f}
#ovSub{font-size:14px; color:#c6d2e8; margin-top:6px}
footer{ padding:6px 14px 10px; text-align:center; font-size:12px; color:#7f8dab; }
```
Vignette: `#stage::after{content:''; position:absolute; inset:0; border-radius:14px; pointer-events:none; box-shadow: inset 0 0 60px rgba(0,0,0,.55);}` line-height 0 on stage to avoid inline gap; canvas display:block.
Small screens: header wraps; stats smaller. Media query: h1 font 16px, stat padding smaller.
The overlay must show "Click or press a key to start" — include exact-ish phrase: "Click or press any key to start". Good.
Now, careful about `ctx.roundRect` — supported in Chrome 99+. Latest Chrome fine. But provide fallback anyway:
```js
function rr(x,y,w,h,r){ r=Math.min(r,w/2,h/2); ctx.beginPath(); ctx.moveTo(x+r,y); ctx.arcTo(x+w,y,x+w,y+h,r); ctx.arcTo(x+w,y+h,x,y+h,r); ctx.arcTo(x,y+h,x,y,r); ctx.arcTo(x,y,x+w,y,r); ctx.closePath(); }
```
Use that everywhere to be safe.
Now AI performance: BFS per tick fine.
One more consideration: passengers only on ring — the AI targets them; path may not exist (blocked) → fallback safe moves keep it alive. Good.
Also ensure demo doesn't die too often: flood fill fallback decent. Add also: avoid moves that lead to cell with no escape? The spaceAt scoring handles.
Let me also add: passengers occasionally "board" animation? skip.
Speed display: show "Speed" as level = Math.round((145-tickMs)/10)+1? Show km/h-ish: Math.round(tickMs...). Just show `🚀 ${Math.round(1000/tickMs*10)}`? Keep simple: show length and score and best and rescued. Maybe speed as "Lv". I'll show: Score, Rescued 🧍, Length 🚃, Best 🏆.
Write updateHUD():
```js
elScore.textContent=score; elRescued.textContent=rescued; elLen.textContent=snake.length; elBest.textContent=best;
```
Sound functions:
```js
let actx=null;
function unlockAudio(){ if(!actx){ try{ actx=new (window.AudioContext||window.webkitAudioContext)(); }catch(e){ actx=null; } } if(actx && actx.state==='suspended'){ actx.resume().catch(()=>{}); } }
function tone(f,d,type='sine',v=0.15,when=0){ if(!actx) return; try{ const t0=actx.currentTime+when; const o=actx.createOscillator(); const g=actx.createGain(); o.type=type; o.frequency.value=f; g.gain.setValueAtTime(v,t0); g.gain.exponentialRampToValueAtTime(0.001,t0+d); o.connect(g); g.connect(actx.destination); o.start(t0); o.stop(t0+d+0.02); }catch(e){} }
function sndPickup(golden){ tone(golden?880:660,0.12,'triangle',0.18); tone(golden?1320:990,0.18,'triangle',0.15,0.08); }
function sndCrash(){ tone(160,0.35,'sawtooth',0.2); tone(90,0.4,'square',0.12,0.05); }
```
Call unlockAudio in pointerdown/keydown handlers.
Now assemble full HTML. Also title tag.
Edge: when mode==='demoCrash', overlay text; ensure setTimeout resets only if still demoCrash (user might have clicked → startPlayer sets mode 'player'; guard).
pointerdown handler:
```js
window.addEventListener('pointerdown', ()=>{ unlockAudio(); if(mode==='demo'||mode==='demoCrash'||mode==='over'){ startPlayer(); } });
```
startPlayer: reset('player').
Also keydown any-key start: as above. But careful: keydown for arrows in player mode preventDefault — done.
Also when user presses key while overlay demo → startPlayer; the same key press shouldn't also steer (it's consumed by start). Fine.
Pause: only via Space in player mode. mode 'paused': loop doesn't advance acc; render continues; overlay shows Paused. togglePause.
One subtlety: in tick, when mode==='demo' we set nextDir=aiDir() — but dir reversal check compares nextDir vs dir. aiDir excludes opposite moves. Good.
Also initial demo: reset('demo') at load; loop starts.
Resize handler: window.addEventListener('resize', ()=>{ resize(); if(snake.some(s=>s.x<0||...||>=cols...)) reset(mode==='paused'?'player':mode); }); careful mode demoCrash → keep. Simplify: if out of bounds → reset(mode==='demoCrash'?'demo':mode==='over'?'player':mode). Eh, just: const m = (mode==='over')?'player':(mode==='demoCrash'?'demo':mode); reset(m). Fine.
Passenger spawn uses cols/rows globals — updated on resize. OK.
Let me now also double check the prevPos logic once more with grow:
- old snake length L, prevPos = old copy length L.
- unshift nh → length L+1.
- if willGrow: no pop → snake length L+1. prevPos = [prevPos[0], ...prevPos.slice(0, L)] = length L+1 ✓. Segment i (i≥1) prev = old snake[i-1] ✓ (since prevPos[0]=old[0], prevPos[1..L] = old[0..L-1]... wait prevPos.slice(0,L) = old[0..L-1], so new prevPos = [old0, old0, old1, ..., oldL-1]? No: [prevPos[0], ...prevPos.slice(0,L)] where prevPos=old → [old0, old0, old1, ...]? prevPos.slice(0, L) with prevPos length L gives all old elements old0..oldL-1. So new = [old0, old0, old1, ..., oldL-1] length L+1. Hmm new prevPos[1] should be old0 (segment 1 = old0) ✓, prevPos[2]=old1 ✓ ... prevPos[L]=oldL-1 = tail ✓. And prevPos[0]=old0 for the new head — head lerps from old head to new head ✓.
- no grow: pop → snake length L. prevPos=[prevPos[0], ...prevPos.slice(0,L-1)] = [old0, old0,...,oldL-2]? slice(0,L-1)=old0..oldL-2 → new length L: [old0, old0, old1, ..., oldL-2]. Segment i≥1 prev = old[i-1] ✓.
But note prevPos[0]=old0 and prevPos[1]=old0 duplicate — fine since segment0 is head (moves to nh), segment1's prev is old0 ✓.
Render t uses acc/tickMs; after tick acc reduced. When multiple ticks in one frame (lag), t could exceed... clamp 0..1; but if 2 ticks happened, interpolation jumps — acceptable; clamp acc to tickMs*? cap while loop iterations to 5.
Also when paused, acc frozen.
Now the drawing of rails under train: rails drawn before train, alpha up to 0.55 — visible under wagons partially. Good.
Let me write drawBoard floor with subtle checker:
```js
ctx.fillStyle='#121826'; ctx.fillRect(0,0,boardW,boardH);
ctx.fillStyle='rgba(255,255,255,0.025)';
for(let y=0;y<rows;y++) for(let x=0;x<cols;x++) if((x+y)%2===0) ctx.fillRect(x*cell,y*cell,cell,cell);
```
Platform ring:
```js
for(let y=0;y<rows;y++)for(let x=0;x<cols;x++){ if(!ring) continue; ctx.fillStyle='#4a3a28'; ctx.fillRect(x*cell,y*cell,cell,cell); ctx.fillStyle='rgba(0,0,0,0.25)'; if(y>0&&y<rows-1){ /* plank line */ } }
```
Simpler plank detail: draw horizontal line at each cell bottom within ring: ctx.fillRect(x*cell, (y+1)*cell-2, cell, 2) rgba(0,0,0,.3). And vertical seams: at x*cell maybe. Keep just bottom lines.
Also add corner "buffers"? skip.
Yellow line:
```js
ctx.strokeStyle='#f5c542'; ctx.lineWidth=3; ctx.setLineDash([14,10]); ctx.strokeRect(cell-1.5, cell-1.5, boardW-2*cell+3, boardH-2*cell+3); ctx.setLineDash([]);
```
Wait inner edge of ring: ring occupies cells 0 and cols-1; inner boundary at x=cell and x=(cols-1)*cell. Rect from (cell-1.5, cell-1.5) size ((cols-2)*cell+3, (rows-2)*cell+3). boardW-2*cell = (cols-2)*cell ✓.
Also maybe label "PLATFORM" text on top ring? Could draw small text on top edge cells: ctx.fillText('— PLATFORM —')? Cute: draw text along top: ctx.save(); font; fillStyle rgba(255,255,255,.25); fillText('P L A T F O R M', center). Add on top ring center and bottom. Let's add subtle: fillText('PLATFORM', boardW/2 - 30, cell*0.65). Font size cell*0.4. Nice touch.
Passenger shirt palette: ['#ff6b6b','#feca57','#48dbfb','#1dd1a1','#f368e0','#ff9f43','#a29bfe'].
Now overlay initial content set by setOverlay on reset.
Also add favicon via emoji? Not needed (no external). Could add `<link rel="icon" href="data:,">` to avoid 404 request — data URL is not a network request; fine to include to prevent console error? A missing favicon doesn't produce console error typically (it's a network 404 in some cases but in sandboxed iframe fine). Add data: favicon to be safe.
Now let me write the whole file.
Also remember: "Games must start in a self-playing demo mode ... behind a 'Click or press a key to start' message". Our overlay says "Click or press any key to start". Good.
Let me write JS in full, carefully.
```js
(() => {
'use strict';
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const overlay = document.getElementById('overlay');
const ovTitle = document.getElementById('ovTitle');
const ovSub = document.getElementById('ovSub');
const elScore = ..., etc.
const DIRS = [[1,0],[0,1],[-1,0],[0,-1]];
const WAGON_COLORS = ['#e74c3c','#e67e22','#f1c40f','#2ecc71','#3498db','#9b59b6','#e84393','#16a085'];
const SHIRT_COLORS = ['#ff6b6b','#feca57','#48dbfb','#1dd1a1','#f368e0','#ff9f43','#a29bfe'];
const LOCO = '#d64545';
let cols=26, rows=17, cell=24, boardW=0, boardH=0, dpr=1;
let snake=[], prevPos=[], dir=[1,0], nextDir=[1,0];
let passengers=[], track=new Map(), particles=[];
let score=0, rescued=0, best=0, tickMs=145, acc=0, colorIdx=0;
let mode='demo', shake=0, crashFlash=0, tickCount=0;
function rr(x,y,w,h,r){...}
function key(x,y){return y*cols+x;}
function resize(){ ... }
```
spawnPassenger:
```js
function spawnPassenger(){
const head=snake[0]||{x:-99,y:-99};
for(let tries=0; tries<300; tries++){
const edge = Math.floor(Math.random()*4);
let x,y;
if(edge===0){ x=Math.floor(Math.random()*cols); y=0; }
else if(edge===1){ x=Math.floor(Math.random()*cols); y=rows-1; }
else if(edge===2){ x=0; y=Math.floor(Math.random()*rows); }
else { x=cols-1; y=Math.floor(Math.random()*rows); }
if(Math.abs(x-head.x)+Math.abs(y-head.y) < 5) continue;
if(snake.some(s=>s.x===x&&s.y===y)) continue;
if(passengers.some(p=>p.x===x&&p.y===y)) continue;
passengers.push({x,y, color: SHIRT_COLORS[(Math.random()*SHIRT_COLORS.length)|0], golden: Math.random()<0.12});
return;
}
}
```
collect:
```js
function collect(nh){
const i = passengers.findIndex(p=>p.x===nh.x&&p.y===nh.y);
if(i<0) return;
const p = passengers[i]; passengers.splice(i,1);
const pts = p.golden?50:10;
score += pts; rescued++;
tickMs = Math.max(70, 145 - rescued*3);
// confetti
const cx=(nh.x+0.5)*cell, cy=(nh.y+0.5)*cell;
for(let k=0;k<16;k++) particles.push({x:cx,y:cy,vx:(Math.random()-0.5)*220,vy:-Math.random()*220-40,g:320,life:1,size:3+Math.random()*4,color:WAGON_COLORS[k%WAGON_COLORS.length],smoke:false});
spawnPassenger();
sndPickup(p.golden);
updateHUD();
}
```
tick as planned. Note: after die() inside tick, return.
AI functions:
```js
function aiDir(){
const head=snake[0];
const blocked=new Set();
for(let i=1;i<snake.length-1;i++) blocked.add(key(snake[i].x,snake[i].y));
const targets=[...passengers].sort((a,b)=> (Math.abs(a.x-head.x)+Math.abs(a.y-head.y)) - (Math.abs(b.x-head.x)+Math.abs(b.y-head.y)));
for(const t of targets){
const step=bfsFirst(head.x,head.y,t.x,t.y,blocked);
if(step>=0){ const sx=step%cols, sy=(step/cols)|0; return [sx-head.x, sy-head.y]; }
}
const opts=[];
for(const d of DIRS){
if(d[0]===-dir[0]&&d[1]===-dir[1]) continue;
const nx=head.x+d[0], ny=head.y+d[1];
if(nx<0||ny<0||nx>=cols||ny>=rows) continue;
if(blocked.has(key(nx,ny))) continue;
opts.push(d);
}
if(!opts.length) return dir;
let bestD=opts[0], bestS=-1;
for(const d of opts){
const s=spaceAt(head.x+d[0], head.y+d[1], blocked);
if(s>bestS){ bestS=s; bestD=d; }
}
return bestD;
}
function bfsFirst(sx,sy,tx,ty,blocked){
if(sx===tx&&sy===ty) return sy*cols+sx;
const W=cols,H=rows;
const prev=new Int32Array(W*H).fill(-1);
const start=sy*W+sx; prev[start]=start;
const q=[start];
for(let qi=0; qi<q.length; qi++){
const c=q[qi], cx=c%W, cy=(c/W)|0;
for(const d of DIRS){
const nx=cx+d[0], ny=cy+d[1];
if(nx<0||ny<0||nx>=W||ny>=H) continue;
const ni=ny*W+nx;
if(prev[ni]!==-1||blocked.has(ni)) continue;
prev[ni]=c;
if(nx===tx&&ny===ty){
// walk back to node whose prev is start
let cur=ni;
while(prev[cur]!==start) cur=prev[cur];
return cur;
}
q.push(ni);
}
}
return -1;
}
function spaceAt(x,y,blocked){
const seen=new Set([key(x,y)]); const q=[[x,y]]; let count=0;
while(q.length && count<80){
const [cx,cy]=q.pop(); count++;
for(const d of DIRS){
const nx=cx+d[0], ny=cy+d[1];
if(nx<0||ny<0||nx>=cols||ny>=rows) continue;
const k=key(nx,ny);
if(seen.has(k)||blocked.has(k)) continue;
seen.add(k); q.push([nx,ny]);
}
}
return count;
}
```
Note bfsFirst blocked includes body except head & tail. Target passenger cell might be blocked if a body segment sits on it — passengers spawn off-snake so fine.
Edge: bfs target equals a blocked cell? Not possible.
die():
```js
function die(){
best=Math.max(best,score); updateHUD();
shake=12; crashFlash=1; sndCrash();
if(mode==='demo'){
mode='demoCrash'; setOverlay();
setTimeout(()=>{ if(mode==='demoCrash') reset('demo'); }, 1500);
} else if(mode==='player'){
mode='over'; setOverlay();
}
}
```
reset:
```js
function reset(m){
mode=m;
const cx=(cols/2)|0, cy=(rows/2)|0;
snake=[]; colorIdx=0;
for(let i=0;i<4;i++){
const c = i===0 ? LOCO : WAGON_COLORS[colorIdx++ % WAGON_COLORS.length];
snake.push({x:cx-i, y:cy, color:c});
}
dir=[1,0]; nextDir=[1,0];
prevPos=snake.map(s=>({x:s.x,y:s.y}));
passengers=[]; track.clear(); particles=[];
score=0; rescued=0; tickMs=145; acc=0; shake=0; crashFlash=0;
for(let i=0;i<4;i++) track.set(key(cx-i,cy),{dx:1,dy:0,a:0.55});
for(let i=0;i<3;i++) spawnPassenger();
updateHUD(); setOverlay();
}
```
nextWagonColor: `WAGON_COLORS[colorIdx++ % WAGON_COLORS.length]`.
setOverlay:
```js
function setOverlay(){
let show=true, t='', s='';
if(mode==='demo'){ t='🚂 Choo-Choo Express'; s='Self-driving demo — click or press any key to start!'; }
else if(mode==='demoCrash'){ t='💥 Crash!'; s='Restarting demo…'; }
else if(mode==='over'){ t='💥 Crash!'; s=`Score ${score} • Best ${best} — click or press any key to ride again`; }
else if(mode==='paused'){ t='⏸ Paused'; s='Press Space to keep rolling'; }
else show=false;
ovTitle.textContent=t; ovSub.textContent=s;
overlay.classList.toggle('hidden', !show);
}
```
Input:
```js
const KEYMAP={ArrowUp:[0,-1],ArrowDown:[0,1],ArrowLeft:[-1,0],ArrowRight:[1,0],w:[0,-1],s:[0,1],a:[-1,0],d:[1,0],W:[0,-1],S:[0,1],A:[-1,0],D:[1,0]};
window.addEventListener('keydown', e=>{
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(e.key)) e.preventDefault();
unlockAudio();
if(mode==='demo'||mode==='demoCrash'||mode==='over'){ startPlayer(); return; }
if(mode==='paused'){ if(e.key===' '||e.key==='Enter'){ mode='player'; setOverlay(); } return; }
if(mode==='player'){
if(e.key===' '){ mode='paused'; setOverlay(); return; }
const d=KEYMAP[e.key]||KEYMAP[e.key.length===1?e.key.toLowerCase():e.key];
if(d){ if(!(d[0]===-nextDir[0]&&d[1]===-nextDir[1]) && !(d[0]===nextDir[0]&&d[1]===nextDir[1])) nextDir=d; }
}
});
window.addEventListener('pointerdown', ()=>{ unlockAudio(); if(mode==='demo'||mode==='demoCrash'||mode==='over') startPlayer(); });
function startPlayer(){ reset('player'); }
```
Hmm KEYMAP lookup: e.key for letters is 'w' etc when no shift; with shift 'W'. I included both cases. Simplify: `const d = KEYMAP[e.key] || KEYMAP[e.key.toLowerCase()];` but e.key.toLowerCase() of 'ArrowUp' is 'arrowup' not in map — fine, undefined handled. But careful: ' ' → toLowerCase ' ' → KEYMAP[' '] undefined → ok, handled before.
Loop:
```js
let last=performance.now();
function frame(now){
const dt=Math.min(50, now-last); last=now;
if(mode==='demo'||mode==='player'){
acc+=dt;
let n=0;
while(acc>=tickMs && n<5){ tick(); acc-=tickMs; n++; if(mode!=='demo'&&mode!=='player'){ acc=0; break; } }
if(n===5) acc=0;
}
updateParticles(dt);
render(now);
requestAnimationFrame(frame);
}
```
Careful: if die() during tick changes mode to 'over'/'demoCrash', break loop.
updateParticles:
```js
function updateParticles(dt){
const s=dt/1000;
for(let i=particles.length-1;i>=0;i--){
const p=particles[i];
p.x+=p.vx*s; p.y+=p.vy*s;
if(p.smoke){ p.vy-=14*s; p.size+=8*s; } else { p.vy+=p.g*s; }
p.life-=dt/(p.smoke?900:1100);
if(p.life<=0) particles.splice(i,1);
}
// decay track
track.forEach((v,k)=>{ v.a-=dt*0.012; if(v.a<=0.03) track.delete(k); });
if(shake>0) shake=Math.max(0, shake-dt*0.03);
if(crashFlash>0) crashFlash=Math.max(0, crashFlash-dt*0.0012);
}
```
track.forEach with delete during iteration — Map supports deleting during forEach safely. OK.
render:
```js
function render(now){
ctx.clearRect(0,0,boardW,boardH);
ctx.save();
if(shake>0) ctx.translate((Math.random()-0.5)*shake,(Math.random()-0.5)*shake);
drawFloor(now);
drawRails();
drawPassengers(now);
drawTrain(now);
drawParticles();
ctx.restore();
if(crashFlash>0){ ctx.fillStyle=`rgba(255,40,40,${crashFlash*0.25})`; ctx.fillRect(0,0,boardW,boardH); }
}
```
drawFloor with platform and labels.
drawRails:
```js
function drawRails(){
track.forEach((v,k)=>{
const [x,y]=k.split(',').map(Number);
const cx=(x+0.5)*cell, cy=(y+0.5)*cell;
ctx.save(); ctx.translate(cx,cy); ctx.rotate(Math.atan2(v.dy,v.dx));
ctx.globalAlpha=Math.max(0,v.a);
ctx.fillStyle='#4a3826';
for(let t=-cell/2; t<cell/2; t+=cell/2.6) ctx.fillRect(t, -cell*0.44, cell*0.22, cell*0.88);
ctx.fillStyle='#aab2bd';
ctx.fillRect(-cell/2, -cell*0.30-1.5, cell, 3);
ctx.fillRect(-cell/2, cell*0.30-1.5, cell, 3);
ctx.restore();
});
ctx.globalAlpha=1;
}
```
drawPassengers:
```js
function drawPassengers(now){
for(const p of passengers){
const c=cell, cx=(p.x+0.5)*c, bob=Math.sin(now/260+p.x*1.3+p.y*0.7)*c*0.05;
const cy=(p.y+0.55)*c+bob;
ctx.save();
if(p.golden){ ctx.shadowColor='#ffd700'; ctx.shadowBlur=c*0.6; }
ctx.fillStyle='#232838';
ctx.fillRect(cx-c*0.11, cy+c*0.08, c*0.09, c*0.16);
ctx.fillRect(cx+c*0.02, cy+c*0.08, c*0.09, c*0.16);
ctx.fillStyle=p.color;
rr(cx-c*0.16, cy-c*0.14, c*0.32, c*0.26, c*0.07); ctx.fill();
ctx.fillStyle='#f1c9a5';
ctx.beginPath(); ctx.arc(cx, cy-c*0.24, c*0.115, 0, 7); ctx.fill();
ctx.fillStyle='#4a3222';
ctx.beginPath(); ctx.arc(cx, cy-c*0.27, c*0.115, Math.PI, 0); ctx.fill();
if(p.golden){ ctx.fillStyle='#ffd700'; ctx.font=`${c*0.3}px sans-serif`; ctx.textAlign='center'; ctx.fillText('★', cx+c*0.16, cy-c*0.34); }
ctx.restore();
}
}
```
arc end angle 7 > 2π fine.
drawTrain:
```js
function drawTrain(now){
const t = mode==='player'||mode==='demo' ? Math.min(1, acc/tickMs) : 1;
const pts=[];
for(let i=0;i<snake.length;i++){
const s=snake[i], pr=prevPos[i]||s;
pts.push({x:(pr.x+(s.x-pr.x)*t+0.5)*cell, y:(pr.y+(s.y-pr.y)*t+0.5)*cell, s});
}
// couplings
ctx.strokeStyle='#20242c'; ctx.lineWidth=Math.max(2,cell*0.14); ctx.lineCap='round';
ctx.beginPath();
for(let i=pts.length-1;i>0;i--){ ctx.moveTo(pts[i].x,pts[i].y); ctx.lineTo(pts[i-1].x,pts[i-1].y); }
ctx.stroke();
// wagons
for(let i=pts.length-1;i>=1;i--){
const p=pts[i]; const pr=prevPos[i]||snake[i];
let ang=Math.atan2(snake[i].y-pr.y, snake[i].x-pr.x);
if(!isFinite(ang)||(snake[i].x===pr.x&&snake[i].y===pr.y)) ang=Math.atan2(dir[1],dir[0]);
drawWagon(p.x,p.y,ang,p.s.color);
}
// loco
drawLoco(pts[0].x, pts[0].y, Math.atan2(dir[1],dir[0]));
}
```
Hmm angle when segment didn't move (grow tail): use dir — fine.
drawWagon:
```js
function drawWagon(x,y,ang,color){
const c=cell;
ctx.save(); ctx.translate(x,y); ctx.rotate(ang);
// wheels
ctx.fillStyle='#15181e';
for(const wx of [-c*0.26, c*0.02, c*0.30]){ ctx.beginPath(); ctx.arc(wx, c*0.30, c*0.115, 0, 7); ctx.fill(); }
ctx.fillStyle='#7f8794';
for(const wx of [-c*0.26, c*0.02, c*0.30]){ ctx.beginPath(); ctx.arc(wx, c*0.30, c*0.045, 0, 7); ctx.fill(); }
// body
ctx.fillStyle=color;
rr(-c*0.44, -c*0.30, c*0.88, c*0.56, c*0.12); ctx.fill();
ctx.strokeStyle='rgba(0,0,0,0.35)'; ctx.lineWidth=Math.max(1,c*0.05); ctx.stroke();
// roof
ctx.fillStyle='rgba(255,255,255,0.25)';
rr(-c*0.44, -c*0.30, c*0.88, c*0.12, c*0.06); ctx.fill();
// window
ctx.fillStyle='rgba(230,245,255,0.9)';
rr(-c*0.30, -c*0.16, c*0.18, c*0.18, c*0.04); ctx.fill();
rr(c*0.06, -c*0.16, c*0.18, c*0.18, c*0.04); ctx.fill();
// stripe
ctx.fillStyle='rgba(0,0,0,0.22)';
ctx.fillRect(-c*0.44, c*0.10, c*0.88, c*0.08);
ctx.restore();
}
```
drawLoco:
```js
function drawLoco(x,y,ang){
const c=cell;
ctx.save(); ctx.translate(x,y); ctx.rotate(ang);
// cowcatcher
ctx.fillStyle='#8f2f2f';
ctx.beginPath(); ctx.moveTo(c*0.55, c*0.06); ctx.lineTo(c*0.30, -c*0.26); ctx.lineTo(c*0.30, c*0.26); ctx.closePath(); ctx.fill();
// wheels
ctx.fillStyle='#15181e';
for(const wx of [-c*0.34, -c*0.02, c*0.28]){ ctx.beginPath(); ctx.arc(wx, c*0.28, c*0.13, 0, 7); ctx.fill(); }
ctx.fillStyle='#8b93a1';
for(const wx of [-c*0.34, -c*0.02, c*0.28]){ ctx.beginPath(); ctx.arc(wx, c*0.28, c*0.05, 0, 7); ctx.fill(); }
// chassis
ctx.fillStyle='#20242c';
rr(-c*0.46, -c*0.14, c*1.0, c*0.30, c*0.07); ctx.fill();
// boiler
ctx.fillStyle=LOCO;
rr(-c*0.12, -c*0.34, c*0.62, c*0.26, c*0.1); ctx.fill();
ctx.fillStyle='rgba(0,0,0,0.18)';
ctx.fillRect(-c*0.12, c*0.02, c*0.62, c*0.06);
// boiler bands
ctx.fillStyle='rgba(255,255,255,0.25)';
ctx.fillRect(c*0.06, -c*0.34, c*0.05, c*0.26);
ctx.fillRect(c*0.28, -c*0.34, c*0.05, c*0.26);
// smokebox front + headlight
ctx.fillStyle='#a83232';
ctx.beginPath(); ctx.arc(c*0.50, -c*0.21, c*0.13, 0, 7); ctx.fill();
ctx.save(); ctx.shadowColor='#ffe08a'; ctx.shadowBlur=c*0.5;
ctx.fillStyle='#ffefb0'; ctx.beginPath(); ctx.arc(c*0.56, -c*0.21, c*0.06, 0, 7); ctx.fill(); ctx.restore();
// chimney
ctx.fillStyle='#7e2a2a';
rr(c*0.30, -c*0.50, c*0.13, c*0.18, c*0.03); ctx.fill();
rr(c*0.26, -c*0.55, c*0.21, c*0.07, c*0.03); ctx.fill();
// cabin
ctx.fillStyle='#b03a3a';
rr(-c*0.46, -c*0.44, c*0.36, c*0.32, c*0.06); ctx.fill();
ctx.fillStyle='#cfeaff';
rr(-c*0.41, -c*0.39, c*0.17, c*0.17, c*0.03); ctx.fill();
// roof
ctx.fillStyle='#5c1f1f';
rr(-c*0.49, -c*0.49, c*0.42, c*0.07, c*0.03); ctx.fill();
ctx.restore();
}
```
Smoke spawn in tick:
```js
function spawnSmoke(){
const head=snake[0]; const ang=Math.atan2(dir[1],dir[0]);
const lx=cell*0.36, ly=-cell*0.58;
const wx=(head.x+0.5)*cell + Math.cos(ang)*lx - Math.sin(ang)*ly;
const wy=(head.y+0.5)*cell + Math.sin(ang)*lx + Math.cos(ang)*ly;
particles.push({x:wx,y:wy,vx:dir[0]*10+(Math.random()-0.5)*8, vy:dir[1]*10 - 22 - Math.random()*10, g:0, life:1, size:cell*0.10+Math.random()*cell*0.06, color:'#c9d2dc', smoke:true});
}
```
drawParticles:
```js
function drawParticles(){
for(const p of particles){
ctx.globalAlpha=Math.max(0, Math.min(1, p.life))*(p.smoke?0.45:0.9);
ctx.fillStyle=p.color;
ctx.beginPath(); ctx.arc(p.x,p.y,p.size*(p.smoke?1+(1-p.life)*1.5:1),0,7); ctx.fill();
}
ctx.globalAlpha=1;
}
```
drawFloor:
```js
function drawFloor(now){
ctx.fillStyle='#121826';
ctx.fillRect(0,0,boardW,boardH);
ctx.fillStyle='rgba(255,255,255,0.028)';
for(let y=0;y<rows;y++) for(let x=0;x<cols;x++) if((x+y)%2===0) ctx.fillRect(x*cell,y*cell,cell,cell);
// platform ring
for(let y=0;y<rows;y++) for(let x=0;x<cols;x++){
if(x!==0&&y!==0&&x!==cols-1&&y!==rows-1) continue;
ctx.fillStyle='#4d3b27'; ctx.fillRect(x*cell,y*cell,cell,cell);
ctx.fillStyle='rgba(0,0,0,0.28)'; ctx.fillRect(x*cell,(y+1)*cell-2,cell,2);
ctx.fillStyle='rgba(255,255,255,0.05)'; ctx.fillRect(x*cell,y*cell,cell,2);
}
// yellow safety line
ctx.strokeStyle='#f5c542'; ctx.lineWidth=3; ctx.setLineDash([cell*0.6, cell*0.4]);
ctx.strokeRect(cell-1.5, cell-1.5, (cols-2)*cell+3, (rows-2)*cell+3);
ctx.setLineDash([]);
// platform labels
ctx.fillStyle='rgba(255,255,255,0.16)';
ctx.font=`bold ${Math.max(9,cell*0.38)}px sans-serif`;
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText('P L A T F O R M', boardW/2, cell*0.5);
ctx.fillText('P L A T F O R M', boardW/2, boardH-cell*0.5);
}
```
Hmm text on ring top: ring cell height = cell; text at cell*0.5 fits. But passengers also on ring may overlap text — fine.
resize:
```js
function resize(){
const header=document.getElementById('hdr'), footer=document.getElementById('ftr');
const availW=window.innerWidth-20;
const availH=window.innerHeight-header.offsetHeight-footer.offsetHeight-20;
cols = availW<560?20:26;
rows = availH<460?13:17;
cell=Math.max(10, Math.floor(Math.min(availW/cols, availH/rows)));
boardW=cols*cell; boardH=rows*cell;
dpr=Math.min(window.devicePixelRatio||1,2);
canvas.style.width=boardW+'px'; canvas.style.height=boardH+'px';
canvas.width=Math.round(boardW*dpr); canvas.height=Math.round(boardH*dpr);
ctx.setTransform(dpr,0,0,dpr,0,0);
}
window.addEventListener('resize', ()=>{
resize();
if(snake.length && snake.some(s=>s.x<0||s.y<0||s.x>=cols||s.y>=rows)){
const m=(mode==='over')?'player':(mode==='demoCrash'?'demo':mode);
reset(m==='paused'?'player':m);
}
});
```
Init:
```js
resize();
reset('demo');
requestAnimationFrame(t=>{ last=t; requestAnimationFrame(frame); });
```
HUD ids: hdr stats. updateHUD:
```js
function updateHUD(){
document.getElementById('stScore').textContent=score;
document.getElementById('stRescued').textContent=rescued;
document.getElementById('stLen').textContent=snake.length;
document.getElementById('stBest').textContent=best;
}
```
Audio functions as above.
Double-check: no localStorage etc. No external requests. Fonts system. Good.
One more: overlay pointer-events none so clicks pass to window listener — window pointerdown fires regardless since overlay is child of stage; events bubble to window. Good.
Also "Something meaningful visible on first paint": canvas renders immediately at load; demo AI moves. Good.
Check tick when mode==='player': nextDir from input; dir applied. Collision detection with willGrow computed via passengers.some — fine.
Potential bug: In tick, after die() we return, but acc may still be >= tickMs; loop guard breaks because mode changed. In frame loop: `if(mode!=='demo'&&mode!=='player'){acc=0;break;}` after tick — good.
Also in demo, aiDir uses passengers — always ≥1 since we maintain 3 (spawn after collect; initial 3). If spawnPassenger fails after 300 tries (board nearly full) passengers could be 0 → aiDir targets empty → fallback safe moves. Fine.
Edge: passenger spawn requires ring cell; with cols=20 rows=13 ring has plenty.
Now the header HTML:
```html
<header id="hdr">
<h1>🚂 Choo-Choo Express</h1>
<div class="stats">
<div class="stat">Score<b id="stScore">0</b></div>
<div class="stat">🧍 Rescued<b id="stRescued">0</b></div>
<div class="stat">🚃 Cars<b id="stLen">4</b></div>
<div class="stat">🏆 Best<b id="stBest">0</b></div>
</div>
</header>
<main>
<div id="stage">
<canvas id="game"></canvas>
<div id="overlay"><div class="card"><div id="ovTitle"></div><div id="ovSub"></div></div></div>
</div>
</main>
<footer id="ftr">Steer with Arrow keys or WASD • Space to pause • Pick up passengers waiting on the platform — every passenger adds a car!</footer>
```
Overlay covering stage inset 0 — stage size = canvas size. Good.
Media query small:
```css
@media (max-width:560px){
header{gap:8px; padding:8px 10px}
h1{font-size:15px}
.stat{padding:2px 8px; font-size:11px}
.stat b{font-size:14px}
footer{font-size:10px}
#ovTitle{font-size:20px}
}
```
Also ensure body flex column with header/main/footer heights accounted. main min-height:0.
Potential issue: `ctx.arc(...,0,7)` — 7 radians > 2π fine.
Font in canvas: use `sans-serif`.
Let me also handle the case where tickMs changes mid-frame; fine.
Also `acc` used for interpolation only when mode demo/player; else t=1.
Let me now also think about the "click to start" requirement: message visible during demo — yes overlay. After click, player mode. If player dies, overlay shows restart message; click/key restarts as player. Good.
Also maybe pressing key 'p' pause? Space enough.
Write final code. Also wrap everything in IIFE. Ensure no console errors: check all element ids exist. AudioContext guarded.
One more detail: `window.AudioContext||window.webkitAudioContext` — in Chrome fine.
Also prevent context menu? not needed.
Also `e.key` for space is ' ' — KEYMAP[' '] undefined, handled by pause branch before. In paused branch, space resumes.
But note: in player mode pressing space preventDefault happens at top (includes ' '). Good.
Also arrow keys preventDefault stops page scroll — body overflow hidden anyway.
Let me write out the final file now, being careful with syntax.
Also, small improvement: when demo crashes and restarts, overlay flips between crash text and demo text — handled by reset→setOverlay.
Also initial `last` set before first frame.
Check frame loop start:
```js
let last=performance.now();
requestAnimationFrame(frame);
```
First frame dt small. fine.
Potential subtle issue: In tick, `prevPos` computed from snake BEFORE unshift — yes: `prevPos = snake.map(...)` then unshift/pop then rebuild prevPos from old array — but after `snake.unshift(nh)`, `snake` changed; I must capture old array first. Let me restructure:
```js
function tick(){
if(mode==='demo') nextDir=aiDir();
if(!(nextDir[0]===-dir[0]&&nextDir[1]===-dir[1]) && !(nextDir[0]===dir[0]&&nextDir[1]===dir[1])) dir=nextDir;
const head=snake[0];
const nx=head.x+dir[0], ny=head.y+dir[1];
if(nx<0||ny<0||nx>=cols||ny>=rows){ die(); return; }
const willGrow = passengers.some(p=>p.x===nx&&p.y===ny);
const limit = willGrow ? snake.length : snake.length-1;
for(let i=1;i<limit;i++){ if(snake[i].x===nx&&snake[i].y===ny){ die(); return; } }
const old=snake;
const prev=old.map(s=>({x:s.x,y:s.y}));
const nh={x:nx,y:ny,color:LOCO};
snake=[nh,...old];
if(willGrow){ const tail=snake[snake.length-1]; tail.color=nextWagonColor(); }
else snake.pop();
prevPos=[prev[0], ...prev.slice(0, snake.length-1)];
track.set(nx+','+ny,{dx:dir[0],dy:dir[1],a:0.55});
if(willGrow) collect(nh);
spawnSmoke();
}
```
Wait: head color should stay LOCO — nh color LOCO ✓ (old head was LOCO too). When growing, tail color changed to new wagon color — but tail is an existing segment; recoloring it makes the "new car" appear at tail with fresh color.
prevPos length check: snake.length after ops: grow → old.length+1; prev.slice(0, snake.length-1)=prev.slice(0, old.length) → all → prevPos length old.length+1 ✓. no grow → snake.length=old.length; slice(0, old.length-1) → length old.length ✓.
collect(nh) uses nh — fine.
Also updateHUD after collect; also maybe call each tick? score changes only on collect. die updates best. ok.
Also track.set uses string key; drawRails splits. ok.
AI blocked uses key() with current cols — fine.
Now think about whether AI's BFS blocked set should include head cell: head moves away, so exclude index 0 — we start BFS at head anyway; blocked includes indices 1..len-2 (slice(1,-1) equivalent). In code above: for i=1; i<snake.length-1 → indices 1..len-2 ✓ (excludes tail len-1).
But wait: if willGrow (passenger at next cell), tail doesn't move, so tail cell remains blocked — AI ignores this; minor, could cause rare death. Acceptable. Could refine: in aiDir, check passenger at head+dir? Overkill.
Also player collision check excludes tail when not growing ✓.
Render angle for head uses dir — smooth.
Now, HUD "Cars" shows snake.length (loco + wagons). fine.
Let me also add a tiny "speed" indicator? skip.
Write the final HTML now. Also include `<meta name="viewport" content="width=device-width, initial-scale=1">`.
Also `#stage::after` vignette — with overlay inside stage, after overlay? ::after paints above canvas but below overlay? Stacking: ::after with no z-index, overlay positioned later in DOM → overlay above. pointer-events none on both. ok.
Actually overlay needs to be above the vignette — DOM order: canvas, overlay, then ::after is generated as last child → paints above overlay. That would tint the card slightly — set ::after z-index:1 and overlay z-index:2. Or put vignette as inset box-shadow on canvas itself: `box-shadow: inset...` doesn't work on canvas (replaced element draws over? box-shadow inset on canvas — canvas is replaced element; inset shadow may not render above content). Use ::after with z-index:1, overlay z-index:2, both pointer-events none. Fine.
Let me write it all out.
Also double-check: no horizontal scrollbar at 375px: canvas width = min computed ≤ innerWidth-20 ✓; header wraps; body overflow hidden anyway.
Font sizes small screens ok.
Final check on the requirement "Reply with exactly ONE fenced code block" — output only the code block.
Let me write the complete code.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>🚂 Choo-Choo Express — Snake Train</title>
<link rel="icon" href="data:,">
<style> ... </style>
</head>
<body>
<header id="hdr"> ... </header>
<main>
<div id="stage">
<canvas id="game"></canvas>
<div id="overlay"><div class="card"><div id="ovTitle">🚂 Choo-Choo Express</div><div id="ovSub">Click or press any key to start</div></div></div>
</div>
</main>
<footer id="ftr">...</footer>
<script> ... </script>
</body>
</html>
```
CSS full:
```css
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden}
body{
display:flex;flex-direction:column;
background:radial-gradient(1100px 620px at 50% -12%, #22345c 0%, #131b2e 45%, #0a0e17 100%);
color:#e8ecf4;font-family:'Trebuchet MS','Segoe UI',system-ui,sans-serif;
-webkit-user-select:none;user-select:none;
}
header{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:10px 16px;background:linear-gradient(#161f33,#10172a);border-bottom:2px solid #2b3a5e}
h1{font-size:20px;letter-spacing:.5px;color:#ffd23f;text-shadow:0 2px 0 rgba(0,0,0,.5);white-space:nowrap}
.stats{display:flex;gap:10px;margin-left:auto;flex-wrap:wrap}
.stat{background:#1a2440;border:1px solid #31436b;border-radius:10px;padding:3px 12px 5px;font-size:11px;color:#93a6cc;text-align:center;min-width:64px}
.stat b{display:block;font-size:17px;color:#fff;font-variant-numeric:tabular-nums;line-height:1.15}
main{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;padding:10px}
#stage{position:relative;line-height:0}
#stage::after{content:'';position:absolute;inset:0;border-radius:14px;pointer-events:none;z-index:1;box-shadow:inset 0 0 70px rgba(0,0,0,.55)}
canvas{display:block;background:#121826;border-radius:14px;box-shadow:0 0 0 4px #2b3a5e,0 20px 60px rgba(0,0,0,.65)}
#overlay{position:absolute;inset:0;z-index:2;display:flex;align-items:center;justify-content:center;pointer-events:none;transition:opacity .25s ease}
#overlay.hidden{opacity:0}
.card{text-align:center;background:rgba(8,12,20,.74);border:1px solid rgba(255,255,255,.16);border-radius:18px;padding:16px 30px;line-height:1.35;backdrop-filter:blur(4px);box-shadow:0 12px 44px rgba(0,0,0,.55)}
#ovTitle{font-size:26px;font-weight:700;color:#ffd23f;text-shadow:0 2px 0 rgba(0,0,0,.6)}
#ovSub{font-size:14px;color:#c9d5ee;margin-top:6px}
footer{padding:6px 12px 10px;text-align:center;font-size:12px;color:#7e8fb4}
@media (max-width:560px){
header{gap:8px;padding:8px 10px}
h1{font-size:15px}
.stats{gap:6px}
.stat{min-width:52px;padding:2px 8px 4px;font-size:10px}
.stat b{font-size:14px}
footer{font-size:10px}
#ovTitle{font-size:19px}
#ovSub{font-size:12px}
.card{padding:12px 18px}
}
```
JS full — assemble carefully.
I think there might be an issue with `ctx.filter` none used. ok.
Also in drawTrain, `prevPos[i]` may be undefined if lengths mismatch after resize reset — reset rebuilds. ok.
Let me finalize and mentally run through for errors:
- `key` uses cols — defined before use at runtime ✓.
- resize before reset: reset uses cols/rows ✓.
- updateHUD defined before reset call ✓ (function declarations hoisted anyway).
- setOverlay uses overlay elements ✓.
- In frame, `tick` may set mode 'over'; loop breaks ✓.
Sound on demo: actx null until gesture; tone() guards `if(!actx) return;` ✓.
unlockAudio called on keydown/pointerdown ✓.
Edge: keydown handler references KEYMAP with e.key possibly 'Shift' etc — fine.
One thing: pressing key while mode==='paused' with key like 'ArrowUp' — our paused branch returns without unpausing unless space/enter. The instructions say arrows steer; while paused nothing. OK. Actually let's make any key resume from pause except when it's just modifiers — simpler: if paused and key is space/enter/escape → resume; arrows also resume and apply? Keep space/enter only, plus any arrow resumes too and applies direction? Simplest: in paused, any key resumes; if it's a steering key also set nextDir. Implement:
```js
if(mode==='paused'){ mode='player'; const d=KEYMAP[e.key]||KEYMAP[e.key.toLowerCase()]; if(d) nextDir=d; setOverlay(); return; }
```
But space while paused: KEYMAP[' '] undefined → resumes without pause toggle. Good — any key resumes. But then pressing space in player pauses, in paused resumes. Consistent.
But careful: e.key.toLowerCase() for ' ' is ' ' — undefined ok.
Now write everything. Also add small detail: show speed as cars count grows... skip.
Also initial overlay text in HTML matches demo (will be overwritten by setOverlay anyway).
Double-check drawRails rotation: rails drawn along travel direction ✓; ties perpendicular ✓.
I think we're good. One more: `ctx.textBaseline` set in drawFloor; other draws don't rely. fine.
Potential perf: track map up to 442 entries, fine.
Let me also ensure the train doesn't spawn overlapping passengers: spawnPassenger checks snake ✓.
Write final answer.