Train snakeOne-line
GLM 5.3
by Z.aiReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 17 min 30 s
- Cost
- $0.156
- Output tokens
- 88,080
- Spent thinking
- 75,264 tokens
- Speed
- 83.9 tok/s
- Console errors
- 0
- First token after
- 470 ms
- First visible text after
- 15 min 47 s
- Prompt tokens
- 363
- Code size
- 34.9 KB
- Finish reason
- stop
- Served by
- Decart
- Model id
z-ai/glm-5.3- Reasoning sent
model default- Max output
- 943,718 tokens
- Generated
- Nov 12, 2026
What it was asked
The prompt
“make me a snake game where the snake is a little train with a locomotive and colorful wagons, picking up passengers waiting on the platform”
System prompt, sent with every prompt
You are generating a self-contained web demo. It will be rendered automatically in a sandboxed iframe and compared side by side with the output of other AI models given the exact same task. Output rules: - Reply with exactly ONE fenced code block containing the complete file: ```html (or ```svg when the task asks for an SVG). No text before or after the code block. - Everything must be inline: CSS in <style>, JavaScript in <script>. No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task. - Do not use localStorage, sessionStorage, cookies, IndexedDB, alert(), confirm(), prompt() or window.open(): they are blocked in the sandbox and will throw. - The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar. - Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction. - Everything that makes the demo interesting must be visible within the first seconds: nothing hidden behind a high score, a later level or a long play session. - Games must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, so the game can be judged in action without playing. - The iframe only receives keyboard events after the user clicks it: if the demo uses the keyboard, display a clear "Click or press a key to start" message. - The code must run with zero errors in the console of the latest Chrome.
Code run-1.html · 828 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<title>The Little Railway — a snake game</title>
<style>
:root{
--bg:#1b2a21; --ink:#f3ead6; --muted:#9cae95;
--card:#f7efdc; --cline:#d9c8a4; --accent:#e2574c;
--pill:#243a2c; --pline:#3a5843;
--fui:'Trebuchet MS','Segoe UI',Verdana,sans-serif;
--fser:Georgia,'Times New Roman',serif;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{background:var(--bg);color:var(--ink);font-family:var(--fui);overflow:hidden;
user-select:none;-webkit-user-select:none;-webkit-font-smoothing:antialiased}
body::before{content:"";position:fixed;inset:0;z-index:0;pointer-events:none;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/></filter><rect width='160' height='160' filter='url(%23n)' opacity='0.06'/></svg>")}
body::after{content:"";position:fixed;inset:0;z-index:0;pointer-events:none;
background:radial-gradient(120% 95% at 50% 38%,rgba(0,0,0,0) 55%,rgba(3,10,5,.55) 100%)}
.app{position:relative;z-index:1;height:100vh;height:100dvh;display:flex;flex-direction:column;
gap:10px;padding:14px 18px 10px;max-width:1600px;margin:0 auto}
header{display:flex;align-items:center;gap:14px;flex-wrap:wrap}
.brand{display:flex;align-items:center;gap:12px;min-width:0}
.logo{width:54px;height:34px;flex:none;filter:drop-shadow(0 3px 5px rgba(0,0,0,.4))}
.brand h1{font-family:var(--fser);font-style:italic;font-weight:700;
font-size:clamp(18px,2vw,23px);line-height:1.05;text-shadow:0 2px 8px rgba(0,0,0,.4)}
.tagline{margin-top:3px;font-size:clamp(8.5px,1vw,10.5px);letter-spacing:.2em;
text-transform:uppercase;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.stats{display:flex;gap:8px;margin-left:auto}
.stat{display:flex;align-items:center;gap:9px;background:var(--pill);border:1px solid var(--pline);
border-radius:13px;padding:7px 13px;box-shadow:0 5px 14px rgba(0,0,0,.3)}
.stat svg{width:16px;height:16px;flex:none}
.stat.s1 svg{color:#f2b134}.stat.s2 svg{color:#4f9dde}.stat.s3 svg{color:#e2574c}
.stat b{display:inline-block;font-size:17px;line-height:1;font-variant-numeric:tabular-nums}
.stat b.bump{animation:bump .4s ease}
@keyframes bump{35%{transform:scale(1.45) translateY(-1px)}}
.stat span{display:block;margin-top:2px;font-size:9px;letter-spacing:.13em;text-transform:uppercase;color:var(--muted)}
.board{flex:1;min-height:0;position:relative;display:flex;align-items:center;justify-content:center}
canvas{display:block;border-radius:16px;
box-shadow:0 0 0 5px #7d5a37,0 0 0 9px #493017,0 28px 60px rgba(0,0,0,.55);touch-action:none}
body[data-state="demo"] canvas{cursor:pointer}
.chip{position:absolute;top:12px;left:14px;z-index:3;pointer-events:none;font-size:10px;
font-weight:700;letter-spacing:.18em;text-transform:uppercase;color:#e9f2df;
background:rgba(18,32,22,.85);border:1px solid rgba(233,242,223,.28);border-radius:999px;
padding:6px 12px;backdrop-filter:blur(4px)}
.chip.pop{animation:chipPop .45s cubic-bezier(.2,1.4,.4,1)}
@keyframes chipPop{0%{transform:scale(.75)}55%{transform:scale(1.1)}100%{transform:scale(1)}}
.card{position:absolute;left:50%;top:50%;z-index:4;pointer-events:none;width:min(88%,400px);
text-align:center;background:var(--card);color:#33402f;border:2px solid var(--cline);
border-radius:20px;padding:24px 30px 22px;box-shadow:0 26px 60px rgba(5,14,7,.55),inset 0 2px 0 #fff;
opacity:0;visibility:hidden;transform:translate(-50%,-46%) scale(.92);
transition:opacity .4s ease,transform .5s cubic-bezier(.2,1.35,.35,1),visibility 0s linear .4s}
body[data-state="demo"] #cardDemo,
body[data-state="dead"] #cardDead{opacity:1;visibility:visible;
transform:translate(-50%,-50%) scale(1);transition-delay:0s,0s,0s}
body[data-state="dead"] #cardDead{transition-delay:.55s,0s,0s}
.eyebrow{font-size:9.5px;font-weight:700;letter-spacing:.22em;text-transform:uppercase;color:#8d967b;margin-bottom:9px}
.card h2{font-family:var(--fser);font-style:italic;font-size:29px;color:#28463a;margin-bottom:9px}
.card p{font-size:13.5px;line-height:1.55;color:#57614b}
.runline b{color:var(--accent);font-size:16px}
.bestline{margin-top:6px;font-size:12px;color:#8d967b}
.bestline.newbest{color:var(--accent);font-weight:700}
.cta{display:inline-block;margin-top:16px;background:#59a96a;color:#10281b;font-weight:700;
font-size:13.5px;padding:11px 20px;border-radius:999px;
box-shadow:0 4px 0 #3c7a4d,0 12px 26px rgba(20,60,30,.35);animation:breathe 2.4s ease-in-out infinite}
@keyframes breathe{50%{transform:translateY(-2px)}}
.keys{margin-top:14px;font-size:11px;color:#8d967b}
kbd{display:inline-block;min-width:20px;background:#ece2c8;border:1px solid #cfc19d;
border-bottom-width:2.5px;border-radius:5px;padding:1px 5px;font-family:var(--fui);
font-size:10.5px;color:#4a5340;margin:0 1px}
.hint{text-align:center;font-size:11.5px;letter-spacing:.05em;color:var(--muted);padding-bottom:2px}
@media (max-width:640px){
.app{padding:10px 10px 8px;gap:8px}
.logo{width:42px;height:27px}
.stats{width:100%;margin-left:0}
.stat{flex:1;justify-content:center;gap:7px;padding:6px 6px;border-radius:11px}
.stat b{font-size:14px}
.stat span{font-size:8px;letter-spacing:.08em}
.tagline{display:none}
canvas{border-radius:12px}
.card{padding:18px 18px 16px;width:92%}
.card h2{font-size:23px}
.card p{font-size:12.5px}
.hint{font-size:9.5px}
.chip{top:8px;left:9px;font-size:8.5px;padding:5px 9px}
}
</style>
</head>
<body data-state="demo">
<div class="app">
<header>
<div class="brand">
<svg class="logo" viewBox="0 0 58 36" aria-hidden="true">
<rect x="1" y="32" width="56" height="3" rx="1.5" fill="#6d5133"/>
<rect x="9" y="12" width="26" height="13" rx="6.5" fill="#f0e6cf"/>
<path d="M35 12l7 13h-7z" fill="#8f362c"/>
<rect x="3" y="6" width="15" height="19" rx="3" fill="#e2574c"/>
<rect x="6.5" y="9.5" width="7" height="7" rx="1.5" fill="#f6eedb"/>
<rect x="30" y="11.5" width="2.6" height="14" fill="#d9a441"/>
<circle cx="24" cy="9.5" r="3" fill="#d9a441"/>
<rect x="33" y="5" width="5.5" height="9" rx="1.5" fill="#3a4552"/>
<g fill="#f6eedb" stroke="#233027" stroke-width="1.4">
<circle cx="9" cy="28" r="4"/><circle cx="18" cy="28" r="4"/><circle cx="30" cy="28" r="4"/>
</g>
<circle cx="9" cy="28" r="1.4" fill="#233027"/><circle cx="18" cy="28" r="1.4" fill="#233027"/><circle cx="30" cy="28" r="1.4" fill="#233027"/>
</svg>
<div>
<h1>The Little Railway</h1>
<p class="tagline">pick up every passenger · don’t derail</p>
</div>
</div>
<div class="stats">
<div class="stat s1" title="Passengers picked up this run">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="7.5" r="3.8"/><path d="M5 20.5c.7-4.2 3.6-6.5 7-6.5s6.3 2.3 7 6.5"/></svg>
<div><b id="score">0</b><span>picked up</span></div>
</div>
<div class="stat s2" title="Wagons on your train">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.5" y="4.5" width="15" height="11" rx="2.5"/><path d="M17.5 8.5h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3"/><circle cx="7" cy="18.5" r="2"/><circle cx="14" cy="18.5" r="2"/><path d="M9 18.5h3"/></svg>
<div><b id="wagons">5</b><span>wagons</span></div>
</div>
<div class="stat s3" title="Best run">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2.8l2.8 5.8 6.4.9-4.6 4.5 1.1 6.3-5.7-3-5.7 3 1.1-6.3L2.8 9.5l6.4-.9z"/></svg>
<div><b id="best">0</b><span>best run</span></div>
</div>
</div>
</header>
<main class="board" id="board">
<canvas id="cv"></canvas>
<div class="chip" id="chip">AUTO-DRIVER</div>
<div class="card" id="cardDemo">
<div class="eyebrow">Demo run · the auto-driver is at the controls</div>
<h2>All aboard!</h2>
<p>Steer the little train around the board to pick up waiting passengers. Every one you collect adds a colorful wagon — just don’t run into your own train or slip off the edge.</p>
<div class="cta">Click or press a key to start</div>
<div class="keys"><kbd>↑</kbd><kbd>←</kbd><kbd>↓</kbd><kbd>→</kbd> or <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> · swipe on touch</div>
</div>
<div class="card" id="cardDead">
<div class="eyebrow">End of the line</div>
<h2>Derailed!</h2>
<p class="runline"><b id="deadCount">0</b> passengers picked up this run.</p>
<p class="bestline" id="deadBest">Best run: 0</p>
<div class="cta">Click or press a key to try again</div>
</div>
</main>
<footer class="hint">Steer with the arrow keys or WASD · swipe on touch · every passenger waiting on a platform adds a wagon to your train</footer>
</div>
<script>
(()=>{ 'use strict';
/* ---------------- helpers ---------------- */
const $=s=>document.querySelector(s);
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const lerp=(a,b,t)=>a+(b-a)*t;
const rnd=(a,b)=>a+Math.random()*(b-a);
const pick=a=>a[(Math.random()*a.length)|0];
const shade=(hex,f)=>{const n=parseInt(hex.slice(1),16);let r=n>>16&255,g=n>>8&255,b=n&255;
if(f<0){r*=1+f;g*=1+f;b*=1+f}else{r+=(255-r)*f;g+=(255-g)*f;b+=(255-b)*f}
return `rgb(${r|0},${g|0},${b|0})`};
const hash2=(x,y)=>{let n=Math.imul(x,374761393)+Math.imul(y,668265263);
n=Math.imul(n^(n>>>13),1274126177);return((n^(n>>>16))>>>0)/4294967296};
if(!CanvasRenderingContext2D.prototype.roundRect){
CanvasRenderingContext2D.prototype.roundRect=function(x,y,w,h,r){r=Math.min(r,w/2,h/2);
this.moveTo(x+r,y);this.arcTo(x+w,y,x+w,y+h,r);this.arcTo(x+w,y+h,x,y+h,r);
this.arcTo(x,y+h,x,y,r);this.arcTo(x,y,x+w,y,r);this.closePath();};
}
/* ---------------- constants ---------------- */
const COAT=['#e2574c','#f2b134','#4f9dde','#59a96a','#ef8354','#a06cd5','#3fb8ac','#e07a9b'];
const SKIN=['#f6d7b8','#eab695','#c98d5f','#9c6b45','#7a4b2f'];
const HAIR=['#3b2a20','#6b4a2f','#b5813f','#d8c08a','#8d8d94','#241d18'];
const CAPS=['#37474f','#e2574c','#4f9dde','#f2b134','#f4efe2'];
const DIRS=[{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
/* ---------------- canvas / grid ---------------- */
const board=$('#board'), cv=$('#cv'), ctx=cv.getContext('2d');
const bgCv=document.createElement('canvas'), bgx=bgCv.getContext('2d');
let cols=26,rows=15,cell=40,u=1,W=1040,H=600,dpr=1;
function chooseGrid(){
const r=board.getBoundingClientRect();
const target=r.width<620?30:42;
cols=clamp(Math.round(r.width/target),12,30);
rows=clamp(Math.round(r.height/target),11,21);
}
function fitCanvas(){
const r=board.getBoundingClientRect();
cell=Math.max(10,Math.min(r.width/cols,r.height/rows));
u=cell/42; W=cell*cols; H=cell*rows;
dpr=Math.min(2,window.devicePixelRatio||1);
cv.style.width=W+'px'; cv.style.height=H+'px';
cv.width=Math.round(W*dpr); cv.height=Math.round(H*dpr);
renderBG();
}
/* ---------------- game state ---------------- */
let state='demo', autoMode=true;
let segs=[],metas=[],trail=[],tAnim=0,stepTimer=0,stepDur=172,smokeAcc=0;
let score=0,best=0,bestStart=0;
let passengers=[],particles=[],floats=[],smoke=[];
let dirQ=[],shake=0,time=0,pendingReset=null;
function newMeta(){return{color:pick(COAT),skin:pick(SKIN),hair:pick(HAIR),
cap:Math.random()<0.28?pick(CAPS):null,suit:Math.random()<0.6,phase:rnd(0,6.28)}}
function updateStepDur(){stepDur=Math.max(104,172-score*2.4)}
function spawnPassenger(){
for(let t=0;t<400;t++){
const x=(Math.random()*cols)|0,y=(Math.random()*rows)|0;
if(Math.abs(x-segs[0].x)+Math.abs(y-segs[0].y)<4)continue;
if(segs.some(s=>s.x===x&&s.y===y))continue;
if(passengers.some(p=>p.x===x&&p.y===y))continue;
const m=newMeta();m.x=x;m.y=y;passengers.push(m);return;
}
}
function resetRun(demo){
clearTimeout(pendingReset);pendingReset=null;
dirQ.length=0;particles.length=0;floats.length=0;smoke.length=0;
score=0;updateStepDur();
autoMode=demo;bestStart=best;
const startW=demo?5:2,V=startW+1;
const hy=rows>>1;
const minHx=Math.min(V+2,(cols>>1)-1);
const hx=clamp((cols*0.38)|0,minHx,cols-4);
segs=[];for(let i=0;i<V;i++)segs.push({x:hx-i,y:hy});
metas=[{loco:true}];
for(let i=0;i<startW;i++)metas.push(newMeta());
trail=[];for(let i=V-1;i>=0;i--)trail.push({x:segs[i].x,y:segs[i].y});
trail.push({x:hx+1,y:hy});
const hx0=trail[0].x; // extra history so the rails fade out nicely
for(let i=1;i<=5;i++)trail.unshift({x:hx0-i,y:hy});
tAnim=0;stepTimer=0;
passengers=[];
const np=(cols*rows<230)?2:3;
for(let i=0;i<np;i++)spawnPassenger();
setState(demo?'demo':'playing');
syncHUD();
}
/* ---------------- path sampling (grid units) ---------------- */
function headPos(){const n=trail.length;
return{x:lerp(trail[n-2].x,trail[n-1].x,tAnim),y:lerp(trail[n-2].y,trail[n-1].y,tAnim)}}
function curDir(){const n=trail.length;
return{x:Math.sign(trail[n-1].x-trail[n-2].x)||1,y:Math.sign(trail[n-1].y-trail[n-2].y)}}
function pathPoint(d){
const n=trail.length;
if(d<0){const h=headPos(),c=curDir();return{x:h.x+c.x*(-d),y:h.y+c.y*(-d)}}
const tA=tAnim;
if(d<=tA+1e-9){const h=headPos();const k=tA>1e-9?d/tA:0;
return{x:h.x+(trail[n-2].x-h.x)*k,y:h.y+(trail[n-2].y-h.y)*k}}
const k=d-tA,idx=Math.floor(k),frac=k-idx,bi=n-2-idx;
if(bi<=0)return{x:trail[0].x,y:trail[0].y};
return{x:lerp(trail[bi].x,trail[bi-1].x,frac),y:lerp(trail[bi].y,trail[bi-1].y,frac)};
}
function angleAt(d){
const a=pathPoint(d-0.22),b=pathPoint(d+0.22);
return Math.atan2(b.y-a.y,b.x-a.x);
}
/* ---------------- stepping ---------------- */
function step(){
const n=trail.length,T={x:trail[n-1].x,y:trail[n-1].y};
const eatIdx=passengers.findIndex(p=>p.x===T.x&&p.y===T.y);
const grow=eatIdx>=0;
let fatal=false;
for(let i=0;i<segs.length;i++){
if(segs[i].x===T.x&&segs[i].y===T.y&&!(i===segs.length-1&&!grow)){fatal=true;break}
}
if(fatal){tAnim=1;die();return}
segs.unshift({x:T.x,y:T.y});
if(grow){collect(passengers[eatIdx]);passengers.splice(eatIdx,1);spawnPassenger()}
else segs.pop();
while(trail.length>segs.length+7)trail.shift();
const from={x:trail[trail.length-1].x,y:trail[trail.length-1].y};
const back={x:trail[trail.length-2].x,y:trail[trail.length-2].y};
const d=(state==='playing')?playerDir(from,back):aiDir(from,back);
const T2={x:from.x+d.x,y:from.y+d.y};
trail.push(T2);
if(T2.x<0||T2.y<0||T2.x>=cols||T2.y>=rows){state='crashing';stepTimer=0}
}
function playerDir(from,back){
const cur={x:Math.sign(from.x-back.x),y:Math.sign(from.y-back.y)};
while(dirQ.length){
const d=dirQ.shift();
if(from.x+d.x===back.x&&from.y+d.y===back.y)continue;
if(d.x===cur.x&&d.y===cur.y)continue;
return d;
}
return cur;
}
function blockedAt(nx,ny,growOK){
for(let i=0;i<segs.length;i++){const s=segs[i];
if(s.x===nx&&s.y===ny){if(!(growOK&&i===segs.length-1))return true}}
return false;
}
function flood(sx,sy){
const blocked=new Set();for(const s of segs)blocked.add(s.y*cols+s.x);
if(blocked.has(sy*cols+sx))return 0;
const cap=Math.min(cols*rows,segs.length*2+40);
const seen=new Uint8Array(cols*rows);seen[sy*cols+sx]=1;
const q=[sx,sy];let i=0,c=0;
while(i<q.length&&c<cap){
const x=q[i++],y=q[i++];c++;
for(const dd of DIRS){
const nx=x+dd.x,ny=y+dd.y;
if(nx<0||ny<0||nx>=cols||ny>=rows)continue;
const k=ny*cols+nx;
if(seen[k]||blocked.has(k))continue;
seen[k]=1;q.push(nx,ny);
}
}
return c;
}
function aiDir(from,back){
const cur={x:Math.sign(from.x-back.x),y:Math.sign(from.y-back.y)};
let tgt=null,td=1e9;
for(const p of passengers){const dd=Math.abs(p.x-from.x)+Math.abs(p.y-from.y);
if(dd<td){td=dd;tgt=p}}
let bestD=cur,bestS=Infinity;
for(const d of DIRS){
if(from.x+d.x===back.x&&from.y+d.y===back.y)continue;
const nx=from.x+d.x,ny=from.y+d.y;
if(nx<0||ny<0||nx>=cols||ny>=rows)continue;
const grow=passengers.some(p=>p.x===nx&&p.y===ny);
if(blockedAt(nx,ny,grow))continue;
let s=Infinity;
if(tgt)s=Math.abs(nx-tgt.x)+Math.abs(ny-tgt.y)+rnd(0,0.35);
const sp=flood(nx,ny);
if(sp<segs.length+3)s+=300+(segs.length+3-sp)*3;
if(d.x===cur.x&&d.y===cur.y)s-=0.22;
if(s<bestS){bestS=s;bestD=d}
}
return bestD;
}
/* ---------------- pickups & death ---------------- */
function collect(p){
metas.push(p);
score++;
if(!autoMode&&score>best)best=score;
updateStepDur();syncHUD(true);
const cx=(p.x+0.5)*cell,cy=(p.y+0.5)*cell;
for(let i=0;i<9;i++)particles.push({t:'star',x:cx+rnd(-4,4),y:cy+rnd(-4,4),
vx:rnd(-50,50),vy:rnd(-70,-14),r:rnd(1.6,3),ttl:rnd(.45,.8),life:0,
color:pick(['#fff8e1','#ffe29a','#ffffff'])});
particles.push({t:'ring',x:cx,y:cy,r0:cell*0.18,ttl:.5,life:0});
floats.push({x:cx,y:cy-cell*0.3,life:0,ttl:.95,text:'+1'});
ding();
}
function die(){
state='dead';shake=430;
const hp=pathPoint(0),hx=(hp.x+0.5)*cell,hy=(hp.y+0.5)*cell;
for(let i=0;i<26;i++)particles.push({t:'spark',x:hx,y:hy,vx:rnd(-170,170),vy:rnd(-170,170),
r:rnd(1.4,3.2),ttl:rnd(.35,.75),life:0,
color:pick(['#ffd27a','#ff9d4d','#fff3d6','#a9a9a9'])});
for(let i=0;i<8;i++)smoke.push(mkSmoke(hx,hy,1.5,true));
crashSound();
if(autoMode){
pendingReset=setTimeout(()=>{if(state==='dead')resetRun(true)},2000);
setState('demo');
}else{
setState('dead');
$('#deadCount').textContent=score;
const isNew=score>bestStart&&score>0;
$('#deadBest').textContent=isNew?'New best run!':'Best run: '+best;
$('#deadBest').classList.toggle('newbest',isNew);
pendingReset=setTimeout(()=>{if(state==='dead')resetRun(true)},5600);
}
}
/* ---------------- DOM / HUD ---------------- */
const chip=$('#chip'),elScore=$('#score'),elWagons=$('#wagons'),elBest=$('#best');
function setState(s){
document.body.dataset.state=(s==='dead'&&autoMode)?'demo':s;
chip.textContent=(s==='demo'||(s==='dead'&&autoMode))?'AUTO-DRIVER':(s==='dead'?'DERAILED':'YOU’RE DRIVING');
chip.classList.remove('pop');void chip.offsetWidth;chip.classList.add('pop');
}
function syncHUD(b){
elScore.textContent=score;elWagons.textContent=metas.length-1;elBest.textContent=best;
if(b){elScore.classList.remove('bump');void elScore.offsetWidth;elScore.classList.add('bump')}
}
/* ---------------- input ---------------- */
function queueDir(d){
const last=dirQ.length?dirQ[dirQ.length-1]:curDir();
if(d.x===last.x&&d.y===last.y)return;
if(d.x===-last.x&&d.y===-last.y)return;
if(dirQ.length<3)dirQ.push(d);
}
function takeoverOrRestart(){
unlockAudio();
if(state==='demo'){
autoMode=false;bestStart=best;setState('playing');toot();
const hp=pathPoint(0);
for(let i=0;i<10;i++)smoke.push(mkSmoke((hp.x+0.5)*cell,(hp.y+0.5)*cell,1.6,false));
return true;
}
if(state==='dead'){clearTimeout(pendingReset);resetRun(false);toot();return true}
return false;
}
window.addEventListener('keydown',e=>{
const k=e.key;let d=null;
if(k==='ArrowUp'||k==='w'||k==='W')d={x:0,y:-1};
else if(k==='ArrowDown'||k==='s'||k==='S')d={x:0,y:1};
else if(k==='ArrowLeft'||k==='a'||k==='A')d={x:-1,y:0};
else if(k==='ArrowRight'||k==='d'||k==='D')d={x:1,y:0};
else if(k===' '||k==='Enter'){e.preventDefault();if(!e.repeat)takeoverOrRestart();return}
else return;
e.preventDefault();
if(e.repeat)return;
if(takeoverOrRestart()){if(state==='playing')queueDir(d);return}
if(state!=='playing')return;
queueDir(d);
});
let ptr=null;
window.addEventListener('pointerdown',e=>{unlockAudio();
if(takeoverOrRestart()){ptr=null;return}
ptr={x:e.clientX,y:e.clientY}});
window.addEventListener('pointermove',e=>{
if(!ptr||state!=='playing')return;
const dx=e.clientX-ptr.x,dy=e.clientY-ptr.y;
if(dx*dx+dy*dy>680){
queueDir(Math.abs(dx)>Math.abs(dy)?{x:Math.sign(dx),y:0}:{x:0,y:Math.sign(dy)});
ptr={x:e.clientX,y:e.clientY};
}
});
window.addEventListener('pointerup',()=>ptr=null);
window.addEventListener('pointercancel',()=>ptr=null);
/* ---------------- audio (created on first gesture) ---------------- */
let AC=null;
function unlockAudio(){
if(!AC){const C=window.AudioContext||window.webkitAudioContext;
if(C){try{AC=new C()}catch(err){AC=null}}}
if(AC&&AC.state==='suspended'){AC.resume().catch(()=>{})}
}
function tone(f,dur,type,vol,delay,slide){
if(!AC)return;
try{
const t0=AC.currentTime+(delay||0);
const o=AC.createOscillator(),g=AC.createGain();
o.type=type;o.frequency.setValueAtTime(f,t0);
if(slide)o.frequency.exponentialRampToValueAtTime(slide,t0+dur);
g.gain.setValueAtTime(0.0001,t0);
g.gain.exponentialRampToValueAtTime(vol,t0+0.015);
g.gain.exponentialRampToValueAtTime(0.0001,t0+dur);
o.connect(g);g.connect(AC.destination);
o.start(t0);o.stop(t0+dur+0.06);
}catch(err){}
}
function ding(){tone(1174,0.14,'sine',0.09,0);tone(1760,0.26,'sine',0.08,0.07)}
function toot(){tone(220,0.14,'square',0.05,0);tone(220,0.2,'square',0.05,0.18);tone(196,0.1,'square',0.035,0.4)}
function crashSound(){
tone(82,0.4,'triangle',0.2,0,55);
if(!AC)return;
try{
const len=(AC.sampleRate*0.32)|0;
const buf=AC.createBuffer(1,len,AC.sampleRate);
const ch=buf.getChannelData(0);
for(let i=0;i<len;i++)ch[i]=(Math.random()*2-1)*Math.pow(1-i/len,2.2);
const src=AC.createBufferSource();src.buffer=buf;
const f=AC.createBiquadFilter();f.type='lowpass';f.frequency.value=750;
const g=AC.createGain();g.gain.value=0.3;
src.connect(f);f.connect(g);g.connect(AC.destination);src.start();
}catch(err){}
}
/* ---------------- smoke & particles ---------------- */
function mkSmoke(x,y,scale,dark){
return{x:x+rnd(-3,3)*u,y:y+rnd(-3,3)*u,vx:rnd(-8,8)+10,vy:rnd(-18,-8),
r:rnd(2,3)*u*scale,gr:rnd(14,20)*u*scale,ttl:rnd(0.8,1.4),life:0,dark:!!dark};
}
function spawnChimneySmoke(){
const c=pathPoint(-0.34);
smoke.push(mkSmoke((c.x+0.5)*cell,(c.y+0.5)*cell,1,false));
}
/* ---------------- drawing : background ---------------- */
function renderBG(){
bgCv.width=cv.width;bgCv.height=cv.height;
const g=bgx;
g.setTransform(dpr,0,0,dpr,0,0);
g.fillStyle='#a3c47e';g.fillRect(0,0,W,H);
for(let y=0;y<rows;y++)for(let x=0;x<cols;x++){
if((x+y)&1){g.fillStyle='rgba(255,255,255,.05)';g.fillRect(x*cell,y*cell,cell+0.6,cell+0.6)}
}
g.lineCap='round';
for(let y=0;y<rows;y++)for(let x=0;x<cols;x++){
const h1=hash2(x,y),h2=hash2(x*5+13,y*3+71);
const cx=(x+0.15+h1*0.7)*cell,cy=(y+0.15+h2*0.7)*cell;
if(h1<0.34){
g.strokeStyle='rgba(70,106,45,.55)';g.lineWidth=Math.max(1,cell*0.045);
g.beginPath();
g.moveTo(cx,cy);g.lineTo(cx-cell*0.05,cy-cell*0.12);
g.moveTo(cx+cell*0.035,cy);g.lineTo(cx+cell*0.05,cy-cell*0.13);
g.moveTo(cx+cell*0.075,cy+cell*0.01);g.lineTo(cx+cell*0.115,cy-cell*0.09);
g.stroke();
}else if(h1>0.93){
const r=Math.max(1.4,cell*0.045);
g.fillStyle='#f7f2df';
g.beginPath();g.arc(cx,cy,r,0,7);g.fill();
g.beginPath();g.arc(cx+r*1.6,cy+r*0.4,r*0.8,0,7);g.fill();
g.fillStyle='#eec648';g.beginPath();g.arc(cx,cy,r*0.55,0,7);g.fill();
}
}
const vg=g.createRadialGradient(W/2,H/2,Math.min(W,H)*0.38,W/2,H/2,Math.max(W,H)*0.75);
vg.addColorStop(0,'rgba(24,44,18,0)');vg.addColorStop(1,'rgba(24,44,18,.32)');
g.fillStyle=vg;g.fillRect(0,0,W,H);
g.strokeStyle='rgba(20,35,15,.25)';g.lineWidth=3;
g.beginPath();g.roundRect(2,2,W-4,H-4,14);g.stroke();
}
/* ---------------- drawing : rails ---------------- */
function drawRails(){
const V=segs.length,tailD=V-1;
const fadeStart=tailD+0.15,fadeSpan=1.7,total=tailD+1.85;
const ga=0.185*cell;
/* sleepers — anchored to the track cells, appearing just in time */
const n=trail.length;
for(let i=0;i<n-1;i++){
const a=trail[i],b=trail[i+1];
const base=tAnim+(n-2-i);
const ang=Math.atan2(b.y-a.y,b.x-a.x);
for(const f of[0.25,0.75]){
if(i===n-2&&f>tAnim+0.12)continue;
const dBeh=base-f;
const al=dBeh>fadeStart?Math.max(0,1-(dBeh-fadeStart)/fadeSpan):1;
if(al<=0.02)continue;
const px=(lerp(a.x,b.x,f)+0.5)*cell,py=(lerp(a.y,b.y,f)+0.5)*cell;
ctx.save();ctx.translate(px,py);ctx.rotate(ang);
ctx.globalAlpha=al*0.95;
ctx.fillStyle='#7c5b39';
ctx.beginPath();ctx.roundRect(-1.6*u,-12.5*u,3.2*u,25*u,1.6*u);ctx.fill();
ctx.restore();
}
}
/* rails — steel lines along the whole path, fading behind the last wagon */
ctx.globalAlpha=1;
let curA=1,bucket=[];
const flush=()=>{
if(bucket.length<2){bucket=[];return}
for(const side of[-1,1]){
ctx.beginPath();let first=true;
for(const q of bucket){
const na=q.a+Math.PI/2;
const X=(q.p.x+0.5)*cell+Math.cos(na)*ga*side;
const Y=(q.p.y+0.5)*cell+Math.sin(na)*ga*side;
if(first){ctx.moveTo(X,Y);first=false}else ctx.lineTo(X,Y);
}
ctx.strokeStyle='#57646f';ctx.lineWidth=Math.max(1,2.4*u);ctx.lineCap='round';ctx.stroke();
ctx.strokeStyle='#c2cdd4';ctx.lineWidth=Math.max(1,1.1*u);ctx.stroke();
}
bucket=[];
};
const step=0.2;
for(let d=-0.55;d<=total+0.001;d+=step){
const dd=Math.min(d,total);
const p=pathPoint(dd),a=angleAt(dd);
const al=dd>fadeStart?Math.max(0,1-(dd-fadeStart)/fadeSpan):1;
const q=Math.round(al*5)/5;
if(q!==curA){flush();curA=q;ctx.globalAlpha=q;bucket=[]}
if(q===0)break;
bucket.push({p,a});
}
flush();ctx.globalAlpha=1;
}
/* ---------------- drawing : passengers ---------------- */
function dot(x,y,r){ctx.beginPath();ctx.arc(x,y,r,0,7);ctx.fill()}
function drawPassengers(){
const hx=(segs[0].x+0.5)*cell,hy=(segs[0].y+0.5)*cell;
for(const p of passengers){
const px=(p.x+0.5)*cell,py=(p.y+0.5)*cell;
const dm=Math.abs(p.x-segs[0].x)+Math.abs(p.y-segs[0].y);
const exc=clamp(1.7-dm*0.22,0.45,1.8);
const t=time*(2.6+3.4*exc)+p.phase;
const bob=Math.sin(t*2)*1.3*exc;
const wave=Math.sin(t*3.1);
ctx.save();ctx.translate(px,py);ctx.scale(u,u);
/* pulse ring */
const ph=(time*0.7+p.phase)%1;
ctx.beginPath();ctx.arc(21,21,20+ph*10,0,7);
ctx.strokeStyle=`rgba(255,255,255,${0.26*(1-ph)})`;ctx.lineWidth=2;ctx.stroke();
/* platform slab */
ctx.beginPath();ctx.roundRect(3.5,3.5,35,35,7);
ctx.fillStyle='#dcd8c8';ctx.fill();
ctx.strokeStyle='#b3ad97';ctx.lineWidth=1.5;ctx.stroke();
ctx.strokeStyle='rgba(90,85,70,.14)';ctx.lineWidth=1;
ctx.beginPath();ctx.moveTo(3.5,15);ctx.lineTo(38.5,15);ctx.moveTo(3.5,27);ctx.lineTo(38.5,27);ctx.stroke();
ctx.fillStyle='rgba(255,255,255,.65)';ctx.fillRect(6,35,29,2);
/* little flag sign — same color as the wagon this passenger will add */
ctx.fillStyle='#5c4a36';ctx.fillRect(33.5,7,2,9);
ctx.beginPath();ctx.roundRect(29,3,10,7,2.4);
ctx.fillStyle=p.color;ctx.fill();
ctx.strokeStyle=shade(p.color,-0.35);ctx.lineWidth=1.3;ctx.stroke();
ctx.fillStyle='rgba(255,255,255,.85)';dot(34,6.5,1.7);
/* suitcase */
if(p.suit){
ctx.beginPath();ctx.roundRect(6,31,8,6,1.6);
ctx.fillStyle='#a3702f';ctx.fill();
ctx.strokeStyle='#7a4f1e';ctx.lineWidth=1.2;ctx.stroke();
ctx.beginPath();ctx.moveTo(8.5,31);ctx.quadraticCurveTo(10,28.5,11.5,31);ctx.stroke();
}
/* person (top-down), turning to watch the train */
ctx.translate(18,22+bob);
ctx.beginPath();ctx.ellipse(0,1.5,8.5,7,0,0,7);
ctx.fillStyle='rgba(40,55,30,.18)';ctx.fill();
ctx.rotate(Math.atan2(hy-py,hx-px));
ctx.beginPath();ctx.roundRect(-5,-8.5,10,17,5);
ctx.fillStyle=p.color;ctx.fill();
ctx.strokeStyle=shade(p.color,-0.35);ctx.lineWidth=1.4;ctx.stroke();
ctx.strokeStyle=shade(p.color,-0.25);ctx.lineWidth=2.6;ctx.lineCap='round';
ctx.beginPath();ctx.moveTo(1,-7);ctx.lineTo(2+wave*4,-12.5);ctx.stroke();
ctx.beginPath();ctx.moveTo(1,7);ctx.lineTo(3,11);ctx.stroke();
ctx.beginPath();ctx.arc(1,0,5,0,7);
ctx.fillStyle=p.skin;ctx.fill();
ctx.strokeStyle=shade(p.skin,-0.3);ctx.lineWidth=1;ctx.stroke();
ctx.beginPath();ctx.arc(1,0,5.05,Math.PI-1.25,Math.PI+1.25);ctx.lineTo(1,0);ctx.closePath();
ctx.fillStyle=p.cap||p.hair;ctx.fill();
if(p.cap){ctx.fillStyle=shade(p.cap,-0.25);dot(0.2,0,1.4)}
const blink=((time*0.55+p.phase)%3.2)<0.13;
ctx.fillStyle='#2e2620';
if(blink){ctx.fillRect(3.6,-2.6,1.8,0.7);ctx.fillRect(3.6,1.9,1.8,0.7)}
else{dot(4.3,-1.9,0.8);dot(4.3,1.9,0.8)}
ctx.restore();
}
}
/* ---------------- drawing : the train ---------------- */
function drawWheels(L,Wd){
ctx.fillStyle='#2c2823';
const wl=7.5,ww=3.6;
for(const sx of[-1,1])for(const sy of[-1,1]){
ctx.beginPath();
ctx.roundRect(sx*L*0.32-wl/2,sy>0?Wd/2-1:-Wd/2-1-ww,wl,ww+1,1.4);
ctx.fill();
}
}
function drawWagon(m){
const L=36,Wd=25;
ctx.fillStyle='rgba(25,40,22,.2)';
ctx.beginPath();ctx.roundRect(-L/2+2,-Wd/2+3.5,L,Wd+1,7);ctx.fill();
drawWheels(L,Wd);
ctx.beginPath();ctx.roundRect(-L/2,-Wd/2,L,Wd,6);
ctx.fillStyle=m.color;ctx.fill();
ctx.strokeStyle=shade(m.color,-0.38);ctx.lineWidth=2;ctx.stroke();
ctx.strokeStyle=shade(m.color,-0.18);ctx.lineWidth=1.3;
ctx.beginPath();
ctx.moveTo(-L*0.24,-Wd/2+2.5);ctx.lineTo(-L*0.24,Wd/2-2.5);
ctx.moveTo(L*0.24,-Wd/2+2.5);ctx.lineTo(L*0.24,Wd/2-2.5);
ctx.stroke();
ctx.beginPath();ctx.arc(1.5,0,8.8,0,7);
ctx.fillStyle='#f5eeda';ctx.fill();
ctx.strokeStyle=shade(m.color,-0.3);ctx.lineWidth=1.4;ctx.stroke();
ctx.beginPath();ctx.arc(2.5,0,5.2,0,7);
ctx.fillStyle=m.skin;ctx.fill();
ctx.strokeStyle=shade(m.skin,-0.32);ctx.lineWidth=1;ctx.stroke();
ctx.beginPath();ctx.arc(2.5,0,5.25,Math.PI-1.15,Math.PI+1.15);ctx.lineTo(2.5,0);ctx.closePath();
ctx.fillStyle=m.cap||m.hair;ctx.fill();
if(m.cap){ctx.fillStyle=shade(m.cap,-0.25);dot(1.8,0,1.3)}
ctx.fillStyle='#2e2620';dot(5.6,-1.8,0.75);dot(5.6,1.8,0.75);
}
function drawLoco(){
const L=39,Wd=26;
ctx.fillStyle='rgba(25,40,22,.22)';
ctx.beginPath();ctx.roundRect(-L/2+2,-Wd/2+3.5,L,Wd+1,7);ctx.fill();
drawWheels(L,Wd);
const g=ctx.createRadialGradient(26,0,1,26,0,14);
g.addColorStop(0,'rgba(255,220,140,.32)');g.addColorStop(1,'rgba(255,220,140,0)');
ctx.fillStyle=g;ctx.beginPath();ctx.arc(26,0,14,0,7);ctx.fill();
ctx.beginPath();ctx.roundRect(-L/2,-Wd/2,L,Wd,6);
ctx.fillStyle='#3f4b55';ctx.fill();
ctx.strokeStyle='#242d35';ctx.lineWidth=2;ctx.stroke();
ctx.beginPath();ctx.roundRect(-L/2-1.5,-Wd/2-2.5,13,Wd+5,5);
ctx.fillStyle='#2f6b4c';ctx.fill();
ctx.strokeStyle='#1d4a33';ctx.lineWidth=1.8;ctx.stroke();
ctx.fillStyle='rgba(255,255,255,.14)';
ctx.beginPath();ctx.roundRect(-L/2+1,-3,8.5,6,2.5);ctx.fill();
ctx.fillStyle='#d9a441';
ctx.fillRect(3.2,-Wd/2+1.2,2.1,Wd-2.4);
ctx.fillRect(11.4,-Wd/2+1.2,2.1,Wd-2.4);
ctx.beginPath();ctx.arc(7.3,0,3.1,0,7);
ctx.fillStyle='#d9a441';ctx.fill();
ctx.strokeStyle='#a3762a';ctx.lineWidth=1;ctx.stroke();
ctx.beginPath();ctx.arc(15.8,0,3.7,0,7);
ctx.fillStyle='#232c33';ctx.fill();
ctx.strokeStyle='#d9a441';ctx.lineWidth=1.6;ctx.stroke();
ctx.beginPath();ctx.arc(18.6,0,1.8,0,7);
ctx.fillStyle='#ffd76a';ctx.fill();
ctx.strokeStyle='#b98a3a';ctx.lineWidth=1;ctx.stroke();
ctx.beginPath();ctx.moveTo(19,-Wd/2+2);ctx.lineTo(25.6,0);ctx.lineTo(19,Wd/2-2);ctx.closePath();
ctx.fillStyle='#c94f42';ctx.fill();
ctx.strokeStyle='#8f362c';ctx.lineWidth=1.6;ctx.lineJoin='round';ctx.stroke();
}
function drawVehicle(i){
const m=metas[i];
const p=pathPoint(i),a=angleAt(i);
ctx.save();
ctx.translate((p.x+0.5)*cell,(p.y+0.5)*cell);ctx.rotate(a);ctx.scale(u,u);
if(m.loco)drawLoco();else drawWagon(m);
ctx.restore();
}
function drawTrain(){
const V=metas.length;
ctx.strokeStyle='#4a3527';ctx.lineWidth=Math.max(2,3.4*u);ctx.lineCap='round';
ctx.beginPath();
for(let i=0;i<V-1;i++){
const a=pathPoint(i),b=pathPoint(i+1);
ctx.moveTo((a.x+0.5)*cell,(a.y+0.5)*cell);
ctx.lineTo((b.x+0.5)*cell,(b.y+0.5)*cell);
}
ctx.stroke();
for(let i=V-1;i>=0;i--)drawVehicle(i);
}
/* ---------------- drawing : fx ---------------- */
function drawSmoke(){
for(const s of smoke){
const a=(1-s.life/s.ttl)*(s.dark?0.5:0.42);
ctx.beginPath();ctx.arc(s.x,s.y,s.r,0,7);
ctx.fillStyle=s.dark?`rgba(120,124,128,${a})`:`rgba(238,242,245,${a})`;
ctx.fill();
}
}
function drawParticles(){
for(const p of particles){
const k=1-p.life/p.ttl;
if(p.t==='ring'){
const r=p.r0+(1-k)*cell*0.55;
ctx.beginPath();ctx.arc(p.x,p.y,r,0,7);
ctx.strokeStyle=`rgba(255,255,255,${0.55*k})`;
ctx.lineWidth=Math.max(1.5,2.2*u);ctx.stroke();
}else{
ctx.globalAlpha=k;
ctx.fillStyle=p.color;
ctx.beginPath();ctx.arc(p.x,p.y,p.r*u,0,7);ctx.fill();
ctx.globalAlpha=1;
}
}
}
function drawFloats(){
if(!floats.length)return;
ctx.font=`700 ${Math.max(11,13*u)}px 'Trebuchet MS',Verdana,sans-serif`;
ctx.textAlign='center';ctx.textBaseline='middle';
for(const f of floats){
const k=1-f.life/f.ttl;
ctx.globalAlpha=Math.min(1,k*2);
ctx.lineWidth=Math.max(2,3*u);ctx.strokeStyle='rgba(38,60,32,.9)';
ctx.strokeText(f.text,f.x,f.y);
ctx.fillStyle='#fffdf4';ctx.fillText(f.text,f.x,f.y);
}
ctx.globalAlpha=1;
}
/* ---------------- render & loop ---------------- */
function render(){
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.drawImage(bgCv,0,0,W,H);
let sx=0,sy=0;
if(shake>0){const k=shake/430;sx=rnd(-1,1)*6*k;sy=rnd(-1,1)*6*k}
ctx.save();ctx.translate(sx,sy);
drawRails();
drawPassengers();
drawTrain();
drawSmoke();
drawParticles();
drawFloats();
ctx.restore();
if(state==='dead'&&!autoMode){ctx.fillStyle='rgba(12,22,12,.28)';ctx.fillRect(0,0,W,H)}
}
let lastT=performance.now();
function loop(now){
requestAnimationFrame(loop);
let dt=(now-lastT)/1000;lastT=now;
if(dt>0.05)dt=0.05;
time+=dt;
if(shake>0)shake=Math.max(0,shake-dt*1000);
if(state==='demo'||state==='playing'){
stepTimer+=dt*1000;
let g=0;
while(stepTimer>=stepDur&&g++<4){
stepTimer-=stepDur;step();
if(state!=='demo'&&state!=='playing')break;
}
tAnim=clamp(stepTimer/stepDur,0,1);
smokeAcc+=dt;
while(smokeAcc>0.13){smokeAcc-=0.13;spawnChimneySmoke()}
}else if(state==='crashing'){
stepTimer+=dt*1000;
tAnim=Math.min(stepTimer/stepDur,0.45);
smokeAcc+=dt;
while(smokeAcc>0.1){smokeAcc-=0.1;spawnChimneySmoke()}
if(stepTimer>=stepDur*0.45){tAnim=0.45;die()}
}
for(let i=particles.length-1;i>=0;i--){const p=particles[i];p.life+=dt;
if(p.life>=p.ttl){particles.splice(i,1);continue}
if(p.vx!==undefined){const f=Math.exp(-3*dt);p.vx*=f;p.vy*=f;p.x+=p.vx*dt;p.y+=p.vy*dt}}
for(let i=floats.length-1;i>=0;i--){const f=floats[i];f.life+=dt;f.y-=36*dt;
if(f.life>=f.ttl)floats.splice(i,1)}
for(let i=smoke.length-1;i>=0;i--){const s=smoke[i];s.life+=dt;
if(s.life>=s.ttl){smoke.splice(i,1);continue}
s.x+=s.vx*dt;s.y+=s.vy*dt;s.r+=s.gr*dt}
render();
}
/* ---------------- init ---------------- */
chooseGrid();
fitCanvas();
resetRun(true);
let rzT=null;
window.addEventListener('resize',()=>{clearTimeout(rzT);rzT=setTimeout(()=>{
const r=board.getBoundingClientRect();
if(Math.min(r.width/cols,r.height/rows)<13){chooseGrid();fitCanvas();resetRun(autoMode)}
else fitCanvas();
},160)});
requestAnimationFrame(t=>{lastT=t;loop(t)});
})();
</script>
</body>
</html>Reasoning trace (75,264 tokens)
Let me think about what makes this demo great:
1. A snake game where the snake is a train — locomotive at the head, colorful wagons behind.
2. Picking up passengers waiting on platforms.
3. Self-playing demo mode on first paint (AI plays), with "Click or press a key to start" overlay.
4. Responsive, 1280×800 viewport, down to 375px wide, no horizontal scroll.
5. Zero console errors.
6. Single HTML file, all inline.
Design direction: I want this to feel like a charming hand-crafted toy railroad, not a generic neon snake. Let me think about the aesthetic:
- A top-down railway world. Maybe a warm cream/paper background with subtle grid of tracks? Actually, snake moves on a grid — I can render the grid as railway ties/sleepers subtle pattern.
- The train: locomotive at front (dark green or red steam engine with chimney, cab), wagons each a different bright color (like toy wooden trains — red, yellow, blue, green, orange). Each wagon has a roof detail, windows with little passenger faces appearing when picked up! That's a nice touch — when you pick up a passenger, they "board" the nearest wagon and you can see little faces in windows.
- Passengers waiting on platforms: little people with distinct colors/hats standing on small platform tiles (a gray platform with a bench or a sign). They could wave (animated arms), have a speech bubble "!" or a little clock showing they've waited. When train passes over, they hop aboard with a small animation (dust puff / "+1" floating).
- Score: "Passengers delivered" — actually in snake, eating grows the train. So picking up a passenger adds a wagon (new passenger boards a new wagon). Nice thematically: each passenger picked up adds a wagon to the train! That's perfect — snake grows = train gets longer with more wagons, and the picked-up passenger's color could become the wagon's color.
Theme: instead of "game over" by hitting walls, train "derails" — but walls could wrap or be fatal. Classic snake: hitting walls or self = game over. Let me make it crash → "Derailed!" message. With a self-playing demo mode AI that plays reasonably well.
Movement: grid-based, tick-based with smooth interpolation? Smooth interpolation looks much better — the train glides. I'll implement smooth movement: each tick the head advances one cell over `stepDuration` ms, interpolating positions. The body follows along a path (like a trail of points). Actually for a train, following the exact path is important — the body should follow the same track as the head, not cut corners. So I'll keep a path of points (the head's history), and body segments sample the path at fixed arc-length distances behind the head. That gives a beautiful train-follows-track effect around corners.
Implementation plan:
- Grid: cols × rows, computed from canvas size, cell size ~ let's fix grid at e.g. 24×16 cells or compute based on aspect. Canvas sized to container with devicePixelRatio.
- Train state: array of segments, each with grid position (gx, gy). Head moves cell by cell. Keep `path` = array of {x, y} pixel points (cell centers) that head has visited, with continuous interpolation. Body segment i is positioned at distance (i+1)*cellSize behind head along path.
- Direction changes at cell boundaries: queue direction; can't reverse.
- Passenger: spawned at random free cell, drawn as a small platform with a person standing. Person has random color for coat, maybe holds a suitcase. Wiggle animation.
- When head enters passenger's cell: grow by one wagon (append segment at tail), score++, passenger boarding animation, spawn new passenger. The new wagon color = passenger's coat color (cute!). Or random bright color.
- Crash: hitting wall or own body (skip tail cell since it moves). On crash: "Derailed!" overlay, screen shake, then reset after a moment; in demo mode the AI just restarts.
AI for demo mode: simple greedy with safety check — prefer directions reducing distance to passenger, but check that the next cell isn't body and do a basic lookahead (flood fill to ensure reachable space, or just avoid immediate collisions plus prefer direction with most free neighbors). A decent approach: at each cell step, compute candidate dirs (not reverse, not wall, not body). Among safe ones, score by Manhattan distance to food, with a flood-fill "space size" check (require reachable area >= train length) to avoid trapping. Flood fill is cheap enough on a 24×16 grid every step. This gives a decent-looking AI that plays a long time. When no safe dir: just continue and crash → respawn.
Actually let me make the demo AI quite good so the screenshot shows a nice long train. Also "everything interesting visible in first seconds" — train moving, picking up passengers. Good.
Rendering details (the craft):
Background: I'll go with a warm "toy town" look. Palette:
- Ground: warm cream/greenish? Let's do a soft sage green grass (#b7cfa8?) or a warm paper. Hmm. A wooden-toy railroad on a table? Let me do: soft green felt-like ground with a subtle checker of slightly different green shades per cell (like snake's classic checkerboard but subtle), plus tiny grass tufts scattered. Rail ties pattern could be too noisy. Maybe: each cell a soft rounded square in alternating two greens, with a very subtle inner shadow — like a board game. That reads "playfield" clearly.
Actually, rails: I could draw actual rails under the train! Since the train follows its path, I can draw the path it has traveled as railway tracks (two metal lines + sleepers) that fade out behind the tail. That would look AMAZING — the train leaves track behind it along its path, and the track is exactly the snake body length. The rails appear under the head and disappear behind the last wagon. That's the signature moment: the train lays track as it moves, and the track is the snake.
So rendering order:
1. Ground (checkerboard grass).
2. Rails: along the path from head to tail (plus a bit ahead of the head in current direction?), draw sleepers perpendicular every ~cell/2, and two rails as lines offset perpendicular to path direction. Drawing rails along a polyline: I can walk the path and for each sampled point compute tangent, draw sleeper quads, and rails as stroked polyline offset... Offsetting polylines is a bit fiddly at corners, but rails with round caps and drawing them as two strokes offset by perpendicular at each sample, connecting with lineTo — corners will be slightly off but with round joins it's fine. Simpler: draw rails as a single thick stroke of the path in a dark color, then a thinner stroke... no, that makes one wide rail not two.
Alternative: draw sleepers (short perpendicular rounded rects) along the path, then draw two thin lines by iterating samples and drawing segments offset by normal. At corners, the offset lines pinch slightly — barely noticeable at small rail separation (like 6px apart). Good enough, looks charming.
Also fade the rails near the tail: draw with decreasing alpha for last segment, or just let them end at tail. Rails only under the train body — the classic "train lays track" illusion. Actually I could also keep the rails slightly behind (a ghost of where track was) fading out. Simpler: rails span exactly head→tail, maybe extending a couple cells behind the tail with fade. Let me do: draw along path from head back to tail + fade out over ~1.5 cells behind.
Hmm wait — path stores history; the tail position is at (bodyCount)*cell behind head. Path beyond that (older) still exists; I can draw rails from head back to tail + 2 cells with alpha gradient. Nice touch.
3. Passengers: platform cell (a light gray rounded rect with edge), a small canopy? Maybe a tiny station sign (a post with a blue "H" or station sign), and the passenger: a rounded body (coat color), head (skin tone), simple face, maybe a hat or hair, holding a little suitcase, and an "arm raised waving" animation. Also bobbing. Add a thought bubble "🚂"? No emoji — I'll draw a tiny clock or just the wave. Maybe a small floating "!" indicator pulsing above them. Let me draw a little signpost with the passenger color matching future wagon — that telegraphs "picking them adds a wagon of their color".
4. Train: locomotive head. Top-down view: rounded rectangle body (dark green or classic black+red), chimney at front with smoke puffs (particles!), cab at rear with roof, cowcatcher at front, headlight glow ahead? Smoke puffs drifting behind is a great animated touch — particles spawn at chimney each tick and drift up-screen with expansion and fade. Since it's top-down, smoke drifts opposite to movement or just ambient upward. Top-down: smoke could drift in a fixed direction (wind) — fine.
Wagons: each a rounded rect in its color with darker outline, a roof ridge line, two windows (with passenger face if boarded — i.e., all wagons after first could show faces? Actually every wagon carries a passenger: show a little head in the window of each wagon — number of faces could equal count). Coupling: small dark link between segments (drawn along path between wagon centers — just a short thick line between consecutive positions).
Wheels: top-down view... wheels would be dark stubs on the sides. I can draw two small dark rects on left/right sides of each wagon body along the perpendicular of travel direction. That adds a lot of train-ness. Yes — each segment knows its direction (from path tangent), so perpendicular is known; draw wheel pairs at both sides near front/back.
Headlight: at night? Keep day scene. Maybe a soft cone in front — could look nice but subtle.
UI: Top bar (HTML, not canvas) with:
- Title "Little Railway" or "Snaketrain"? Title idea: "PICKUP LINE"? Let me name it something charming: "Trainsnake"? "The Little Pickup Train"? I'll go with "SNACK... no. "WAGONS & PASSENGERS"? Let me call it "Platform 9 — The Pickup Train". Or simply a title like "LITTLE PICKUP RAILWAY". I'll title it "The Little Railway" with subtitle "a snake game".
- Score display: passengers picked up (icon of person + count), train length (wagons), best score (in-memory only, no localStorage — keep a session best in a variable).
- Speed indicator maybe.
HUD style: cream panel, chunky rounded, a display font. Fonts: no external files allowed... Google Fonts CDN is a network request — "No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task." The task doesn't allow any CDN. So I must use system fonts. I'll avoid leaning on a default; use a font stack with character: e.g. `font-family: "Trebuchet MS", "Verdana", ...`? Hmm, my usual taste is to avoid boring system-ui as identity font, but with no network allowed, I have to use system stacks. I can use "Georgia" / "Iowan Old Style" for the title for a storybook feel, or a rounded feel via "Trebuchet MS" / "Comic..." no, Comic Sans is risky but actually fits toy train vibe... I'll use a stack like: `'Trebuchet MS', 'Comic Sans MS', 'Chalkboard SE', sans-serif` — playful and widely available, definitely not Inter/Roboto/Arial/system-ui. For numbers maybe same. Actually let me use Georgia italic for the title ("The Little Railway") — storybook. And Trebuchet for UI. That's a distinctive pairing from system fonts.
Colors palette (toy railway):
- Page bg: deep green? Let's make the page a warm wooden table texture? The playfield sits on a "table". Page background: `#2f4f43`-ish dark green or warm dark brown like walnut `#3a2d24` with subtle grain via repeating-linear-gradient. Then the playfield is a rounded board (cream/sage) with a chunky border like a wooden frame. Hmm — but full-bleed might look better in 1280×800. Let me do: dark warm background (#22333b? or forest green #1e3527), with the game board as a big rounded card with a thick cream/wood border and soft shadow. Header row above with title left, HUD right.
Actually for maximum charm: full-screen playfield. Let me structure:
- Outer page: warm dark green (`#173225`?) with subtle noise/stripes.
- Center: a "board" with wooden frame (border: 10px solid #8a5a3b with lighter inner rail? or CSS layered borders/shadows to fake wood). Inside the canvas.
Board cells: two greens: `#9dbf7e` and `#a7c98a`? Slightly desaturated sage. Or cream/beige board with green? Classic snake checkerboard in green feels right for grass. Add tiny darker grass tufts (pre-generated random positions, drawn as little "v" strokes) — subtle texture that doesn't steal attention.
Passengers platform: light gray-blue `#d8d8d0` rounded tile with white edge, a small bench or sign. Person: coat in one of the bright palette (same palette used for wagons: red #e2574c, yellow #f2b134, blue #4f9dde, green #59a96a, orange #ef8354, purple #9b6dd6, teal #45b8ac, pink #e07a9b). Head: skin tones varied (#f5d3b3, #c98d5f, #8d5a3a). Little hair/hat. Suitcase brown. Bobbing sine. Waving arm: a line rotating.
Train locomotive: classic red? Head locomotive color: dark charcoal `#3a3f44` body with red accents? Or bright green engine `#2f6f4f` with dark roof. Let me do a classic look: boiler dark green `#3d6b4f`... To keep head distinct from colorful wagons: make locomotive dark slate `#37474f` with red buffer/cowcatcher and a brass dome `#d9a441`. Roof of cab slightly lighter. Chimney dark with brass ring. That'll read clearly as "engine".
Wheels: dark `#2b2b2b` with lighter hub.
Rails: sleepers `#8a5a3b` (wood brown), rails `#7c8a94` (steel gray) with slight highlight. On green grass, brown sleepers + gray rails pop nicely.
Smoke: light gray circles, alpha fade, spawn at chimney each step, drift with slight wind + random, expand.
Boarding effect: when passenger collected: spawn small star/heart particles + a floating "+1" text in HUD? Floating text in canvas: "+1" in white rounded chip rising and fading. Also maybe a little "ding" — sound? Web Audio is allowed (no network). A short synthesized "ding-dong" station bell on pickup and a low "crash" noise on derail would be delightful. Autoplay restrictions: AudioContext needs user gesture; in demo mode (no gesture) audio may be blocked — I can create AudioContext lazily on first user interaction, or resume on click. In demo mode keep silent. That's fine — sounds only after user starts. I'll add tasteful little sounds: pickup bell (two sine notes), chugging? Maybe soft chug-chug loop tied to steps — could be annoying; keep pickup ding + derail thud + maybe a tiny "toot" on direction change? No, keep it minimal: pickup ding and crash thud. Maybe a startup "toot toot" when game starts. Cute.
Overlay states:
- Demo mode (auto AI): semi-transparent? The rules say games must start in self-playing demo mode behind a "Click or press a key to start" message. So overlay a message card: "Click or press a key to start" with title, brief controls hint. The AI plays behind it, visible. Overlay should be light enough to see the game — a compact centered card, not full-screen dim. Good: small floating card at center-top? Center card with slight backdrop blur only behind the card. Let me place a card center with title + "Click or press any key to start" + controls (arrow keys / WASD / swipe). The board visible around it.
- Playing: HUD updates, hide card.
- Derailed: "Derailed!" card with score, "Press any key / click to restart", auto-restart countdown in demo mode (but demo only when never interacted). Once the user has played, on death show restart prompt (with maybe auto-restart after 5s to keep it lively? If idle, go back to demo AI after showing "Derailed" for 2.5s — that keeps screen alive. I'll do: after death, show Derailed card ~1.6s then auto-resume: if user was playing, resume as user-controlled? Better: after derail, return to attract/demo mode (AI plays) with the "Click or press a key to start" card again. That satisfies "self-playing behind message" continuously. But players might want to keep playing... Standard: after death, show "Derailed! Score X — click/tap to play again". If they don't interact within 4s, demo AI resumes. That's polished behavior. Let me implement: states: 'demo', 'playing', 'dead'. dead → after 4s no input → demo. Any input in dead → restart playing. Any input in demo → start playing (reset board with fresh train).
Wait: on first user input from demo, should we reset or hand over mid-run? Handover mid-run feels seamless and cool — "you take the controls now". But the train might be in a spot... it's fine, just keep going, user steers from current state. I'll hand over seamlessly: state playing, keep train. Show a small "You're driving!" toast. Nice. But if train is about to die at moment of handoff, unlucky; acceptable.
Input: keyboard (arrows/WASD), also touch swipes and on-screen? For 375px mobile, swipe support is good. Add swipe detection on canvas via touchstart/touchmove. Also mouse drag as swipe? Simple: pointerdown/up delta. Keyboard events only after click — the message covers that.
Direction queue: maintain `dirQueue` array, on input push if not opposite of last effective dir and queue length < 2. At each step, shift from queue.
Step timing: base step duration ~160ms, decreasing slightly with score (min ~95ms). Smooth interpolation: head pixel pos = lerp(prevCell, nextCell, t). Path approach: maintain `pathPoints` array where index 0 = newest (head) — or push at head and read backwards. Implementation: `path` = array of points, push head point each step; each frame compute headPix = lerp between last two path points by t. To find body positions: sample at distance d behind headPix along path. I'll write a function `pointAt(dist)` walking from head backwards accumulating segment lengths (all segments are cell-length). Since path points are spaced exactly cellSize apart (head visited cell centers), distance = index * cell. With interpolation between points: pos(d) = lerp between floor(d/cell) and next, along the polyline. Careful with corners: cutting corners slightly when interpolating — fine, trains round corners.
Actually corner behavior: path is polyline of cell centers; body positions sampled along it will cut the corner a bit at 90° turns (moving diagonally near corners). With round rects it looks like the train is banked around the corner — looks good actually, toy-like.
Body segment direction: tangent at sample for orientation. For wheels/roof orientation, compute angle from neighboring sample points.
Rendering the train: iterate segments from tail to head (draw head last on top). For each: get center, angle. Draw wheels (2 pairs), body roundRect, details, couplings (draw before bodies: connect segment i to i+1 with a thick short line). Windows: if this wagon "has passenger" (all wagons correspond to picked passengers — actually wagons count = pickups; each wagon was created by a pickup, so give each wagon a face of that passenger in window — store passenger colors!). Store per-segment: color, faceColor. So the train becomes a cute record of who boarded.
Locomotive details (top-down):
- Body: rounded rect dark slate, length ~0.92 cell, width ~0.62.
- Front: red cowcatcher — a trapezoid/dark red wedge at the front.
- Chimney: near front, small dark circle with brass ring, smoke from here.
- Boiler bands: two thin brass lines across.
- Cab: rear third slightly wider dark green with roof rect + tiny vent.
- Headlight: small yellow dot at front? A subtle warm glow ellipse ahead.
Wagon details:
- Body color fill, darker stroke (shade of same color).
- Roof: lighter inner rect along center? For top-down, wagons show roof: maybe roof color = lighter shade with a ridge line. Add windows on the sides? Top-down windows don't show... but toy-style: draw two small sky-blue window squares symmetric near front — stylized. It's a toy, fine. Plus a face inside one window (the boarded passenger): tiny circle head with hair color.
- Number on wagon? Maybe small white circle with index number on the roof — like wagon numbers. Cute but might be noisy; skip or do subtle.
Smoke particles: array {x,y,r,life,vx,vy}. Spawn on each step at chimney pos (compute from head angle: chimney offset from head center). Update each frame: pos += v, r grows, alpha decays. Draw before train? Behind train (smoke above chimney visually means drawn after train, but top-down it drifts behind — draw after train body so it's visible). It should render above the train. OK draw smoke after train.
Also maybe crossing-tie "click-clack" — skip audio complexity, keep ding/crash.
Grass tufts: pre-generate per-cell deterministic random tufts (seeded by cell coords via hash) — 0–2 tufts per cell, tiny 3-line strokes in darker green. Also occasional tiny flowers (white/yellow dots). Subtle.
Platforms: passenger tile drawn as platform slab; maybe alternate orientation. Include a tiny sign: post + blue square with white "S"? A round blue sign like a station sign. Or a lamp post. Let me draw: platform slab (light concrete `#cfd2c9` with border `#b7bab0`), a small bench (two brown lines), and the person. Person drawn above slab. Also a subtle pulsing ring or bouncing exclamation marker to attract attention? The waving arm + bobbing suffices; plus draw a small floating "flag"? Keep: bobbing + waving + occasional blink.
Spawn logic: random free cell not on train path cells (cells occupied by segments), not within 3 cells of head (so it's visible ahead), and not the current passenger cell. Also avoid spawning right in front within 1 cell? min distance 3 from head.
Death conditions: head next cell out of bounds OR cell occupied by body (excluding tail cell if tail is about to move — classic nuance; but since we grow by appending at tail, tail vacates unless growing. Implement: check against body cells except last segment when not growing). On death: state dead, particles: burst of sparks + derailed pose? Simple: screen shake + sparks + "Derailed" card. Maybe the wagons tilt (rotate slightly)? Could jitter segments with random small offsets — cute "derailment". I'll add a `derailWobble` per segment and freeze.
Screen shake: canvas translate random for ~400ms.
Demo AI: at each step (when at cell boundary, choosing next dir):
```
function aiChooseDir() {
candidates = dirs != opposite(current), in bounds, next cell not in body (excluding tail)
for each candidate: score =
flood fill from next cell counting free cells (body cells considered blocked, maybe treat tail...) => space
if space < trainLen + 2 => heavy penalty
dist = manhattan to passenger
score = dist + (spacePenalty)
pick best; tie-break random.
}
```
Flood fill each candidate: 4 fills per step on ~24×15=360 cells → trivial.
Also AI should avoid reversing into itself. And maybe prefer straightness slightly. Fine.
Grid size: canvas fits container. Let me pick cols based on width: cell target ~ 40px on desktop. Board aspect: fit within available height. Approach: compute grid cols=26, rows=17 (classic-ish)? For responsiveness: canvas element resized to container via ResizeObserver / window resize; compute cell = min(w/cols, h/rows); keep logical grid fixed 26×16 and scale cell size; canvas letterboxed inside via CSS width/height computed. Simplest robust: fixed logical grid COLS=26, ROWS=16; canvas CSS width = 100% of board container with aspect-ratio 26/16; internal resolution = clientWidth*dpr. Cell = width/COLS. Height = cell*ROWS. So board always 26:16. On 375px wide: cell ≈ 14px — tiny but OK? 375-24 padding = ~351/26 = 13.5px cells. Train details would be mush. Better: fewer columns on narrow screens — choose grid by aspect: e.g., if width < 640: COLS=16, ROWS=20 (portrait-ish)? But height constraint too. Hmm, on mobile 375×(available height maybe 600): cell= 375/16 ≈ 22px, rows= 600/22 ≈ 27 → cap rows ~ 22. Let me make grid adaptive: at load (and resize) compute cols/rows from container size with target cell ≈ clamp so cols*rows reasonable: cellTarget=44 desktop; on small screens 30. cols = floor(w/cellTarget), rows = floor(h/cellTarget), clamp cols 12..30, rows 12..22. On resize mid-game... changing grid mid-game breaks positions. I'll only recompute on resize if state resets, or map positions? Simplest: handle resize by scaling cell size only (keep grid), and choose grid once at start based on initial size; on significant resize (orientation change), reset game. Or: recompute grid on each restart. I'll do: `computeGrid()` used at each game start/restart; on window resize just rescale canvas (keep grid) — safe. Demo starts at load with initial size.
Actually simpler and safer: define grid at boot from viewport: desktop 1280×800 → playfield area approx 1280 wide minus padding, height ~ 800-header(64)-footer/padding. Let's compute container ~1180×640 → cell 40 → cols 29, rows 16. Hmm cols 29 fine. Rows 16. Nice widescreen board.
Mobile 375×800: header maybe stacked, container 375-20=355 wide, height ~ 620. cell 30 → cols 11, rows 20. OK.
Let me implement:
```
function fitGrid(){
const r = board.getBoundingClientRect(); // board flex container
const target = r.width < 560 ? 30 : 42;
let cols = clamp(Math.round(r.width/target), 11, 30);
let rows = clamp(Math.round(r.height/target), 11, 22);
...
}
```
Then canvas sized to cols*cell × rows*cell where cell = min(r.width/cols, r.height/rows), centered.
Actually make the board itself sized exactly to canvas: board container flex-centers canvas; canvas has explicit pixel CSS size computed in JS.
HUD: header bar above board: left: title with a small drawn logo? Could draw a mini train in an inline SVG — allowed (inline, no network). Title: "The Little Railway" in Georgia italic + small caps subtitle "SNAKE · TRAIN · PASSENGERS"? Keep simple: Title left; right side: stats chips: "Passengers picked up: N", "Wagons: M", "Best: B". Chips with small icons — draw icons as tiny inline SVGs (person icon, wagon icon, trophy). Since no library CDNs allowed... my usual go-to icon libraries are CDN; here no external requests allowed at all (task doesn't whitelist CDNs). So inline SVG icons it is (I'll hand-draw minimal ones — person, wagon, star).
Footer hint: "Arrows / WASD to steer · Swipe on touch" small muted text under board.
Layout:
```
body: dark green background with subtle pattern (repeating-linear-gradient noise? or radial vignette)
.app: max-width 1280, height 100vh flex column, padding
header: flex row, gap, align baseline; on mobile wrap.
board: flex:1, position relative, centers canvas; also the overlay cards positioned absolute over board.
footer: hints.
```
Canvas has rounded corners + border + shadow to look like a board. Border: `border: 6px solid #6b4a2f` (wood) + inner outline `outline: 3px solid #8a6a45`? Layer: box-shadow multiple to fake frame: `box-shadow: 0 0 0 6px #7a5233, 0 0 0 9px #59391f, 0 24px 48px rgba(0,0,0,.45)`. Wood frame via two rings of browns. Plus canvas inner drawing adds its own vignette.
The overlay cards: absolutely positioned centered in board container; pointer-events none except... The whole page click starts game — listen on window pointerdown and keydown. Card purely visual.
Message card style: cream paper `#f6efe0`, rounded 18px, border 2px `#d9c9a8`, shadow; content: small train divider? Title "All aboard!" big serif; line "Click or press any key to start driving"; controls chips: [↑][←][↓][→] "or WASD / swipe". Chars like ↑ are fine (not emoji, they're arrows). "Click or press a key to start" must be verbatim-ish: "Click or press a key to start" — yes include exactly that sentence.
Dead card: "Derailed!" + "You picked up N passengers" + "Click or press a key to try again" + small "demo resumes in 3…" hmm keep clean: "Click or press a key to try again".
Also small state chip top-left of board showing "DEMO — auto driving" vs "DRIVING" — nice.
Sound: create AudioContext on first gesture. Functions: `ding()` two-tone (E5→A5 sine with quick decay), `crash()` noise burst lowpass + low thud, `toot()` square wave two short blasts for start. Volume low (0.15). Wrap in try/catch.
Now the tick/update loop:
- requestAnimationFrame loop with dt.
- `stepTimer += dt; if (stepTimer >= stepDur) { stepTimer = 0; doStep(); }` and `t = stepTimer/stepDur` for interpolation. Use leftover: stepTimer -= stepDur for consistent speed.
- doStep: choose dir (from queue for player; AI if demo), compute next head cell, check collisions, move: unshift new head position into segments, push to path; if eating: keep tail (grow) + effects + spawn passenger; else pop tail.
- segments store {gx, gy, color, face} for head... head is locomotive (no color needed but keep for uniformity). On grow, new tail segment gets passenger color + face skin/hair.
Positions for rendering: headPix from path interpolation; body i at distance (i+1)*cell behind head along path. But careful: right after a step, path has head cell pushed; during interpolation the head is between path[last-1] and path[last]. Distance to segment i center = (i+1)*cell measured from headPix going backwards: first go back along the partial segment to path[last-1], then full cells. Implement `samplePath(dist)`:
```
// path[0..n-1], path[n-1] is newest head cell
// headPix between path[n-2] and path[n-1] by t...
```
Hmm wait: define head moves from path[n-2] to path[n-1]? If at step we push the NEW cell, then during interpolation head goes from previous head cell to new cell: prev=path[n-2], next=path[n-1], headPix = lerp(prev,next,t). Then distance behind head: walk from headPix back toward prev (length = cell*(1-t)? no: distance from headPix to prev = t*... let's compute: headPix = prev + (next-prev)*t. Distance from headPix back to prev = t*cell. Then continue back through older points each cell apart.
So samplePath(d): remaining = d; cur = headPix; prev points backwards: to path[n-2] distance t*cell; then each earlier point cell apart. General walker:
```
let pts = path; let i = pts.length-1;
// position along from head backwards
```
Let me write it as: build an array `anchor = [headPix, pts[n-2], pts[n-3], ...]` implicitly. Simpler implementation: compute distances iteratively:
```
function pointBehind(d){
// returns point d pixels behind headPix along path
let remain = d;
// segment from headPix to path[len-2]
let a = headPix, b = path[path.length-2];
let segLen = t * cell; // from headPix back to b
if (remain <= segLen){ return lerpPt(b, a, 1 - remain/segLen)?? }
```
Hmm if segLen could be 0 at t=0 → guard. Alternative cleaner: keep path as full history, and treat headPix itself: define current fractional position `s = (stepsDoneFloat)`. Let me define total path length parameter: head at arc position `S = (path.length-2 + t) * cell` from start... overcomplicating.
Cleaner: maintain `trail` = array of points from oldest to newest, where trail[last] is the newest cell center (the cell head is moving INTO). headPix = lerp(trail[last-1], trail[last], t). To sample distance d behind headPix:
```
function sampleBehind(d){
let idx = trail.length - 1;
let pos = mix(trail[idx-1], trail[idx], t); // = headPix
let distToPrev = dist(pos, trail[idx-1]); // = t*cell (since consecutive points cell apart... unless wrap, they are cell apart in one axis)
if (d <= distToPrev) return mix(pos, trail[idx-1], d/distToPrev) (guard distToPrev>0)
let rem = d - distToPrev; idx--;
while (rem > 0 && idx >= 1){
const seg = dist(trail[idx-1], trail[idx]); // cell
if (rem <= seg) return mix(trail[idx], trail[idx-1], rem/seg);
rem -= seg; idx--;
}
return trail[Math.max(0, idx-1)]... // clamp to oldest
}
```
Wait mixing direction: moving backwards from pos toward trail[idx-1]. Yes.
But when train is very young (length 3 start), trail has enough points? Start: trail = [tailCell, midCell, headCell] (3 points) with head at headCell... At start, t=0, headPix = trail[last]. But we need trail[last-1] defined — yes with 3 points. But actually at the very start, before first step: head hasn't got a "next" cell. Let me initialize by setting t=0 and trail = [s0, s1, s2] where segments occupy s2 (head), s1, s0. headPix = trail[2]? With my formula headPix = mix(trail[last-1], trail[last], t) = trail[2] at t=0.
Hmm, but that means head is AT trail[2], and "moving into" trail[2] — inconsistent with post-step state where trail[last] is the target cell. At init, set t=1? Let's define: at init, push one extra: trail = [s0,s1,s2,s3] with head segment at s3 and t=1 (headPix = s3). Then first step pushes s4, t resets to 0 → headPix = mix(s3,s4,0)=s3. Consistent. Actually simpler: don't reset t at step; drive t continuously: t advances from 0..1 over stepDur, at t>=1 do step (push new cell) and t -= 1. Init: trail=[s0,s1,s2], t=1 initially? Then first frame t=1 → immediately step. Eh, just init trail = [(-1 dirs)...] I'll init trail with 4 points along -x direction: head at (hx,hy), points (hx,hy),(hx-1,hy),(hx-2,hy),(hx-3,hy), and t=1 (so headPix=head cell), then first tick step occurs pushing next cell. Actually with t=1, stepTimer logic: I use stepTimer accumulates; when >= stepDur, doStep. t = stepTimer/stepDur. At init stepTimer=0 → t=0 → headPix = mix(trail[last-1], trail[last], 0) = trail[last-1] = (hx-1,hy)?? Wrong — head would appear one cell back.
OK let me just say: trail[last] = the cell head is currently moving toward; headPix between last-1 and last. Initialize trail = [(hx-2),(hx-1),(hx),(hx+1)] where head segment at (hx), moving right toward (hx+1); segments: head (hx,hy), body (hx-1), tail (hx-2), t=0. headPix = (hx,hy) at t=0.
Then segment positions: segment k (0=head) at distance k*cell behind headPix. Head at 0, body1 at 1cell, body2 at 2cell. Trail points: (hx-2)→(hx-1): distance from headPix(hx) to (hx-1) = cell ✓; sample d=cell → (hx-1) ✓.
When step occurs (t reaches 1): headPix = trail[last] = (hx+1). Then push new target (hx+2)?? Wait no — on step, we choose new dir and the new target cell is pushed. But the step happens when head ARRIVES at (hx+1). At that moment segments should be at (hx+1),(hx),(hx-1). Then new target pushed = (hx+2) (or turned). Push trail=new target. Now headPix = mix(trail[last-1]=(hx+1), trail[last]=(hx+2), t=0) = (hx+1) ✓ continuous.
Eating/collision: evaluate when head ARRIVES at a cell (at step time), based on target cell = the new trail[last]... hmm wait. Collision should be checked against the target cell BEFORE moving into it. Two options: check at step-arrival: when t hits 1 and headPix reaches target cell, if that cell is fatal → die there. So sequence at t=1: head is at target cell. If target cell = body (fatal) → dead. If target = passenger → eat. Then choose next dir + next target, push. This is clean: all cell events happen at arrival. And rendering at death: head exactly at the collision cell — good visual (train smashes into wagon).
But the AI must decide next direction at arrival too (before pushing next target). And player queue applied at arrival.
The snake growth mechanics: segments array `segs` of cells; at arrival: newHeadCell = target (already pushed conceptually)... Let me restructure: on arrival at cell C (which is trail[last] pre-push):
1. segs.unshift(C) — head now at C.
2. if C == passenger: eat (don't pop tail) else segs.pop().
3. Collision check BEFORE unshift: if C is wall (shouldn't happen — wall means we never pushed it; handle at push time) ... walls: when choosing next target, if out of bounds → death. So at arrival at C, C is always in-bounds. Body collision: check if C (in-bounds) is in segs (excluding the tail cell that will vacate if not eating). Standard:
```
willEat = (C == passengerCell)
fatal = segs.some((s,i) => s.x==C.x && s.y==C.y && !(i == segs.length-1 && !willEat))
```
If fatal → dead (before unshift — head crashes into body at boundary between cells; visually head at C... we want head to appear to crash INTO the wagon. So on death, set headPix override = mix(prevCell, C, 0.45) so it visibly rams. Hmm, actually crash occurs at t=1 when head at C exactly overlapping the wagon — that reads as a crash too, plus shake+sparks. Simple: on fatal, do unshift the head anyway (visually overlapping) and mark dead; freeze. With wobble it looks derailed. OK.)
For walls: at arrival, compute chosen dir; if next cell out of bounds → die with headPix continuing slightly past edge? Simplest: at arrival, if chosen dir leads out of bounds → dead; head visually stops at current cell with sparks at edge + shake. Slightly less satisfying than ramming the wall, but fine. Alternatively pre-check at arrival: pick dir; if OOB, dead. And AI will basically never hit walls (it checks). Player will. OK.
Hmm wait, there's subtlety: should we detect wall collision at arrival-time (when choosing next target) or at next arrival? Equivalent timing offset by one cell of motion. Doing it at choosing time = train stops one cell before wall with "derailed" — looks odd (derailed without touching anything). Better: allow pushing an OOB target?? Can't sample beyond. Alternative: at arrival, when next is OOB, set a flag `crashIntoWall`, keep interpolating head for ~40% into the wall then die. I can implement a small special: `dying = {t0, dir}` — during ~150ms, headPix moves from current cell toward wall by 0.4 cell, then dead state triggers. That's polish — worth it? It's ~10 lines. Let me do it: `pendingCrash` — during pendingCrash, head animates extra; body follows (sample behind head normally). Then dead.
Actually simpler unified approach: crash detection happens at the moment the head would ENTER a blocked cell — i.e., when t reaches 1 and target is blocked. For body-blocked, target was already pushed (in-bounds) so arrival works. For walls, treat the wall cell as a valid "target" conceptually: allow trail to contain an OOB point! Sampling works fine with OOB points (it's just math). The head will render partially outside the board — clip visually is fine (canvas clip to board? it'd overdraw the frame — actually the canvas is exactly the board; drawing outside canvas is auto-clipped). Head sticks halfway out of the board into the frame — visually "crashed off the edge" — actually that looks GREAT for a derailment: the loco plunges off the board edge! Let me do that: allow OOB target; at arrival at OOB cell → dead (don't unshift OOB into segs; or do, doesn't matter once dead). The head rendered outside canvas gets clipped at canvas edge, looks like it fell off the table edge. Charming. And body-blocked target: at arrival → dead. Unified!
But careful: AI must treat OOB cells as blocked (it checks bounds) ✓. Player queue must prevent choosing OOB? No — allow it (that's how you die by wall). But we should prevent direction reversal (target = segs[1] cell) ✓ handled at queue/choose time.
Edge: eating check at arrival; if the passenger sits... passenger never spawns OOB ✓.
Also: at arrival at C which contains passenger: eat. Also what if passenger cell == tail cell that's vacating — allowed, standard.
Grow: segs.unshift(C); if eat: keep tail, add newWagonInfo to a parallel array? Since each seg corresponds to a wagon with color/face: store objects {x,y,color,skin,hair}. New tail seg (when eating, the tail seg stays — but the NEW wagon appears at the tail... Actually in snake growth, after unshift without pop, the new visual wagon is at the old tail position — the tail cell. Its metadata: new passenger's color. But segs are ordered head-first; the tail-most seg is the newest wagon. The locomotive is segs[0] always. Wagons = segs[1..]. Each seg needs meta; when eating, after unshift+no-pop, the tail seg (last) — its meta should become the new passenger's colors? The last seg previously was a wagon with its own color... wait no: after unshift, segs length grows by 1; the last element is the same object as before (old tail). Hmm, the old tail keeps its color — but visually the train gained a wagon at the tail — which is that old tail seg. So I should assign the NEW passenger's color to the tail seg at eat time? But the old tail seg had its own color from its own passenger...
In classic snake with metadata-per-segment: it's fine to think of it as: each seg keeps its meta; growth duplicates the tail. The visual result: the new wagon appears at tail with... it should be the new passenger's color to feel causal. Easiest: on eat, set meta of last seg (tail) to new passenger's meta before/after unshift? Sequence: on eat: unshift head; then instead of pop, set segs[last] = {x,y of old tail, meta: newPassenger meta}. Wait the old tail seg object at index last — replace its meta with passenger's. But that reassigns the previous tail wagon's identity... but the previous tail wagon is still there — index last-1? No! unshift added one at front; without pop, the array is longer; the tail object is the same old tail object. There's no duplicated element. So the train visually has one more segment position (because head extended forward, everything shifted up one slot), and the tail-most segment is the old tail object with old meta. The "new" wagon in terms of positions is at the old tail's cell. So to make the new wagon show the new passenger: overwrite the tail object's meta with passenger meta. The previous "old tail wagon" now occupies position last-1, keeping... its meta is attached to the object which we overwrote. Hmm.
Correct approach: metas should be assigned to POSITIONS-relative-to-head, not objects. Standard solution: when eating, duplicate: after unshift, push a copy of the old tail (same cell, same meta) as an extra, then... let me think: classic: segs.unshift(head); if !eat pop(). For meta: think of it as queue of wagon metas where wagons are ordered from head. When we unshift a head cell, every wagon shifts forward one slot; the new empty slot at tail must be filled with a new wagon = duplicate of previous tail meta OR new passenger meta. Implementation:
- metas = array parallel, metas[0] = loco (ignore), metas[i] = wagon i meta.
- On move without eat: metas stay same (wagons keep identity as they shift slots) — since each slot shift corresponds to the same wagon... wait no! If wagons are physical objects that move with the train, each wagon advances one cell each step — the meta belongs to the physical wagon which is tied to a seg slot index that stays constant. segs[i] cell changes each step but meta[i] stays with slot i. YES: slot i = the i-th vehicle from the loco. So metas indexed by slot, constant unless eating.
- On eat: slots stay, and a NEW slot appears at the tail end: metas.push(newPassengerMeta). And segs gets one more cell (unshift without pop) — the new tail slot = index segs.length-1, meta = new passenger ✓.
- On non-eat: unshift + pop, metas unchanged ✓.
- Initial: metas length = segs.length (loco + 2 wagons initial? Start with loco + 2 wagons, meta colors random).
The rendering: vehicle i at distance i*cell behind head along trail; meta[i] gives color/face.
Self-collision check with slot model: cells occupied = segs cells; exclude tail cell when not eating ✓ (use willEat computed from passenger position vs target C).
Passenger boarding: at eat, effects at C. New passenger spawn.
Passengers plural? Classic snake has ONE food. But the theme says "passengers waiting on the platforms" plural — I could spawn 1 at a time (classic) but draw it as a platform scene. Or spawn 2-3 passengers simultaneously, any can be picked (eat the nearest), and respawn one elsewhere after pickup. Multiple passengers makes the board livelier and the scene more interesting ("passengers waiting on the platforms" plural!). With multiple targets, AI targets nearest. Snake purists: fine. Let me spawn up to 3 passengers. Hmm, but with 3, the train grows fast and board gets busy — good for demo visual richness! Each pickup +1 wagon. Spawn count: `min(3, 1 + floor(score/4))`? Keep constant 3 for liveliness. Wait, with 3 passengers always available, the platform cells occupy space; fine on a 29×16 board. Let me do 3 always (2 on small boards). Each passenger: color from palette (distinct-ish), skin, hair/hat, waiting time for bob phase.
Score: "passengers picked up". Best: session best in JS variable.
Speed: stepDur from 170ms → decreases: `stepDur = max(100, 170 - score*2.2)`. In demo maybe slightly slower start for watchability? Same.
Now visual sizing: cell ~40px desktop. Train vehicle length ~0.92*cell, width ~0.66*cell. With 40px cell: loco 37×26px — enough detail. Wagons slightly shorter: 0.84*cell.
Detail drawing per wagon (angle θ from trail tangent):
```
ctx.translate(x,y); ctx.rotate(θ);
// wheels: 4 dark rounded rects at corners outside body width? top-down wheels slightly visible: at x=±len*0.3, y=±(w/2) small rects 6×4
// body roundRect(-L/2, -W/2, L, W, r) fill color, stroke darker 2px
// roof: lighter roundRect inset (-L*0.42, -W*0.42, L*0.84, W*0.84? no—roof along center: draw a stripe: rect(-L/2+2, -3, L-4, 6) lighter — like a roof ridge running lengthwise? For top-down toy view, a center ridge stripe lengthwise reads as roof.
// windows: two small squares near front at ±W*0.28? Top-down windows odd... alternative: draw the passenger face ON the roof stripe? Toy logic: show little heads peeking — draw one small circle (face) centered near front on the ridge. Cute: heads poking out of the wagon? Like windows on the front?
```
Hmm. Let me reconsider: maybe side-view would show passengers better, but movement is top-down grid... A slightly "3/4 top-down" toy style (like Micro Machines): body from top, but with windows drawn as if slightly visible. I'll draw two window squares symmetrically placed across the center-line (left/right like side windows seen from top-down-ish): at (front-ish x, ±(W/2 - 5)) small 5×5 rounded squares in light blue with darker border; and the passenger face drawn as a small circle inside ONE window (or split heads across both windows — draw head circle in each window: skin circle with hair arc). With 40px cells, windows 7×7 with 4px faces — readable enough as "someone's inside". And gives each wagon life.
Alternatively simpler & bolder: each wagon shows a big white circular "window/roof hatch" center with the passenger's face (head + hair) — like looking down into the wagon. A single big face per wagon, 12px diameter — very readable, very charming. I'll do: center circle (light color) with face (skin circle, hair crescent, two dot eyes, smile). That's the boarded passenger visible from above in an open wagon. Plus small side vents stripes. This reads clearly even at 22px cells? Face 10px... on mobile cell 30 → wagon 25px, face ~9px: eyes 1px dots — okay-ish.
Locomotive design (θ forward = +x after rotate):
- Under-frame: dark rect slightly larger, then main body.
- Body: rounded rect L=0.95c, W=0.6c, fill #37474f (dark blue-gray), stroke #263238.
- Cowcatcher: front wedge: path from (L/2, -W/2) to (L/2+0.16c, 0) to (L/2, W/2), fill red #c94f42, rounded-ish. Plus front buffer.
- Boiler bands: 2 vertical brass lines at x = L*0.15, L*0.3 (width 2, #d9a441).
- Chimney: at x = L*0.32: dark circle r=4 with brass ring (stroke). Or a rectangle funnel top-down = circle. ✓
- Cab: rear rect from -L/2 to -L*0.22, slightly wider (W*1.06), dark green #3b5e4a? Make cab roof #2e7d5b with a border and a small ridge. Loco color scheme: slate boiler + green cab + red cowcatcher + brass accents = classic toy steam engine.
- Headlight: small yellow circle at front center + soft radial glow ellipse ahead (subtle, alpha .18 warm).
- Roof detail on cab: small vent square.
Wheels loco: 3 pairs? Keep 2 pairs like wagons, slightly larger, dark with red side rod? skip rods.
Coupling: between vehicle i and i+1: draw dark brown thick line (3px) between their centers, drawn UNDER bodies: from vehicle i center to vehicle i+1 center — since spacing = cell and bodies ~0.9 cell, gap ~0.1cell → coupling visible as short link ✓.
Rails drawing: sample points along trail from head backwards at every 6px up to (N-1)*cell + tailExtra(1.2cell fade). For sleepers: every ~cell*0.5 place a perpendicular rounded line of length 0.8cell? Sleepers should be wider than track gauge: gauge = 0.4cell between rails; sleeper length 0.62cell, width 3px, color #8a5a3b (alpha .9), rounded caps. Rails: two lines offset ±0.2cell perpendicular, color #9aa7b0 (steel), width 2, with slight darker shadow line beneath? Single pass fine. Draw order: sleepers first, then rails. Fade: alpha = smoothstep for last 1.5cell before the end (behind tail) → rails appear from nowhere behind the train (like the track is being laid & picked up — magic toy vibe). And ahead of the head? No rails ahead (train lays track as it goes) ✓ consistent with "rail appears under head".
Also the fade start: from head back: full alpha until tail, then fade over 1.5 cells behind tail (samples exist in trail since trail retains history — trim trail to needed length: keep path length up to (segs.length + 4) * cell → trim old points beyond that).
Wait — trail trimming: trail points beyond (N+3) cells behind can be discarded. Trim when trail.length > N+6: shift. Careful sampleBehind clamps.
Perf: sampling every frame: N vehicles ~ up to 60 * sample walk O(N) → 3600 ops trivial. Rails sampling: (N+1.5)*cell / 6px steps ≈ (60*40)/6 = 400 points, each with a small line — fine at 60fps? 400 sleepers?? No wait: sleeper every cell*0.5 = 20px → 60*2 = 120 sleepers, each a stroke — fine. Rails: two polylines of 400 pts each — canvas handles easily. Use one beginPath per rail with moveTo/lineTo chain ✓. For alpha fade on rails, the fade region needs separate strokes (constant alpha per stroke): split: main part (full alpha) as one polyline, fade part split into ~3 segments with decreasing alpha. Simpler: draw fade region as ~4 short strokes. OK.
Also don't draw rails under passengers' platform cells? Rails pass under platforms visually if train drives over... train can't drive onto passenger cell without eating it. But rails behind tail might cross platform cells — rails over platform looks fine actually (track passing the platform! realistic!). ✓ Rails drawn before platforms or after? If platform slab drawn after rails, slab covers rails → platform sits beside/over. Passenger platform occupying a cell where rails pass = rails hidden under slab — fine, looks like the platform edge. Either order ok. I'll draw rails, then platforms, then train.
Grass: checkerboard with 2 greens + deterministic tufts. Precompute tuft list at grid setup: for each cell, hash → maybe 1 tuft with random offset; store in array; draw each frame (N cells ≈ 464 tufts = fine but maybe cap: only ~40% cells have tufts → ~180 tiny 3-stroke tufts each frame. Could pre-render background to offscreen canvas once per resize! Yes: offscreen canvas with grass+tufts+vignette, redrawn only on resize/reset. Big perf win & allows richer static decor: flowers, little pond?? Keep grass tufts + flowers + subtle vignette + maybe some dirt patches. Also pre-draw board edge shadow.
Platforms: passenger cells redraw each frame (they animate). Platform drawing:
- slab: roundRect inset 2px, fill #d6d3c4, stroke #b5b2a0, plus a hazard-free edge line on one side (white line along the "platform edge" facing... whatever, draw a thin white stripe along one edge).
- bench: small brown rect with legs on slab corner.
- sign: circle sign on a post: blue #3f6fb5 circle w/ white "S"? Or a small totem pole with the passenger color band = telegraphs wagon color! Let me do a small standing sign: post (2×8 dark) + rounded rect sign colored = passenger's coat color with white border — "flag" of the future wagon ✓ cute + informative.
- Person: standing ON slab: legs? top-down-ish 3/4: body ellipse (coat color) 10×12, head circle skin r=4, hair arc, tiny feet? Add suitcase: small brown rect beside. Waving arm: line from body rotating ±. Bobbing: whole person y += sin(time*3 + phase)*1.5. Occasionally jump? Add "hop" every few seconds (squash & stretch scale). Blink: eyes 2 dots, close every ~3s.
- Also a soft pulsing arrow/marker above? The waving suffices. Maybe a subtle white pulsing ring under the platform to catch the eye (alpha .15 ring expanding) — subtle, ok. I'll add gentle sparkle: 2-3 tiny white "waiting" dots like "· · ·" above head in a small speech bubble? A tiny speech bubble with "!"? Keep: waving + hop + ring pulse.
When eaten: person disappears with effect: puff of white smoke particles + "+1" floating text + brief flash ring. Wagon added with their color. Also they should appear in the new wagon's face — meta ✓.
Now HUD DOM:
```
<header class="hud">
<div class="brand">
<svg logo (mini loco)> <div><h1>The Little Railway</h1><p class="tag">snake, but it's a train</p></div>
</div>
<div class="stats">
<div class="stat"><svg person> <b id="score">0</b><span>passengers</span></div>
<div class="stat"><svg wagon> <b id="wagons">2</b><span>wagons</span></div>
<div class="stat"><svg trophy><b id="best">0</b><span>best</span></div>
</div>
</header>
```
Style: dark bg, cream text; stat chips: dark green pills with border. Typography: h1 Georgia italic 22px cream; tag 11px letterspaced uppercase muted.
Board container with `flex:1; min-height:0; display:flex; align-items:center; justify-content:center; position:relative; padding: 8px`. Canvas with wood frame styling.
Overlay card `.card` absolute centered, transitions (scale/opacity). Also `.chip` top-left of board: "DEMO · auto-driving" / "YOU'RE DRIVING".
Footer: `.hint` muted: "Arrows / WASD to steer · swipe on touch · don't derail!"
Page bg: `#1c2b23` deep green + subtle radial vignette + noise via repeating-conic? Use a subtle SVG-noise data URI? That's inline data (allowed — no network). `background-image: url("data:image/svg+xml,...feTurbulence...")` with low opacity overlay. I can add a fixed pseudo-element with fractal noise data URI at 4% opacity — adds texture. Keep small.
Fonts: `--font-display: Georgia, 'Times New Roman', serif;` for title (italic). UI: `'Trebuchet MS', 'Segoe UI', Verdana, sans-serif`? Trebuchet has playful character ✓. Numbers bold.
Now the "message" requirements: "Games must start in a self-playing demo mode (a simple AI plays) behind a 'Click or press a key to start' message". So card:
```
<h2>All aboard!</h2>
<p>The little train is picking up passengers by itself right now.</p>
<button-like pill>Click or press a key to start</button-like>
<p class="keys">↑ ← ↓ → or WASD · swipe on touch</p>
```
Card should not fully block the view — it's ~340px wide centered; board visible around ✓.
Start behavior: pointerdown anywhere or keydown → if state demo: hide card, chip "YOU'RE DRIVING", toot sound, state playing, keep current train (seamless). If dead: reset & play. If playing: input = steering.
Also prevent scrolling on arrow keys / space: preventDefault on handled keys.
Touch: pointerdown records, pointermove/pointerup delta > 24px → dir. Also taps start game. Use pointer events; add `touch-action: none` on canvas/board to stop scroll gestures. Whole page fixed height anyway: `height:100dvh; overflow:hidden` ensures no scrollbars ✓ (careful with small screens: content might overflow — use clamp sizes; header shrinks).
Small screens 375: header stacks (brand row small, stats row), board fills rest. Fonts smaller via media query ✓.
Now AI details:
```
function aiDir(){
const head = trail target logic...
```
Wait — where does direction live? Current direction = from trail[last-1] to trail[last]. At arrival we choose nextTarget. Let me define at arrival (head reaches trail[last]):
```
cur = trail[last]
// choose next cell N among 4 neighbors:
// exclude reverse (neighbor == trail[last-1]) — that's the cell we came from (would be going backwards into neck)
```
Wait reverse: next target shouldn't be the cell the head currently occupies (trail[last-1] is where head just was? No: at arrival, head IS at trail[last]; trail[last-1] is the previous cell = neck cell = segs[1] typically. Choosing target=trail[last-1] = reversing into the neck → forbidden ✓.
Player queue: store requested dirs as {dx,dy}; at arrival: shift until finding one that isn't reverse (dx==-curdx && dy==-curdy) and isn't same as current; apply. Keep max 3 queue entries, dedupe consecutive.
Collision detection for target T (the cell head moves into during next step): death is evaluated AT ARRIVAL at T. Body cells at that future moment: after unshift(T) and maybe pop. If T equals segs[j] for j < len-1 (excluding current tail if it will vacate... careful: at arrival time, we first evaluate fatal BEFORE mutating:
```
const willEat = passengers.some(p => p.x==T.x && p.y==T.y) // T in-bounds
let fatal = false
for j in 0..segs.length-1:
if segs[j].x==T.x && segs[j].y==T.y:
if j == segs.length-1 && !willEat → tail vacates, ok
else fatal
```
Hmm also if willEat and T==tail cell: fatal (tail won't vacate) ✓ handled. Also T might equal segs[0]... segs[0] is head's current cell = trail[last] — T is neighbor, can't equal unless 0 cells. Fine.
Walls: T may be OOB (allowed as target). At arrival, if T OOB → dead. But careful: segs and rendering: on death by wall, head at T (partially off-board). We won't unshift. Freeze. Sparks at head.
Hmm wait, actually also need: when T is OOB, we still push it to trail so interpolation moves head toward it ✓ then at arrival → dead (before pushing next). Also passengers can't be OOB ✓.
Also death by wall: AI never picks OOB. Player can (that's their fault) ✓.
Wait, one more: what if T is in-bounds and equals the cell of the CURRENT passenger AND ALSO body? Passenger never spawns on body cells ✓ (spawn checks).
At arrival (T in-bounds, not fatal): unshift T into segs; if willEat: metas.push(meta of that passenger), remove passenger, effects, score++, spawnNew; else pop tail. Then trim trail: ensure trail length ≤ needed + margin. Then choose next T:
```
if state playing: apply queued dir if valid (not reverse into trail[last-1]... note after unshift, trail not yet changed: current cells: head at trail[last]; trail[last-1] = neck. Reverse target = trail[last-1] ✓
else (demo): aiDir()
push T to trail; t resets (t -= 1)
```
Hmm — ordering: I evaluate at arrival BEFORE pushing next target. Let me restructure the step function cleanly:
```
function step(){ // called when t >= 1, i.e., head reached trail[last]
const T = trail[trail.length-1];
// 1. Resolve arrival events at T
if (T out of bounds){ die('wall'); return; }
if (hitsBody(T)){ die('body'); return; } // head visually at T overlapping wagon
// move:
segs.unshift({x:T.x, y:T.y});
const pi = passengerAt(T);
if (pi){ eat(pi); } else { segs.pop(); }
// 2. choose next target
const from = T; const back = trail[trail.length-2];
let d = chooseDir(from, back); // player queue or AI
trail.push(from + d);
trimTrail();
}
```
For die('body'): to make head overlap the wagon, push a slight overshoot? At arrival head is exactly AT T = wagon cell — overlapping ✓ good enough. Add shake + sparks + freeze. But if fatal, should I still unshift so head stays at T? Rendering uses trail for positions, not segs — headPix = trail[last] = T ✓. segs unchanged — fine (frozen). But metas/positions of body frozen ✓.
Die('wall'): head at T (OOB, clipped at edge). Sparks at edge inside. Good.
chooseDir(from, back):
```
candidates = [ {1,0},{-1,0},{0,1},{0,-1} ] filtered:
not (back.x == from.x+d.dx && back.y == from.y+d.dy) // no reverse
not (from.x+dx==back.x ...) same thing ✓
if playing: use queue: pop entries until valid candidate or empty; fallback: current direction (from - back) — but current might be... current dir always valid as fallback? Could be reverse-blocked only. Current dir = from-back; as candidate it's not reverse ✓; could lead OOB → allowed (player drives into wall). So fallback current ✓.
if demo: AI:
score each candidate:
n = from+d
if OOB → skip
if hitsBodyFuture(n) → skip // approximate: body collision same as fatal check with willEat flag
// space via flood fill from n with body blocked:
space = flood(n, blockedSet of segs cells (excluding tail if not eating? approximate: exclude tail cell))
distScore = manhattan(n, nearestPassenger)
total = distScore + (space < segs.length+2 ? 500 : 0) + small random tiebreak + slight penalty for turning?
pick min total; if no candidate → pick any (dies) — choose current dir (dramatic crash)
```
hitsBodyFuture(n): treat like arrival check: blocked if n in segs except tail (assume not eating unless n is a passenger cell — can compute willEat for n precisely ✓).
Flood fill: BFS with Uint8Array(cols*rows), blocked = segs cells (exclude tail cell when not eating — since tail vacates when head arrives... but during BFS after arrival the tail has already popped — body is current segs post-step ✓ so no exclusion needed: segs is already post-move state.
Note AI called AFTER unshift/pop, so segs = actual current body ✓ neat.
Also AI: prefer the passenger: nearest by manhattan. Add small randomness so demo looks organic. Also avoid immediate trap: the flood fill does it. AI quality: decent. Cap BFS at, say, 400 cells (early exit) → if space >= segs.length+2, no penalty. Use visited count vs threshold min(segs.length*1.5+4, cap).
Also AI on small boards with 3 passengers — fine.
One more AI touch: at game start (fresh board), AI should also handle everything ✓ same code.
Death effects:
- sparks: 20 particles orange/yellow, gravity? top-down: just scatter & fade.
- screen shake 300ms.
- sound crash (if audio unlocked).
- card "Derailed!" after ~600ms delay (let the crash read first), with score + "Click or press a key to try again". If no input within 4.5s → back to demo (state='demo', show demo card, reset board). Reset board = fresh train/positions/score? Keep best.
Reset: score→best update; score=0; speed reset; train reinit at center-ish random; passengers respawn 3; trail reset; metas init 2 wagons random colors.
Demo should be lively immediately: start demo with train length maybe 6 (pre-fill some wagons) so screenshot shows a nice colorful train! Pre-fill: on demo start (page load), give train length 5-6 with random colored wagons + score starts... score displayed should be honest? In demo mode, HUD showing a few passengers already = richer look. I'll pre-seed demo train with 4 wagons (score 4? hmm). The demo is attract mode — starting with a longer train looks better. I'll set demo initial wagons = 5, and score displays continue from pickups (starting at 0? inconsistent with wagons count 6). Just show wagons count = metas-1 = 5, score = 0→ counts pickups this run. Fine — stats show "wagons 5", "passengers 0". Consistent enough (they boarded before you took over, story-wise!). When user takes over, keep train as-is (fun). On death & restart, reset to 2 wagons standard.
Hmm wait: with a 6-long train and small demo boards, AI has room ✓.
Also demo speed: keep same speed curve; score low → 170ms/cell — watchable ✓.
Floating "+1" texts: canvas particles list {x,y,vy,life,text}.
Toasts: for "You're driving" use the chip element change + a small pop animation on the chip instead of toast. ✓
Let me also consider a subtle "rail clickety" — audio only chug loop after start? Skip; keep ding/crash/toot.
Extra polish candidates (time-boxed):
- Vignette on board (pre-rendered) ✓
- Board frame in CSS ✓
- Rails appear with slight "shine" ✓ skip
- Smoke ✓
- Sparks on crash ✓
- Wagon faces ✓
- Waiting passengers wave ✓
- Hop animation for passenger ✓ (adds life)
- Little pond or trees as static decor? Trees would block... decorative trees on random cells could confuse gameplay (looks like obstacle). Skip obstacles; just grass/flowers.
- A station building somewhere? Passenger platforms are the stations ✓.
- Day cycle? skip.
Audio implementation:
```
let AC=null; function ac(){ if(!AC){ try{AC=new (window.AudioContext||window.webkitAudioContext)()}catch(e){} } if(AC&&AC.state==='suspended')AC.resume(); return AC; }
function tone(freq,dur,type,vol,delay=0){ const a=ac(); if(!a)return; const o=a.createOscillator(),g=a.createGain(); o.type=type;o.frequency.value=freq; g.gain.setValueAtTime(0.0001,a.currentTime+delay); g.gain.exponentialRampToValueAtTime(vol,a.currentTime+delay+0.012); g.gain.exponentialRampToValueAtTime(0.0001,a.currentTime+delay+dur); o.connect(g).connect(a.destination); o.start(a.currentTime+delay); o.stop(a.currentTime+delay+dur+0.05);}
ding(){ tone(1318,0.18,'sine',0.12); tone(1760,0.3,'sine',0.10,0.09); }
toot(){ tone(311,0.14,'square',0.06); tone(311,0.2,'square',0.06,0.18); } // two blasts
crash(){ noise burst: create bufferSource with random noise 0.3s through lowpass + tone(90,0.35,'triangle',0.2) }
```
Create AudioContext only on user gesture (pointerdown/keydown) to avoid autoplay warnings ✓.
Now canvas rendering code structure:
```
const cv = document.getElementById('cv'), ctx = cv.getContext('2d');
let cell, cols, rows, W, H, dpr;
setup(): compute grid & canvas size
prerender bg offscreen
resize handler: recompute canvas scale (cell changes) & redraw bg; keep grid (cols/rows) fixed once chosen at first setup; only scale cell. Actually if cell = W/cols exactly and W changes with container, positions scale fine since everything derives from cell ✓. On resize: recompute cell from container, set canvas size, re-render bg. Trail points are stored in CELL units (grid coords), convert to px at draw time! Yes — store trail & segs & passengers in grid coords; all drawing multiplies by cell. Then resize is trivial ✓. sampleBehind works in grid units (distance in cells). Cleaner!
```
So: trail = [{x,y}...] grid coords (centered: cell center = (x+0.5)*cell). sampleBehind(d in cells) returns grid coords (fractional). Drawing: px = (gx+0.5)*cell.
Sampling function in grid units:
```
function sampleBehind(d){ // d in cells behind head
const n = trail.length;
const ax = trail[n-1].x, ay = trail[n-1].y; // target
const bx = trail[n-2].x, by = trail[n-2].y; // from
const hx = bx + (ax-bx)*tAnim, hy = ...
// walk back
let px=hx, py=hy; let rem = d;
// first partial: distance from h to b = tAnim (in cells)
const d0 = tAnim; // cells (since consecutive cells are 1 apart — but turns: path from b to a is axis-aligned single-axis, so distance = |ax-bx|+|ay-by| = 1 ✓... unless wrap? no wrap ✓)
```
Wait consecutive trail points always differ by exactly 1 in one axis ✓ (targets are neighbor cells, even OOB ✓). So segment lengths = 1 cell each ✓
```
if (rem <= d0 + 1e-9) return {x: hx - dirx*rem?...}
```
Hmm the first partial goes from h back to b along direction (b-a): point = h + (b-h)*(rem/d0)... if d0==0, skip. Let me write generic walker:
```
function sampleBehind(d){
let need = d;
// positions from head backwards: h, then b=trail[n-2] at dist tAnim, then trail[n-3] at tAnim+1, ...
// compute current point & distance to previous anchor
const hx = trail[n-2].x + (trail[n-1].x - trail[n-2].x)*tAnim; ...
```
Let me implement iteratively:
```
let cx = hx, cy = hy; // current point
let i = n-2; // index of next anchor behind
let distToAnchor = tAnim; // distance from (hx,hy) to trail[n-2]?
```
Hmm no: distance from h to trail[n-2] = tAnim (fraction along b→a). Yes since |a-b|=1: h = b + (a-b)*t → dist(h,b) = t ✓.
```
while (i >= 0){
if (need <= distToAnchor){ const k = distToAnchor? need/distToAnchor:0; return {x: cx + (trail[i].x - cx)*k, y: ...}; }
need -= distToAnchor;
// move to anchor i; next anchor i-1, distance 1
cx = trail[i].x; cy = trail[i].y;
distToAnchor = 1; i--;
}
return {x: trail[0].x, y: trail[0].y};
```
Wait bug: after returning to anchor i, next segment is anchor i → anchor i-1, length 1 ✓. Initial: current=h, anchor = trail[n-2], dist = tAnim ✓. But at i-- we then check need <= 1 with anchor trail[i]... let me trace: i=n-2, distToAnchor=tAnim: if need<=tAnim → interp between h and trail[n-2] ✓. Else need-=tAnim; cx=trail[n-2]; distToAnchor=1; i=n-3: if need<=1 → interp between trail[n-2] and trail[n-3] ✓.
Edge: tAnim can be exactly 0 right after step: need<=0 handled (d=0 → return h with k=0) ✓ if need=0 and distToAnchor=0: k=NaN → guard: `if (need <= distToAnchor + 1e-9){ const k = distToAnchor>1e-9 ? need/distToAnchor : 0; ...}` ✓.
Angle for vehicle i: tangent = normalize(sample(i+0.25) - sample(i-0.25))? For i=0 head: use head direction = trail[n-1]-trail[n-2] (current motion dir) ✓ steady. For others: delta between sampleBehind(i*c + 0.35) and sampleBehind(i*c - 0.35)... wait ahead-sample for vehicle i is at distance i*c - 0.35 (closer to head) ✓. angle = atan2(dy,dx). Store per vehicle each frame or compute during draw loop (cheap).
Rendering loop per frame:
1. ctx.setTransform(dpr,0,0,dpr,0,0); if shake: translate random.
2. drawImage(bgCanvas)
3. drawRails()
4. drawPassengers (draw under train? Person should be visible... train passes over platform → eats → gone. Draw platforms before train ✓)
5. draw pickups' ring pulses etc.
6. drawTrain()
7. drawSmoke & particles & float texts
8. done
Update loop: stepTimer += dt; while (stepTimer >= stepDur && state != dead/frozen) { stepTimer -= stepDur; doStep(); } tAnim = stepTimer/stepDur. On doStep death: state=dead, break. Freeze tAnim at 1 (head at target) — but for wall death we want head to complete the motion INTO the wall: at death-by-wall, tAnim continues to 1 naturally (it's at 1 when death triggers — since death happens AT arrival ✓). So freeze tAnim=1. ✓ visual: head fully off-board → clipped half off.
Wait — at wall arrival, head has fully exited the board... head center at OOB cell center → e.g., 0.5-1.5 cells outside → loco fully invisible?? Bad. Let me reconsider: head at OOB cell center = one full cell beyond the edge → loco drawn there is off-canvas entirely (clipped). Hmm. So better: at wall arrival, don't die yet — push ANOTHER target continuing same dir? No... Simplest fix: die when arriving at the LAST in-bounds cell?? Then train stops before edge (weak). Alternative: die at arrival at OOB but render head clamped... ugly.
Better approach: walls kill when the head ENTERS the wall cell — i.e., trigger death at the START of the motion into OOB target, not at arrival. So: when pushing next target, if OOB → set state='dying' with a shorter fatal anim: continue interpolating until tAnim=0.45 then freeze + effects. During dying, no steering. That gives the "plunges off the edge" look, half a cell out, loco nose over the edge ✓.
Similarly for body collision: currently head arrives fully INTO the wagon cell, overlapping. That reads as crash ✓ keep arrival-based. Actually slightly nicer: also trigger at tAnim≈0.5 crossing into it? Arrival overlap + shake is fine and simpler. Keep arrival.
So: doStep():
```
const T = trail[n-1] (current target, arrived now)
if OOB(T) → shouldn't happen since we intercept earlier...
```
New flow:
- At arrival: handle events (eat / body-collision-death).
- Then chooseDir → next T.
- If next T OOB → don't push to trail... but we need trail to animate toward it. Push it, set state='crashing', crashT0. During crashing: stepTimer continues until tAnim reaches 0.45*stepDur then state=dead (freeze tAnim there via a flag: store tAnimFrozen=0.45; skip further steps). Manage: in update: if state=='crashing': advance stepTimer; tAnim=min(stepTimer/stepDur, 0.45); when reached → die(). If state=='playing'/'demo' normal.
- Also during 'crashing', AI/queue ignored ✓.
- die(): state='dead'; effects; schedule card; schedule demo-resume timeout (reset timeouts if user interacts).
Hmm also: what if next T is in-bounds but body-collision — normal, handled at next arrival ✓.
Also potential inconsistency: AI skips OOB candidates; if no candidate → picks current dir which may be OOB → crashing (AI dies dramatically, then respawn). Good fallback.
Init positions: head starts at (floor(cols*0.4), floor(rows/2)) facing right, trail extends left. Ensure enough in-bounds room ✓.
Passenger spawn: choose random cell: in-bounds, not occupied by segs, not on trail cells near head? (trail cells are segs cells roughly + recent) — just check segs cells + all passenger cells + min manhattan distance from head ≥ 4. Also avoid cells adjacent to head? distance≥4 fine. Also avoid the cell right at tail tip to prevent instant weirdness — covered by segs check.
Passenger meta: {x,y,color,skin,hair,hat?,phase, t0} — palette bright colors; skins 3 tones; hair colors (dark brown, black, blonde, gray, ginger, or hat = cap color random). Draw: if hat: cap = colored arc over head. Suitcase 50% chance.
Board bg pre-render details:
- fill base green
- per cell: alternate slightly lighter/darker rounded... checkerboard via fillRect whole cells (no rounding for seamless checker — subtle 2-tone) — classic but subtle: colors #a3c47c / #9dbc74? Let me pick fresher: base #9cbf72 and alt #a6c97c — soft sage. Rounded cell corners would create grid of dots — actually a subtle grid: fill each cell with slight inset rounded rect on alt cells only? Simplest tasteful: flat 2-tone checker + faint inner shadow vignette + tufts/flowers. ✓
- tufts: per cell deterministic hash rand: 55% cells get 1 tuft (3 tiny strokes, darker green #7fa356), 12% get flower (white petals dot + yellow center, or just 3px dot cluster). Draw with slight alpha .8.
- vignette: radial gradient transparent→rgba(35,55,30,.22) at edges.
Also maybe a faint "yard" texture: a couple of long horizontal darker green strips? skip.
Rails render function:
```
function drawRails(){
const n=segs.length; // vehicles
const railLen = n + 1.6; // cells of rail behind head
const stepPx = Math.max(4, cell*0.14); // sampling step in px
... sample along from d=0 (head) to d=railLen in cells, at px steps
```
Wait rails should start slightly AHEAD of head? Loco sits on rails: rails under head ✓ from d=0 back. Maybe extend 0.5 cell ahead of head in motion direction (track continues ahead!) — actually that's realistic: track exists ahead of train?? But "train lays track" concept says no. But a train moving on pre-laid track with the track under the whole body + fading behind is the standard "snake as train on its own track" look. I'll extend ahead by 0.4 cell so the loco's front (which is ahead of head center by ~0.45 cell... hmm head center = cell center; loco front extends ~0.48 cell ahead). So rails ahead 0.55 cell so the whole loco sits on rails ✓. Negative d sample: sampleBehind handles negative? Interp beyond head: extend forward: point = h + dir*(−d). Handle: if d<0: return {x: hx - ... } — write separate small branch: for d slightly negative, anchor forward: head + curDir*d. I'll allow d in [-0.6, railLen].
Sleeper spacing: every 0.5 cell, aligned along path? Their positions: d = -0.3, then +0.5k? Just iterate d from -0.25 step 0.5 cells; at each sample draw sleeper perpendicular (angle from local tangent via two nearby samples). Sleepers count = 2 per cell * (n+2) cells ≈ 130 for n=60. OK.
Rails: sample every ~0.25 cells; two offset polylines. Alpha fade for d > n-0.5 (behind tail, i.e., between tail and n+1.6): alpha = 1 - smoothstep((d - (n-0.3))/1.9). Split polyline into alpha bands: simpler: draw rails in ~10 chunks with per-chunk alpha (each chunk its own stroke) — 20 strokes total, negligible cost, smooth enough fade.
Tangent at d: sample (d-0.2) and (d+0.2) → angle. For d=0 use motion dir.
Colors: sleepers #8a6a45 → maybe with slight dark edge; rails: outer stroke #6d7b86 (darker) width cell*0.10? Rails gauge: ±0.19*cell, rail width cell*0.075, color #b9c4cc (light steel) with thin darker core? One stroke #9fb0bd width 0.08cell + highlight? Keep simple: dark base line width .11cell #55616c then bright line width .05cell #cfd9df on top → metal look ✓ 2 strokes per rail per chunk = manageable.
Also ballast under sleepers? skip — grass works.
drawPassengers: platform slab + person. Pulse ring: expanding circle stroke alpha decaying, period 1.6s.
drawTrain: for i from metas.length-1 (tail) down to 0: pos = sampleBehind(i (cells)); angle; drawVehicle(i). Couplings drawn first in same loop for i>0: line from pos i to pos i-1 — draw all couplings first (under all bodies): loop i=1..n-1: p1=sample(i), p0=sample(i-1), stroke line dark #3b2f27 width .14cell, round caps. Then bodies tail→head.
drawVehicle(i):
```
const m = metas[i]; // {type:'loco'} or {color, skin, hair, hat}
L = (i==0? 0.94 : 0.86)*cell, W = (i==0?0.62:0.6)*cell
translate(pos), rotate(angle)
// wheels
wheels at x=±L*0.32, y=±(W/2): fill #2a2622 roundRect(w x h = .16c x .12c) — sticking out slightly (y beyond W/2 by .06c) ✓
// body roundRect(-L/2, -W/2, L, W, r=W*0.32)
loco details / wagon details as planned
```
Wagon details:
- fill m.color; stroke darken(color, .35) lw=2
- inner border light stripe: stroke lighten? Add "planks": 2 thin lines across body at x=-L*0.15, +L*0.15 with darker alpha .25 (open wagon planks) — nice.
- roof stripe: hmm with open wagon + planks + face in middle, looks like an open carriage viewed from above ✓.
- face: circle r=.20c fill #f7f0dd (wagon interior), stroke darker; inside: head circle r=.105c skin; hair crescent (arc top) hair color; eyes 2 dots; smile arc. Face oriented up-ish (facing forward? rotate slightly toward motion? Draw face looking forward (toward +x): eyes offset +x ✓ cute — passengers face the direction of travel!
- small number plate at rear: tiny white rounded rect with number i? skip (tiny).
Loco details as planned. Headlight glow: radial gradient ellipse at front, warm, alpha .25 — maybe skip to keep crispness? Include subtle: gradient circle radius .5c at (L/2+... ) alpha .12. ok include, cheap.
Smoke: spawn when? every step (arrival) spawn 2 puffs at chimney position (sampleBehind(-0.3?) chimney is at x = +L*0.32 from head center along dir: pos = head + dir*0.3cell. velocity: slight opposite of motion + upward-drift? Top-down: wind = fixed (0.25, -0.5) cells/s + random. r grows 4→14px, life 1.4s, alpha .35→0, color #f2f4f5. Also spawn extra puff on toot? cute: when toot (game start), big puff.
Particles system: array of {x,y,vx,vy,r,life,ttl,color,type} updated with dt. Types: smoke, spark, star, puff. Float texts separate.
Text rendering: font `${12}px Trebuchet`; "+1" in white with dark stroke... use fillText with strokeText outline.
HUD updates: on eat: score++, wagons=metas.length-1; animate stat pop (CSS class re-trigger). Best updated on death.
DOM overlay management:
```
setState(s): body[data-state] → CSS shows/hides cards with transitions
cards: #demoCard (click to start), #deadCard (derailed)
chip: #modeChip text "AUTO-DRIVER" / "YOU'RE DRIVING"
```
Dead card content: "Derailed!" title, "The train picked up <b>N</b> passenger(s) this run." + best line + "Click or press a key to try again" pill. Also "New best!" badge if applicable ✓ fun.
Timing details for first paint: everything inits synchronously on DOMContentLoaded? Script at end of body → canvas sized immediately, first frame drawn within ~16ms, screenshot at 3s shows the train mid-motion with smoke, passengers waving, rails visible ✓. Demo train pre-length 6 → colorful ✓. Make sure initial tAnim=0 and first doStep happens quickly (stepDur 170ms — by 3s, ~17 steps in ✓). Maybe make initial demo speed slightly slower (200ms) for the first few steps? no, fine.
Also HUD initial: wagons 5, passengers 0, best 0 ✓.
Grid: at 1280×800: app padding 16, header ~64px, hint ~28px → board area ≈ 1248×680. cell target 42 → cols=clamp(1248/42=29.7→30, max 30)=30, rows=clamp(680/42=16.2→16, max 22)=16. cell=min(1248/30=41.6, 680/16=42.5)=41.6 → canvas 1248×666. ✓
At 375×800: padding 10, header stacked ~90px, hint 24 → board ≈ 355×670. target 30 → cols=clamp(11.8→12,min 12)=12, rows=clamp(22.3→ clamp max 22)=22. cell=min(355/12=29.6, 670/22=30.4)=29.6 → canvas 355×652 ✓ fits, no overflow. Header on mobile: brand smaller, stats wrap ✓.
But wait: grid chosen once at load; if user resizes desktop→mobile, grid 30×16 at width 355 → cell=11.8 tiny. Acceptable degradation? Add resize handling: if container aspect deviates hugely (cell < 16), re-init grid + reset game (keep state). I'll implement: on resize (debounced 150ms): recompute cell; if newCell < 15 or grid mismatch → full re-init (new grid, reset run, keep best & state; if demo, new demo). Slight jank acceptable on real resize events. Actually simpler: on resize, always re-fit cell to CURRENT grid; only rebuild grid if cell would drop below 15px. Fine.
Media queries for header text sizes; also `@media (max-width:560px)` adjustments.
Prevent horizontal scrollbar: body overflow hidden + all widths bounded ✓.
Contrast check: dark green bg #1d2b22, cream text #f3ead6 — good contrast. Board greens mid-tone with dark details — colorful wagons pop ✓.
Let me also add keyboard: Space/Enter also start/restart; P pause? Add pause on 'p'? Extra states complicate; skip pause... Actually a pause is nice but risky (screenshot at 3s: if user paused... they can't have). Skip pause to reduce bug surface. Hmm, add Escape to return to demo? skip.
Prevent default on arrows/space/wasd keydown ✓ (avoid page scroll — page has overflow hidden anyway, but iframe...).
Also handle the case: user presses key while state 'playing' → steering ✓ ignore if reverse or same as current motion dir... The queue: push {dx,dy} if queue.length<3 and last queued (or current dir) != requested and not reverse of it. Current effective dir = from trail[n-2]→trail[n-1].
Swipe: pointerdown store; pointermove: if dist>26 → dir by dominant axis; push; reset start to current (allow continuous swiping). Also pointerdown alone starts game / restarts. Note: click also fires on card — global handler covers ✓.
Edge: keydown repeat (holding key) — filter e.repeat ✓ to avoid queue spam.
Now write the AI flood fill efficiently:
```
function floodCount(sx, sy, blocked){ // blocked: Set of "x,y"
if (blocked.has(sx+','+sy)) return 0;
const cap = Math.min(cols*rows, segs.length*2+30);
const seen = new Set(); const q=[[sx,sy]]; seen.add(sx+','+sy); let c=0;
while(q.length && c<cap){ const [x,y]=q.pop(); c++;
for dirs: nx,ny in bounds && !blocked && !seen → push }
return c;
}
```
With cap, cheap. blocked = set of segs cells (post-move). AI per candidate: flood once each (≤4) → fine even at 60 steps/s? Steps are ~6/s ✓ trivial.
AI candidate scoring:
```
function aiChoose(from){
const cands = DIRS.filter(d => !(from.x+d.x===back.x && from.y+d.y===back.y)); // not reverse
let best=null, bestScore=1e9;
const target = nearestPassenger(from);
for (d of cands){
nx=from.x+d.x...; if OOB continue;
if body-blocked (with willEat exception) continue;
const space = floodCount(nx,ny);
let s = manhattan(nx,ny,target) + Math.random()*0.3;
if (space < segs.length + 2) s += 400 + (segs.length - space)*2;
// slight lookahead: also penalize if next-next options are few:
if (s<bestScore){...}
}
if(!best) return currentDir (crash);
return best;
}
```
Also add tiny penalty for turning (prefer straight by 0.15) → smoother-looking AI driving ✓.
Also the demo AI passenger targeting: nearest by manhattan ✓.
Player death→demo resume: after 4.5s in 'dead' with no input → resetRun(demo=true). Also while dead, ignore steering inputs (they restart). When user restarts: resetRun(demo=false).
resetRun(demo):
```
score=0; stepDur=170; trail/segs/metas init; startWagons = demo?5:2 → build metas: loco + startWagons random-colored
segs cells: head at hx,hy ... extend back along -x: need startWagons+1 cells to the left in-bounds: hx = max(startWagons+2, floor(cols*0.35)); ensure hx < cols-3.
trail: points from (hx - (startWagons+2)) ... to head, then target (hx+1, hy). trail length = startWagons+3+1? Let's define: vehicles count V=startWagons+1. segs = [head, ...V-1 cells to the left]. trail = [segs[V-1], ..., segs[0], {hx+1,hy}] → length V+1. tAnim=0.
passengers: spawn 3 (or 2 if cols*rows<260).
particles cleared; smoke cleared.
```
Check sampleBehind: max d = (V-1) cells; trail has V+1 points: indices: n-1 = target, n-2 = head cell. Distances: head→n-2: tAnim; then anchors... d max = V-1: with tAnim∈[0,1], walking back: needs anchors down to index... path cells behind head: head cell (dist tAnim), then segs[1] at tAnim+1, ..., segs[V-1] at tAnim+V-1. Points available: trail[0]=segs[V-1] ✓ so d up to V-1+... at tAnim=0: d=V-1 lands exactly on trail[1]=segs[V-1]? Walk: need=V-1, tAnim=0: skip first (need>0, need-=0), cx=trail[n-2]=head... hmm at tAnim=0 the first partial has distToAnchor=0 → need -= 0, cx=head cell, distToAnchor=1, i=n-3... after k steps need=V-1-k... returns interp; reaches trail[1] = segs[V-1] when need exhausted ✓. But exactly when need == distToAnchor, returns anchor ✓. What if need exceeds available (tAnim>0 and d=V-1): total available behind head = tAnim + (n-2) = tAnim + V-1 ≥ need ✓ ok since first anchor at tAnim then 1 each: anchors count n-2 = V-1 → total = tAnim + (V-1) ≥ V-1 ✓.
Rail length n+1.6 cells (V+1.6) — trail total behind head = tAnim+V-1 ≈ V < V+1.6 → sampleBehind clamps at trail[0] → rails behind tail would all pile at trail[0] point (degenerate: sleeper stack at one point, rail polyline segments zero-length — visual: a blob?). Need trail to retain MORE history than the train: after each step, don't trim to V+1; keep trail length up to V+6 (i.e., 5 extra cells of history behind the tail for the rail fade ✓). Trim: while (trail.length > segs.length + 6) trail.shift(). ✓ Then rail fade region (d from V-0.3 to V+1.6... wait tail at d=V-1; fade behind tail: d up to V-1+1.9 = V+0.9 ≤ available V + tAnim? available = tAnim + (trail.length-2) ≥ 0 + V+... trail.length = V+1+history... after trim ≤ V+6: available ≈ V+4 ✓ plenty. ✓
But careful: sampleBehind when d > available returns trail[0] (clamp) — with enough history won't happen ✓.
Also on death freeze: tAnim stays; rails still drawn ✓.
Passenger spawn constraints: not on any segs cell, not within Chebyshev distance 2 of head cell? Use manhattan ≥ 4. Also not on other passenger cells. Also not directly in front of head within 2 cells in current dir? manhattan 4 covers most; edge case: passenger 4 ahead in path — fine, that's a pickup soon ✓ good actually (demo picks up quickly → wagon growth visible in first seconds ✓). Maybe min 2 so first pickup happens within ~2s. Let me set min manhattan 3 from head. Also not in first row/col edges? in-bounds fine anywhere.
Passengers count: board cells 30×16=480 → 3 passengers. Small board 12×22=264 → 3 also fine? They occupy little space ✓ keep 3 everywhere (2 if cells<200).
Now, palette:
```
const COAT = ['#e2574c' red, '#f2b134' yellow, '#4f9dde' blue, '#59a96a' green, '#ef8354' orange, '#a06cd5' purple, '#3fb8ac' teal, '#e07a9b' pink]
SKINS = ['#f6d7b8', '#eab99a', '#c98d5f', '#9c6b45', '#7a4b2f']
HAIR = ['#3b2a20', '#6b4a2f', '#b5813f', '#d8c08a', '#8a8a8a', '#241d18']
```
Wagon outline: darken(color) via helper shade(hex, f).
shade helper: parse hex → scale.
Platform person size relative to cell: cell 42 → person body ~ 0.30c wide. r values: bodyW=0.34c? Let me draw in a local frame where cell=1 then scale ctx by cell: `ctx.scale(cell/42, cell/42)` then design in 42px units — much easier to keep proportions constant! I'll design everything in unit-cell coordinates: ctx.translate(center), ctx.scale(cell,cell)?? then lineWidths need /cell... Rather design in "u" units where 1u = cell/42: ctx.scale(u,u) with u=cell/42, then use 42-based px constants. lineWidth 2 → 2u ✓ scales with cell ✓. I'll write drawing helpers using a local coordinate system per cell in 42-units. For text: set font size in u units too.
Hmm but strokes at sub-pixel on small screens — fine with dpr scaling.
Let me now also design the passenger drawing (in 42-unit cell):
- Platform slab: roundRect(3, 6, 36, 36, 6) — slightly off-center? Center it: (2.5,2.5,37,37)? Use (3,3,36,36,7), fill #d8d4c3, stroke #b9b39c lw 1.5. Edge stripe: white line along bottom: rect(5,34.5,32,1.6) alpha .8 → looks like platform edge. Plus a few slab seams: 2 lines alpha .15 dark.
- Bench (some passengers have): small: at top-left: rect legs... maybe skip bench, add a small sign post + a tiny plant pot? Keep: sign + person + suitcase sometimes.
- Sign: post at (34, 8) height 10: rect(33,8,1.8,10) dark #5b4a38; sign board: roundRect(29.5, 3.5, 9, 7, 2) fill coat color, stroke shade, tiny white dot center. — signals wagon color ✓
- Person centered ~ (17, 24) with bob: body: rounded vertical pill: roundRect(13.5, 17, 9, 12, 4.5) coat color, stroke shade(.35) lw1.2; arms: waving arm: line from (16,20) to (16+armSwing, 13): stroke coat dark lw 2.2 round; other arm down. head: circle (18, 12.5) r 4.6 skin, stroke shade(skin,.4) lw1; hair: arc top half (r 4.8) hair color, or cap: cap = arc + tiny brim rect in a random cap color; eyes: 2 dark dots (16.8,12.3),(19.4,12.3) r .7 — blink: scaleY; smile: small arc stroke .7.
- suitcase (60%): roundRect(24, 27, 7, 5.5, 1.5) #a3702f stroke #7a4f1e + handle line.
- pulse ring under platform: circle radius (cell*(0.5 + 0.2*phase)) stroke white alpha .25*(1-phase) — around platform center.
- hop: whole-person translate y += hop offset: hop = |sin(t*?)| small bounces: y = -2.2*max(0, sin(time*2.2 + phase))^2? Use: bobbing = sin(time*4+phase)*1.2 always; wave arm: angle = sin(time*7+phase)*0.5.
Draw person AFTER platform. If train approaches within 2 cells, passenger could get "excited" (wave faster + hop bigger)? Cute detail: excitement = 1/(1+dist to head) — wave speed scales. Cheap to add: speedFactor = 1 + 1.8*max(0, 1 - dist/5). ✓ nice alive touch.
"+1" float text and star particles on pickup ✓.
Board flash on pickup: brief white ring expanding at pickup point ✓ (reuse pulse).
Ding sound on pickup ✓.
Now the crash: sparks: 26 particles from head position, colors [#ffd27a, #ff9d4d, #fff3d6], random dir, speed decel, life .7s, size 2-3.5u. Plus "clank" noise. Plus a few smoke puffs gray-dark. Screen shake amp 6px decay.
Dead card shows after 700ms (let crash breathe). Use setTimeout guarded by state check.
Also update Best on death: best = max(best, score).
Wagons stat = metas.length - 1.
One more consideration: the demo card says "Click or press a key to start" — must be visible & legible on first screenshot ✓. Card auto-positioned center — the train drives under it? Card is opaque cream — the board behind partially hidden but visible around ✓ fine. Maybe position card slightly up and keep train visible... center is fine, board is big.
Also add small "AUTO" chip on card? The card copy explains: "The auto-driver is at the controls right now." ✓
HTML structure:
```
<body data-state="demo">
<div class="app">
<header> brand + stats </header>
<main class="board">
<canvas id="cv"></canvas>
<div class="chip" id="chip">AUTO-DRIVER</div>
<div class="card" id="cardDemo"> ... </div>
<div class="card" id="cardDead"> ... </div>
</main>
<footer class="hint">...</footer>
</div>
```
Cards absolutely positioned in .board (which is relative). Canvas centered in board.
Card CSS:
```
.card{position:absolute; left:50%; top:50%; transform:translate(-50%,-50%) scale(.92); opacity:0; pointer-events:none; transition:.35s cubic-bezier(...); background:#f6eedb; border:2px solid #d9c9a4; border-radius:18px; padding:22px 28px; box-shadow: 0 20px 50px rgba(10,20,12,.5), 0 2px 0 #fff inset; text-align:center; max-width:min(90%,420px)}
body[data-state="demo"] #cardDemo{opacity:1; transform:translate(-50%,-50%) scale(1)}
body[data-state="dead"] #cardDead{...}
```
Wait during dead also want to see crash → card delay handled via JS class add after 700ms; simpler: JS toggles body[data-state] immediately at death but card transition-delay: .55s on opacity ✓ CSS-only delay: `transition: opacity .4s ease .55s` for showing. And when hiding (restart) no delay. Manageable: give #cardDead `transition-delay: .55s` on the visible state only.
Chip: top-left absolute in board: `position:absolute; top:14px; left:14px;` pill dark green bg, cream text, small; changes content on state: AUTO-DRIVER / YOU'RE DRIVING. Pop animation on change via re-adding class.
Also I want the canvas cursor: pointer? default fine.
Score stat pop animation: `.stat b.bump{animation: bump .3s}` re-trigger by removing/adding with reflow.
Fonts sizing: h1 20px Georgia italic; on mobile 17px.
The hint footer: muted small: "Arrow keys / WASD to drive · swipe on touch · pick up waiting passengers, don't derail!"
Title copy final: "The Little Railway". Tagline: "pick up every passenger · don't derail".
Card demo content:
```
<div class="card-eyebrow">DEMO RUN</div>? maybe skip
<h2>All aboard!</h2>
<p>A tiny train is picking up passengers by itself. Take the controls whenever you like.</p>
<div class="cta">Click or press a key to start</div>
<div class="keys"><span>↑</span><span>←</span><span>↓</span><span>→</span> <em>or</em> WASD <em>or</em> swipe</div>
```
CTA pill: green button-like with pulsing shadow.
Card dead:
```
<h2>Derailed!</h2>
<p class="dead-score"><b id="deadCount">7</b> passengers picked up this run.</p>
<p class="dead-best" id="deadBest">Best: 12</p> (+ "new best!" badge)
<div class="cta">Click or press a key to try again</div>
```
Edge case: state 'crashing' — body[data-state] stays 'playing' during crash (no card), then 'dead'. ✓
Update HUD during crash? frozen anyway.
Audio unlock: first pointerdown/keydown → create ctx. Start sounds only after gesture ✓ (toot when taking controls).
Now let me also double check "no horizontal scrollbar": html,body{overflow:hidden; height:100%} ✓. .app{height:100dvh; display:flex; flex-direction:column}.
Canvas rounded corners: border-radius on canvas + the drawing bg fills corners — corners of canvas are grass ✓ rounded via CSS border-radius clips? border-radius clips canvas visually? For canvas, border-radius does clip paint (overflow of element paint is clipped by border-radius? Actually border-radius on canvas element does visually clip in Chrome — yes, canvas is clipped by its border-radius like any element with background... hmm, canvas content IS clipped by border-radius+overflow? Elements' painted content is clipped by border-radius on the element itself? For replaced elements like canvas, border-radius does clip the displayed content in modern browsers? I recall canvas does get rounded-corner clipping in Chrome when border-radius applied. Yes it does.) ✓ add radius 14px + frame via box-shadow rings:
```
box-shadow: 0 0 0 5px #7c5a38, 0 0 0 8px #4c3018, 0 24px 60px rgba(0,0,0,.5);
```
Wood tones ✓.
Let me also double-check the trail/dir bookkeeping for the reverse rule when train length... reverse target = trail[n-2]... at chooseDir time (inside step after arrival), head at trail[n-1] (=from), previous cell = trail[n-2] (=back). Choosing d with from+d == back = reversing ✓ blocked. When V==1 (never; V≥3) fine.
Player queue: dirQ push filtering against last effective:
```
function queueDir(d){
const last = dirQ.length ? dirQ[dirQ.length-1] : curDir();
if (d.x===last.x && d.y===last.y) return;
if (d.x===-last.x && d.y===-last.y) return;
if (dirQ.length<2) dirQ.push(d);
}
```
curDir = trail[n-1] - trail[n-2] normalized (each ±1 in one axis) ✓.
At step: while(dirQ.length){ d=dirQ.shift(); if not reverse && not same as current: use; } if none → keep current dir (target = from + curDir) ✓.
Note: same-as-current should also be skipped (harmless either way).
Now the update/render loop details:
```
let last=performance.now();
function frame(now){
const dt = Math.min(50, now-last)/1000... in ms actually track ms
if (state==='demo'||state==='playing'||state==='crashing'){
stepTimer += dtms;
if (state==='crashing'){
if (stepTimer >= stepDur*0.45){ tAnim = 0.45(fixed); die(); }
else tAnim = stepTimer/stepDur;
} else {
let guard=0;
while (stepTimer >= stepDur && guard++<4){ stepTimer-=stepDur; step(); if(state==='dead'||state==='crashing') break; }
if(state!=='crashing') tAnim = stepTimer/stepDur; else tAnim = min(stepTimer/stepDur, .45);
}
}
updateParticles(dt); updateSmoke...
render();
requestAnimationFrame(frame);
}
```
Hmm crashing enters via step() pushing OOB target and setting state='crashing' & stepTimer=0 (so the partial motion animates). In step(), after pushing target: if OOB → state='crashing'; stepTimer=0; return. die() sets state='dead', tAnim frozen at 0.45.
Also body-crash death inside step: state='dead' directly, tAnim stays 1 (head at target cell). ✓ effects in die().
Guard while loop <4 iterations to avoid spiral (dt capped anyway).
Also when tab hidden: rAF pauses; dt capped 50ms → fine.
Let me write passenger excitement & drawing with time.
For deterministic decor: hash function:
```
function h(x,y){ let n = x*374761393 + y*668265263; n = (n^(n>>13))*1274126177; return ((n^(n>>16))>>>0)/4294967295; }
```
JS overflow: use Math.imul for 32-bit ✓.
Background prerender:
```
bg = document.createElement('canvas') sized W*dpr? To keep crisp: bg in device px matching main canvas; draw with same transform. On resize rebuild.
function renderBG(){
bgc.width = cv.width; bgc.height=cv.height; bctx.setTransform(dpr,0,0,dpr,0,0);
base fill; cells; tufts; flowers; vignette;
}
```
drawImage(bg,0,0,W,H) with ctx at dpr transform → draw with dest size W,H ✓.
Vignette: radial gradient centered, inner transparent to rgba(30,50,25,.28) at edges. Also thin inner frame line: stroke rounded rect inset 1 with rgba(0,0,0,.18)? subtle ✓.
Checker: for cells (x+y)%2 → fill alt color. Also add per-cell subtle variation: two greens + hash-based slight lighten on some cells (alpha .05 white) — enough.
Tufts: draw as 3 short curved strokes: at position, tiny arcs. Simple: lines: (0,0)→(-1.5,-4), (1,0)→(1.5,-4.5), with lw 1.2 round, color rgba(63,102,48,.55). Flowers: circle r1.6 white/#f7e9a0 + center dot #e8b93c. Sparse.
OK, and one more flourish: crossing sign / tiny trees OUTSIDE the playfield? The canvas IS the playfield edge-to-edge. Skip.
Wagon colors darkening helper:
```
function shade(hex, f){ // f<0 darken, >0 lighten
const n=parseInt(hex.slice(1),16); let r=n>>16&255,g=n>>8&255,b=n&255;
if(f<0){r*=1+f;g*=1+f;b*=1+f}else{r+=(255-r)*f;...}
return `rgb(${r|0},${g|0},${b|0})`
}
```
Time to also handle "wagons" stat on eat: metas.push({color, skin, hair, cap...}) from passenger meta ✓ and remove passenger, spawn another, effects, ding, score++, stepDur update.
stepDur: `stepDur = Math.max(105, 172 - score*2.4)`.
Let me think about whether the loco & wagons read well at 41px cell in the 1280 screenshot: loco 38×26px + details (chimney 5px, bands) — yes visible. Wagons 35×25 with 12px interior circle + face ~10px ✓.
Potential visual issue: vehicles at corners — adjacent vehicles rotate around corner with gap visible at outside of turns (couplings stretch). Coupling line drawn between centers — covers gap mostly ✓.
Another: when V large (long train), sampling fine.
What about the loco headlight: draw as small yellow circle on front + faint gradient. ✓
Let me now also think about the "signature moment": rails materializing under the moving train + smoke + colorful growing train with faces = memorable ✓. Also the derailment plunge off the board edge.
Now — sizes of code: this is a big single file (~900-1100 lines). That's fine.
Let me carefully write the code now.
Structure:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>The Little Railway — a snake game</title>
<style> ... </style>
</head>
<body data-state="demo">
<div class="app">
<header class="hud">
<div class="brand">
<svg class="logo" ...loco side view...>
<div class="brand-txt">
<h1>The Little Railway</h1>
<p>pick up waiting passengers · don't derail</p>
</div>
</div>
<div class="stats">
<div class="stat" id="statScore"><svg person/><div><b id="score">0</b><span>on board</span></div></div>
<div class="stat" id="statWagons"><svg wagon/><div><b id="wagons">5</b><span>wagons</span></div></div>
<div class="stat" id="statBest"><svg star/><div><b id="best">0</b><span>best run</span></div></div>
</div>
</header>
<main class="board" id="board">
<canvas id="cv"></canvas>
<div class="chip" id="chip">AUTO-DRIVER</div>
<div class="card" id="cardDemo">...</div>
<div class="card" id="cardDead">...</div>
</main>
<footer class="hint">Arrow keys or WASD to drive · swipe on touch · every passenger adds a wagon</footer>
</div>
<script> ... </script>
</body>
</html>
```
Logo SVG: a small side-view steam loco drawn with paths: simple: boiler rounded rect + cab + chimney + wheels circles + cowcatcher triangle, in cream/green on transparent, 40×28. Let me hand-craft:
```
<svg viewBox="0 0 48 32" class="logo">
<rect x="4" y="12" width="26" height="12" rx="4" fill="#e2574c"/> boiler? Let me draw:
```
Design side-view loco (facing right):
- body/boiler: rect x6 y10 w24 h12 r6 fill #4c6b58? boiler light
- cab: rect x26 y6 w12 h16 r3 fill #37474f, window: rect x29 y9 w6 h6 r1 fill #f6eedb
- chimney: rect x10 y4 w5 h7 r1 fill #2c354c; cap circle
- cowcatcher: path M30,26 → wedge
- wheels: circles cx 10,20,34 cy26 r4 fill #f6eedb stroke dark; center dots
- rail line: rect x0 y30 w48 h2 fill #d9c9a4
Colors: cream/dark on dark bg: use cream #f6eedb and warm red #e2574c, dark #2c354c. Simple enough; craft with a few shapes.
Person icon svg (stat): circle head + shoulders path, currentColor.
Wagon icon: rounded rect + 2 wheels.
Star/trophy: star path.
Keep icons 16-18px, stroke currentColor style for crispness. I'll hand-write small paths.
CSS plan:
```
:root{
--bg:#1c2b22; --ink:#f3ead6; --muted:#a8b8a0; --card:#f6eedb; --card-line:#d8c7a3; --accent:#e2574c; --green:#59a96a; --pill:#24382c;
--font-ui:'Trebuchet MS','Segoe UI',Verdana,sans-serif; --font-display:Georgia,'Times New Roman',serif;
}
*{box-sizing:border-box; margin:0; padding:0}
html,body{height:100%}
body{background:...; color:var(--ink); font-family:var(--font-ui); overflow:hidden}
.app{height:100dvh; display:flex; flex-direction:column; padding:14px 18px 10px; gap:10px; max-width:1500px; margin:0 auto}
header .brand{display:flex; gap:12px; align-items:center}
h1{font-family:var(--font-display); font-style:italic; font-weight:700; font-size:clamp(18px,2.2vw,24px); letter-spacing:.2px}
.brand p{font-size:clamp(10px,1.2vw,12px); color:var(--muted); letter-spacing:.14em; text-transform:uppercase}
.stats{display:flex; gap:8px; margin-left:auto}
.stat{display:flex; align-items:center; gap:8px; background:var(--pill); border:1px solid #35513f; border-radius:12px; padding:7px 12px; min-width:86px}
.stat svg{width:17px;height:17px; color:var(--accent)...}
.stat b{font-size:17px; line-height:1; font-variant-numeric:tabular-nums}
.stat span{display:block; font-size:9.5px; color:var(--muted); text-transform:uppercase; letter-spacing:.1em; margin-top:2px}
.board{flex:1; min-height:0; position:relative; display:flex; align-items:center; justify-content:center}
canvas{display:block; border-radius:14px; box-shadow: rings; touch-action:none}
.chip{...}
.card{...}
.cta{display:inline-block; background:var(--green); color:#12241a? no — text dark: #10281b; font-weight:700; padding:10px 18px; border-radius:999px; box-shadow: 0 4px 0 #3c7a4d; animation: pulse 1.8s infinite}
.hint{ text-align:center; color:var(--muted); font-size:12px; letter-spacing:.04em; padding-bottom:2px}
```
CTA pulse: `@keyframes ctaPulse{0%,100%{transform:translateY(0)}50%{transform:translateY(-2px)}}` + shadow — subtle.
Background pattern: body::before with data-URI noise:
```
body::before{content:""; position:fixed; inset:0; background-image:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120"><filter id="n"><feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2"/></filter><rect width="120" height="120" filter="url(%23n)" opacity="0.05"/></svg>'); pointer-events:none; z-index:0}
```
Careful URL-encoding: use encodeURIComponent-safe string manually: quotes → use single quotes inside, encode # as %23 ✓. And radial vignette overlay: body::after with radial-gradient transparent→rgba(0,0,0,.35) fixed. z-index: app content z-index 1 relative.
Media query <600px: hide brand tagline? shrink stats (span hidden?), padding smaller, .stat min-width auto, font smaller. Header wraps: flex-wrap. Ensure total height fits.
Actually on short screens (mobile landscape 375 wide?) — landscape phones rare; ignore gracefully (overflow hidden could clip)... board flex-1 min-height 0 handles.
Now the JS. Let me write it carefully section by section.
```js
(() => {
'use strict';
// ---------- helpers
const $ = s => document.querySelector(s);
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const lerp=(a,b,t)=>a+(b-a)*t;
const rnd=(a=1,b)=> b===undefined? Math.random()*a : a+Math.random()*(b-a);
const pick=arr=>arr[(Math.random()*arr.length)|0];
const hash2=(x,y)=>{ let n=Math.imul(x,374761393)+Math.imul(y,668265263); n=Math.imul(n^(n>>>13),1274126177); return ((n^(n>>>16))>>>0)/4294967296; };
function shade(hex,f){...}
// ---------- constants
const COAT=['#e2574c','#f2b134','#4f9dde','#59a96a','#ef8354','#a06cd5','#3fb8ac','#e07a9b'];
const SKIN=['#f6d7b8','#eab695','#c98d5f','#9c6b45','#7a4b2f'];
const HAIR=['#3b2a20','#6b4a2f','#b5813f','#d8c08a','#8d8d94','#241d18'];
const CAPS=['#37474f','#e2574c','#4f9dde','#f2b134','#ffffff'];
const DIRS=[{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
// ---------- canvas / grid
const cv=$('#cv'), ctx=cv.getContext('2d');
const bgCv=document.createElement('canvas'), bgx=bgCv.getContext('2d');
let cell=40, cols=26, rows=16, W=0,H=0, dpr=1;
function fitCanvas(){
const r=$('#board').getBoundingClientRect();
...
}
```
Grid init: choose at boot:
```
function chooseGrid(){
const r=board.getBoundingClientRect();
const target = r.width<600?30:42;
cols = clamp(Math.round(r.width/target), 12, 30);
rows = clamp(Math.round(r.height/target), 12, 22);
}
function fitCanvas(){
const r=board.getBoundingClientRect();
cell = Math.min(r.width/cols, r.height/rows);
W=cell*cols; H=cell*rows;
dpr=Math.min(2, window.devicePixelRatio||1);
cv.style.width=W+'px'; cv.style.height=H+'px';
cv.width=Math.round(W*dpr); cv.height=Math.round(H*dpr);
renderBG();
}
```
On resize:
```
window.addEventListener('resize', ()=>{ clearTimeout(rt); rt=setTimeout(()=>{
const r=board.getBoundingClientRect();
if (Math.min(r.width/cols, r.height/rows) < 14){ chooseGrid(); resetRun(state==='demo'); } // rebuild
fitCanvas();
},150);});
```
Hmm if grid rebuilt, resetRun keep demo. If playing and resize slightly, just refit ✓. If user is mid-play and rotates phone → reset (acceptable).
But careful: chooseGrid at boot requires layout done: script at end of body, board has flex size after CSS applied — should be laid out; call after a rAF? getBoundingClientRect at script exec time (after DOM parse, styles applied) works since CSS in head ✓. But dvh etc fine.
Edge: at boot board height could be 0 if... flex column with header/footer natural, board flex:1 min-height:0 ✓ has size.
// ---------- state
```
let state='demo'; // demo | playing | crashing | dead
let segs=[], metas=[], trail=[], tAnim=0, stepTimer=0, stepDur=170, score=0, best=0;
let passengers=[], particles=[], floats=[], smoke=[];
let dirQ=[];
let shake=0; // ms remaining
let time=0;
let deadTimer=null, demoTimer=null;
```
resetRun(demo):
```
function resetRun(demo){
dirQ.length=0; particles.length=0; smoke.length=0; floats.length=0;
score=0; updateStepDur();
const startW = demo?5:2; // wagons
const V=startW+1;
const hy=Math.floor(rows/2);
const hx=clamp(Math.floor(cols*0.35), V+2, cols-V-2);
segs=[]; for(let i=0;i<V;i++) segs.push({x:hx-i, y:hy});
metas=[{loco:true}];
for(let i=0;i<startW;i++) metas.push(newMeta());
trail=segs.map(s=>({x:s.x,y:s.y})).reverse(); // oldest..head? segs[0]=head; map gives head..tail; reverse → tail..head ✓
trail.push({x:hx+1,y:hy}); // target
tAnim=0; stepTimer=0;
passengers=[]; const np = cols*rows<220?2:3;
for(let i=0;i<np;i++) spawnPassenger();
setState(demo?'demo':'playing');
syncHUD();
}
```
Wait trail = reversed segs = [tail,...,head] then push target ✓ order oldest→newest ✓ matches sampleBehind assumption (trail[n-1]=target) ✓.
newMeta(): {color:pick(COAT), skin:pick(SKIN), hair:pick(HAIR), cap: Math.random()<0.3?pick(CAPS):null}
spawnPassenger():
```
function spawnPassenger(){
for(let tries=0;tries<300;tries++){
const x=(Math.random()*cols)|0, y=(Math.random()*rows)|0;
if (Math.abs(x-segs[0].x)+Math.abs(y-segs[0].y) < 4) continue;
if (segs.some(s=>s.x===x&&s.y===y)) continue;
if (passengers.some(p=>p.x===x&&p.y===y)) continue;
passengers.push({x,y, ...newMeta(), suit: Math.random()<0.6, phase:rnd(6.28), born:time});
return;
}
}
```
step():
```
function step(){
const n=trail.length;
const T={x:trail[n-1].x, y:trail[n-1].y};
// arrived at T (in-bounds guaranteed? T might be OOB if we allowed pushing it — we don't push OOB; crashing handles walls)
```
Wait I changed flow: OOB target never pushed; instead state='crashing' animates... but crashing needs trail target to animate toward! Let me re-examine: at chooseDir, if chosen target OOB: set crash state WITHOUT pushing: the head would continue moving toward... trail[n-1] is where head already arrived. To animate a partial lunge toward the OOB cell, I need the target in trail. So push the OOB point to trail, set state='crashing', stepTimer=0, and tAnim goes 0→0.45, then die() with tAnim frozen 0.45. Then step() is never called while crashing (guarded). At die, trail has the OOB target with tAnim=0.45 ✓ head partially out ✓. And segs untouched ✓. Also must ensure sampleBehind works with tAnim=0.45 (fine) and rails drawing extends 0.55 ahead of head — into the wall — hmm rails ahead of head pointing off-board: draws rails off-canvas, clipped ✓ fine (looks like broken track at edge).
So chooseDir returns d; T2 = {x:from.x+d.x, y:from.y+d.y}; if OOB(T2) { trail.push(T2); state='crashing'; stepTimer=0; return; } else trail.push(T2).
And in step(): T = trail[n-1] guaranteed in-bounds ✓ (since only in-bounds pushed in normal flow).
```
// body collision at T
const eat = passengers.findIndex(p=>p.x===T.x&&p.y===T.y);
const willGrow = eat>=0;
let fatal=false;
for(let i=0;i<segs.length;i++){
if(segs[i].x===T.x&&segs[i].y===T.y){ if(!(i===segs.length-1 && !willGrow)) fatal=true; }
}
if(fatal){ tAnim=1; die(); return; }
// move
segs.unshift({x:T.x,y:T.y});
if(willGrow>=0...
```
```
if(eat>=0){ collect(passengers[eat]); passengers.splice(eat,1); spawnPassenger(); }
else segs.pop();
// trim trail history
while(trail.length > segs.length + 7) trail.shift();
// choose next
const from={x:trail[trail.length-1].x, y:trail[trail.length-1].y};
const back={x:trail[trail.length-2].x, y:trail[trail.length-2].y};
let d;
if(state==='playing'){ d=playerDir(from,back); } else { d=aiDir(from,back); }
const T2={x:from.x+d.x, y:from.y+d.y};
if(T2.x<0||T2.y<0||T2.x>=cols||T2.y>=rows){ trail.push(T2); state='crashing'; stepTimer=0; return; }
trail.push(T2);
}
```
playerDir:
```
function playerDir(from,back){
const cur={x:Math.sign(from.x-back.x), y:Math.sign(from.y-back.y)}; // one of ±1 axis... from-back is ±1 in one axis ✓
while(dirQ.length){
const d=dirQ.shift();
const rev = (d.x===-cur.x && d.y===-cur.y) || (d.x===cur.x&&d.y===cur.y);
if(!rev && !(d.x===back.x-from.x && d.y===back.y-from.y)) ...
```
Hmm reverse means target = back cell: d such that from+d == back → d = back-from. Simpler check: `if (from.x+d.x===back.x && from.y+d.y===back.y) continue;` → skip queued dir that reverses. Also skip d same as current (no-op but harmless — skip to consume). Return first valid; fallback cur.
```
while(dirQ.length){
const d=dirQ.shift();
if(from.x+d.x===back.x && from.y+d.y===back.y) continue;
if(d.x===cur.x&&d.y===cur.y) continue;
return d;
}
return cur;
}
```
Also dirQ entries might become stale-ish; fine.
aiDir:
```
function aiDir(from,back){
const cur={x:Math.sign(from.x-back.x),y:Math.sign(from.y-back.y)};
let target=null,td=1e9;
for(const p of passengers){const d=Math.abs(p.x-from.x)+Math.abs(p.y-from.y); if(d<td){td=d;target=p;}}
let bestD=cur,bestS=1e9,any=false;
for(const d of DIRS){
if(from.x+d.x===back.x&&from.y+d.y===back.y) continue; // reverse
const nx=from.x+d.x, ny=from.y+d.y;
if(nx<0||ny<0||nx>=cols||ny>=rows) continue;
// blocked?
const willGrow = passengers.some(p=>p.x===nx&&p.y===ny);
let blocked=false;
for(let i=0;i<segs.length;i++){ const s=segs[i]; if(s.x===nx&&s.y===ny){ if(!(i===segs.length-1&&!willGrow)) blocked=true; } }
if(blocked) continue;
const space=flood(nx,ny);
let s=Math.abs(nx-target.x)+Math.abs(ny-target.y) + rnd(0,0.4);
if(space < segs.length+3) s += 300+(segs.length+3-space);
if(d.x===cur.x&&d.y===cur.y) s-=0.25; // prefer straight
if(s<bestS){bestS=s;bestD=d;any=true;}
}
return bestD; // if !any → cur → likely OOB → crashing (dramatic) — but cur may be blocked?? then next arrival fatal; ok
}
```
Wait if !any and cur points to blocked body cell, head proceeds → fatal at arrival ✓ fine.
flood:
```
function flood(sx,sy){
const blocked=new Set(segs.map(s=>s.x+','+s.y));
if(blocked.has(sx+','+sy)) return 0;
const cap=Math.min(cols*rows, segs.length*2+40);
const seen=new Uint8Array(cols*rows); seen[sy*cols+sx]=1;
const qx=[sx,sy]; let c=0, qi=0;
```
Use array queue with index:
```
const q=[sx,sy]; let head=0,count=0;
while(head<q.length && count<cap){
const x=q[head++], y=q[head++]; // store pairs
```
Hmm store pairs flat: q=[sx,sy]; head reads two. Let me:
```
const q=[sx,sy]; let i=0,count=0;
while(i<q.length && count<cap){
const x=q[i++], y=q[i++];
count++;
for(const d of DIRS){
const nx=x+d.x, ny=y+d.y;
if(nx<0||ny<0||nx>=cols||ny>=rows) continue;
const k=ny*cols+nx;
if(seen[k]) continue;
const key=nx+','+ny; // string keys slow-ish but fine
if(blocked.has(key)) continue;
seen[k]=1; q.push(nx,ny);
}
}
return count;
```
blocked as Set of ints: blocked = new Set(segs.map(s=>s.y*cols+s.x)) ✓ use int keys.
collect(p):
```
function collect(p){
const m={color:p.color,skin:p.skin,hair:p.hair,cap:p.cap};
metas.push(m);
score++;
best=Math.max(best,score); // live best update — nice
updateStepDur(); syncHUD(true);
// fx
const cx=(p.x+0.5)*cell, cy=(p.y+0.5)*cell;
for(let i=0;i<10;i++) particles.push(spark-ish star: {x:cx,y:cy, vx:rnd(-60,60),vy:rnd(-80,-10), r:rnd(1.5,3), ttl:rnd(.5,.9), life:0, color: pick(['#fff','#ffe9a8','#ffd27a']), type:'star'});
floats.push({x:cx,y:cy,vy:-38,ttl:0.9,life:0,text:'+1'});
rings.push? use particles type 'ring': {x:cx,y:cy,r0:cell*0.2, ttl:.5,life:0,type:'ring',color:'#fff'}
ding();
}
```
Wait — best update live: best = max(best, score) at collect ✓ simpler than at death.
updateStepDur: `stepDur = Math.max(105, 172 - score*2.4);`
die():
```
function die(){
state='dead'; tAnim= state==='crashing'? 0.45 : tAnim; // crashing sets .45 before calling...
```
Handle: die called from two places: (a) fatal body at arrival (tAnim=1 — head at T cell) — but wait, at fatal we didn't move segs; head visually at T (overlapping the wagon) ✓; (b) from crashing handler with tAnim=0.45.
```
shake=420;
const hp=headPix();
for(let i=0;i<26;i++) particles.push({x:hp.x,y:hp.y,vx:rnd(-160,160),vy:rnd(-160,160),r:rnd(1.5,3.2),ttl:rnd(.4,.8),life:0,color:pick(['#ffd27a','#ff9d4d','#fff3d6','#8a8a8a']),type:'spark',drag:3});
crashSound();
setState('dead');
$('#deadCount').textContent=score;
$('#deadBest').textContent='Best run: '+best;
newBestBadge if score===best&&score>0 → show
clearTimeout(demoTimer); demoTimer=setTimeout(()=>{ if(state==='dead') resetRun(true); }, 5200);
}
```
Wait for (b) crash into wall: also spawn some smoke-dark puffs.
Also update chip text.
setState(s): body.dataset.state=s; chip text: demo→'AUTO-DRIVER', playing/crashing→'YOU'RE DRIVING', dead→'DERAILED'. Chip pop animation: chip.classList.remove('pop'); void chip.offsetWidth; add 'pop'.
Input handlers:
```
function anyInput(){
unlockAudio();
if(state==='demo'){ setState('playing'); toot(); big smoke puff; return true; }
if(state==='dead'){ clearTimeout(demoTimer); resetRun(false); toot(); return true; }
return false;
}
window.addEventListener('keydown', e=>{
const k=e.key;
let d=null;
if(k==='ArrowUp'||k==='w'||k==='W')d={x:0,y:-1};
else if(k==='ArrowDown'||k==='s'||k==='S')d={x:0,y:1};
else if(k==='ArrowLeft'||k==='a'||k==='A')d={x:-1,y:0};
else if(k==='ArrowRight'||k==='d'||k==='D')d={x:1,y:0};
if(d||k===' '||k==='Enter') e.preventDefault();
if(e.repeat) return;
if(!d){ if(k===' '||k==='Enter'){ anyInput(); } return; }
if(anyInput()) return; // just started/restarted; swallow first
queueDir(d);
});
```
Hmm: when demo → user presses arrow: anyInput() takes controls AND we might also want to steer with the same press — taking controls is enough; the arrow consumed. Fine.
Pointer:
```
let pd=null;
const board=$('#board');
window.addEventListener('pointerdown', e=>{ unlockAudio(); if(anyInput()) {pd=null; return;} pd={x:e.clientX,y:e.clientY}; });
window.addEventListener('pointermove', e=>{
if(!pd||state!=='playing') return;
const dx=e.clientX-pd.x, dy=e.clientY-pd.y;
if(Math.hypot(dx,dy)>26){
const d=Math.abs(dx)>Math.abs(dy)?{x:Math.sign(dx),y:0}:{x:0,y:Math.sign(dy)};
queueDir(d); pd={x:e.clientX,y:e.clientY};
}
});
window.addEventListener('pointerup', ()=>pd=null);
```
Note: after restart via tap, pd=null so no accidental steer ✓.
Careful: anyInput() on pointerdown while playing returns false → pd set → swipe steers ✓. But tapping canvas while playing = nothing (fine).
Rendering. headPix():
```
function headPix(){ // in grid coords (fractional)
const n=trail.length;
return {x:lerp(trail[n-2].x, trail[n-1].x, tAnim), y:lerp(trail[n-2].y, trail[n-1].y, tAnim)};
}
function curDirVec(){ const n=trail.length; return {x:Math.sign(trail[n-1].x-trail[n-2].x), y:Math.sign(trail[n-1].y-trail[n-2].y)}; }
```
Hmm careful with sign for diagonal — never diagonal ✓.
sampleBehind(d) as derived. For negative d (ahead of head):
```
function sampleBehind(d){
if(d<=-0.0){ // ahead of head along current dir
const h=headPix(); const c=curDirVec();
return {x:h.x+c.x*(-d), y:h.y+c.y*(-d)}; // d negative → -d positive ahead
```
wait ahead = h + c*|d| → with d<0: h + c*(-d) ✓.
Then normal walk for d>0 as designed. d=0 → return headPix ✓ (branch: if(d<0)... else walk; walk with need=0 → first check need<=distToAnchor: k=0 → returns h ✓ but distToAnchor=0 edge → guard).
```
const n=trail.length;
const h=headPix();
let need=d, cx=h.x, cy=h.y, i=n-2, distA=tAnim;
while(i>=0){
if(need<=distA+1e-6){ const k=distA>1e-6?need/distA:0; return {x:cx+(trail[i].x-cx)*k, y:cy+(trail[i].y-cy)*k}; }
need-=distA; cx=trail[i].x; cy=trail[i].y; distA=1; i--;
}
return {x:trail[0].x, y:trail[0].y};
}
```
Trace: n≥2 always ✓ (trail min length V+1≥4).
angleAt(d):
```
function angleAt(d){
const a=sampleBehind(d-0.22), b=sampleBehind(d+0.22);
return Math.atan2(b.y-a.y, b.x-a.x);
}
```
For d=0 (loco): a=sampleBehind(-0.22) ahead ✓ works.
But per-vehicle angle stability: near corners angle interpolates smoothly ✓.
drawRails():
```
function drawRails(){
const V=segs.length;
const tailD=V-1;
const total=tailD+1.9; // fade fully by tailD+1.9
const ga=0.19*cell; // gauge offset (±)
// sample pts
const stepU=0.22; // cells
const pts=[];
for(let d=-0.55; d<=total; d+=stepU) pts.push({d, p:sampleBehind(d)});
...
```
Hmm sampleBehind per point cost O(V) → total samples (V+2.5)/0.22 ≈ 12V... V=60 → 700 samples × walk up to V... worst-case O(V²)=3600 samples*... each walk step cheap; 700 points avg walk ~V/2=30 anchors → 21k ops/frame — fine.
Actually I realize I can precompute cumulative: build array of vehicle positions once per frame (needed for train too) — and rails sample via same walk. Simpler: just call sampleBehind; fine perf-wise (60fps desktop easily; mobile mid ok). Let me optimize slightly: cache per-frame the interpolation into an array of anchor points with cumulative distances, then sample O(1) via index math! Since anchors are exactly 1 cell apart (except first partial), I can sample directly:
```
// per frame: hp = headPix(); anchor list: A0=hp (dist 0), A1=trail[n-2] (dist tAnim), A2=trail[n-3] (dist tAnim+1)...
function framePathPoint(d){
if(d<0){...}
if(d<=tAnim) return lerp hp→trail[n-2] by d/tAnim;
const k=d-tAnim; const idx=Math.floor(k); const frac=k-idx; // anchor trail[n-2-idx] → trail[n-3-idx]
... need bounds guard
}
```
This is O(1) ✓. Implement carefully:
- d in (tAnim, tAnim + m] where m = number of full 1-cell segments available = (n-2) (anchors trail[n-2] ... trail[0]: segments between consecutive: (n-2) segments of length 1: from trail[n-2] to trail[n-3], ..., trail[1] to trail[0]).
- k=d-tAnim; idx=floor(k); frac=k-idx; base index in trail: bi = n-2-idx; point = lerp(trail[bi], trail[bi-1], frac); guard bi-1>=0 else clamp trail[0].
Good — O(1) sampling. I'll write `pathPoint(d)` handling d<0 via curDir extension, and clamp beyond end to trail[0].
Now rails drawing with pts array (each with x,y grid → px):
```
ctx.save();
// sleepers
const sl=[]; for(let d=-0.28; d<=total; d+=0.5) sl.push({p:pathPoint(d), a:angleAtD(d)});
for each sleeper: alpha fade by d: a=1 for d<tailD+0.2, fade to 0 at total: alpha = clamp(1-(d-(tailD+0.2))/1.7,0,1)
draw: translate px, rotate a; fillRect? roundRect(-1.5, -0.31*cell, 3, 0.62*cell) hmm sleeper dims: width along track 3px→ in cell units: 0.09cell; length 0.62cell.
Actually sleeper: ctx.rotate(a); fillRoundRect(-0.045*cell? ... use u=cell/42: sleeper w=3u? Let me use u: w=3u? sleeper thickness along travel ~3.5u, length 26u.
```
I'll define `u = cell/42` globally updated in fitCanvas, and draw rail detail in 42-space per point: translate(px,py) rotate(a) then roundRect(-1.75*u? use u multiplications.
Sleeper count: (total+0.28)/0.5 ≈ V+4 → ~64 for V=60 → each a rounded rect fill — fine.
Rails: two polylines. Build with alpha chunks: split d-range into segments of 0.55 cells; for each chunk stroke both offsets with alpha. Chunks count ≈ (V+2.5)/0.55 ≈ 2V → 120 chunks × 2 strokes = 240 strokes — okay. Or simpler: stroke full-alpha for d in [-0.55, tailD+0.2] as ONE polyline pair (using pts filtered), then fade region as ~5 short strokes with descending alpha. Points every 0.22 cells. Let me implement:
```
const railPts = pts.map(...) // px pts with angle for offset
offset point = p ± normal*ga where normal = (−sin a, cos a)
```
Draw rail pair: path for left rail: moveTo first, lineTo along; stroke with base dark then light thin. Same for right. Base pass: color '#5b6a75' lw .12cell; light pass '#c7d3da' lw .05cell. Hmm two passes per side = 4 strokes for full + fade chunks ×4 each... fine.
Fade: for last region from d0=tailD+0.2 to total: chunks of ~0.38 cells with alpha lerp 1→0.
Let me simplify implementation: helper `strokeRailPair(ptsSlice, alpha)`.
Since I'm hand-managing, keep code tidy:
```
function drawRails(){
const V=segs.length, tailD=V-1;
const dMin=-0.55, dMax=tailD+1.9, step=0.22;
// build samples
const S=[];
for(let d=dMin; ; d+=step){ const dd=Math.min(d,dMax); S.push({d:dd, x:.., y:.., a:..}); if(dd>=dMax)break; }
const fadeStart=tailD+0.15;
const px=p=>({x:(p.x+0.5)*cell, y:(p.y+0.5)*cell});
// sleepers
for(let i=0;i<S.length;i++){ const s=S[i]; if(i%2) continue?? sleeper every 0.5 → S step 0.22... just separate loop for sleepers with its own sampling every 0.5
```
Cleaner: separate loops with own stepping (sleepers every 0.52 cells starting -0.3; rails sampling every 0.22).
Sleeper alpha: `al = s.d>fadeStart ? max(0,1-(s.d-fadeStart)/1.75) : 1`.
For rails polyline with fade, chunking: iterate S; accumulate points; whenever alpha bucket changes (quantize alpha to 0.2 steps: q=round(al*5)/5), stroke accumulated chunk and start new. Implement generic:
```
function railPath(slice){ // slice of sample pts
for(side of [-1,1]) build path: for each s: nx = s.x... offset by normal*ga in px: ox=(s.x+0.5)*cell + Math.cos(s.a+PI/2)*ga*side...
```
normal angle = a+90°. offset = ga*side.
Then stroke twice (dark wide, light narrow). Per chunk. Chunks with alpha 0 skip.
Good enough; ~ (samples/stepPerChunk) fine.
drawPassengers():
```
function drawPassengers(){
for(const p of passengers) drawPassenger(p);
}
function drawPassenger(p){
const px=(p.x+0.5)*cell, py=(p.y+0.5)*cell;
const u=cell/42;
ctx.save(); ctx.translate(px,py); ctx.scale(u,u);
// slab
roundRect(ctx,3,3,36,36,7); fill '#dad6c6'; stroke '#b7b19a' lw 1.5
// seams
ctx.globalAlpha=.12; lines...
// edge stripe
white rect(6,35,30,1.8) alpha .7
// pulse ring (before slab? under): draw ring around slab: circle r=26+6*ph stroke white alpha .25(1-ph)
// sign
...
// person with bob & wave
const dist=Math.abs(p.x-segs[0].x)+Math.abs(p.y-segs[0].y);
const exc = clamp(1.6-dist*0.18, 0.5, 1.9); // closer → bigger
const t=time*(3+3*exc)+p.phase;
const bob=Math.sin(t*2)*1.2*exc;
const wave=Math.sin(t*3.2);
ctx.translate(0,bob);
... person draw as planned
ctx.restore();
}
```
Note faces of passenger: use p.skin/p.hair/p.cap.
drawTrain():
```
function drawTrain(){
const V=metas.length; // vehicles = segs length should equal metas length — keep synced: segs.length===metas.length? segs holds V cells; metas V. On grow: segs.unshift+no-pop AND metas.push ✓ both +1. On move: unshift+pop, metas unchanged ✓ lengths stay equal ✓. Initial equal ✓.
// couplings
ctx.strokeStyle='#4a3527'; lw=0.13*cell; lineCap round
for(i=1;i<V;i++){ p0=pathPoint((i-1)*? — vehicle i at d=i? WAIT: vehicle 0 (loco) at d=0; vehicle i at d=i. tail at V-1. Let me re-verify with segs: head cell = trail[n-2] region... vehicle i center should sit at the cell of segs[i] when tAnim=1... at tAnim=1: pathPoint(i) = ? tAnim=1: d=i: i>1: k=i-1, idx=i-1... base=trail[n-2-(i-1)] = trail[n-1-i]... hmm at tAnim=1, head at trail[n-1]=T(newest cell = segs[0] after move). vehicle 1 should be at previous head cell = trail[n-2]. pathPoint(1) with tAnim=1: d=1>1? d<=tAnim → 1<=1 → lerp(hp→trail[n-2], 1/1) = trail[n-2] ✓. pathPoint(2): k=1, idx=1, bi=n-3: lerp(trail[n-3],trail[n-4],0)=trail[n-3] ✓ = segs[2] ✓ (segs[2] = two behind previous head... segs after move: [T, oldHead, old1, old2...] where oldHead=trail[n-2], old1=trail[n-3] ✓).
```
✓ vehicle i at pathPoint(i), i in [0, V-1].
At tAnim=0 (right after step): head = trail[n-2] ✓ pathPoint(0)=hp ✓; pathPoint(1): d=1>tAnim=0: k=1,idx=1,bi=n-3: lerp(trail[n-3],trail[n-4],0)=trail[n-3] ✓ = segs[1] ✓ .
Couplings between i and i+1 centers: draw line pathPoint(i)→pathPoint(i+1) ✓ but only the gap shows (bodies cover rest) — draw couplings BEFORE bodies ✓.
Bodies: for i=V-1 down to 0: pos=pathPoint(i), a=angleAt(i): drawVehicle(i).
drawVehicle(i):
```
function drawVehicle(i){
const m=metas[i];
const p=pathPoint(i), a=angleAt(i);
const u=cell/42;
ctx.save(); ctx.translate((p.x+0.5)*cell,(p.y+0.5)*cell); ctx.rotate(a); ctx.scale(u,u);
if(i===0) drawLoco(); else drawWagon(m,i);
ctx.restore();
}
```
Wheels helper:
```
function wheels(L,Wd,rad){
ctx.fillStyle='#2e2a26';
for(const sx of [-1,1]) for(const sy of [-1,1]) roundRect(ctx, sx*L*0.30-rad, sy*(Wd/2+2)-rad*0.8, rad*2, rad*1.6, 2); fill
```
Hmm wheels visible from top: small rects protruding sideways: y from ±Wd/2 to ±(Wd/2+3.5). rect at x = ±L*0.3. Fine:
```
function drawWheels(L,Wd){
ctx.fillStyle='#2b2723';
const wl=7,ww=3.4;
for(const sx of[-1,1]) for(const sy of[-1,1]){
roundRect(ctx, sx*L*0.32-wl/2, sy>0? Wd/2-1 : -Wd/2-1-ww, wl, ww+1, 1.5); ctx.fill();
}
}
```
Wheel color slightly visible against body ✓ and grass.
drawWagon(m, idx):
```
const L=36, Wd=25;
drawWheels(L,Wd);
roundRect(ctx,-L/2,-Wd/2,L,Wd,6); ctx.fillStyle=m.color; ctx.fill();
ctx.lineWidth=2; ctx.strokeStyle=shade(m.color,-0.38); ctx.stroke();
// end planks
ctx.strokeStyle=shade(m.color,-0.22); ctx.lineWidth=1.4;
line at x=-L*0.22: moveTo(-L*0.22,-Wd/2+3) lineTo(-L*0.22,Wd/2-3); same x=+L*0.22; stroke
// interior opening with face
ctx.beginPath(); ctx.arc(2.5,0,8.6,0,7); ctx.fillStyle='#f4ecd9'; ctx.fill(); ctx.strokeStyle=shade(m.color,-0.3); ctx.lineWidth=1.4; ctx.stroke();
// face (looking forward +x)
head: ctx.beginPath(); ctx.arc(2.5,0,5.1,0,7); fill m.skin; stroke shade(skin,-.35) lw1;
hair: if cap: cap= arc(2.5,0,5.3, PI, 0) fill capColor + brim: roundRect(4.5,-5.3,4.2,2.4,1)?? cap brim toward +x: rect from x=4.5..? Let me: cap dome: ctx.arc(2.5,0,5.25,Math.PI*0.95,Math.PI*2.05) fill cap color; brim: roundRect(5,-4.6,3.6,2,1) fill same... brim on side facing +x: rect(5.?,-2, 3.8, 4?) hmm cap brim forward: rect x from 5.2 to 8.4 width... eyes at (4.5,±2)? Getting too fiddly — simplify: hair: crescent: arc(2.5, -0.4, 5.2, PI*1.02, PI*1.98...
```
Let me simplify face:
- head circle skin r 5.1 at (2.5,0)
- hair: if m.cap: draw cap: circle arc top-back: path: arc(2.5,0,5.3, 0.75*PI, 2.25*PI)?? Orientation: "top" of a top-down face is ambiguous; hair/cap should cover the BACK of the head (opposite of travel = -x side): draw hair as: ctx.arc(2.5, 0, 5.15, PI*0.55, PI*1.45)?? Let me think: angles: 0 = +x (forward). Back = angle PI. Hair covers from PI-1.1 to PI+1.1 (a wedge around the back): ctx.beginPath(); ctx.arc(2.5,0,5.15, Math.PI-1.15, Math.PI+1.15); ctx.lineTo(2.5,0)? A filled wedge/pac-man: arc + closePath gives chord — a filled half-moon look ✓ decent as hair from above. Or crescent: two arcs. Wedge is fine at this tiny size.
- if cap instead: wedge in cap color + small brim: roundRect at back? Cap brim would be at front... skip brim, wedge cap + tiny button circle center back. ok.
- eyes: two dark dots at (4.3,-1.9) and (4.3,1.9)? Eyes on either SIDE of the forward direction (like looking forward top-down, eyes at left/right of front): dots at angle ±50°: (2.5+3.3*cos(±0.9), 3.3*sin(±0.9)) ≈ (2.5+2.05, ±2.57) → (4.55, ±2.6), r 0.75, fill #2e2620.
- cheeks blush? skip.
- Also small mouth invisible top-down ✓ skip smile.
This face reads: skin circle + hair wedge at back + 2 dots forward = person seen from above ✓ consistent with top-down view! And passenger standing on platform is drawn from a slightly raised 3/4 view (frontal-ish)... inconsistent views but toy-style forgivable. Hmm — alternatively draw waiting passenger ALSO top-down (head circle + shoulders + waving arms + suitcase)! That would be stylistically consistent: top-down passengers! Person from above: head circle (hair covering most of top! from directly above you mostly see hair), shoulders (coat ellipse), arms, feet. From above: hair dominates the head. Design: coat rounded rect (shoulders) 12×10, two arms: one waving rotated, head circle skin r4.4 with hair wedge covering... from above, hair covers everything except face sliver. Draw: head r4.6 skin; hair: circle r4.6 but with a face notch: draw hair as full circle hairColor, then skin "face" as a small rounded wedge at the front-lower? Simpler & cuter: draw hair as full circle + skin face-patch: ellipse (rx2.6, ry2.0) offset toward front (+x or fixed down?) Let me give waiting passengers fixed facing direction (e.g., facing the track/down = +y?) They should look around... they can rotate to face the train! Passenger facing = toward head position (clamped) — they watch the train approach! That's ALIVE. angle = atan2(headY-py, headX-px). Face patch toward that angle. Waving arm on one side. Suitcase beside feet. Feet: two tiny dark dots below? from above feet hidden under body mostly: skip feet.
I love this: waiting passengers turn to watch the train as it approaches, waving faster. Big charm win, consistent top-down style. And in wagons, faces look forward ✓.
So drawPassenger person (in 42u cell, centered around (18,22)):
```
const ang = Math.atan2(hp.y... in px? compute angle in grid→ same as px.
ctx.translate(18,22+bob); ctx.rotate(ang);
// suitcase: at (-13, -6)?? draw before body: roundRect(-14,-8,7,5,1.5) brown — offset behind-left, not rotated with person? It's in person frame rotated by facing — suitcase would swing around as they turn... acceptable (they're holding it). Place suitcase beside: at (-11,8): looks like set down. Slight jitter.
// coat/shoulders: roundRect(-7,-8,14,16,6)?? shoulders wider than deep: from above, shoulders ellipse: width (x, facing dir) 9, depth (y) 16: roundRect(-4.5,-8,9,16,4.5) fill coat, stroke shade.
// arms: left arm from shoulder (0,-8) waving: rotate line by wave angle: draw arm as line lw2.4 coat-dark from (0,-7) to (0+wave*4, -13); right arm down: (0,7)→(0,12) static or slight sway.
// head: circle (0,-1)? head sits forward of shoulders center? from above head center ≈ body center: circle r4.8 at (1,0) skin.
// hair: circle r4.8 at (1,0) fill hair... then face: skin wedge toward +x: path: arc(1,0,4.8, -0.9, 0.9) + lineTo back to center → fill skin → face front wedge; then eyes dots at (1+3, ±1.6) r0.7 dark — eyes on the face wedge ✓.
```
Order: hair full circle first? Then face wedge skin on top (front), eyes on wedge. Head outline stroke subtle. ✓ Cap: hair circle replaced by cap color + button dot.
Blink: scale eyes to 0 height periodically: `const blink = (Math.sin(time*1.7+p.phase*3)>0.99)?0:1` cheap: if sin hash near peak → draw lines instead. Simple: blink = ((time*0.6+p.phase)%3.4)<0.12 → eyes closed (draw 1.4-long lines). ✓
Waving arm: from top-down, arm raised out to side & swinging: line from (0,-8) to (wave*5, -8-5) roughly perpendicular-ish. Good.
Hop: bob plus occasional hop: skip, waving+turning is enough. Keep small bob.
Suitcase: draw unrotated relative to person? Put suitcase before rotate: at fixed offset (13, 6) in platform frame: roundRect brown 8×6 + handle. ✓ set down on platform ✓.
drawLoco():
```
const L=39, Wd=26;
drawWheels(L,Wd);
// frame under: roundRect(-L/2+1,-Wd/2-1,L-2,Wd+2,7) fill '#22282e'? maybe skip
// boiler: main body from x=-4 to L/2: roundRect(-4,-Wd/2,L/2+... hmm easier: full body dark slate then cab overlay at back.
roundRect(-L/2,-Wd/2,L,Wd,6) fill '#3c4750' stroke '#232c33' lw2
// cab (rear): roundRect(-L/2+1.5,-Wd/2-1.5, 13, Wd+3, 5) fill '#2f6b4f' stroke shade... cab wider than boiler ✓ classic. cab roof details: inner stroke lighter '#3f8a66' line; small vent: rect(-1?)
Wait cab at rear = x from -L/2 to -L/2+13 = -19.5..-6.5. boiler front part -6.5..19.5.
// boiler bands: brass verticals at x=6 and x=12: rect(x-1, -Wd/2+1, 2, Wd-2) fill '#d9a441' alpha .9
// chimney: at x=14: circle r3.6 fill '#232c33' stroke '#d9a441' lw1.5 → funnel from above ✓ smoke origin at this offset: chimney local (14,0) → world offset = rotate (14u, 0).
Wait chimney near front: front is +x = L/2=19.5; chimney at 13, dome at 6? Classic: chimney front-most, then dome, cab rear ✓ chimney x=14, steam dome circle at x=6 r3 brass fill '#d9a441' stroke dark.
// front lamp: circle at (17.5,0) r2 '#ffd76a' stroke '#b98'; plus glow: radialGradient circle r 10 at (24,0) alpha .12 warm — draw before body? over grass: draw glow in loco frame before body parts (after body fine too). Keep subtle.
// cowcatcher: path: moveTo(L/2-1, -Wd/2+1) lineTo(L/2+6, 0) lineTo(L/2-1, Wd/2-1) close; fill '#c94f42' stroke '#8f362c' lw1.5, rounded joins.
// cab window? top-down cab roof: add roof stripe: rect(-L/2+4,-2.5,8,5) darker '#27553f' + ridge line.
```
Good.
Smoke spawn: in update loop each step (or continuous): spawn when crossing cells — spawn 1 puff per step arrival + occasionally between: rate-based: accumulate `smokeAcc += dt; while(smokeAcc>0.09){ spawn at chimney with jitter; smokeAcc-=0.09 }` — continuous is smoother ✓. Chimney world pos: pathPoint(-14/42?) chimney offset from head center: local x=14u = 14/42 cell = 0.333 cell ahead: pos = pathPoint(-0.333)?? pathPoint(d) with d negative → ahead along curDir — but that's along path direction only; chimney is ON the loco so exactly ahead of head center ✓ = pathPoint(-0.333). ✓ nice.
Smoke puff: {x,y (px), vx,vy, r, ttl,life}. wind: vx += (windX - vx)*dt... simple: vx=rnd(-8,8)+(-curDir.x*12?) drift slightly opposite travel + global wind (14, -10) px/s... At top-down, wind direction fixed (to the "north-west") reads fine. Update: r += 6*dt*u? r0=3u.. grows to ~13u; alpha=.38*(1-life/ttl). Draw circles fill '#eef1f2' with alpha; maybe two-tone: core lighter. Keep single.
Sparks & stars: particles with vx,vy, drag: v *= (1-drag*dt); pos += v*dt; alpha=1-life/ttl. star draw small circle. ring: expanding circle stroke.
Floats: y -= 38*dt; alpha fades; text '+1' bold 13u? font: `bold ${13*u}px var?` canvas font can't use CSS var: use "'Trebuchet MS',sans-serif". Stroke dark green under, fill white.
updateHUD: score, wagons=metas.length-1, best. bump anim on score stat.
Now renderBG in detail:
```
function renderBG(){
bgCv.width=cv.width; bgCv.height=cv.height;
const g=bgx; g.setTransform(dpr,0,0,dpr,0,0);
g.fillStyle='#a4c67f'; g.fillRect(0,0,W,H);
for(let y=0;y<rows;y++)for(let x=0;x<cols;x++){
if((x+y)&1){g.fillStyle='rgba(255,255,255,0.045)'; g.fillRect(x*cell,y*cell,cell+0.5,cell+0.5);}
}
// per-cell tufts & flowers deterministic
const u=cell/42;
for(let y=0;y<rows;y++)for(let x=0;x<cols;x++){
const h1=hash2(x,y), h2=hash2(x*7+3,y*13+1);
const cx=(x+0.18+h1*0.64)*cell, cy=(y+0.18+h2*0.64)*cell;
if(h1<0.42){ // tuft
g.strokeStyle='rgba(74,112,49,0.5)'; g.lineWidth=1.3*u*?? use px: 1.3*Math.max(1,u*1.2)
```
Hmm keep line widths in px scaled: lw = Math.max(1, 1.2*u)... simpler: sizes relative to cell: tuft strokes length 0.11*cell.
```
g.strokeStyle='rgba(70,105,45,0.55)'; g.lineWidth=Math.max(1,cell*0.035); g.lineCap='round';
g.beginPath();
g.moveTo(cx,cy); g.lineTo(cx-0.05*cell, cy-0.11*cell);
g.moveTo(cx+0.03*cell,cy); g.lineTo(cx+0.05*cell, cy-0.12*cell);
g.moveTo(cx+0.07*cell,cy); g.lineTo(cx+0.11*cell, cy-0.08*cell);
g.stroke();
} else if(h1>0.9){ // flower
g.fillStyle='#f9f4e3'; 3 tiny petals: circles r cell*0.035 around; center '#eebc47' r cell*0.03
}
}
// vignette
const vg=g.createRadialGradient(W/2,H/2, Math.min(W,H)*0.35, W/2,H/2, Math.max(W,H)*0.72);
vg.addColorStop(0,'rgba(25,45,20,0)'); vg.addColorStop(1,'rgba(25,45,20,0.30)');
g.fillStyle=vg; g.fillRect(0,0,W,H);
}
```
Checker with white alpha overlay on alt cells ✓ subtle. Add slightly different green tint too: alt cells fill 'rgba(60,110,40,0.05)'? One treatment enough.
Hmm: also draw a faint mowed-stripe pattern? skip.
render():
```
function render(){
ctx.setTransform(dpr,0,0,dpr,0,0);
// shake
let sx=0,sy=0;
if(shake>0){ const s=shake/420; sx=rnd(-1,1)*7*s; sy=rnd(-1,1)*7*s; }
ctx.clearRect? drawImage covers all: ctx.drawImage(bgCv,0,0,W,H);
ctx.save(); ctx.translate(sx,sy);
// everything shifted incl bg? bg not shifted (only world) → draw bg first then translate world: rails/train shake relative to grass — actually shaking the whole board including bg looks better; but bg drawn unshaken leaves edges... draw bg with shake too: drawImage(bgCv, sx, sy, W, H) then world translate. Edges show gap when shifted? drawImage at offset leaves 7px gap at opposite edge with cleared canvas — fill base color first: ctx.fillStyle='#a4c67f'; fillRect covers, gap in vignette barely noticeable. Simpler: don't shake bg (shake world only) — looks like the world jolts, fine. Keep: bg unshaken, world shaken.
drawRails(); drawPassengers(); drawTrain(); drawParticles(); drawSmoke(); drawFloats();
ctx.restore();
}
```
Also dark overlay when dead? Slight dim: if state dead: ctx.fillStyle='rgba(15,25,15,0.25)'; fillRect — then card pops. Add subtle.
Update loop:
```
let lastT=performance.now();
function frame(now){
let dt=(now-lastT)/1000; lastT=now; dt=Math.min(dt,0.05);
time+=dt;
if(shake>0)shake=Math.max(0,shake-dt*1000);
const running = state==='demo'||state==='playing';
if(running){
stepTimer+=dt*1000;
let guard=0;
while(stepTimer>=stepDur && guard++<3){
stepTimer-=stepDur; step();
if(state==='crashing')break;
}
tAnim = state==='crashing'? Math.min(stepTimer/stepDur,0.45) : Math.min(stepTimer/stepDur,1);
```
Hmm for crashing: after step() set state='crashing' and stepTimer=0 (inside step). Then tAnim continues 0→0.45; when stepTimer>=0.45*stepDur → die(). Implement:
```
} else if(state==='crashing'){
stepTimer+=dt*1000;
tAnim=Math.min(stepTimer/stepDur,0.45);
if(stepTimer>=stepDur*0.45){ tAnim=0.45; die('wall'); }
}
```
die(reason): if reason wall → tAnim already 0.45 ✓. if body → tAnim=1.
Also spawn smoke continuously when running/crashing:
```
smokeAcc+=dt; const iv = 0.12;
while(smokeAcc>iv){ smokeAcc-=iv; spawnSmoke(); }
```
spawnSmoke uses chimney pos from pathPoint(-0.333) — during dead state too? Dead: engine stopped — stop smoking (only spawn when running/crashing) ✓. But at death spawn a burst of dark smoke ✓ in die().
updateParticles(dt) for particles/floats/smoke: life+=dt, remove expired, integrate.
syncHUD(bump):
```
$('#score').textContent=score; $('#wagons').textContent=metas.length-1; $('#best').textContent=best;
if(bump){ bumpEl($('#scoreEl')) }
```
Also initial HUD: wagons=5 (demo start wagons 5) ✓ syncHUD called in resetRun ✓.
Audio:
```
let AC=null;
function unlockAudio(){ if(!AC){try{AC=new (window.AudioContext||window.webkitAudioContext)()}catch(e){AC=null}} if(AC&&AC.state==='suspended')AC.resume(); }
function tone(f,dur,type,vol,delay=0){ if(!AC)return; try{ const t0=AC.currentTime+delay; const o=AC.createOscillator(), g=AC.createGain(); o.type=type; o.frequency.setValueAtTime(f,t0); g.gain.setValueAtTime(0.0001,t0); g.gain.exponentialRampToValueAtTime(vol,t0+0.015); g.gain.exponentialRampToValueAtTime(0.0001,t0+dur); o.connect(g); g.connect(AC.destination); o.start(t0); o.stop(t0+dur+0.05);}catch(e){} }
function ding(){ tone(1174,0.15,'sine',0.10); tone(1568,0.28,'sine',0.09,0.08); }
function toot(){ tone(233,0.13,'square',0.05); tone(233,0.2,'square',0.05,0.17); tone(311,0.13,'square',0.045,0.0)? two low blasts only. }
function crashSound(){ if(!AC)return; try{ noise buffer 0.35s lowpass 600 gain 0.25 decay; tone(72,0.4,'triangle',0.22); tone(50,0.5,'sine',0.2,0.02);}catch(e){} }
```
Noise: create AudioBuffer with random samples, BufferSource → BiquadFilter lowpass 500 → gain envelope. Write it.
Call unlockAudio() at pointerdown/keydown ✓ then ding etc. work.
HUD wagons stat when demo: 5 wagons ✓.
Let me write CSS for cards & chip carefully for polish:
```
.chip{
position:absolute; top:12px; left:12px; z-index:3;
font-size:10.5px; letter-spacing:.16em; text-transform:uppercase; font-weight:700;
color:#e9f2df; background:rgba(20,34,24,.82); border:1px solid rgba(233,242,223,.25);
padding:6px 11px; border-radius:999px; backdrop-filter: blur(4px);
}
.chip.pop{animation:chipPop .4s}
@keyframes chipPop{0%{transform:scale(.8)}60%{transform:scale(1.08)}100%{transform:scale(1)}}
```
Chip shows current mode always. During dead: "DERAILED".
Card:
```
.card{
position:absolute; left:50%; top:50%; z-index:4;
transform:translate(-50%,-46%) scale(.9); opacity:0; visibility:hidden;
transition:opacity .35s ease, transform .45s cubic-bezier(.2,1.4,.4,1), visibility 0s .35s;
background:var(--card); color:#2f3b2c; border:2px solid var(--card-line);
border-radius:20px; padding:26px 34px 24px; text-align:center; width:min(88%,400px);
box-shadow:0 24px 60px rgba(8,16,9,.55), inset 0 2px 0 #fff;
}
.card.show{opacity:1; visibility:visible; transform:translate(-50%,-50%) scale(1); transition-delay:0s}
#cardDead.show{transition-delay:.55s} /* delay when appearing */
```
Hmm transition-delay applies to all transitions when .show added — with .show having delay .55s, the card appears .55s after death ✓; when removing .show (restart), delay 0? The non-.show rule has no delay ✓.
Card inner:
```
.eyebrow{font-size:10px; letter-spacing:.22em; text-transform:uppercase; color:#8a9179; font-weight:700; margin-bottom:8px}
.card h2{font-family:var(--font-display); font-style:italic; font-size:30px; color:#28463a; margin-bottom:8px}
.card p{font-size:13.5px; line-height:1.5; color:#5c6553}
.cta{margin:16px auto 4px; ...}
.keys{margin-top:12px; font-size:11.5px; color:#8a9179}
.keys kbd{display:inline-block; background:#e8dfc8; border:1px solid #cfc3a4; border-bottom-width:2px; border-radius:5px; padding:2px 6px; font-family:var(--font-ui); font-size:11px; color:#4a5340; margin:0 1px}
```
kbd styling — nice touch for arrows/WASD.
Demo card content:
```
<div class="eyebrow">Demo run</div>
<h2>All aboard!</h2>
<p>The auto-driver is out picking up passengers. Grab the controls whenever you're ready — every passenger adds a wagon to your train.</p>
<div class="cta">Click or press a key to start</div>
<div class="keys"><kbd>↑</kbd><kbd>←</kbd><kbd>↓</kbd><kbd>→</kbd> or <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> · swipe on touch</div>
```
Dead card:
```
<div class="eyebrow">End of the line</div>
<h2>Derailed!</h2>
<p><b id="deadCount">0</b> passengers picked up this run.</p>
<p id="deadBest" class="best">Best run: 0</p>
<div class="cta">Click or press a key to try again</div>
```
`#deadBest` styling: margin-top small, color, maybe with star icon inline? Keep text.
New best badge: if score===best && score>0: deadBest text = "New best run!" colored accent ✓ JS sets.
Footer hint:
```
<footer class="hint">Arrow keys / <kbd>? plain text: "Steer with arrow keys or WASD · swipe on touch · every passenger adds a wagon"
```
Keep muted simple text.
Header stats icons — inline SVGs:
Person:
```
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-7 8-7s8 3 8 7"/></svg>
```
Wagon:
```
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ...><rect x="2.5" y="5" width="19" height="11" rx="2.5"/><circle cx="7" cy="18.5" r="2"/><circle cx="17" cy="18.5" r="2"/><path d="M9 18.5h6"/></svg>
```
Trophy:
```
<svg viewBox="0 0 24 24" ...><path d="M7 4h10v5a5 5 0 0 1-10 0V4z"/><path d="M7 5H4a3 3 0 0 0 3 4M17 5h3a3 3 0 0 1-3 4"/><path d="M12 14v3M8 21h8M9 21c0-2 1-3 3-3s3 1 3 3"/></svg>
```
Hmm trophy path hand-made; acceptable. Or use a star (easier):
```
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.5l2.9 6 6.6.9-4.8 4.6 1.2 6.5L12 17.4 6.1 20.5l1.2-6.5L2.5 9.4l6.6-.9z"/></svg>
```
Star ✓ simpler & clean. Use star for best.
Logo loco SVG (side view, 54×34):
```
<svg class="logo" viewBox="0 0 56 34" aria-hidden="true">
<rect x="0" y="31" width="56" height="3" rx="1.5" fill="#5a4632"/> rail/ground
<rect x="6" y="10" width="24" height="14" rx="7" fill="#f0e6cf"/> boiler cream
<rect x="28" y="5" width="16" height="19" rx="3" fill="#e2574c"/> cab red
<rect x="31.5" y="8.5" width="7" height="7" rx="1.5" fill="#f6eedb"/> window
<rect x="10" y="3" width="6" height="8" rx="1.5" fill="#3a4552"/> chimney
<rect x="8" y="9" width="20" height="2.5" fill="#d9a441"/> brass band
<path d="M30 24l10 6H22z" ... cowcatcher? front is left (chimney left) hmm loco faces LEFT then.
```
Let me make loco face right: chimney right:
- boiler: rect x=8 y=10 w=26 h=14 r=7 cream
- cab: x=4? cab at LEFT: rect x=2 y=4 w=14 h=20 r=3 red; window inside.
- chimney: rect x=30 y=3 w=6 h=9 r=1.5 dark
- brass band: rect x=26 y=9.5 w=2.5 h=15? vertical band across boiler: x=26,y=10,w=3,h=14 brass.
- dome: circle cx=21 cy=9 r=3 brass.
- cowcatcher: path M34 24 L42? front at x=34 (boiler right edge): wedge: M34,11 L41,24 L34,24 z red-dark?
- wheels: circles cx=8,18,30 cy=28 r=4 fill #3a4552 stroke cream? On dark header bg, use cream wheels: fill #f0e6cf stroke #22303a... Let me: wheels fill '#f6eedb', hub dots dark.
- ground: rect 0,32 w=56 h=2 fill #d9a441? subtle.
Simplify to look good small (40px wide). It's decorative; fine.
Colors on dark green header: cream boiler pops, red cab, brass — nice brand mark.
Now — potential issue: `backdrop-filter` in sandboxed iframe: fine in Chrome.
Also stats `font-variant-numeric` ✓.
One more mechanic consideration: passengers spawn possibly on cells the fading rails pass over — visually person standing on track then boarding — fine (platforms are ON the track here — it's a pickup railway, the "platform" is a trackside slab; passengers board when train stops... in game the train just drives over the cell and they board instantly. Visual story ok.)
Passenger waiting spot = platform cell where train must pass THROUGH. The slab drawn under train when passing — slab drawn before train so train rides over slab ✓ looks like crossing the platform — fine, it's a toy.
Let me now also double check collisions never incorrectly kill on spawn: resetRun places segs left of head on same row, no passengers within manhattan 4 of head ✓ but wagons extend left — passengers could spawn ON wagon cells? Check `segs.some` ✓ excluded.
Now, "crashing" state + demo AI: AI never crashes into walls (skips OOB) unless trapped. If trapped → maybe crash into body → dead → respawn soon ✓. Demo dead: cardDead shows... in demo mode after death, show dead card too? In demo, death → auto respawn after ~2.5s without card (keep demo card showing!). Since body[data-state='dead'] — demo card hidden, dead card shown with "click to try again"... but it's demo (nobody clicked yet). Better: if death occurs while demo (userNeverInteracted... state was demo), skip dead card: stay showing demo card, quick respawn after 2s. Implementation: die(cause): if wasDemo (track a flag `autoMode` = state before crash was demo): setState('demo-hidden'?)...
Let me add distinct states: 'demo' (AI + demo card), 'playing', 'crashing', 'dead'. On die: if autoMode flag: keep state='demo' visually? Need AI to keep running after respawn without card blink: resetRun(true) → setState('demo') → card stays visible ✓. So: die(): if (autoMode) { setTimeout(()=>resetRun(true), 1800); no card } else { setState('dead'); card; demoTimer to resume attract after 5.2s }.
autoMode = true when demo run active; set false when user takes controls; resetRun(true) sets autoMode=true.
During the 1.8s respawn wait, state='dead'? If state 'dead', card shows... use separate: after demo-crash: state stays 'dead' internally but we must not show dead card → gate cards by both state and autoMode: CSS can't know autoMode → set body[data-state='demo'] during demo death wait! I.e., die in demo: setState('demo') (card stays), and internally state='dead'... conflicting. Add internal var `state` separate from body dataset: body dataset = autoMode?'demo': state. So setState mirrors with that rule. During demo death wait: state='dead' but body shows demo ✓ chip "AUTO-DRIVER" ✓ fine. Rendering: state dead → frozen ✓. Timer resets run ✓.
Edge: user clicks during the demo-death-wait: anyInput: state==='dead' → resetRun(false) ✓ works (clear pending timer! clearTimeout in resetRun ✓ store timer id globally `pendingReset` and clear in resetRun & anyInput).
Also "crashing" while demo: after 0.45 → die → autoMode true → wait 1.8 → reset ✓.
Best: should demo runs update best? Demo is attract mode — updating best from AI feels wrong. Only update best if !autoMode ✓. So in collect: if(!autoMode) best=Math.max(best,score).
Wagons/score HUD during demo: shows demo's run numbers ✓ fine (it's a live demo).
Now write drawParticles etc.
Also note: `ctx.roundRect` — Chrome 99+ has native roundRect ✓ latest Chrome required. I'll add tiny fallback polyfill just in case:
```
if(!CanvasRenderingContext2D.prototype.roundRect){ polyfill }
```
Cheap insurance; include.
Text in canvas floats: '+1' with font `700 ${Math.max(11, 13*u)}px 'Trebuchet MS', sans-serif` — u might make it small on mobile: max(11px,...) ✓.
Let me think about drawRails alpha/anti-alias performance: fine.
Another detail: rails ahead of head drawn from d=-0.55: at corners the "ahead" extension goes straight along curDir while the track curve... only 0.55 cell, negligible.
Sleepers ahead too? From d=-0.3 ✓ fine (under loco).
What about rails UNDER the loco while turning: the trail path curves at cell centers; vehicles sample along it ✓ consistent.
Now, metas array and "every wagon shows its passenger" — new wagon appears at the TAIL with the new passenger's face. The causal connection passenger→tail wagon happens far from pickup point (pickup at head, wagon at tail!). Player sees: pick up person at head, and a new colored wagon appears at tail. The face is at tail ✓ acceptable... To strengthen causality: the "+1" float + matching color sign — the sign at the platform shows the color, then the tail wagon has that color + face ✓ readable.
Alternative: new passenger rides in the LOCO? No — tail fine.
Wagon interior circle: shows the face — draw m.skin etc. ✓.
Let me also double-check angleAt for tail vehicle at d=V-1: samples d±0.22 within available ✓ (history ≥ V+5).
Also for i beyond available when V grows... pathPoint clamps via bi guard: `if(bi-1<0) return trail[0]` — with history 5+ cells behind tail, never hit except transient after eating? When eating, V increases by 1; trail history: after unshift-no-pop, segs V+1; trail length ≥ V+? trail trim keeps length ≤ segs.length+7 → history ≥ needed ✓. pathPoint(d) for d=V-1: k=d-tAnim, idx=floor ≤ V-1... anchors: bi=n-2-idx ≥ n-2-(V-1); n≈V+1..V+7 → bi≥ V-1-... fine ≥1 ✓ guard anyway.
Trim check: `while(trail.length > segs.length + 7) trail.shift();` — but wait trail must ALWAYS have ≥ V+1 points for sampling (V cells + target) ✓ satisfied.
Hmm one subtle bug: at tAnim close to 1 and d large, pathPoint might reference trail[0] when history short right after resetRun: resetRun trail length = V+1 exactly (V from segs + target). d max = V-1: k=V-1-tAnim; idx ≤ V-1; bi=n-2-idx = (V+1)-2-idx = V-1-idx ≥ 0 when idx≤V-1 ✓; bi-1≥-1... if bi=0 → bi-1=-1 → clamp guard returns trail[0] — happens when tAnim small & d=V-1: k=V-1 (tAnim=0): idx=V-1, bi=V-1-(V-1)=0 → clamp trail[0] = correct point! (d=V-1 at tAnim=0 = trail[1]... let me recompute: tAnim=0: pathPoint(V-1): d>tAnim (V-1>0 ✓): k=V-1, idx=V-1, frac=0, bi=n-2-idx=(V+1)-2-(V-1)=0 → bi-1=-1 → guard: return trail[0]?? WRONG — expected lerp(trail[0], trail[-1]...) doesn't exist: correct value = trail[bi]=trail[0] with frac 0 → lerp(trail[0], trail[-1], 0) = trail[0] ✓ so guard returning trail[0] IS correct ✓. And tAnim=0.5, d=V-1: k=V-1.5, idx=V-2, frac=-0.5?? k=V-1-0.5=V-1.5, idx=floor=V-2 (for V≥2), frac=-0.5 → NEGATIVE frac — bug! Because available path from head at tAnim=0.5 back = 0.5+ (n-2) = 0.5+V-1 = V-0.5 < V-1? No wait: V-1 vs V-0.5: V-1 ≤ V-0.5 ✓ available. Let me recompute k: distance beyond first anchor: k=d-tAnim=V-1-0.5=V-1.5. Anchors after first: from trail[n-2] back: segments trail[n-2]→trail[n-3] ... count = n-2 = V-1 segments ✓ total reach = tAnim + (n-2) = V-0.5 ✓ > V-1 ✓. k=V-1.5; idx=floor(V-1.5)=V-2; frac = k-idx = 0.5 ✓ positive! I miscalced: V-1.5 floor is V-2, frac 0.5 ✓. bi = n-2-idx = V-1-(V-2)=1: lerp(trail[1], trail[0], 0.5) ✓ correct: at tAnim=0.5, tail (d=V-1) is halfway between trail[1] and trail[0]? Expected: tail should be at segs[V-1] cell... segs[V-1] = trail[1]? trail = [segs[V-1],...,segs[0],target]. Head at tAnim=0.5 is between segs[0](=trail[n-2]) and target. Tail at distance V-1 behind → should be halfway between trail[1] (=segs[V-1]) and trail[0]?? Hmm segs has V cells; at tAnim=0.5 the train occupies from head (half cell past segs[0]) to tail = V-1 cells behind = half cell BEFORE segs[V-1]=trail[1]... but trail[0] is one further back — but trail[0] isn't a seg cell in resetRun? trail[0] = segs[V-1] = tail ✓ and trail[1] = segs[V-2]. Wait trail built as reversed segs: segs=[head,...,tail] (head first); map → [head...tail]; reversed → [tail,...,head]; push target. So trail[0]=tail=segs[V-1] ✓ trail[1]=segs[V-2] ✗ I had it backwards!
Let me recompute: expected tail position at tAnim=0.5: half a cell behind trail[0]=tail cell → lerp(trail[0], further-back??) — there IS no further back in trail at reset! d=V-1, available = tAnim+(n-2) = 0.5 + (V-1) = V-0.5 ≥ V-1 ✓ reach OK: k=V-1.5, idx=V-2, bi=n-2-(V-2) = (V+1)-2-(V-2)=1 → lerp(trail[1], trail[0], 0.5) = halfway between segs[V-2] and segs[V-1]... but expected: half-cell behind segs[V-1] along its entering direction — direction from segs[V-2]→segs[V-1] = toward tail ✓ half-cell behind tail cell = midpoint between trail[1] and an imaginary continuation... lerp(trail[1],trail[0],0.5) = midpoint between segs[V-2] and segs[V-1] = half cell BEFORE reaching tail cell — hmm expected tail = 0.5 cell behind trail[0]: position = trail[0] + (trail[0]-trail[1])*0.5 = extrapolated beyond trail[0]. But my formula gives midpoint between trail[1] and trail[0] = trail[0] - 0.5*(dir) — SAME thing ✓✓ (midpoint between trail[1] and trail[0] IS half a cell before trail[0]) ✓ correct!
And the apparent "off by half": total behind-head distance of trail[0] = tAnim + (V-1)... at tAnim=0.5: trail[0] is V-0.5 behind head ✓ (head half-past segs[0], trail[0]=segs[V-1] is V-1 behind segs[0] → V-0.5 behind head ✓). So pathPoint(d) with d up to V-0.5 valid; d=V-1 tail: correct position half-cell behind trail[0]?? WAIT — that means the tail vehicle sits BEHIND the last seg cell by up to half a cell at tAnim>0... but classic snake: all segments at cell centers at tAnim=0... Let me re-verify at tAnim=0 (fresh reset): pathPoint(V-1) = trail[0] = segs[V-1] ✓ tail at its cell ✓. At tAnim=1 (arrival): pathPoint(V-1): d=V-1 ≤ tAnim=1? Only if V≤2. k=V-2, idx=V-2, frac=0 → bi=n-2-(V-2)=1 → lerp(trail[1],trail[0],0)=trail[1]=segs[V-2] ✓ tail at its new cell after the shift ✓✓. So mid-motion tail is between segs[V-2] and segs[V-1] — correct continuous motion ✓ (each vehicle moves from its old cell to the next along path ✓).
Great, the math checks out.
Now AI flood & passenger targeting need passengers non-empty always (3 spawn; if spawn fails after 300 tries — board full — train huge; then passengers could be empty → aiDir target null → crash guard: if(!target) return cur. Fine.
Also `passengers.findIndex` per step O(P) trivial.
HUD element ids: score, wagons, best; stat containers for bump.
Let me write bump:
```
function bump(el){ el.classList.remove('bump'); void el.offsetWidth; el.classList.add('bump'); }
CSS: .stat b.bump{animation:bump .35s ease} @keyframes bump{30%{transform:scale(1.35)}}
```
b on score only (b is inline → transform needs inline-block: `.stat b{display:inline-block}`) ✓.
Now, the demo card position over board might cover the chip? Chip top-left, card center ✓ no overlap mostly.
Mobile: card padding smaller, h2 24px.
Let me also give the board a subtle inset: nothing.
Also: the "playing" chip 'YOU'RE DRIVING' — apostrophe in JS string: use "YOU’RE DRIVING" typographic ✓.
Dead chip: "DERAILED".
Now write the smoke drawing: circles; color rgba(240,244,246,alpha); maybe slight gray-blue. Under? Over train ✓ over.
Floats and particles drawn over everything ✓.
Also add subtle train shadow? Top-down: small dark soft ellipse under each vehicle: before body draw: ellipse offset (2,3)px alpha .15 — adds depth cheaply ✓ do in vehicle frame: ctx.fillStyle='rgba(20,30,20,.18)'; roundRect slightly offset (1.5,2.5) BEFORE wheels? Shadow under wheels too — draw first: roundRect(-L/2+2, -Wd/2+3, L, Wd+2, 8) fill shadow ✓.
Passenger shadow: small ellipse under person alpha .15 ✓.
Now, careful about drawing order inside vehicle: shadow → wheels → body → details.
Edge case: tAnim frozen at 1 on body-crash death: pathPoint uses tAnim=1: head at target cell (the wagon cell) ✓ overlapping wagon — loco drawn last (on top) ✓ crash reads.
For wall crash: tAnim=0.45, head 0.45 cell past edge cell... e.g., edge cell at x=cols-1, target x=cols → head center at x=(cols-1)+1*0.45+0.5?? in grid units head = lerp((cols-1),(cols),0.45) = cols-0.55 → px center = (cols-0.05)*cell ≈ 2px inside right edge? Hmm: (gx+0.5)*cell with gx=cols-0.55 → (cols-0.05)*cell = W - 0.05cell → 2px from edge — loco front (0.48cell ahead) extends past edge → clipped ✓ nose off the board ✓ front wheels at edge ✓
Sparks spawn at headPix which is near edge ✓ visible.
die('wall') vs die(): unify die(cause) storing; both set state='dead', effects.
One more: when state==='dead' and user restarts via keypress → resetRun(false) → but AudioContext unlocked already ✓ toot plays ✓.
When user takes controls from demo: anyInput: state='demo' → setState('playing') — keep everything; autoMode=false; toot(); ALSO spawn celebratory smoke burst ✓; chip 'YOU'RE DRIVING' ✓. HUD score continues from demo run (e.g., demo picked 2 → user continues from 2). Acceptable? Slight oddity: their run started mid-way. On death, "picked up N this run" includes demo's pickups — meh. Alternative: on taking controls, reset fresh run for clean scoring. But then seamless takeover is lost... I prefer seamless BUT reset score at takeover: keep train length but zero the score? Then wagons=big, score=0 — mismatch acceptable (wagons "carried over"). Hmm... simplest honest: keep score too; it's a demo handover, arcade attract mode standard is full reset. Actually standard arcade: taking over from attract = fresh game. But seamless feels magic here. I'll keep train & score (seamless); it's charming, and "best run" still meaningful. Fine.
Wait, one more thing: demo pre-seeded with 5 wagons → user takes over with 6-vehicle train, score maybe 0-3. OK.
Restart after death: fresh (2 wagons) ✓.
Let me also handle: while state 'crashing', ignore steering (queueDir only if playing ✓ pointermove checks state playing ✓ keydown: anyInput returns false when playing... during crashing anyInput: state==='crashing' not handled → falls through return false → then queueDir(d) queues for after? After crash → dead, queue cleared on reset ✓ harmless.
anyInput cases: demo → takeover; dead → restart; crashing/playing → false.
Also add: pressing key during 'demo' with a direction ALSO queues that direction after takeover? anyInput consumed it; next press steers ✓ fine.
Now the noise texture data URI — write carefully:
```
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='140' height='140'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/></filter><rect width='140' height='140' filter='url(%23n)' opacity='0.4'/></svg>");
```
with the pseudo-element opacity .05 overall. In CSS url with double quotes, inner single quotes ✓ encode # as %23 ✓ spaces ok? Some parsers need %20... Chrome handles spaces in data URI in CSS fine (quotes). Keep spaces; Chrome tolerant. To be safe I could encodeURIComponent-style... I'll keep simple, tested pattern widely used ✓.
Vignette: body::after radial-gradient.
Also app content should be above noise: .app{position:relative; z-index:1}.
Let me also consider: iframe 1280×800 screenshot — header + board + footer fill nicely ✓.
Font stack note: `'Trebuchet MS'` on Linux fallback → Verdana/sans-serif ✓ fine.
Alright — also the chip during dead (user mode): 'DERAILED'.
Let me now write out the full code mentally with care for bugs:
Key risk areas:
1. pathPoint math (verified above).
2. trail/trim consistency (length ≥ V+1 ✓).
3. state transitions & timers.
4. resize handling.
5. draw functions referencing undefined vars.
Also ensure no `console.error`s: guard AudioContext creation, roundRect polyfill.
Let me write the final code.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<title>The Little Railway — a snake game</title>
<style>
:root{
--bg:#1b2a21; --ink:#f3ead6; --muted:#9fb09a;
--card:#f6eedb; --card-line:#d8c7a2; --accent:#e2574c; --pill:#23392c; --pill-line:#395543;
--font-ui:'Trebuchet MS','Segoe UI',Verdana,sans-serif;
--font-display:Georgia,'Times New Roman',serif;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{background:#1b2a21;color:var(--ink);font-family:var(--font-ui);overflow:hidden;-webkit-font-smoothing:antialiased}
body::before{content:"";position:fixed;inset:0;z-index:0;pointer-events:none;opacity:.5;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/></filter><rect width='160' height='160' filter='url(%23n)' opacity='0.07'/></svg>");}
body::after{content:"";position:fixed;inset:0;z-index:0;pointer-events:none;
background:radial-gradient(120% 90% at 50% 40%,rgba(0,0,0,0) 55%,rgba(4,10,6,.5) 100%);}
.app{position:relative;z-index:1;height:100dvh;display:flex;flex-direction:column;gap:10px;padding:14px 18px 10px;max-width:1560px;margin:0 auto}
header{display:flex;align-items:center;gap:14px;flex-wrap:wrap}
.brand{display:flex;align-items:center;gap:12px;min-width:0}
.logo{width:52px;height:34px;flex:none;filter:drop-shadow(0 3px 4px rgba(0,0,0,.35))}
h1{font-family:var(--font-display);font-style:italic;font-size:clamp(18px,2vw,23px);line-height:1.1;letter-spacing:.2px;text-shadow:0 2px 6px rgba(0,0,0,.35)}
.tagline{margin-top:2px;font-size:clamp(9px,1vw,10.5px);letter-spacing:.18em;text-transform:uppercase;color:var(--muted);white-space:nowrap}
.stats{display:flex;gap:8px;margin-left:auto}
.stat{display:flex;align-items:center;gap:9px;background:var(--pill);border:1px solid var(--pill-line);border-radius:13px;padding:7px 13px;box-shadow:0 4px 10px rgba(0,0,0,.25)}
.stat svg{width:16px;height:16px;flex:none;color:var(--accent)}
.stat b{display:inline-block;font-size:17px;line-height:1;font-variant-numeric:tabular-nums}
.stat span{display:block;margin-top:2px;font-size:9px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted)}
.stat b.bump{animation:bump .4s ease}
@keyframes bump{35%{transform:scale(1.4) translateY(-1px)}}
.board{flex:1;min-height:0;position:relative;display:flex;align-items:center;justify-content:center}
canvas{display:block;border-radius:16px;box-shadow:0 0 0 5px #7d5a37,0 0 0 9px #493017,0 26px 60px rgba(0,0,0,.55);touch-action:none}
.chip{...}
...
```
Chip CSS: as planned.
Card CSS + cta + kbd + keys.
Footer hint.
Media queries:
```
@media (max-width:640px){
.app{padding:10px 10px 8px;gap:8px}
.logo{width:40px;height:26px}
.stats{gap:6px;width:100%;margin-left:0}
.stat{flex:1;justify-content:center;padding:6px 8px;gap:7px}
.stat b{font-size:15px}
.stat span{font-size:8px}
.tagline{display:none}? keep? hiding keeps clean; keep but shrink: font 8px... I'll keep hidden below 480.
canvas{border-radius:12px}
.card{padding:20px 20px 18px}
.card h2{font-size:24px}
.hint{font-size:10px}
}
```
Header wrap: brand full width + stats row below on mobile ✓ via flex-wrap + stats width 100%.
Now JS full write-up. Let me carefully produce it.
```js
(()=>{ 'use strict';
const $=s=>document.querySelector(s);
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const lerp=(a,b,t)=>a+(b-a)*t;
const rnd=(a,b)=>a+Math.random()*(b-a);
const pick=a=>a[(Math.random()*a.length)|0];
const shade=(hex,f)=>{const n=parseInt(hex.slice(1),16);let r=n>>16&255,g=n>>8&255,b=n&255;
if(f<0){r*=1+f;g*=1+f;b*=1+f}else{r+=(255-r)*f;g+=(255-g)*f;b+=(255-b)*f}
return `rgb(${r|0},${g|0},${b|0})`};
const hash2=(x,y)=>{let n=Math.imul(x,374761393)+Math.imul(y,668265263);n=Math.imul(n^(n>>>13),1274126177);return((n^(n>>>16))>>>0)/4294967296};
if(!CanvasRenderingContext2D.prototype.roundRect){
CanvasRenderingContext2D.prototype.roundRect=function(x,y,w,h,r){r=Math.min(r,w/2,h/2);
this.moveTo(x+r,y);this.arcTo(x+w,y,x+w,y+h,r);this.arcTo(x+w,y+h,x,y+h,r);this.arcTo(x,y+h,x,y,r);this.arcTo(x,y,x+w,y,r);this.closePath();};
}
```
(roundRect native exists anyway in latest Chrome.)
Constants, palettes.
Canvas & grid fit:
```
const cv=$('#cv'),ctx=cv.getContext('2d');
const bgCv=document.createElement('canvas'),bgx=bgCv.getContext('2d');
const board=$('#board');
let cols=26,rows=15,cell=40,u=cell/42,W=1040,H=600,dpr=1;
function chooseGrid(){
const r=board.getBoundingClientRect();
const target=r.width<620?30:42;
cols=clamp(Math.round(r.width/target),12,30);
rows=clamp(Math.round(r.height/target),11,21);
}
function fitCanvas(){
const r=board.getBoundingClientRect();
cell=Math.max(12,Math.min(r.width/cols,r.height/rows));
u=cell/42; W=cell*cols; H=cell*rows;
dpr=Math.min(2,window.devicePixelRatio||1);
cv.style.width=W+'px';cv.style.height=H+'px';
cv.width=Math.round(W*dpr);cv.height=Math.round(H*dpr);
renderBG();
}
```
Note rows max 21 (odd fine).
State vars:
```
let state='demo',autoMode=true;
let segs=[],metas=[],trail=[],tAnim=0,stepTimer=0,stepDur=172,score=0,best=0;
let passengers=[],particles=[],floats=[],smoke=[];
let dirQ=[],shake=0,time=0,smokeAcc=0,pendingReset=null;
```
DIRS const.
resetRun:
```
function newMeta(){return{color:pick(COAT),skin:pick(SKIN),hair:pick(HAIR),cap:Math.random()<0.28?pick(CAPS):null,suit:Math.random()<0.6,phase:rnd(0,6.28)}}
function resetRun(demo){
clearTimeout(pendingReset);pendingReset=null;
dirQ.length=0;particles.length=0;floats.length=0;smoke.length=0;
score=0;updateStepDur();
autoMode=demo;
const startW=demo?5:2,V=startW+1;
const hy=rows>>1;
const hx=clamp((cols*0.38)|0,V+2,cols-V-3);
segs=[];for(let i=0;i<V;i++)segs.push({x:hx-i,y:hy});
metas=[{loco:true}];
for(let i=0;i<startW;i++)metas.push(newMeta());
trail=[];for(let i=V-1;i>=0;i--)trail.push({x:segs[i].x,y:segs[i].y});
trail.push({x:hx+1,y:hy});
tAnim=0;stepTimer=0;
passengers=[];
const np=(cols*rows<230)?2:3;
for(let i=0;i<np;i++)spawnPassenger();
setState(demo?'demo':'playing');
syncHUD();
}
```
spawnPassenger:
```
function spawnPassenger(){
for(let t=0;t<400;t++){
const x=(Math.random()*cols)|0,y=(Math.random()*rows)|0;
if(Math.abs(x-segs[0].x)+Math.abs(y-segs[0].y)<4)continue;
if(segs.some(s=>s.x===x&&s.y===y))continue;
if(passengers.some(p=>p.x===x&&p.y===y))continue;
const m=newMeta();m.x=x;m.y=y;
passengers.push(m);return;
}
}
```
path sampling:
```
function headPos(){const n=trail.length;return{x:lerp(trail[n-2].x,trail[n-1].x,tAnim),y:lerp(trail[n-2].y,trail[n-1].y,tAnim)}}
function curDir(){const n=trail.length;return{x:Math.sign(trail[n-1].x-trail[n-2].x)||1,y:Math.sign(trail[n-1].y-trail[n-2].y)}}
function pathPoint(d){
const n=trail.length;
if(d<0){const h=headPos(),c=curDir();return{x:h.x+c.x*(-d),y:h.y+c.y*(-d)}}
const tA=tAnim;
if(d<=tA+1e-9){const h=headPos();const k=tA>1e-9?d/tA:0;return{x:h.x+(trail[n-2].x-h.x)*k,y:h.y+(trail[n-2].y-h.y)*k}}
```
Hmm wait: moving from head BACK toward trail[n-2]: point = h + (trail[n-2]-h)*k ✓.
```
let k=d-tA,idx=Math.floor(k),frac=k-idx;
let bi=n-2-idx;
if(bi<=0)return{x:trail[0].x,y:trail[0].y};
return{x:lerp(trail[bi].x,trail[bi-1].x,frac),y:lerp(trail[bi].y,trail[bi-1].y,frac)};
}
function angleAt(d){
const a=pathPoint(d-0.22),b=pathPoint(d+0.22);
return Math.atan2(b.y-a.y,b.x-a.x);
}
```
Check bi<=0: when idx ≥ n-2: d beyond available → clamp trail[0] ✓. bi-1 index: bi≥1 ✓.
Hmm one edge: pathPoint(0) when tA=0: d<=tA branch k=0 → head ✓.
pathPoint(-0.33) during first motion ✓.
Step:
```
function step(){
const n=trail.length,T={x:trail[n-1].x,y:trail[n-1].y};
const eatIdx=passengers.findIndex(p=>p.x===T.x&&p.y===T.y);
const grow=eatIdx>=0;
let fatal=false;
for(let i=0;i<segs.length;i++){
if(segs[i].x===T.x&&segs[i].y===T.y&&!(i===segs.length-1&&!grow)){fatal=true;break}
}
if(fatal){tAnim=1;die('train');return}
segs.unshift({x:T.x,y:T.y});
if(grow){collect(passengers[eatIdx]);passengers.splice(eatIdx,1);spawnPassenger()}
else segs.pop();
while(trail.length>segs.length+7)trail.shift();
const from={x:trail[trail.length-1].x,y:trail[trail.length-1].y};
const back={x:trail[trail.length-2].x,y:trail[trail.length-2].y};
const d=(state==='playing')?playerDir(from,back):aiDir(from,back);
const T2={x:from.x+d.x,y:from.y+d.y};
trail.push(T2);
if(T2.x<0||T2.y<0||T2.x>=cols||T2.y>=rows){state='crashing';stepTimer=0}
}
```
Wait: unshift into segs BEFORE choosing — segs used by AI = post-move body ✓.
Hmm subtle: when grow, metas.push happens in collect BEFORE... collect pushes meta ✓ keep order: collect() then splice/spawn ✓. segs grew (no pop) and metas grew ✓ same length? segs.length = V+1 after unshift; metas pushed → V+1 ✓.
playerDir:
```
function playerDir(from,back){
const cur={x:Math.sign(from.x-back.x),y:Math.sign(from.y-back.y)};
while(dirQ.length){
const d=dirQ.shift();
if(from.x+d.x===back.x&&from.y+d.y===back.y)continue;
if(d.x===cur.x&&d.y===cur.y)continue;
return d;
}
return cur;
}
```
Note: from-back is ±1 single axis ✓ (consecutive trail cells).
aiDir:
```
function blockedAt(nx,ny,growOK){
for(let i=0;i<segs.length;i++){const s=segs[i];
if(s.x===nx&&s.y===ny){if(!(growOK&&i===segs.length-1))return true}}
return false;
}
function flood(sx,sy){
const blocked=new Set();for(const s of segs)blocked.add(s.y*cols+s.x);
if(blocked.has(sy*cols+sx))return 0;
const cap=Math.min(cols*rows,segs.length*2+40);
const seen=new Uint8Array(cols*rows);seen[sy*cols+sx]=1;
const q=[sx,sy];let i=0,c=0;
while(i<q.length&&c<cap){
const x=q[i++],y=q[i++];c++;
for(const dd of DIRS){
const nx=x+dd.x,ny=y+dd.y;
if(nx<0||ny<0||nx>=cols||ny>=rows)continue;
const k=ny*cols+nx;
if(seen[k]||blocked.has(k))continue;
seen[k]=1;q.push(nx,ny);
}
}
return c;
}
function aiDir(from,back){
const cur={x:Math.sign(from.x-back.x),y:Math.sign(from.y-back.y)};
let tgt=null,td=1e9;
for(const p of passengers){const dd=Math.abs(p.x-from.x)+Math.abs(p.y-from.y);if(dd<td){td=dd;tgt=p}}
let best=cur,bestS=Infinity;
for(const d of DIRS){
if(from.x+d.x===back.x&&from.y+d.y===back.y)continue;
const nx=from.x+d.x,ny=from.y+d.y;
if(nx<0||ny<0||nx>=cols||ny>=rows)continue;
const grow=passengers.some(p=>p.x===nx&&p.y===ny);
if(blockedAt(nx,ny,grow))continue;
let s=Infinity;
if(tgt)s=Math.abs(nx-tgt.x)+Math.abs(ny-tgt.y)+rnd(0,0.35);
const sp=flood(nx,ny);
if(sp<segs.length+3)s+=300+(segs.length+3-sp)*3;
if(d.x===cur.x&&d.y===cur.y)s-=0.22;
if(s<bestS){bestS=s;best=d}
}
return best;
}
```
(best stays cur if all blocked → dramatic.)
collect:
```
function collect(p){
metas.push(p);
score++;
if(!autoMode&&score>best)best=score;
updateStepDur();syncHUD(true);
const cx=(p.x+0.5)*cell,cy=(p.y+0.5)*cell;
for(let i=0;i<9;i++)particles.push({t:'star',x:cx+rnd(-4,4),y:cy+rnd(-4,4),vx:rnd(-50,50),vy:rnd(-70,-14),r:rnd(1.6,3),ttl:rnd(.45,.8),life:0,color:pick(['#fff8e1','#ffe29a','#ffffff'])});
particles.push({t:'ring',x:cx,y:cy,r0:cell*0.18,ttl:.5,life:0,color:'#ffffff'});
floats.push({x:cx,y:cy-cell*0.3,life:0,ttl:.95,text:'+1'});
ding();
}
```
Note: passenger object itself becomes the wagon meta (has x,y extra fields — harmless).
die:
```
function die(){
state='dead';
shake=430;
const hp=pathPoint(0),hx=(hp.x+0.5)*cell,hy=(hp.y+0.5)*cell;
for(let i=0;i<26;i++)particles.push({t:'spark',x:hx,y:hy,vx:rnd(-170,170),vy:rnd(-170,170),r:rnd(1.4,3.2),ttl:rnd(.35,.75),life:0,color:pick(['#ffd27a','#ff9d4d','#fff3d6','#a9a9a9'])});
for(let i=0;i<8;i++)smoke.push(mkSmoke(hx,hy,rnd(2.5,4.5),true));
crashSound();
if(autoMode){
pendingReset=setTimeout(()=>{if(state==='dead')resetRun(true)},2000);
setState('demo');
}else{
setState('dead');
$('#deadCount').textContent=score;
const nb=score>0&&score>=best&&!prevBestReached? — simplify: newBest flag: const isNew = score===best && score>0; wait best updated live so best>=score always; new best if best===score (this run set it) — track: let madeBest=false set in collect when score>prevBest... simpler: track runBestAtStart... Let me store `bestAtRunStart` in resetRun(false): bestStart=best; then isNew = score>bestStart. ✓
$('#deadBest').textContent=isNew?'New best run!':'Best run: '+best;
$('#deadBest').classList.toggle('newbest',isNew);
pendingReset=setTimeout(()=>{if(state==='dead')resetRun(true)},5600);
}
}
```
Add `let bestStart=0;` set in resetRun when !demo: bestStart=best.
Hmm autoMode die: setState('demo') — but state var is 'dead'; body dataset demo → demo card visible during the 2s wait; dead card hidden ✓.
setState:
```
const chip=$('#chip');
function setState(s){
document.body.dataset.state=(s==='dead'&&autoMode)?'demo':s;
chip.textContent=s==='demo'?'AUTO-DRIVER':(s==='dead'?'DERAILED':'YOU’RE DRIVING');
chip.classList.remove('pop');void chip.offsetWidth;chip.classList.add('pop');
}
```
Wait when autoMode death: dataset 'demo', chip should say AUTO-DRIVER: with s==='dead' and autoMode → chip 'DERAILED' would flash then reset — prefer 'AUTO-DRIVER'. Adjust:
```
const label=(s==='dead'&&autoMode)?'AUTO-DRIVER':(s==='demo'?'AUTO-DRIVER':s==='dead'?'DERAILED':'YOU’RE DRIVING');
```
syncHUD:
```
const elScore=$('#score'),elWagons=$('#wagons'),elBest=$('#best');
function syncHUD(b){elScore.textContent=score;elWagons.textContent=metas.length-1;elBest.textContent=best;
if(b){const e=elScore;e.classList.remove('bump');void e.offsetWidth;e.classList.add('bump')}}
```
updateStepDur: stepDur=Math.max(104,172-score*2.4).
Input:
```
function takeoverOrRestart(){
unlockAudio();
if(state==='demo'){autoMode=false;setState('playing');toot();
const hp=pathPoint(0);for(let i=0;i<10;i++)smoke.push(mkSmoke((hp.x+0.5)*cell,(hp.y+0.5)*cell,rnd(2.5,4),true));
return true}
if(state==='dead'){clearTimeout(pendingReset);resetRun(false);toot();return true}
return false;
}
window.addEventListener('keydown',e=>{
const k=e.key;
let d=null;
if(k==='ArrowUp'||k==='w'||k==='W')d={x:0,y:-1};
else if(k==='ArrowDown'||k==='s'||k==='S')d={x:0,y:1};
else if(k==='ArrowLeft'||k==='a'||k==='A')d={x:-1,y:0};
else if(k==='ArrowRight'||k==='d'||k==='D')d={x:1,y:0};
else if(k===' '||k==='Enter'){e.preventDefault();if(!e.repeat)takeoverOrRestart();return}
else return;
e.preventDefault();
if(e.repeat)return;
if(takeoverOrRestart())return;
if(state!=='playing')return;
queueDir(d);
});
function queueDir(d){
const last=dirQ.length?dirQ[dirQ.length-1]:curDir();
if(d.x===last.x&&d.y===last.y)return;
if(d.x===-last.x&&d.y===-last.y)return;
if(dirQ.length<3)dirQ.push(d);
}
let ptr=null;
window.addEventListener('pointerdown',e=>{unlockAudio();if(takeoverOrRestart()){ptr=null;return}ptr={x:e.clientX,y:e.clientY}});
window.addEventListener('pointermove',e=>{
if(!ptr||state!=='playing')return;
const dx=e.clientX-ptr.x,dy=e.clientY-ptr.y;
if(dx*dx+dy*dy>680){queueDir(Math.abs(dx)>Math.abs(dy)?{x:Math.sign(dx),y:0}:{x:0,y:Math.sign(dy)});ptr={x:e.clientX,y:e.clientY}}
});
window.addEventListener('pointerup',()=>ptr=null);
window.addEventListener('pointercancel',()=>ptr=null);
```
26px ≈ 676 ✓ use 680.
Careful: pointerdown on the cards — they're pointer-events:none? I set `.card{pointer-events:none}` ✓ so clicks pass to window anyway (window listener regardless) ✓.
Also prevent context menu long-press? skip.
Audio:
```
let AC=null;
function unlockAudio(){
if(!AC){const C=window.AudioContext||window.webkitAudioContext;if(C){try{AC=new C()}catch(e){AC=null}}}
if(AC&&AC.state==='suspended'){AC.resume().catch(()=>{})}
}
function tone(f,dur,type,vol,delay=0,slide=0){
if(!AC)return;
try{
const t0=AC.currentTime+delay;
const o=AC.createOscillator(),g=AC.createGain();
o.type=type;o.frequency.setValueAtTime(f,t0);
if(slide)o.frequency.exponentialRampToValueAtTime(slide,t0+dur);
g.gain.setValueAtTime(0.0001,t0);
g.gain.exponentialRampToValueAtTime(vol,t0+0.015);
g.gain.exponentialRampToValueAtTime(0.0001,t0+dur);
o.connect(g);g.connect(AC.destination);
o.start(t0);o.stop(t0+dur+0.06);
}catch(e){}
}
function ding(){tone(1174,0.14,'sine',0.09);tone(1760,0.26,'sine',0.08,0.07)}
function toot(){tone(220,0.14,'square',0.05);tone(220,0.2,'square',0.05,0.18);tone(196,0.09,'square',0.035,0.4)}
function crashSound(){
tone(82,0.4,'triangle',0.2,0,55);
if(!AC)return;
try{
const len=(AC.sampleRate*0.32)|0;
const buf=AC.createBuffer(1,len,AC.sampleRate);
const ch=buf.getChannelData(0);
for(let i=0;i<len;i++)ch[i]=(Math.random()*2-1)*Math.pow(1-i/len,2.2);
const src=AC.createBufferSource();src.buffer=buf;
const f=AC.createBiquadFilter();f.type='lowpass';f.frequency.value=750;
const g=AC.createGain();g.gain.value=0.3;
src.connect(f);f.connect(g);g.connect(AC.destination);src.start();
}catch(e){}
}
```
Smoke:
```
function mkSmoke(x,y,r0,dark){
return {x:x+rnd(-2,2),y:y+rnd(-2,2),vx:rnd(-9,9)+rnd(6,14),vy:rnd(-14,-6),r:r0*u? careful units
```
Let me store smoke in px: r0 param passed as px-ish... I called mkSmoke(hx,hy,rnd(2.5,4.5),true) — treat r0 as u-multiplier: r=r0*u*4? Simplify: mkSmoke(x,y,scale) where r=rnd(2.5,4)*scale? Let me define r in px directly scaled by u: base r0 = rnd(0.05,0.09)*cell... I'll write:
```
function mkSmoke(x,y,big){
return {x:x+rnd(-u*3,u*3),y:y+rnd(-u*3,u*3),
vx:rnd(-6,6)+8,vy:rnd(-16,-7),
r:(big?rnd(0.10,0.16):rnd(0.05,0.08))*cell,
gr:cell*(big?0.28:0.16)/1,
ttl:rnd(0.9,1.5),life:0,dark:!!big};
}
```
gr = growth px/s: reach extra ~0.16cell over 1s ✓.
Chimney smoke spawn in loop:
```
function spawnChimneySmoke(){
const c=pathPoint(-0.34);
smoke.push(mkSmoke((c.x+0.5)*cell,(c.y+0.5)*cell,false));
}
```
Hmm chimney ahead of loco center: local chimney x=14u → 14/42=0.333 cells ✓ d=-0.333.
update smoke:
```
for(let i=smoke.length-1;i>=0;i--){const s=smoke[i];s.life+=dt;if(s.life>=s.ttl){smoke.splice(i,1);continue}
s.x+=s.vx*dt;s.y+=s.vy*dt;s.r+=s.gr*dt;}
```
draw:
```
for(const s of smoke){const a=(1-s.life/s.ttl)*(s.dark?0.5:0.42);
ctx.beginPath();ctx.arc(s.x,s.y,s.r,0,7);ctx.fillStyle=s.dark?`rgba(120,124,128,${a})`:`rgba(238,242,245,${a})`;ctx.fill()}
```
Particles update:
```
p.life+=dt; if(p.life>=p.ttl) remove;
if(p.t==='star'||p.t==='spark'){p.vx*=Math.pow(0.05,dt)? use exp decay: p.vx*=Math.exp(-3.2*dt); same vy; x+=vx*dt...}
```
`p.vy += 40*dt` gravity? top-down: no gravity; sparks decelerate ✓.
ring draw: r=r0+ (life/ttl)*cell*0.5; alpha=(1-life/ttl)*0.5; stroke lw 2.5*u white.
floats: y-=34*dt; alpha=1-life/ttl; also slight scale up at start.
Draw text:
```
ctx.font=`700 ${Math.max(11,13*u)}px 'Trebuchet MS',Verdana,sans-serif`;
ctx.textAlign='center';ctx.textBaseline='middle';
ctx.lineWidth=3*u;ctx.strokeStyle='rgba(43,66,38,0.85)';ctx.strokeText('+1',x,y)... fill '#fff'
```
Now drawRails full:
```
function drawRails(){
const V=segs.length,tailD=V-1;
const fadeStart=tailD+0.15,total=tailD+1.85;
const ga=0.185*cell;
// sleepers
ctx.save();
for(let d=-0.3;d<=total+0.001;d+=0.5){
const p=pathPoint(d),a=(d<0?angAhead():angleAt(d));
```
angleAt(d) for d=-0.3 uses samples at -0.52.. fine ✓ just use angleAt(d) always (works for negative via extension ✓).
```
const al=d>fadeStart?Math.max(0,1-(d-fadeStart)/1.7):1;
if(al<=0.02)break? continue
const px=(p.x+0.5)*cell,py=(p.y+0.5)*cell;
ctx.save();ctx.translate(px,py);ctx.rotate(a);
ctx.globalAlpha=al*0.95;
ctx.fillStyle='#7c5b39';
ctx.beginPath();ctx.roundRect(-1.6*u,-12.5*u,3.2*u,25*u,1.6*u);ctx.fill();
// sleeper highlight top edge? skip
ctx.restore();
}
// rails
const step=0.2;
const pts=[];
for(let d=-0.55;d<=total+0.001;d+=step){
const dd=Math.min(d,total);
const p=pathPoint(dd),a=angleAt(clamp(dd,-0.4,total-0.0001));
```
Hmm angleAt near end when d+0.22 > total → pathPoint clamps → both points clamp to trail[0] → angle NaN (atan2(0,0))! Guard: for clamped region angle of last two distinct... Let me compute angle via neighbors of dd but if both clamp → use angleAt(total-0.3). Simplify: compute angle from points at dd-0.15 and dd+0.15 BEFORE clamping d for position? The path truly ends at trail[0]; beyond it's off-history. Total ≤ tailD+1.85 ≤ available (tAnim+V-1+...) — available distance = tAnim + (trail.length-2); trail.length ≤ V+8 → available ≥ V-1 = tailD... hmm available max = tAnim+(V+8-2) = V+6+tAnim ≥ total=V+0.85 ✓ so pathPoint never clamps as long as trail history kept at 7 extra ✓ (trim to segs.length+7 → trail.length-2 ≥ V+5 → available ≥ V+5 ≥ total ✓).
So angleAt safe ✓ (guard anyway in pathPoint with clamp; angles fine since no clamping in practice).
Wait: right after resetRun, trail.length=V+1 → available = tAnim+V-1 = at tAnim=0: V-1 = tailD → total=tailD+1.85 EXCEEDS available → pathPoint clamps → rails pile at trail[0] for d>tailD! Fade region broken right after reset (first ~1s). The piled points → angle NaN for pts beyond... pathPoint returns trail[0] exactly; angleAt(d) with both samples at trail[0] → atan2(0,0)=0 → angle 0 → sleepers/rails at trail[0] pointing right — a cluster blob at tail start. Fix: in resetRun, pre-extend trail history backwards with extra points (they're just behind the tail, collinear): push... trail = [extend 6 more cells behind tail] — cells at hx-V-... x from hx-V-5? They'd be off... they're within board presumably (hx ≥ V+2 ensures room: hx-V-5 ≥ -3 → could go negative! hx≥V+2 only guarantees hx-V≥2. Choose hx ≥ V+6? clamp(hx, V+6, cols-V-3) needs cols ≥ 2V+9: cols≥12, V demo=6 → 21 ✓ 12 vs 2*3+9=15 hmm user-run V=3 → 15 > 12 on tiny boards → clamp range invalid (min>max): handle: hx = clamp(..., Math.min(...)) fallback Math.floor(cols/2). Let me:
```
const minHx=Math.min(V+6, (cols>>1)-1);
const hx=clamp((cols*0.38)|0, minHx, cols-4);
```
This guarantees left extension room of min(V+6, cols/2-1) cells; if board narrow, fewer history cells — partial history ok (fade only slightly broken initially on tiny boards; acceptable). Simpler: after building trail, prepend extra history points:
```
const hx0=trail[0].x;
for(let i=1;i<=5;i++)trail.unshift({x:hx0-i,y:hy});
```
If hx0-i <0 → OOB history points — rails drawn off-board briefly — invisible (clipped) ✓ harmless! Great, just prepend 5 cells regardless.
Then trail.length = V+1+5 → available ≥ V+4+tAnim ≥ total ✓.
Also trim keeps ≥ V+7 going forward ✓ consistent.
Rails drawing code:
```
let curAlpha=1,bucket=[];
const flush=()=>{ if(bucket.length<2){bucket=[];return}
for(const side of[-1,1]){
ctx.beginPath();
for(const q of bucket){
const ox=Math.cos(q.a+Math.PI/2)*ga*side,oy=Math.sin(q.a+Math.PI/2)*ga*side;
const X=(q.p.x+0.5)*cell+ox,Y=(q.p.y+0.5)*cell+oy;
q===bucket[0]?ctx.moveTo(X,Y):ctx.lineTo(X,Y);
}
ctx.strokeStyle='#57646f';ctx.lineWidth=Math.max(1,2.4*u);ctx.lineCap='round';ctx.stroke(); — base wide
ctx.strokeStyle='#c2cdd4';ctx.lineWidth=Math.max(1,1.1*u);ctx.stroke(); — reuse same path? stroke twice: second stroke over ✓ same path still active ✓
}
bucket=[];
};
for(let d=-0.55;d<=total+0.001;d+=step){
const p=pathPoint(d),a=angleAt(d);
const al=d>fadeStart?Math.max(0,1-(d-fadeStart)/1.7):1;
const q=Math.round(al*5)/5;
if(q!==curAlpha){flush();curAlpha=q}
ctx.globalAlpha=q; // set before stroke
bucket.push({p,a});
}
flush();ctx.globalAlpha=1;
```
Set globalAlpha before flush inside flush: flush uses current ctx.globalAlpha ✓ set alpha per bucket before pushing? Order: compute q; if changed → flush (with old alpha set previously ✓) then set ctx.globalAlpha=q. Initialize ctx.globalAlpha=1 before loop. ✓
drawPassengers: as planned. Details code:
```
function drawPassengers(){
const hp=pathPoint(0);
for(const p of passengers){
const px=(p.x+0.5)*cell,py=(p.y+0.5)*cell;
const dist=Math.abs(p.x-hp... use grid dist to head: Math.abs(p.x - (hp.x|0))?? use segs[0] cells: manhattan from segs[0]:
const dm=Math.abs(p.x-segs[0].x)+Math.abs(p.y-segs[0].y);
const exc=clamp(1.7-dm*0.22,0.45,1.8);
const t=time*(2.6+3.4*exc)+p.phase;
const bob=Math.sin(t*2)*1.3*exc*u;
const wave=Math.sin(t*3.1);
ctx.save();ctx.translate(px,py);ctx.scale(u,u);
// pulse ring
const ph=(time*0.7+p.phase)%1;
ctx.beginPath();ctx.arc(21,21,20+ph*10,0,7);
ctx.strokeStyle=`rgba(255,255,255,${0.28*(1-ph)})`;ctx.lineWidth=2;ctx.stroke();
// slab
ctx.beginPath();ctx.roundRect(3.5,3.5,35,35,7);
ctx.fillStyle='#dcd8c8';ctx.fill();
ctx.strokeStyle='#b3ad97';ctx.lineWidth=1.5;ctx.stroke();
// seams
ctx.strokeStyle='rgba(90,85,70,.14)';ctx.lineWidth=1;
ctx.beginPath();ctx.moveTo(3.5,15);ctx.lineTo(38.5,15);ctx.moveTo(3.5,27);ctx.lineTo(38.5,27);ctx.stroke();
// platform edge stripe
ctx.fillStyle='rgba(255,255,255,.65)';ctx.fillRect(6,35,29,2);
// sign
ctx.fillStyle='#5c4a36';ctx.fillRect(33.5,7,2,9);
ctx.beginPath();ctx.roundRect(29,3,10,7,2.4);ctx.fillStyle=p.color;ctx.fill();
ctx.strokeStyle=shade(p.color,-0.35);ctx.lineWidth=1.3;ctx.stroke();
ctx.fillStyle='rgba(255,255,255,.85)';ctx.beginPath();ctx.arc(34,6.5,1.7,0,7);ctx.fill();
// suitcase
if(p.suit){ctx.beginPath();ctx.roundRect(6,31,8,6,1.6);ctx.fillStyle='#a3702f';ctx.fill();ctx.strokeStyle='#7a4f1e';ctx.lineWidth=1.2;ctx.stroke();
ctx.strokeStyle='#7a4f1e';ctx.beginPath();ctx.moveTo(8.5,31);ctx.quadraticCurveTo(10,28.5,11.5,31);ctx.stroke();}
// person (rotates to watch the train)
const ang=Math.atan2((segs[0].y+0.5)*? — in px: Math.atan2((segs[0].y+0.5)*cell-py,(segs[0].x+0.5)*cell-px);
```
Wait inside scaled/translated frame — compute angle BEFORE translate using px,py ✓ compute ang first then use.
```
ctx.translate(18,22+bob);
// shadow
ctx.beginPath();ctx.ellipse(0,1.5,8.5,7,0,0,7);ctx.fillStyle='rgba(40,55,30,.18)';ctx.fill();
ctx.rotate(ang);
// waving arm
ctx.strokeStyle=shade(p.color,-0.25);ctx.lineWidth=2.6;ctx.lineCap='round';
ctx.beginPath();ctx.moveTo(1,-7);
const ax=1+wave*3.5, ay=-11-Math.abs(wave)*1.5? hmm arm raised:
ctx.lineTo(2+wave*4,-12);ctx.stroke();
// other arm
ctx.beginPath();ctx.moveTo(1,7);ctx.lineTo(3,11);ctx.stroke();
// shoulders
ctx.beginPath();ctx.roundRect(-5,-8.5,10,17,5);
ctx.fillStyle=p.color;ctx.fill();ctx.strokeStyle=shade(p.color,-0.35);ctx.lineWidth=1.4;ctx.stroke();
// head
ctx.beginPath();ctx.arc(1,0,5,0,7);
ctx.fillStyle=p.skin;ctx.fill();
ctx.strokeStyle=shade(p.skin,-0.3);ctx.lineWidth=1;ctx.stroke();
// hair / cap (covers back of head)
ctx.beginPath();ctx.arc(1,0,5.05,Math.PI-1.25,Math.PI+1.25);
ctx.lineTo(1,0);ctx.closePath();
ctx.fillStyle=p.cap||p.hair;ctx.fill();
if(p.cap){ctx.beginPath();ctx.arc(0.2,0,1.4,0,7);ctx.fillStyle=shade(p.cap,-0.25);ctx.fill();}
// eyes
const blink=((time*0.55+p.phase)%3.2)<0.13;
ctx.fillStyle='#2e2620';
if(blink){ctx.fillRect(3.6,-2.6,1.8,0.7);ctx.fillRect(3.6,1.9,1.8,0.7);}
else{ctx.beginPath();ctx.arc(4.3,-1.9,0.8,0,7);ctx.arc(4.3,1.9,0.8,0,7);ctx.fill();}
ctx.restore();
}
}
```
Hair wedge: arc from PI-1.25 to PI+1.25 around back ✓ drawn over head, wedge closes via lineTo center → chord shape: covers back half + sides. Face (front wedge) remains skin ✓ eyes at x=4.3 (front) ✓.
Note the arm drawn before shoulders so it appears from behind shoulder — arm at y=-7 is shoulder edge; line to (2+wave*4, -12) — extends beyond shoulder ✓ looks like waving. Since drawn before body, partially hidden — draw arm AFTER shoulders instead for visibility: reorder: shadow → shoulders → arms → head. Waving arm from shoulder top edge: moveTo(0,-7)→(2+wave*4,-12.5) ✓ visible ✓.
Sign color = p.color ✓.
drawTrain:
```
function drawTrain(){
const V=metas.length;
// couplings
ctx.strokeStyle='#4a3527';ctx.lineWidth=Math.max(2,3.4*u);ctx.lineCap='round';
ctx.beginPath();
for(let i=0;i<V-1;i++){
const a=pathPoint(i),b=pathPoint(i+1);
ctx.moveTo((a.x+0.5)*cell,(a.y+0.5)*cell);ctx.lineTo((b.x+0.5)*cell,(b.y+0.5)*cell);
}
ctx.stroke();
for(let i=V-1;i>=0;i--)drawVehicle(i);
}
```
drawVehicle:
```
function drawVehicle(i){
const m=metas[i];
const p=pathPoint(i),a=angleAt(i);
ctx.save();ctx.translate((p.x+0.5)*cell,(p.y+0.5)*cell);ctx.rotate(a);ctx.scale(u,u);
if(m.loco)drawLoco();else drawWagon(m);
ctx.restore();
}
function drawWheels(L,Wd){
ctx.fillStyle='#2c2823';
const wl=7.5,ww=3.6;
for(const sx of[-1,1])for(const sy of[-1,1]){
ctx.beginPath();
ctx.roundRect(sx*L*0.32-wl/2, sy>0?Wd/2-1:-Wd/2-1-ww, wl, ww+1, 1.4);
ctx.fill();
}
}
function drawWagon(m){
const L=36,Wd=25;
// shadow
ctx.fillStyle='rgba(25,40,22,.2)';
ctx.beginPath();ctx.roundRect(-L/2+2,-Wd/2+3.5,L,Wd+1,7);ctx.fill();
drawWheels(L,Wd);
// body
ctx.beginPath();ctx.roundRect(-L/2,-Wd/2,L,Wd,6);
ctx.fillStyle=m.color;ctx.fill();
ctx.strokeStyle=shade(m.color,-0.38);ctx.lineWidth=2;ctx.stroke();
// plank lines
ctx.strokeStyle=shade(m.color,-0.18);ctx.lineWidth=1.3;
ctx.beginPath();ctx.moveTo(-L*0.24,-Wd/2+2.5);ctx.lineTo(-L*0.24,Wd/2-2.5);
ctx.moveTo(L*0.24,-Wd/2+2.5);ctx.lineTo(L*0.24,Wd/2-2.5);ctx.stroke();
// interior + passenger
ctx.beginPath();ctx.arc(1.5,0,8.8,0,7);
ctx.fillStyle='#f5eeda';ctx.fill();
ctx.strokeStyle=shade(m.color,-0.3);ctx.lineWidth=1.4;ctx.stroke();
// head
ctx.beginPath();ctx.arc(2.5,0,5.2,0,7);
ctx.fillStyle=m.skin;ctx.fill();
ctx.strokeStyle=shade(m.skin,-0.32);ctx.lineWidth=1;ctx.stroke();
// hair/cap
ctx.beginPath();ctx.arc(2.5,0,5.25,Math.PI-1.15,Math.PI+1.15);ctx.lineTo(2.5,0);ctx.closePath();
ctx.fillStyle=m.cap||m.hair;ctx.fill();
if(m.cap){ctx.beginPath();ctx.arc(1.8,0,1.3,0,7);ctx.fillStyle=shade(m.cap,-0.25);ctx.fill();}
// eyes forward
ctx.fillStyle='#2e2620';
ctx.beginPath();ctx.arc(5.6,-1.8,0.75,0,7);ctx.arc(5.6,1.8,0.75,0,7);ctx.fill();
}
```
Eyes at x=5.6: face circle center 2.5 r5.2 → front edge x=7.7; eyes at 5.6 within ✓.
drawLoco:
```
function drawLoco(){
const L=39,Wd=26;
ctx.fillStyle='rgba(25,40,22,.22)';
ctx.beginPath();ctx.roundRect(-L/2+2,-Wd/2+3.5,L,Wd+1,7);ctx.fill();
drawWheels(L,Wd);
// headlight glow (soft, ahead)
const g=ctx.createRadialGradient(26,0,1,26,0,14);
g.addColorStop(0,'rgba(255,220,140,.32)');g.addColorStop(1,'rgba(255,220,140,0)');
ctx.fillStyle=g;ctx.beginPath();ctx.arc(26,0,14,0,7);ctx.fill();
// boiler body
ctx.beginPath();ctx.roundRect(-L/2,-Wd/2,L,Wd,6);
ctx.fillStyle='#3f4b55';ctx.fill();
ctx.strokeStyle='#242d35';ctx.lineWidth=2;ctx.stroke();
// cab (wider, at the back)
ctx.beginPath();ctx.roundRect(-L/2-1.5,-Wd/2-2.5,13,Wd+5,5);
ctx.fillStyle='#2f6b4c';ctx.fill();
ctx.strokeStyle='#1d4a33';ctx.lineWidth=1.8;ctx.stroke();
// cab roof detail
ctx.fillStyle='rgba(255,255,255,.14)';
ctx.beginPath();ctx.roundRect(-L/2+1,-3,8.5,6,2.5);ctx.fill();
// boiler bands (brass)
ctx.fillStyle='#d9a441';
ctx.fillRect(4,-Wd/2+1.2,2.1,Wd-2.4);
ctx.fillRect(10.5,-Wd/2+1.2,2.1,Wd-2.4);
// steam dome
ctx.beginPath();ctx.arc(1.5? hmm dome between bands: center x= 7.5 conflicts bands at 4 &10.5 → dome at 7.25 r3
```
Let me lay out boiler front region: boiler spans x from -6.5 (cab edge) to 19.5. Bands at x=4 and x=10.5. Dome circle at (7.25,0) r 3.2 — overlaps bands slightly; move dome to x=15? chimney at x=15.5? Classic: chimney front-most. Layout: chimney (15.5,0) r3.8; dome (7.5, 0) r3.2; bands at 3.5 & 11.8? Fine-tune:
- bands x=3.2 and x=11.4 (width 2)
- dome at 7.3 r3.1
- chimney at 15.8 r3.7
- headlight small circle at (18.2,0) r1.7 yellow with darker ring — near front edge 19.5 ✓
- cowcatcher: from x=19.5 wedge to 25.5.
```
// steam dome
ctx.beginPath();ctx.arc(7.3,0,3.1,0,7);ctx.fillStyle='#d9a441';ctx.fill();ctx.strokeStyle='#a3762a';ctx.lineWidth=1;ctx.stroke();
// chimney
ctx.beginPath();ctx.arc(15.8,0,3.7,0,7);ctx.fillStyle='#232c33';ctx.fill();
ctx.strokeStyle='#d9a441';ctx.lineWidth=1.6;ctx.stroke();
// headlight
ctx.beginPath();ctx.arc(18.6,0,1.8,0,7);ctx.fillStyle='#ffd76a';ctx.fill();ctx.strokeStyle='#b98a3a';ctx.lineWidth=1;ctx.stroke();
// cowcatcher
ctx.beginPath();ctx.moveTo(19,-Wd/2+2);ctx.lineTo(25.6,0);ctx.lineTo(19,Wd/2-2);ctx.closePath();
ctx.fillStyle='#c94f42';ctx.fill();ctx.strokeStyle='#8f362c';ctx.lineWidth=1.6;ctx.lineJoin='round';ctx.stroke();
}
```
Glow at x=26 ✓ matches cowcatcher tip.
renderBG with colors: base '#a6c67f'? Let me tune palette: grass base #a3c47e; alt overlay rgba(255,255,255,.05); tuft stroke rgba(72,108,46,.5); flower white/cream + yellow center; vignette rgba(26,44,20,.32).
Also add faint outer border inside canvas: after vignette: stroke roundRect(3,3,W-6,H-6,12) rgba(0,0,0,.12) lw 2 — a subtle inset line ✓.
Frame loop:
```
let lastT=performance.now();
function loop(now){
requestAnimationFrame(loop);
let dt=(now-lastT)/1000;lastT=now;
if(dt>0.05)dt=0.05;
time+=dt;
if(shake>0)shake=Math.max(0,shake-dt*1000);
if(state==='demo'||state==='playing'){
stepTimer+=dt*1000;
let g=0;
while(stepTimer>=stepDur&&g++<4){
stepTimer-=stepDur;step();
if(state!=='demo'&&state!=='playing')break;
}
tAnim=clamp(stepTimer/stepDur,0,1);
smokeAcc+=dt;
while(smokeAcc>0.13){smokeAcc-=0.13;spawnChimneySmoke();}
}else if(state==='crashing'){
stepTimer+=dt*1000;
tAnim=Math.min(stepTimer/stepDur,0.45);
if(stepTimer>=stepDur*0.45){tAnim=0.45;die();}
smokeAcc+=dt;while(smokeAcc>0.1){smokeAcc-=0.1;spawnChimneySmoke();}
}
// particles
for(let i=particles.length-1;i>=0;i--){const p=particles[i];p.life+=dt;
if(p.life>=p.ttl){particles.splice(i,1);continue}
if(p.vx!==undefined){const f=Math.exp(-3*dt);p.vx*=f;p.vy*=f;p.x+=p.vx*dt;p.y+=p.vy*dt;}
}
for(let i=floats.length-1;i>=0;i--){const f=floats[i];f.life+=dt;f.y-=36*dt;if(f.life>=f.ttl)floats.splice(i,1)}
for(let i=smoke.length-1;i>=0;i--){const s=smoke[i];s.life+=dt;
if(s.life>=s.ttl){smoke.splice(i,1);continue}
s.x+=s.vx*dt;s.y+=s.vy*dt;s.r+=s.gr*dt;}
render();
}
requestAnimationFrame(t=>{lastT=t;loop(t)});
```
render:
```
function render(){
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.drawImage(bgCv,0,0,W,H);
let sx=0,sy=0;
if(shake>0){const k=shake/430;sx=rnd(-1,1)*6*k;sy=rnd(-1,1)*6*k}
ctx.save();ctx.translate(sx,sy);
drawRails();
drawPassengers();
drawTrain();
drawSmoke();
drawParticles();
drawFloats();
ctx.restore();
if(state==='dead'&&!autoMode){ctx.fillStyle='rgba(12,22,12,.28)';ctx.fillRect(0,0,W,H)}
}
```
Hmm drawImage(bgCv,0,0,W,H): bgCv internal size = device px; drawing with dest W,H under dpr transform ✓ correct.
drawParticles:
```
function drawParticles(){
for(const p of particles){
const k=1-p.life/p.ttl;
if(p.t==='ring'){
const r=p.r0+(1-k)*cell*0.55;
ctx.beginPath();ctx.arc(p.x,p.y,r,0,7);
ctx.strokeStyle=`rgba(255,255,255,${0.55*k})`;ctx.lineWidth=2.2*u;ctx.stroke();
}else{
ctx.globalAlpha=k;
ctx.fillStyle=p.color;
ctx.beginPath();ctx.arc(p.x,p.y,p.r*u*(p.t==='star'?1:0.9),0,7);ctx.fill();
ctx.globalAlpha=1;
}
}
}
```
Wait r stored 1.6-3 → times u ✓.
drawSmoke as above.
drawFloats:
```
function drawFloats(){
if(!floats.length)return;
ctx.font=`700 ${Math.max(11,13*u)}px 'Trebuchet MS',Verdana,sans-serif`;
ctx.textAlign='center';ctx.textBaseline='middle';
for(const f of floats){
const k=1-f.life/f.ttl;
ctx.globalAlpha=Math.min(1,k*2);
ctx.lineWidth=3*u;ctx.strokeStyle='rgba(38,60,32,.9)';
ctx.strokeText(f.text,f.x,f.y);
ctx.fillStyle='#fffdf4';ctx.fillText(f.text,f.x,f.y);
}
ctx.globalAlpha=1;
}
```
renderBG:
```
function renderBG(){
bgCv.width=cv.width;bgCv.height=cv.height;
const g=bgx;
g.setTransform(dpr,0,0,dpr,0,0);
g.fillStyle='#a3c47e';g.fillRect(0,0,W,H);
for(let y=0;y<rows;y++)for(let x=0;x<cols;x++){
if((x+y)&1){g.fillStyle='rgba(255,255,255,.05)';g.fillRect(x*cell,y*cell,cell+0.6,cell+0.6)}
}
g.lineCap='round';
for(let y=0;y<rows;y++)for(let x=0;x<cols;x++){
const h1=hash2(x,y),h2=hash2(x*5+13,y*3+71);
const cx=(x+0.15+h1*0.7)*cell,cy=(y+0.15+h2*0.7)*cell;
if(h1<0.34){
g.strokeStyle='rgba(70,106,45,.55)';g.lineWidth=Math.max(1,cell*0.045);
g.beginPath();
g.moveTo(cx,cy);g.lineTo(cx-cell*0.05,cy-cell*0.12);
g.moveTo(cx+cell*0.035,cy);g.lineTo(cx+cell*0.05,cy-cell*0.13);
g.moveTo(cx+cell*0.075,cy+cell*0.01);g.lineTo(cx+cell*0.115,cy-cell*0.09);
g.stroke();
}else if(h1>0.93){
const r=Math.max(1.4,cell*0.045);
g.fillStyle='#f7f2df';
g.beginPath();g.arc(cx,cy,r,0,7);g.arc(cx+r*1.6,cy+r*0.4,r*0.8,0,7);g.fill();
g.fillStyle='#eec648';g.beginPath();g.arc(cx,cy,r*0.55,0,7);g.fill();
}
}
const vg=g.createRadialGradient(W/2,H/2,Math.min(W,H)*0.38,W/2,H/2,Math.max(W,H)*0.75);
vg.addColorStop(0,'rgba(24,44,18,0)');vg.addColorStop(1,'rgba(24,44,18,.32)');
g.fillStyle=vg;g.fillRect(0,0,W,H);
g.strokeStyle='rgba(20,35,15,.25)';g.lineWidth=3;
g.beginPath();g.roundRect(2,2,W-4,H-4,14);g.stroke();
}
```
Note when h1<0.34 tufts every cell ~1/3 — plus 0.34-0.93 nothing, .93+ flowers ~7%. Tuft positions: hash2(x*5+13...) fine.
Init sequence:
```
chooseGrid();fitCanvas();resetRun(true);
window.addEventListener('resize',...)
requestAnimationFrame(...)
```
Resize:
```
let rzT=null;
window.addEventListener('resize',()=>{clearTimeout(rzT);rzT=setTimeout(()=>{
const r=board.getBoundingClientRect();
if(Math.min(r.width/cols,r.height/rows)<13){chooseGrid();fitCanvas();resetRun(state==='demo'||autoMode)}
else fitCanvas();
},160)});
```
Hmm if user playing & tiny... resetRun(false)? If autoMode true → demo reset; if playing on a tiny resize — resetting play feels harsh but grid changed; do resetRun(autoMode) — autoMode flag: if playing, autoMode=false → resetRun(false) keeps them playing fresh board ✓.
Also fitCanvas guards cell ≥ ... if r.width tiny, cell small — the <13 check triggers re-choose ✓.
Edge: `rows>>1` center.
One more consideration — metas length vs segs after death: frozen ✓.
HUD initial values in HTML: 0/5/0 then syncHUD on resetRun ✓ (wagons=5).
The dead card "run" numbers: `#deadCount`.
Check the take-over while demo: takeoverOrRestart sets autoMode=false, setState('playing') — chip updates ✓ card hides via body[data-state] ✓. Score/best remain from demo; best NOT updated by demo ✓ (collect guarded by !autoMode). But bestAtRunStart for "new best" — set in resetRun only... takeover keeps current run: bestStart stale (0) → if score exceeds best during play, "New best run!" shows even though... best starts 0 (demo didn't update), user's continuing run: score starts at demo value s0>0! If they die with score s0+2 > best=... hmm best updated live when !autoMode: best=max(best,score) each collect after takeover ✓. isNew = score>bestStart where bestStart set at resetRun... takeover didn't reset. Set bestStart=best at takeover too: in takeover: bestStart=best (which is ≤ demo score? best could be 0 while score 3 → then any pickup → score 4 > bestStart 0 → "New best" — technically true (their first real run). Fine; set bestStart=best at takeover ✓.
Also on takeover the train length 6+ from demo — playing a longer train is harder but fun; acceptable. Alternatively on takeover keep as is ✓.
Also HUD "wagons" counts demo wagons ✓.
Wait — one problem: `collect` pushes the passenger object as meta — passenger has x,y fields which later conflict? meta only reads color/skin/hair/cap ✓ harmless. But `spawnPassenger` checks `passengers.some(p=>p.x===x...)` — the collected passenger is spliced ✓.
passengers contain suit/phase ✓ used in drawing ✓.
Now — the sign uses p.color ✓ and the wagon that appears uses same color ✓ plus face skin/hair ✓ lovely.
Double-check drawRails usage of `angAhead` — removed, using angleAt(d) ✓ works for negative d.
angleAt(d) = atan2 of sample(d+0.22)-sample(d-0.22): for d=-0.3: samples -0.52 & -0.08 → both in head-extension region (d<0) → both computed via headPos+curDir*(-d) → collinear → angle = atan2(c.y,c.x) exactly ✓ good.
For d in [0, tAnim] region mixed ✓ smooth.
Sleepers drawn every 0.5 — spacing stable relative to path length (origin at d=-0.3, moving with head? d is relative to HEAD → sleepers move WITH the train! That's wrong visually — rails should stay fixed to the path, not slide! d=-0.3+0.5k relative to head: as head advances (tAnim 0→1 then new cell), the sampling positions shift... After a step, head is 1 cell further; sleeper at d fixed relative to head = 1 cell further in world → sleepers SLIDE forward with the train. BAD — very noticeable (sleepers moving under a stationary... everything moves anyway, but sleepers would appear to move with train rather than being stationary track).
Fix: sample sleepers (and rail fade?? rails fade relative to tail — fade is tied to tail which is fine—actually the fade region is relative to train, that's intended "track being laid"). But sleeper POSITIONS must be anchored to the path (world-fixed). Path arc-length parameter: total distance from path start... The path's oldest point changes as trail shifts (history trimmed) — anchoring to world: anchor to path cells! Sleeper positions at cell-center + half-cell offsets: for each pair of consecutive trail cells (segments), sleepers at... simplest: sleeper anchored per path SEGMENT: each 1-cell segment between trail[i] and trail[i+1] gets 2 sleepers at fractions 0.25 & 0.75 of that segment — positions fixed to cells ✓ (during head interpolation the newest segment (partial) — head segment from trail[n-2] to trail[n-1]: sleepers on it? It's being "laid" — place sleepers on COMPLETED segments only, plus the partial first sleeper appears when tAnim>0.5 at fixed fraction of the newest cell? To keep it simple: for the segment being entered (b→target), sleepers appear progressively: at fraction 0.25 when tAnim≥0.25... overkill. Simplest robust: iterate trail segments i from 0..n-2 (each 1 cell, world-fixed between cell centers); skip the newest (n-1↔n-2) segment until completed? The head rides on it immediately — needs rails under loco NOW. Since loco front extends 0.5 cell past head center, the head segment needs sleepers as the loco advances. Use fractional appearance: for the newest segment, draw sleeper at fraction f only if tAnim ≥ f + 0.15 (sleeper appears just before wheels reach it). f positions 0.28, 0.72? Each segment gets sleepers at f=0.3 & 0.7 (relative to direction of travel from older to newer). That yields spacing 0.5 cells, world-anchored ✓ and appears laid just-in-time ✓ delightful.
Sleepers to draw: segments older than newest: full 2 each; newest: those with f ≤ tAnim+0.1.
But ALSO the extension ahead of head (-0.55..0) needs rails: that's along the newest segment beyond headPos — the segment from trail[n-2]→trail[n-1] IS the newest: the part ahead of headPos is at fraction tAnim..1 of it — its sleepers haven't "appeared" yet (they appear just in time) but RAILS (the steel lines) ahead of the loco should exist? Rails drawn along whole path polyline INCLUDING partial newest segment ahead of head? If rails ahead exist, why no sleepers? Visually: rails appearing slightly ahead (0.55 cell) is like the loco "unrolling" track — with sleepers popping in right under it. I think rails drawn continuous over the sampled path INCLUDING 0.55 ahead is fine and reads as "track just laid". Sleepers appear with the small delay. Good.
So rails polyline sampling: from d=-0.55 to tailD+1.85 relative to head — since d is head-relative, the rail POINTS at path positions are world-fixed anyway (the path is fixed; d just parameterizes). The polyline includes all fixed path points ✓ rails don't slide ✓ only the fade cutoff is train-relative ✓ correct.
And sleepers: anchored per segment fractions ✓.
Sleeper drawing loop:
```
const n=trail.length;
for(let i=0;i<n-1;i++){
// segment from trail[i] to trail[i+1]; skip pre-history? all fine
const a=trail[i],b=trail[i+1];
// distance behind head for fade: position fraction f: point = a+(b-a)*f; distance behind head ≈ (n-2-i) + tAnim - f?? compute per sleeper: dist = (distance from head to a) - f*1: distance from head to a = tAnim + (n-2-i)... a=trail[i]: from head back: head→trail[n-2]: tAnim; trail[n-2]→trail[n-3]: 1 each: distance to trail[i] = tAnim + (n-2-i). So sleeper at fraction f from a toward b (b is NEWER, closer to head): position distance behind head = tAnim + (n-2-i) - f.
for(const f of [0.3,0.7]){
const isNewest=(i===n-2);
if(isNewest && f>tAnim+0.12)continue; // just-in-time
const dBeh=tAnim+(n-2-i)-f;
if(dBeh< -0.55)continue; // ahead of head? only newest can be; with just-in-time rule, f≤tAnim+0.12 → dBeh≥-0.12 ok
const al=dBeh>fadeStart?Math.max(0,1-(dBeh-fadeStart)/1.7):1;
if(al<=0.02)continue;
const x=lerp(a.x,b.x,f),y=lerp(a.y,b.y,f);
const ang=Math.atan2(b.y-a.y,b.x-a.x);
draw sleeper at (x,y) rotated ang, alpha al
}
}
```
Wait direction: a=trail[i] older, b newer: segment direction = travel direction ✓ ang from a→b ✓.
dBeh for oldest segments: i=0: dBeh=tAnim+(n-2)-f — for long history: trail.length≤V+8 → i ranges to n-2 → dBeh max ≈ tAnim+n-2 ≈ V+6 ≥ total ✓ fade covers.
Sleeper alpha for pre-history (beyond fade) → 0 skip ✓.
Also: sleepers count = (n-1)*2 ≈ 2(V+8) — fine.
This is cleaner AND world-anchored.
Rails polyline world-anchored: sample d from -0.55 to total: the negative part ahead is head-relative but on the newest segment line (fixed line b→target) ✓ points are world-fixed along that segment ✓ no sliding (the segment line itself is fixed once pushed ✓).
One more check — trail pre-history prepended at reset: those segments get sleepers/rails ✓ fade region behind tail fully drawn at start ✓ nice.
But wait: prepended history points at reset could be OOB (x<0) → rails drawn off-canvas — clipped ✓ invisible ✓ fine.
Also sleepers on the newest segment rule: at tAnim small, no sleepers under loco front... loco front is at d=-0.48 (ahead): rails there but no sleepers for the first 0.1s — fine, appears instantly.
Now chip/JS DOM ids all set.
Also: `document.body.dataset.state` initial HTML: data-state="demo" ✓.
CSS card visibility mapping:
```
.card{...; opacity:0; visibility:hidden; transform:translate(-50%,-46%) scale(.92); transition:opacity .4s ease, transform .5s cubic-bezier(.2,1.3,.35,1), visibility 0s linear .4s;}
.card.show{opacity:1;visibility:visible;transform:translate(-50%,-50%) scale(1);transition-delay:0s}
body[data-state="demo"] #cardDemo{...show...}
body[data-state="dead"] #cardDead{...}
```
Simpler: use `.show` class toggled by JS? CSS body-state selectors keep JS simpler:
```
body[data-state="demo"] #cardDemo, body[data-state="dead"] #cardDead{opacity:1;visibility:visible;transform:translate(-50%,-50%) scale(1);transition-delay:0s,0s,0s}
body[data-state="dead"] #cardDead{transition-delay:.55s,0s,0s}
```
transition-delay list order matches transition properties (opacity, transform, visibility) ✓ opacity delayed .55s when appearing. When hiding, base rule delay: visibility gets .4s delay ✓ others 0 ✓.
Hmm but `#cardDead` matching BOTH rules — second more specific? Same specificity (body[data-state] #id vs body[data-state] #id) — later wins ✓ order them correctly.
Dead card appears .55s after death ✓.
Everything else: hint bar, done.
Now let me double check the passenger's sign: drawn at local (33.5,7..16) inside 42-cell — the sign post near top-right of the slab ✓ person at (18,22) ✓ suitcase bottom-left ✓ balanced.
The pulse ring: centered (21,21) radius 20+ph*10 → grows 20→30 (of 42 cell) ✓ subtle.
Now, one more important check: **passengers drawn under the train**: passenger cells never coincide with train cells EXCEPT momentarily while being collected (train head passes over → gone). Platform slab could visually hide the track when the tail's fading rails pass over the slab cell? Rails drawn BEFORE passengers → slab covers rails → when train later... the passenger gets eaten when head arrives; rails exist only under/behind train; slab cell is ahead of train until eaten → rails never under slab except leftover fade if train turned away — then rails under slab hidden ✓ fine.
Also z-order: person ON slab ✓.
**Fonts of floats** — Trebuchet ✓.
**Potential NaN**: angleAt when two samples identical (pathPoint clamp region for d>available): pathPoint clamps only beyond history; rails total ≤ V+0.85 < available V+4 ✓ never clamps. Sleepers use segment direction ✓ never NaN. Vehicle angles: i≤V-1 ✓ fine. headPos at tAnim∈[0,1] ✓.
curDir: trail[n-1]-trail[n-2] — always nonzero axis ✓ even at crash (target pushed) ✓.
**Initial tAnim=0 with trail last= target**: headPos = trail[n-2] = head cell ✓.
**stepTimer while-loop with state changes**: after die() state='dead' → loop breaks ✓ (condition checks state demo/playing).
Wait in the while loop I wrote `if(state!=='demo'&&state!=='playing')break;` ✓.
**crashing then die()**: die() sets state='dead' ✓ effects at headPos (0.45 into wall) ✓ sparks visible near edge ✓.
**Demo AI performance**: flood per candidate: 4 candidates × BFS up to cap(min(cells, 2V+40)) — with V=40 → cap=120, visited array alloc per call (Uint8Array cols*rows ~ 630 bytes ×4/step ~ fine).
Set for blocked built per flood call — 4× per step, segs up to ~50 → 200 set inserts/step @ 6 steps/s trivial ✓.
**Long-run growth**: AI survives long → V could reach 60-80 on a 480-cell board before AI dies (~50% fill). Sampling cost fine.
Demo runs 5.6s then auto-resets — during the wait the dead train sits — screenshot unlikely mid-wait ✓.
**Best display when demo**: stays whatever user achieved ✓.
**deadCount element inside <p> with <b>** ✓.
Now HTML head/body assembly + copy.
Chip: initial 'AUTO-DRIVER' in HTML ✓ (JS resets anyway).
Also the chip during crashing: 'YOU'RE DRIVING' (state playing label) ✓ fine.
Hint text: "Steer with the arrow keys or WASD · swipe on touch · every waiting passenger adds a wagon to your train" — keep shortish.
Title/branding: h1 "The Little Railway", tagline "A SNAKE GAME WITH A TICKET TO RIDE"? Keep: "pick up every passenger · don't derail" — with uppercase styling via CSS.
Stats labels: "picked up", "wagons", "best run".
Icons color: give each stat icon a distinct accent color via inline fill? Use color per stat: person #f2b134, wagon #4f9dde, star #e2574c? Set via CSS nth-child or class. Nice touch: `.stat svg{color:...}` per stat class. I'll add classes s1/s2/s3.
Card demo paragraph mention: "Every waiting passenger adds another colorful wagon — just don't run into your own train or off the board."
Let me also reconsider the wagon face orientation: hair wedge at back (PI±1.15) and eyes forward ✓ done.
The loco has no face — it's the engine ✓ distinct.
Edge: `metas[i]` for i beyond metas length never (lengths synced) ✓.
Let me also make sure the canvas rounded-corner clipping doesn't clip the wooden frame box-shadow (no—shadow outside) ✓.
Potential CSS gotcha: `100dvh` in sandboxed iframe — fine in Chrome. Fallback: `height:100vh` then `100dvh` override — declare both.
Also `overflow:hidden` on body prevents scrollbars ✓; plus `position:fixed` inset? Not needed.
Text selection during play: `user-select:none` on body ✓ good for game.
Cursor: default.
One more polish: `canvas{cursor:pointer}` in demo state? Subtle: body[data-state="demo"] canvas{cursor:pointer} ✓ nice.
Now let me also think about whether the AI reversing check could fail at tAnim... no, dir logic independent of tAnim.
Potential issue: `segs.some` inside spawnPassenger during resetRun when segs just built ✓ fine.
`passengers.findIndex` returns -1 ✓ handled.
`collect` — before splice — push meta referencing same object; the object also remains in `metas` forever (fine).
Also **updateStepDur** after collect uses score — stepDur shrinks ✓ speed applies to next while-loop iterations ✓.
**Smoke during demo card**: visible ✓ nice.
Let me write mkSmoke for takeover burst: dark=true → gray. Actually takeover should be light celebratory puffs: use dark=false bigger scale: mkSmoke(x,y,false) with r bigger? Add param big → r larger & more opaque. Use dark flag for crash, and for takeover pass big: let me make signature mkSmoke(x,y,scale) with scale=1 normal, 2 = big; and a `dark` param:
```
function mkSmoke(x,y,scale,dark){
return {x:x+rnd(-3,3)*u,y:y+rnd(-3,3)*u,vx:rnd(-6,6)+9*scale,vy:rnd(-16,-7)*scale? no...
```
Keep it simple:
```
{x:..., y:..., vx:rnd(-8,8)+10, vy:rnd(-18,-8), r:rnd(2.2,3.6)*u*scale, gr:rnd(10,16)*u*scale, ttl:rnd(0.8,1.4), life:0, dark:!!dark}
```
Normal scale=1 gives r≈3u≈3px at 42px cell... too small; chimney puffs should start ~2.5px and grow to ~12px: r=rnd(2,3)*u*1? u=cell/42≈1 at desktop → r 2-3px, gr: rnd(14,20)*u → +17px/s over 1s → 20px ✓ good. scale for bursts 1.6.
spawnChimneySmoke: smoke.push(mkSmoke(cx,cy,1)).
Takeover burst: for i<10: mkSmoke(hx,hy,1.6) ✓.
Crash: 8× mkSmoke(hx,hy,1.5,true) ✓.
Good.
Also floats "+1" — maybe also show the passenger count milestone? skip.
Now writing the final HTML. Also must double check no `localStorage` etc ✓ none. No external requests ✓ (noise texture is data URI ✓). No alert ✓.
Icon SVGs — make them clean, `stroke="currentColor"` with `fill="none"`, stroke-width 2, viewBox 24.
Person:
```
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="7.5" r="3.8"/><path d="M5 20.5c.7-4.2 3.6-6.5 7-6.5s6.3 2.3 7 6.5"/></svg>
```
Wagon:
```
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="4.5" width="15" height="11" rx="2.5"/><path d="M17.5 8.5h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3"/><circle cx="7" cy="18.5" r="2"/><circle cx="14" cy="18.5" r="2"/><path d="M9 18.5h3"/></svg>
```
Hmm that's like a wagon with a coupler poking right ✓ cute.
Star (best):
```
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.8l2.8 5.8 6.4.9-4.6 4.5 1.1 6.3-5.7-3-5.7 3 1.1-6.3L2.8 9.5l6.4-.9z"/></svg>
```
Logo loco (facing right):
```
<svg class="logo" viewBox="0 0 58 36" aria-hidden="true">
<rect x="1" y="32" width="56" height="3" rx="1.5" fill="#6d5133"/>
<rect x="9" y="12" width="26" height="13" rx="6.5" fill="#f0e6cf"/>
<rect x="3" y="6" width="15" height="19" rx="3" fill="#e2574c"/>
<rect x="6.5" y="9.5" width="7" height="7" rx="1.5" fill="#f6eedb"/>
<rect x="33" y="5" width="5.5" height="9" rx="1.5" fill="#3a4552"/>
<rect x="30" y="11" width="2.6" height="15" fill="#d9a441"/>
<circle cx="24" cy="9.5" r="3" fill="#d9a441"/>
<path d="M35 13l7 12h-7z" fill="#8f362c"/>
<g fill="#f6eedb" stroke="#233027" stroke-width="1.4">
<circle cx="9" cy="28" r="4"/><circle cx="18" cy="28" r="4"/><circle cx="30" cy="28" r="4"/>
</g>
<circle cx="9" cy="28" r="1.4" fill="#233027"/>... hubs for each
</svg>
```
Cowcatcher path: boiler front edge x=35; wedge from (35,13)?? boiler spans y12..25; front at x=35: path M35,12 L42,25 L35,25 Z ✓ red-dark #8f362c... make it accent red like cab? Cowcatcher dark red ✓.
Wheels y=28 r4 → bottom 32 = ground line ✓ chimney top y=5 ✓ dome at (24,9.5) sits on boiler top y=12 — dome center 9.5 r3 → touches 12.5 ✓ overlapping boiler slightly ✓ fine.
Band rect at x=30 y=11 h15 — vertical across boiler (y12..25): y11..26 slightly over ✓ fine, tweak y=11.5 h=14.
OK. This logo is decorative; good enough at 52×34.
Now — final re-check of the "no horizontal scrollbar at 375px": app padding 10px; board fits canvas width = min(r.width/cols...) ≤ container ✓; header stats flex wrap ✓. Canvas W could exceed container? cell=min(rw/cols, rh/rows) → W=cell*cols ≤ rw ✓.
Also on 375 the card width min(88%,400) ✓.
The tagline `white-space:nowrap` might overflow tiny screens — hide under 640 via media query or allow wrap; set `overflow:hidden;text-overflow:ellipsis;max-width:100%`? Just hide on small ✓.
Let me now also double-check `chooseGrid` when board rect available — initial call before first paint: DOM ready, CSS applied ✓. But fonts/layout: board flex height depends on header height which depends on fonts — all laid out synchronously ✓.
Sandbox iframe: `100dvh` = iframe height ✓.
One risk: The evaluation harness screenshot at ~3s — our demo started, train moved ~15 cells, picked up maybe 1-2 passengers (min manhattan 4 → within reach) ✓ wagons growing ✓ smoke visible ✓ demo card visible ✓.
Another risk: **AI targets nearest passenger but passengers spawn ≥4 away — AI eats quickly ✓**.
Let me reconsider `spawnPassenger` min distance 4 — with 3 passengers, on 480 cells fine.
**Wagon gap check**: vehicles at 1-cell spacing; wagon L=36u=0.857cell, loco 39u=0.93cell → gap between loco rear (-19.5u) and wagon1 front (18u from its center at 42u away): distance between centers 42u; loco rear at -19.5u from loco center → wagon front edge at 42-18=24u from loco center → gap = 24-19.5 = 4.5u ✓ coupling visible ✓.
**Roundness**: body radius 6u ✓.
**Colors on grass**: wagons bright ✓; platform light gray-beige ✓; loco slate/green ✓.
I think the design is solid. Now also handle `visibilitychange`: when hidden, rAF stops; on return dt clamped 0.05 → train jumps ≤2 steps ✓ fine.
Write dead card paragraph: `<p class="big"><b id="deadCount">0</b> passengers picked up this run.</p>` and `<p id="deadBest" class="bestline">Best run: 0</p>`; newbest class → color accent + bold.
`.cta` on both cards.
Also add to demo card a small note about the current demo: fine as is.
Potential CSS collision: `.card p b` inherits.
Finally, let me count that everything referenced exists: ids: cv, board, chip, cardDemo, cardDead, deadCount, deadBest, score, wagons, best. ✓
Let me write the complete file now, being careful with syntax.
Also — `ctx.roundRect` used via `ctx.beginPath(); ctx.roundRect(...)` — polyfill defined on prototype handles args (x,y,w,h,r) with numeric r ✓ native exists anyway.
In drawRails flush(): I reference `bucket[0]===q` for moveTo — instead track `first` boolean:
```
let first=true;
for(const q of bucket){const X=..., Y=...; first?ctx.moveTo(X,Y):ctx.lineTo(X,Y); first=false;}
```
✓ cleaner.
Also flush uses ctx.globalAlpha set BEFORE flush called ✓: sequence: `if(q!==curA){flush();curA=q;ctx.globalAlpha=q;}` — wait flush must run with the OLD alpha (points accumulated under old q) — ctx.globalAlpha currently = old ✓ then set new ✓. Initial: curA=1, ctx.globalAlpha=1 ✓.
Edge: fade region alpha quantized 0.2 — at q=0 skip pushing (break out): if q===0 → flush and break ✓.
Now the sleepers: draw inside same function before rails with own loop ✓.
Let me also add tiny "buffer stops" at the rail end behind the tail? skip.
double-check drawPassengers uses `hp` — I compute dm from segs[0] instead (head cell) ✓ no need for hp... but the passenger turns to watch the train: angle toward segs[0] head ✓.
Also passenger should turn toward the TRAIN (any part) or head — head ✓.
**One more important thing**: keydown listener receives keys only after iframe focus/click — we display the message ✓.
**preventDefault for arrows** — must not block F5/F12: only preventDefault for handled keys ✓.
Alright, also consider iOS Safari? Target is latest Chrome ✓.
Now, writing everything out. I'll be methodical. Estimated ~700 lines.
One more check on `pathPoint` for d negative during 'dead' by wall (tAnim=0.45): headPos interp toward OOB target ✓ drawVehicle(0) at that pos ✓ others fine ✓.
And `spawnChimneySmoke` during crashing/dead? I only spawn in running/crashing branches ✓ dead stops ✓.
In render, when state dead by wall, loco partially off-canvas — the drawImage clip — canvas boundary clips ✓.
Also during 'crashing' the AI/queue not consulted ✓ step() not called ✓.
Now, `updateStepDur` also called at resetRun ✓.
`bestStart` variable declare.
Also — after resetRun from dead via demoTimer: resetRun(true) → autoMode=true → setState('demo') — body dataset demo → demo card pops again ✓. HUD wagons=5 ✓ score=0 ✓.
If the user had played (best>0) and idles → attract resumes ✓ nice loop.
I think there's one more subtle rendering question: the loco's headlight glow drawn INSIDE loco frame before body — the glow at x=26 extends beyond body; drawn before body → body covers overlap ✓ glow visible ahead ✓.
drawWheels before body → wheels peek at sides ✓ shadows drawn before wheels ✓.
Order in drawWagon: shadow → wheels → body → details ✓. In drawLoco same: shadow → wheels → glow → boiler → cab → bands → dome → chimney → light → cowcatcher ✓.
Hmm cab drawn over boiler rear — cab wider covers boiler rear ✓.
Also couplings color: '#4a3527' lw 3.4u round ✓ drawn before all vehicles ✓.
Rail colors vs coupling? distinct ✓.
Final structure of <script> — wrap in IIFE, 'use strict'.
Let me also sanity check `angleAt` cost per vehicle: 2 pathPoint calls O(1) ✓ per frame V×2 ✓ trivial.
drawRails pathPoint calls: sleepers 2×(n-1) — each needs pathPoint? No — sleepers computed from segment lerp directly (no pathPoint) ✓ cheap. Rails: samples every 0.2 cells × pathPoint+angleAt(2 more) → (V+2.4)/0.2 ≈ 5V+12 samples ×3 pathPoint ≈ 15V+36 — V=60 → ~936 O(1) calls ✓ fine.
OK writing final code now. During writing I'll keep functions in dependency order (hoisting: function declarations hoist ✓ so order flexible; const arrow funcs must precede use at CALL time, which is runtime after all defined ✓ since everything triggered after definitions... chooseGrid etc called at bottom init ✓).
Careful: `const board=$('#board')` must come after DOM — script at end of body ✓.
Let me write the HTML skeleton with final copy.
Footer hint: "Steer with arrow keys or WASD · swipe on touch · every passenger waiting on a platform adds a wagon to your train"
Length ok.
Demo card:
```
<div class="card" id="cardDemo">
<div class="eyebrow">Demo run · the auto-driver is at the controls</div>
<h2>All aboard!</h2>
<p>Steer the little train around the board to pick up waiting passengers. Every one you collect adds a colorful wagon — don’t hit your own train or slip off the edge.</p>
<div class="cta">Click or press a key to start</div>
<div class="keys"><kbd>↑</kbd><kbd>←</kbd><kbd>↓</kbd><kbd>→</kbd> or <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> · swipe on touch</div>
</div>
```
Dead card:
```
<div class="card" id="cardDead">
<div class="eyebrow">End of the line</div>
<h2>Derailed!</h2>
<p class="runline"><b id="deadCount">0</b> passengers picked up this run.</p>
<p class="bestline" id="deadBest">Best run: 0</p>
<div class="cta">Click or press a key to try again</div>
</div>
```
CSS for .runline b{color accent, font-size bigger}, .bestline{margin-top:6px;color:#7a8468;font-size:12px} .bestline.newbest{color:#c8452f? accent red;font-weight:700}.
Chip default text 'AUTO-DRIVER'.
Media queries final.
Also: `.keys kbd` styling; `·` chars fine.
Let me make sure the CTA pulse doesn't look gimmicky: gentle shadow breathing:
```
.cta{display:inline-block;margin-top:16px;background:#59a96a;color:#122b1b;font-weight:700;font-size:13.5px;letter-spacing:.02em;padding:11px 20px;border-radius:999px;box-shadow:0 4px 0 #3c7a4d, 0 10px 22px rgba(20,60,30,.35);animation:breathe 2.2s ease-in-out infinite}
@keyframes breathe{50%{transform:translateY(-2px)}}
```
Green button on cream card ✓.
Card h2 color deep green #28463a ✓; eyebrow letterspaced muted olive.
OK also `body[data-state] canvas cursor pointer` rule.
I'm fairly confident. Let me assemble the entire file carefully now, mentally compiling JS for syntax errors as I go.
Also remember: no console errors: careful with `roundRect` polyfill only defining when missing ✓; `AudioContext` guards ✓; `AC.resume()` promise rejection caught with .catch ✓ (resume returns promise; guard try/catch + .catch).
Potential unhandled promise from `AC.resume().catch()` handled ✓.
`tone()` guards AC null ✓.
Write out `syncHUD` before first use in resetRun — function declaration hoisted ✓ (I'll use function declarations throughout).
Also the initial HTML stat "wagons" should read 5 after resetRun(true) syncs ✓.
Let me handle one last edge in `queueDir`: curDir() uses trail — fine.
In keydown handler, `else if(k===' '||k==='Enter')` then `else return` — note keys like 'Shift' fall to else return without preventDefault ✓.
Also ignore keydown when meta/ctrl pressed? Minor; skip.
Edge: pressing 'w' etc triggers takeover then returns — first direction press consumed by takeover; acceptable.
Hmm — actually let me improve: on takeover from a direction key, ALSO queue that direction (so the player instantly steers). queueDir is safe (checks reverse). I'll do:
```
if(takeoverOrRestart()){ if(d&&state==='playing') queueDir(d); return }
```
Wait takeover just set state='playing' ✓ queueDir(d) applies if valid ✓ nice responsiveness. But careful: takeover when state==='demo' returns true and state now 'playing' ✓. When restart (dead→resetRun) state='playing' too — queue d also fine ✓ (train just spawned moving right; pressing left would be reverse-blocked ✓).
In pointerdown takeover: no dir to queue ✓.
`resetRun` clears dirQ at start ✓ then we queue after ✓ order: takeoverOrRestart() → resetRun inside → then queueDir ✓.
Now finalize sleeper alpha: fadeStart=tailD+0.15, fade span 1.7 → fully gone at tailD+1.85 ✓ matches total ✓.
Rails `total` = tailD+1.85 ✓.
Also draw a small red buffer-stop at the very end of fading track? skip.
Final answer assembly. Let me write it.
Additional check — `#cardDead` transition-delay when RESTARTING (removing .show via dataset change to demo/playing): base `.card` rule has `transition:opacity .4s ease, transform .5s ..., visibility 0s linear .4s` with no delay ✓ hides instantly-ish. But wait the rule `body[data-state="dead"] #cardDead{transition-delay:.55s,...}` — when dataset changes from dead→playing, that rule stops applying → base transition (no delay) ✓ good.
And when demo→dead: rule applies → delay .55s on opacity&transform, 0s on visibility — visibility flips instantly while opacity delayed .55 → card invisible-but-occupying? visibility:visible immediately + opacity still 0 for .55s → fine (fades in after .55) ✓.
For cardDemo hide (demo→playing): base transition ✓ fades out .4s ✓.
Alright — also ensure `.card` has `pointer-events:none` ALWAYS (never interactive) ✓.
Chip `pointer-events:none` too ✓ so it never blocks board clicks — actually window-level listeners get events anyway; but keep pointer-events none for both ✓.
Writing final file now.
let me pre-draft the CSS completely:
```css
:root{
--bg:#1b2a21;--ink:#f3ead6;--muted:#9cae95;
--card:#f7efdc;--cline:#d9c8a4;--accent:#e2574c;
--pill:#243a2c;--pline:#3a5843;
--fui:'Trebuchet MS','Segoe UI',Verdana,sans-serif;
--fser:Georgia,'Times New Roman',serif;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{background:var(--bg);color:var(--ink);font-family:var(--fui);overflow:hidden;user-select:none;-webkit-user-select:none;-webkit-font-smoothing:antialiased}
body::before{content:"";position:fixed;inset:0;z-index:0;pointer-events:none;background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/></filter><rect width='160' height='160' filter='url(%23n)' opacity='0.06'/></svg>")}
body::after{content:"";position:fixed;inset:0;z-index:0;pointer-events:none;background:radial-gradient(120% 95% at 50% 38%,rgba(0,0,0,0) 55%,rgba(3,10,5,.55) 100%)}
.app{position:relative;z-index:1;height:100vh;height:100dvh;display:flex;flex-direction:column;gap:10px;padding:14px 18px 10px;max-width:1600px;margin:0 auto}
header{display:flex;align-items:center;gap:14px;flex-wrap:wrap}
.brand{display:flex;align-items:center;gap:12px;min-width:0}
.logo{width:54px;height:34px;flex:none;filter:drop-shadow(0 3px 5px rgba(0,0,0,.4))}
.brand h1{font-family:var(--fser);font-style:italic;font-weight:700;font-size:clamp(18px,2vw,23px);line-height:1.05;text-shadow:0 2px 8px rgba(0,0,0,.4)}
.tagline{margin-top:3px;font-size:clamp(8.5px,1vw,10.5px);letter-spacing:.2em;text-transform:uppercase;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.stats{display:flex;gap:8px;margin-left:auto}
.stat{display:flex;align-items:center;gap:9px;background:var(--pill);border:1px solid var(--pline);border-radius:13px;padding:7px 13px;box-shadow:0 5px 14px rgba(0,0,0,.3)}
.stat svg{width:16px;height:16px;flex:none}
.stat.s1 svg{color:#f2b134}.stat.s2 svg{color:#4f9dde}.stat.s3 svg{color:#e2574c}
.stat b{display:inline-block;font-size:17px;line-height:1;font-variant-numeric:tabular-nums}
.stat b.bump{animation:bump .4s ease}
@keyframes bump{35%{transform:scale(1.45) translateY(-1px)}}
.stat span{display:block;margin-top:2px;font-size:9px;letter-spacing:.13em;text-transform:uppercase;color:var(--muted)}
.board{flex:1;min-height:0;position:relative;display:flex;align-items:center;justify-content:center}
canvas{display:block;border-radius:16px;box-shadow:0 0 0 5px #7d5a37,0 0 0 9px #493017,0 28px 60px rgba(0,0,0,.55);touch-action:none}
body[data-state="demo"] canvas{cursor:pointer}
.chip{position:absolute;top:12px;left:14px;z-index:3;pointer-events:none;font-size:10px;font-weight:700;letter-spacing:.18em;text-transform:uppercase;color:#e9f2df;background:rgba(18,32,22,.85);border:1px solid rgba(233,242,223,.28);border-radius:999px;padding:6px 12px;backdrop-filter:blur(4px)}
.chip.pop{animation:chipPop .45s cubic-bezier(.2,1.4,.4,1)}
@keyframes chipPop{0%{transform:scale(.75)}55%{transform:scale(1.1)}100%{transform:scale(1)}}
.card{position:absolute;left:50%;top:50%;z-index:4;pointer-events:none;width:min(88%,400px);text-align:center;background:var(--card);color:#33402f;border:2px solid var(--cline);border-radius:20px;padding:24px 30px 22px;box-shadow:0 26px 60px rgba(5,14,7,.55),inset 0 2px 0 #fff;opacity:0;visibility:hidden;transform:translate(-50%,-46%) scale(.92);transition:opacity .4s ease,transform .5s cubic-bezier(.2,1.35,.35,1),visibility 0s linear .4s}
body[data-state="demo"] #cardDemo,body[data-state="dead"] #cardDead{opacity:1;visibility:visible;transform:translate(-50%,-50%) scale(1);transition-delay:0s,0s,0s}
body[data-state="dead"] #cardDead{transition-delay:.55s,0s,0s}
.eyebrow{font-size:9.5px;font-weight:700;letter-spacing:.22em;text-transform:uppercase;color:#8d967b;margin-bottom:9px}
.card h2{font-family:var(--fser);font-style:italic;font-size:29px;color:#28463a;margin-bottom:9px}
.card p{font-size:13.5px;line-height:1.55;color:#57614b}
.runline b{color:var(--accent);font-size:16px}
.bestline{margin-top:6px;font-size:12px;color:#8d967b}
.bestline.newbest{color:var(--accent);font-weight:700}
.cta{display:inline-block;margin-top:16px;background:#59a96a;color:#10281b;font-weight:700;font-size:13.5px;padding:11px 20px;border-radius:999px;box-shadow:0 4px 0 #3c7a4d,0 12px 26px rgba(20,60,30,.35);animation:breathe 2.4s ease-in-out infinite}
@keyframes breathe{50%{transform:translateY(-2px)}}
.keys{margin-top:14px;font-size:11px;color:#8d967b}
kbd{display:inline-block;min-width:20px;background:#ece2c8;border:1px solid #cfc19d;border-bottom-width:2.5px;border-radius:5px;padding:1px 5px;font-family:var(--fui);font-size:10.5px;color:#4a5340;margin:0 1px}
.hint{text-align:center;font-size:11.5px;letter-spacing:.05em;color:var(--muted);padding-bottom:2px}
@media (max-width:640px){
.app{padding:10px 10px 8px;gap:8px}
.logo{width:42px;height:27px}
.stats{width:100%;margin-left:0}
.stat{flex:1;justify-content:center;gap:7px;padding:6px 6px;border-radius:11px}
.stat b{font-size:14px}
.stat span{font-size:7.5px;letter-spacing:.08em}
.tagline{display:none}
canvas{border-radius:12px}
.card{padding:18px 18px 16px;width:92%}
.card h2{font-size:23px}
.card p{font-size:12.5px}
.hint{font-size:9.5px}
.chip{top:8px;left:9px;font-size:8.5px;padding:5px 9px}
}
```
Note `.card p` also styles .keys? .keys is div ✓ ok. `.bestline` is p ✓ overrides.
stat span font 7.5px tiny but uppercase muted — hmm contrast: muted #9cae95 on pill #243a2c ok-ish; keep 8px min. Set 8px.
Also bump animation on `.stat b` ✓.
Now full JS final pass — I'll write it in one go, double checking syntax as I write. Also make sure I don't use any reserved issues.
One more thought on drawing passengers and `p.cap||p.hair` — CAPS includes '#ffffff' white cap → shade works on white ✓ wedge white with stroke? Add stroke to hair wedge: stroke shade(color,-0.25) lw .8 ✓ subtle.
Eyes: two arcs in one path with moveTo issue: `ctx.arc(4.3,-1.9,0.8,0,7);ctx.arc(4.3,1.9,0.8,0,7);` — second arc draws connecting line from first arc end to second start within same path with fill — line between them filled → thin sliver? Fill of two arcs connected by a chord line — could create a weird band. Use separate beginPath per eye or moveTo before each arc:
```
ctx.beginPath();ctx.moveTo(4.3+0.8,-1.9);ctx.arc(4.3,-1.9,0.8,0,7);ctx.fill();
```
Simpler: two separate paths. I'll do helper `dot(x,y,r)`.
Similarly blink rects fine.
Let me also double check smoke drift: vx positive ~+10 (wind to the right) & vy negative (up) — top-down wind arbitrary ✓.
Chimney world pos uses pathPoint(-0.34) ✓ but during turn the chimney follows path ✓.
Final: ensure `renderBG` called after `fitCanvas` and `resetRun` after both ✓ init order: chooseGrid(); fitCanvas(); resetRun(true); rAF loop.
Also `bestStart` init 0.
Hmm — one more: in `collect`, `if(!autoMode&&score>best)best=score;` then syncHUD shows best ✓.
In dead card: isNew=score>bestStart ✓.
When autoMode death: no card shown; best untouched ✓.
I also want dead-state dim only when user mode ✓ done.
Let me also double-check that during 'dead' the render loop still renders (particles finishing, shake) — loop always renders ✓ update branches skip stepping ✓.
Everything seems consistent. Time to write the final file.
Actually, wait — one more possible visual improvement for screenshot wow: initial demo train has 5 wagons in RANDOM colors ✓ colorful. Passengers 3 with colored signs ✓. Board green with wooden frame on dark green bg with cream typography ✓ distinctive, not a generic neon snake.
Also double-check the checkerboard: subtle 5% white — might look washed; use rgba(0,0,0,0.045) instead for alt? White overlay lightens → fresher grass. Keep white .05.
One more nuance: `drawRails` before `drawPassengers` means rails under slabs ✓; but the PULSE ring (white, around platform) drawn over rails ✓ fine.
Let me also reconsider: `floats` text drawn with `'Trebuchet MS'` — fine.
Final check of dead card ids referenced: `#deadCount`, `#deadBest` ✓.
Also in setState: dataset 'dead' → both chip & CSS ✓.
What about `document.body.dataset.state` when 'crashing'? state='crashing' → dataset='crashing' → no card matches → cards hidden ✓ chip label falls to 'YOU'RE DRIVING' ✓ good.
Now write it all out. I'll be careful about template literals inside the HTML (fine) and apostrophes: use `YOU\u2019RE DRIVING` or "YOU’RE DRIVING" inside double quotes ✓.
Also avoid `<` `>` issues inside script: `for(let i=0;i<...` fine in HTML script tag (only `</script` sequence dangerous) ✓ avoid writing "</script" in strings ✓ none.
Full code:
Also add `aria-hidden` to decorative svgs ✓.
Let me write the wheels rounding: `roundRect(sx*L*0.32-wl/2, y0, wl, ww+1, 1.4)` where y0 = sy>0 ? Wd/2-1 : -Wd/2-1-ww → wheel extends outside body edge by ww... For sy>0: from Wd/2-1 to Wd/2-1+ww+1 = Wd/2+ww → protrudes ww=3.6 ✓.
drawWheels param naming conflict with Wd fine.
In drawLoco glow gradient — createRadialGradient within scaled ctx: coords in local units ✓.
Now — the canvas letterboxing: `.board` flex centers canvas ✓ canvas exact W×H CSS px.
Potential subpixel: cell fractional (e.g. 41.6) → canvas W = fractional px → CSS width fractional fine ✓ crisp enough with dpr scaling.
For crispness maybe floor W,H to integers: W=Math.floor(cell*cols) with cell recomputed = W/cols... keep fractional, fine.
Alright — also the sleepers: spacing 0.3/0.7 fractions per 1-cell segment → 0.5 apart ✓ alternating alignment continuous across segments ✓ (0.3→0.7→next segment 1.3(=0.3+1)... 0.6 gap between segments' 0.7 and next 0.3+1?? 0.7 → 1.3 = 0.6 gap! Not uniform! Positions: ...,0.3,0.7,1.3,1.7,... gaps: 0.4,0.6,0.4,0.6 — alternating! Hmm should be uniform 0.5. Fix: fractions [0.25,0.75]: gaps 0.5,0.5 ✓ uniform. Use 0.25/0.75 ✓. And just-in-time threshold: f ≤ tAnim+0.12: sleeper 0.25 appears when tAnim≥0.13; 0.75 at tAnim≥0.63 ✓.
Sleepers under loco at start: newest segment just pushed at tAnim=0 — sleeper 0.25 appears at tAnim 0.13 (~22ms) ✓ effectively instant.
Ahead of head extension (d -0.55..0): that region is the newest segment beyond headPos: fraction > tAnim — no sleepers there (just-in-time) but RAILS extend ahead 0.55 — rails without sleepers ahead of loco — visually "rails being laid ahead" ✓ intentional.
OK good catch on fractions.
Another check — couplings visible gap 4.5u with coupling lw 3.4u ✓.
Let me also add a tiny detail: when the train picks up a passenger, briefly speed smoke burst at pickup: small white puff at pickup point — the star particles + ring cover it ✓ enough.
Writing final now — also remember `const DIRS=[{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];`
Let me make sure the `takeoverOrRestart` demo branch: state==='demo' — also possible state==='crashing' while demo: takeover during crash: state demo? state is 'crashing' → falls through returns false → then keydown tries queueDir but state!=='playing' → ignored ✓. But wait — demo mode: state==='demo' branch requires state exactly 'demo'; during demo-run the state IS 'demo' (AI plays within state 'demo') ✓ — I earlier considered state values 'demo'|'playing' as the running states ✓ yes AI runs while state==='demo' ✓ and 'crashing' can occur from demo too (AI trapped) — then death → autoMode → wait → resetRun(true) ✓ takeover during that wait: state==='dead' → restart as player ✓ (pendingReset cleared in resetRun ✓).
But careful: during 'crashing' in AUTO mode, takeover attempt does nothing (returns false) — user presses key, nothing happens until dead→then needs another press. Minor: handle state 'crashing' in takeover: treat like playing for takeover? If autoMode && state==='crashing' → let them take controls? The crash is inevitable (already crashing into wall) — taking over mid-crash is weird; simplest: ignore (they'll press again). But any keydown during demo usually... demo AI rarely crashes; fine. Actually to be safe, during 'crashing' let takeover succeed by switching to player AFTER death? Skip — acceptable edge.
Hmm, but one thing: if user clicks during 'crashing' (auto): pointerdown → takeoverOrRestart false → ptr set → pointermove state!=='playing' → no steer ✓ harmless.
OK. Also `queueDir` guard `state!=='playing'` at keydown ✓.
There's still one more scenario: user is playing, train crashes (state dead), auto demo-resume timer 5.6s fires while user reading card — resets to demo with demo card ("Click or press a key to start"). Standard attract behavior ✓.
Now, writing the file for real. I'll number-check braces carefully.
Also: In drawPassengers I referenced `u` before translate — pulse ring uses ctx.arc(21,21,...) inside scaled frame ✓ (radius in units) — lw 2 units scaled ✓.
The slab rounded rect 3.5..38.5 within 42 cell ✓.
Wait — in drawPassengers I do ctx.translate(px,py); ctx.scale(u,u); then later ctx.translate(18, 22+bob) — bob computed with `*u`?? I wrote bob=Math.sin(t*2)*1.3*exc*u — but if used inside scaled frame, bob should be in UNITS not px: bob_units = Math.sin(t*2)*1.3*exc (no u) since frame is scaled ✓. Fix: bob=Math.sin(t*2)*1.3*exc.
And angle computed before translate ✓ using px,py (px,py in device px) ✓ rotate within scaled frame — angle unaffected by scale ✓.
Suitcase/quadratics in units ✓.
Also the wave arm coordinates in units ✓.
drawWagon: all units ✓ since ctx.scale(u,u) applied in drawVehicle before calling ✓. lineWidth values are in units → scaled ✓ good (2 units = 2/42 cell ≈ 2px at 42px cell ✓).
drawLoco same ✓.
Sleeper roundRect in units with ctx scaled? drawRails: I translate(px,py) rotate(a) — did NOT scale. I used `-1.6*u,-12.5*u,3.2*u,25*u` multiplied by u manually ✓ equivalent. Rails ga=0.185*cell px ✓ lineWidths `Math.max(1,2.4*u)` px ✓.
drawTrain couplings px: lw Math.max(2,3.4*u) ✓ positions px ✓.
drawSmoke/floats/particles px ✓ (r stored in px via *u at creation ✓ — star r: rnd(1.6,3) then multiplied by u at draw `p.r*u`? I stored r and draw p.r*u — in collect I pushed r:rnd(1.6,3) raw; draw uses p.r*u ✓ consistent. Ring r0=cell*0.18 px ✓ drawn raw ✓.)
Smoke: mkSmoke r uses *u at creation: r:rnd(2,3)*u*scale ✓ px; draw raw s.r ✓. gr: rnd(14,20)*u px/s ✓.
spawnChimneySmoke scale=1 ✓.
Good. Also `floats` y in px ✓.
Let me also confirm drawParticles ring: `p.r0+(1-k)*cell*0.55` where k=1-life/ttl → (1-k)=life/ttl ✓ grows 0→0.55cell ✓ alpha 0.55*k ✓ fades ✓.
Everything checks. Final write.
One last copy check for the required phrase: "Click or press a key to start" ✓ exact in CTA.
Also mention swipe hint ✓.
Let me write the dead-card auto note? "…or watch the auto-driver take over" — skip, keep card clean.
Now produce the final single HTML file. I'll write ~650 lines carefully.
During final write, remember `const elScore=$('#score')` etc after DOM.
Also `let bestStart=0`.
Also `updateStepDur()` defined before resetRun? Function declarations hoisted ✓ using function keyword everywhere.
In `resetRun(demo)`, for user runs set `bestStart = demo?best:best;` → bestStart=best always ✓ simple.
double-check `syncHUD(true)` bump only on collect ✓.
`elWagons` etc ✓.
Alright — also the demo initial: chooseGrid on load — if board rect is 0 (extreme case display:none? no) ✓.
Writing final answer now. Also include `<meta name="color-scheme" content="dark">`? optional; skip.
Also small: the `title` in head.
For the `chip` initial HTML text: "AUTO-DRIVER".
Let me also give `h1` a bit of kerning love: font-feature? skip.
Final file below.
Wait — one more logic trace of `step()` full flow with a turn, to be sure trail and segs stay in sync:
Setup: V=3, segs=[A,B,C] (A head), trail=[C,B,A,T0] plus 5 history prepended: [C-5.., C-1?, C, B, A, T0]... prepending 5 to [C,B,A,T0]: unshift 5 points behind C. Trail: [h5..h1,C,B,A,T0] length 9. segs.length 3. Trim condition: trail.length(9) > segs.length+7(10)? No ✓ keeps.
tAnim=0: head at A ✓. Step 1: arrive T0 (say = A+right). No eat: segs.unshift(T0) → [T0,A,B], pop → [T0,A,B] (C removed) ✓ length 3 ✓. metas unchanged ✓. Trail push T1: length 10. Trim: 10>10? no ✓. tAnim resets... positions: pathPoint(0)=head between A→T0 at new tAnim ✓ vehicle1 at pathPoint(1): expected at A ✓: tAnim=0: k=1-0=1,idx=1,bi=n-2-1=10-2-1=7 → trail[7]=A, trail[6]=B: lerp(A,B,0)=A ✓✓.
Eat at T0: segs=[T0,A,B,C] (no pop) length 4; metas.push → 4 ✓; trail push T1 → 11; trim: 11 > 4+7=11? no ✓ (strictly greater) ✓. Next trim after another non-eat move: segs 4, trail 11→ push 12 → 12>11 → shift one (drops h5) ✓ keeps 11 ✓ history maintained ✓.
pathPoint max need: tail d=V-1=3 with tAnim up to 1: k=3-1=2,idx=2,bi=n-2-2=7 → trail[7],trail[6] ✓ within bounds (n=11) ✓.
Rails total=V-1+1.85≈4.85 from head; available behind head = tAnim+(n-2)= up to 1+9=10 ✓ plenty (rail fade region extends into old history ✓ drawn with fade ✓ history beyond needed... wait rails drawn only up to total 4.85 ✓ available 10 ✓ fine — excess history unused but harmless (adds a couple pathPoints; trim keeps +7 anyway).
All good.
One more: pathPoint guard `if(bi<=0)` — bi could be 0 legitimately (returns trail[0] ✓ same result) and negative only beyond history → clamp ✓ but note when bi=0 we return trail[0] which equals lerp(trail[0], trail[-1], frac) intent = trail[0] ✓ correct.
And `frac` negative impossible: k=d-tAnim ≥ ... d>tAnim ✓ k>0 ✓.
Also `d<=tA+1e-9` with tA=0 & d=0 → k=0 → head ✓.
DONE. Write it.
Actually also need to double check `aiDir` when `tgt` null (no passengers — impossible normally) → s=Infinity for all unless penalties apply → best=cur if nothing better... s=Infinity+? space penalty adds → all Infinity → bestS stays Infinity? `s<bestS` Infinity<Infinity false → best stays cur → drives straight → dies eventually ✓ acceptable fallback.
And `flood` cap: `segs.length*2+40` with long trains fills board fine.
Now — could the AI oscillate? Straight-preference + passenger target + random tiebreak → looks purposeful ✓.
FINAL WRITE.
Let me also think about `#deadBest` when new best: text 'New best run!' ✓ class newbest.
Include favicon? skip (data URI favicon could avoid 404 console... in sandbox no network anyway; browsers don't fetch favicon in srcdoc iframes typically. Skip.)
Write the code.
def final check on HTML entities: use `·` as · char directly (UTF-8) ✓, ’ ✓, ↑ arrows ✓.
Also in JS strings with apostrophes: "YOU’RE DRIVING" uses typographic ✓ inside double quotes fine.
Code time. I'll write it fully and carefully; then final review pass for syntax.