Train snakeOne-line
Qwen3.8 Max (0902)
by Qwenreasoning defaultClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 18 min 39 s
- Cost
- $0.294
- Output tokens
- 48,902
- Spent thinking
- 32,173 tokens
- Speed
- 43.8 tok/s
- Console errors
- 0
- First token after
- 2.3 s
- First visible text after
- 13 min 52 s
- Prompt tokens
- 418
- Code size
- 41.1 KB
- Finish reason
- stop
- Served by
- Alibaba
- Model id
qwen/qwen3.8-max-0902- Reasoning sent
model default- Max output
- 131,072 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 · 794 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light">
<title>Puffer's Meadow Railway 🚂</title>
<style>
:root{
--display:"Chalkboard SE","Comic Sans MS","Segoe Print","Comic Neue",cursive;
--body:"Trebuchet MS","Segoe UI",Verdana,sans-serif;
--cream:#f7ecd7; --red:#c8402f; --brass:#e0aa4e; --ink:#3b2a1a; --wood-d:#3f2412;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0;overflow-x:hidden}
body{font-family:var(--body);color:var(--ink);min-height:100dvh;background:#bfe3f2}
/* ---------- ambient sky scene ---------- */
.sky{position:fixed;inset:0;z-index:0;overflow:hidden;
background:linear-gradient(#a9dcef 0%,#cdeaf3 42%,#e7f4dd 78%,#d3ecb2 100%)}
.sun{position:absolute;top:34px;right:11%;width:92px;height:92px;border-radius:50%;
background:radial-gradient(circle at 36% 34%,#fff8d8,#ffd95e 62%,#f7b733);
animation:sunPulse 6s ease-in-out infinite}
.cloud{position:absolute;height:34px;background:#fff;border-radius:40px;opacity:.9;animation:drift linear infinite}
.cloud::before,.cloud::after{content:"";position:absolute;background:#fff;border-radius:50%}
.cloud::before{width:46%;height:165%;left:13%;bottom:18%}
.cloud::after{width:34%;height:120%;right:14%;bottom:28%}
.c1{width:160px;top:7%;animation-duration:80s;animation-delay:-22s}
.c2{width:215px;top:15%;animation-duration:110s;animation-delay:-64s;opacity:.75}
.c3{width:115px;top:26%;animation-duration:62s;animation-delay:-6s;opacity:.85}
.hill{position:absolute;border-radius:50%}
.h1{left:-12vw;bottom:-150px;width:70vw;height:300px;background:#b7da8b}
.h2{right:-16vw;bottom:-190px;width:75vw;height:340px;background:#a8d17b}
.h3{left:22vw;bottom:-160px;width:120vw;height:260px;background:#c3e296}
.balloon{position:absolute;left:9%;top:104%;width:30px;height:58px;animation:floatUp 52s linear infinite;opacity:.95}
.balloon i{display:block;width:30px;height:36px;border-radius:50% 50% 46% 54%/56% 56% 44% 44%;
background:radial-gradient(circle at 36% 30%,#ff9d8a,#d63f2e 72%);border:1px solid rgba(0,0,0,.15)}
.balloon::after{content:"";position:absolute;left:50%;bottom:0;width:10px;height:8px;margin-left:-5px;
background:#7a4b2c;border-radius:2px;box-shadow:0 -8px 0 -3px #7a4b2c}
/* ---------- station chrome ---------- */
.station{position:relative;z-index:1;max-width:1040px;margin:0 auto;padding:14px 16px 20px;
display:flex;flex-direction:column;align-items:center;gap:12px;user-select:none}
#topsign{width:100%;display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;
background:linear-gradient(#7c4d2a,#5e371c);border:3px solid var(--wood-d);border-radius:16px;
padding:10px 20px;position:relative;
box-shadow:0 8px 0 var(--wood-d),0 16px 26px rgba(40,25,10,.35)}
#topsign::before,#topsign::after{content:"";position:absolute;top:50%;width:10px;height:10px;margin-top:-5px;border-radius:50%;
background:radial-gradient(circle at 35% 30%,#ffe9b0,#c98f2e 70%);border:1px solid #6b4a12}
#topsign::before{left:8px}#topsign::after{right:8px}
.sign-left{display:flex;align-items:center;gap:14px}
.lamp{width:15px;height:15px;border-radius:50%;border:2px solid #2c1a0c;flex:none}
.lamp-l{animation:glowA 3s steps(1) infinite}
.lamp-r{animation:glowB 3s steps(1) infinite}
h1{font-family:var(--display);font-size:clamp(21px,3.6vw,40px);margin:0;color:var(--cream);
text-shadow:0 2px 0 rgba(0,0,0,.35);letter-spacing:.5px;line-height:1.05}
.tag{margin:2px 0 0;color:#e8cfa6;font-size:clamp(10px,1.4vw,14px);font-style:italic}
.sign-right{display:flex;gap:10px;align-items:center;flex-wrap:wrap;justify-content:flex-end}
.plaque{background:linear-gradient(#f2cd7e,#d9a441);border:2px solid #8a5f1d;border-radius:10px;
padding:4px 12px 6px;text-align:center;min-width:64px;
box-shadow:inset 0 2px 0 rgba(255,255,255,.55),0 3px 0 #8a5f1d}
.pl{display:block;font-size:9px;letter-spacing:1.6px;text-transform:uppercase;color:#6b4a12;font-weight:700}
.plaque strong{font-family:var(--display);font-size:24px;color:#402c08;line-height:1}
.pips{display:flex;gap:3px;padding:7px 2px 6px;justify-content:center}
.pips i{width:9px;height:9px;border-radius:50%;background:rgba(138,95,29,.2);border:1px solid #8a5f1d}
.pips i.on{background:#ff9d2e;box-shadow:0 0 6px #ff9d2e}
#muteBtn{width:38px;height:38px;border-radius:50%;border:2px solid #8a5f1d;cursor:pointer;font-size:16px;
background:linear-gradient(#f2cd7e,#d9a441);box-shadow:0 3px 0 #8a5f1d}
#muteBtn:active{transform:translateY(2px);box-shadow:none}
.board-wrap{width:100%;display:flex;justify-content:center}
.board-frame{position:relative;display:inline-block;background:linear-gradient(#8a5a33,#654022);
border:3px solid var(--wood-d);border-radius:18px;padding:10px;touch-action:none;
box-shadow:0 10px 0 var(--wood-d),0 20px 32px rgba(40,25,10,.35)}
.screw{position:absolute;width:11px;height:11px;border-radius:50%;
background:radial-gradient(circle at 35% 30%,#ffe9b0,#b98527 72%);border:1px solid #5d3f10}
.screw::after{content:"";position:absolute;left:1px;right:1px;top:50%;height:1.6px;background:#5d3f10;transform:rotate(40deg)}
.s1{top:5px;left:5px}.s2{top:5px;right:5px}.s3{bottom:5px;left:5px}.s4{bottom:5px;right:5px}
canvas{display:block;border-radius:10px;background:#9ecb68;
box-shadow:inset 0 0 0 3px rgba(43,66,36,.5),inset 0 6px 18px rgba(0,0,0,.28)}
/* ---------- overlays ---------- */
#banner{position:absolute;top:12px;left:50%;transform:translateX(-50%);z-index:5;pointer-events:none;
background:var(--cream);border:3px solid var(--red);border-radius:12px;padding:9px 20px 11px;
text-align:center;max-width:90%;box-shadow:0 10px 20px rgba(30,20,10,.35);
animation:wob 3.2s ease-in-out infinite}
#banner strong{display:block;font-family:var(--display);font-size:clamp(17px,3vw,26px);color:var(--red);margin:2px 0 1px}
#banner .sub{font-size:clamp(10px,1.6vw,13.5px);color:#6b4a12;font-weight:700}
.demo-badge{display:inline-block;background:var(--red);color:#fff;font-size:9px;font-weight:700;
letter-spacing:2.4px;padding:2px 9px;border-radius:20px;animation:blink 1.4s steps(2) infinite}
#fullOverlay{position:absolute;inset:10px;border-radius:10px;background:rgba(26,44,36,.55);
display:flex;align-items:center;justify-content:center;z-index:6}
.card{background:var(--cream);border:4px solid var(--red);border-radius:16px;padding:20px 34px 22px;
text-align:center;box-shadow:0 16px 34px rgba(0,0,0,.45);animation:pop .38s cubic-bezier(.2,1.7,.4,1)}
.card h2{font-family:var(--display);color:var(--red);font-size:clamp(26px,4.5vw,42px);margin:2px 0 8px}
.ov-kicker{letter-spacing:3.4px;font-size:10px;font-weight:700;color:#8a5f1d;text-transform:uppercase}
.ov-stats{display:flex;gap:10px;justify-content:center;font-size:15px;margin:4px 0 0}
.ov-stats b{color:#8a5f1d}
.ov-cta{margin:12px 0 0;font-weight:700;color:#402c08;font-size:14px;animation:blink 1.6s steps(2) infinite}
#toast{position:absolute;bottom:14px;left:50%;transform:translateX(-50%) translateY(10px);z-index:7;
background:rgba(63,36,18,.93);color:var(--cream);padding:7px 18px;border-radius:20px;
font-size:13px;font-weight:700;opacity:0;transition:all .3s ease;pointer-events:none;white-space:nowrap}
#toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
.hidden{display:none!important}
/* ---------- footer deck ---------- */
#deck{width:100%;display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap}
.hint{font-size:12px;color:#5d4a2f}
kbd{background:#fff8ea;border:1px solid #c9b489;border-bottom-width:3px;border-radius:5px;
padding:1px 6px;font-family:var(--body);font-size:11px;color:#6b4a12}
.consist-wrap{display:flex;align-items:center;gap:8px}
.cw-label{font-size:9px;letter-spacing:2.4px;text-transform:uppercase;color:#8a5f1d;font-weight:700}
#consist{display:flex;gap:4px;flex-wrap:wrap;max-width:62vw;justify-content:flex-end;align-items:center}
.chip{width:15px;height:11px;border-radius:3px;border:1px solid rgba(0,0,0,.35);animation:chipIn .32s cubic-bezier(.2,1.8,.4,1)}
.chip.loco{width:20px;background:#c8402f!important;border-radius:3px 7px 3px 3px;position:relative}
.chip.loco::after{content:"";position:absolute;top:-4px;left:3px;width:5px;height:5px;background:#33302c;border-radius:2px}
@keyframes drift{from{transform:translateX(-30vw)}to{transform:translateX(115vw)}}
@keyframes floatUp{0%{transform:translate(0,0)}45%{transform:translate(52px,-62vh)}100%{transform:translate(-30px,-138vh)}}
@keyframes wob{0%,100%{transform:translateX(-50%) rotate(-1.5deg)}50%{transform:translateX(-50%) rotate(1.5deg)}}
@keyframes blink{0%,55%{opacity:1}56%,100%{opacity:.35}}
@keyframes pop{from{transform:scale(.55);opacity:0}}
@keyframes chipIn{from{transform:scale(0)}}
@keyframes sunPulse{0%,100%{box-shadow:0 0 50px 16px rgba(255,214,100,.40)}50%{box-shadow:0 0 74px 28px rgba(255,214,100,.55)}}
@keyframes glowA{0%,49%{background:#e23b2e;box-shadow:0 0 9px #e23b2e}50%,100%{background:#5c9440;box-shadow:0 0 9px #79c254}}
@keyframes glowB{0%,49%{background:#5c9440;box-shadow:0 0 9px #79c254}50%,100%{background:#e23b2e;box-shadow:0 0 9px #e23b2e}}
@media (prefers-reduced-motion:reduce){
.cloud,.balloon,#banner,.demo-badge,.ov-cta,.lamp,.sun{animation:none!important}
}
@media (max-width:700px){
#topsign{justify-content:center;text-align:center}
.sign-left{flex-direction:column;gap:6px}
.lamp{display:none}
#consist{max-width:100%}
#deck{justify-content:center;text-align:center}
}
</style>
</head>
<body>
<div class="sky" aria-hidden="true">
<div class="sun"></div>
<div class="cloud c1"></div><div class="cloud c2"></div><div class="cloud c3"></div>
<div class="hill h3"></div><div class="hill h1"></div><div class="hill h2"></div>
<div class="balloon"><i></i></div>
</div>
<main class="station">
<header id="topsign">
<div class="sign-left">
<div class="lamp lamp-l"></div>
<div>
<h1>Puffer's Meadow Railway</h1>
<p class="tag">Collect every waiting passenger — mind your own wagons!</p>
</div>
<div class="lamp lamp-r"></div>
</div>
<div class="sign-right">
<div class="plaque"><span class="pl">Aboard</span><strong id="score">0</strong></div>
<div class="plaque"><span class="pl">Best</span><strong id="best">0</strong></div>
<div class="plaque"><span class="pl">Speed</span><span class="pips" id="pips"></span></div>
<button id="muteBtn" aria-label="Toggle sound">🔊</button>
</div>
</header>
<div class="board-wrap" id="boardWrap">
<div class="board-frame" id="frame">
<span class="screw s1"></span><span class="screw s2"></span>
<span class="screw s3"></span><span class="screw s4"></span>
<canvas id="game"></canvas>
<div id="banner">
<span class="demo-badge">DEMO RUN</span>
<strong>All aboard!</strong>
<span class="sub">The demo engineer is driving — click / tap or press any key to take the wheel</span>
</div>
<div id="fullOverlay" class="hidden"></div>
<div id="toast"></div>
</div>
</div>
<footer id="deck">
<div class="hint"><kbd>←↑↓→</kbd> / <kbd>WASD</kbd> drive · <kbd>P</kbd> pause · swipe on touch</div>
<div class="consist-wrap"><span class="cw-label">Consist</span><div id="consist"></div></div>
</footer>
</main>
<script>
'use strict';
/* ================= helpers ================= */
const $=id=>document.getElementById(id);
const TAU=Math.PI*2;
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const lerp=(a,b,t)=>a+(b-a)*t;
function lerpAngle(a,b,t){let d=(b-a)%TAU;if(d>Math.PI)d-=TAU;if(d<-Math.PI)d+=TAU;return a+d*t;}
function shade(hex,f){const n=parseInt(hex.slice(1),16);
const r=clamp(Math.round(((n>>16)&255)*f),0,255),g=clamp(Math.round(((n>>8)&255)*f),0,255),b=clamp(Math.round((n&255)*f),0,255);
return `rgb(${r},${g},${b})`;}
function tint(hex,f){const n=parseInt(hex.slice(1),16);
const r=Math.round(((n>>16)&255)+(255-((n>>16)&255))*f),g=Math.round(((n>>8)&255)+(255-((n>>8)&255))*f),b=Math.round((n&255)+(255-(n&255))*f);
return `rgb(${r},${g},${b})`;}
function rr(c,x,y,w,h,r){r=Math.min(r,w/2,h/2);c.beginPath();c.moveTo(x+r,y);
c.arcTo(x+w,y,x+w,y+h,r);c.arcTo(x+w,y+h,x,y+h,r);c.arcTo(x,y+h,x,y,r);c.arcTo(x,y,x+w,y,r);c.closePath();}
function mulberry(seed){return function(){seed|=0;seed=seed+0x6D2B79F5|0;
let t=Math.imul(seed^seed>>>15,1|seed);t=t+Math.imul(t^t>>>7,61|t)^t;
return((t^t>>>14)>>>0)/4294967296;};}
/* ================= constants ================= */
const COLS=22, ROWS=14, BASE_MS=150, MIN_MS=72;
const CAR_COLORS=['#e2574c','#3e8fd6','#f3b53f','#4fae7d','#9a6bd6','#ee8f4e','#45bfc4','#e77fb3'];
const COATS=['#d64570','#4c6ef5','#f59f00','#e8590c','#7048e8','#0d9bb0','#f06595','#c2255c','#5f3dc4','#e67700'];
const SKINS=['#f6cba4','#e2a878','#b97a50','#8a5a3b'];
const HATS=['#2f3e46','#c92a2a','#1864ab','#5f3dc4','#e67700','#087f5b','#a61e4d'];
const NAMES=['Pip','Momo','June','Ozzy','Bea','Tito','Nia','Gus','Lulu','Ivo','Suki','Remy','Ada','Bo','Cleo','Dot','Ezra','Faye','Hugo','Iris','Juno','Kai','Milo','Nell'];
const DIRS=[{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
const pick=a=>a[(Math.random()*a.length)|0];
/* ================= dom ================= */
const canvas=$('game'), ctx=canvas.getContext('2d');
const frameEl=$('frame'), boardWrap=$('boardWrap'), bannerEl=$('banner');
const fullOverlay=$('fullOverlay'), toastEl=$('toast');
const scoreEl=$('score'), bestEl=$('best'), pipsEl=$('pips'), consistEl=$('consist');
const topsign=$('topsign'), deck=$('deck'), muteBtn=$('muteBtn');
for(let i=0;i<8;i++) pipsEl.appendChild(document.createElement('i'));
/* ================= state ================= */
let dpr=1, cssW=600, cssH=0, cell=40;
const bgCanvas=document.createElement('canvas');
let mode='demo', paused=false, crashed=false;
let snake=[], prevSnake=[], dir={x:1,y:0}, angFrom=0, angTo=0;
let passengers=[], particles=[], inputQ=[];
let score=0, best=0, stepMs=BASE_MS, acc=0, stepCount=0;
let shakeT=0, smokeAcc=0, spawnCd=0, demoResetT=0, overlayTO=0, toastTO=0;
let timeNow=0;
/* ================= audio ================= */
let AC=null, audioOk=false, soundOn=true;
function unlock(){ if(audioOk)return; audioOk=true;
try{ AC=new (window.AudioContext||window.webkitAudioContext)(); if(AC.state==='suspended')AC.resume(); }catch(e){AC=null;} }
function tone(f,dur,type,peak,delay=0){ if(!audioOk||!soundOn||!AC)return;
try{ const t0=AC.currentTime+delay, o=AC.createOscillator(), g=AC.createGain();
o.type=type;o.frequency.value=f;o.connect(g);g.connect(AC.destination);
g.gain.setValueAtTime(0.0001,t0);g.gain.linearRampToValueAtTime(peak,t0+0.012);
g.gain.exponentialRampToValueAtTime(0.0001,t0+dur);o.start(t0);o.stop(t0+dur+0.1);}catch(e){} }
function ding(){ tone(880,.13,'triangle',.16); tone(1318,.2,'triangle',.13,.07); }
function whistleS(){ tone(587,.5,'triangle',.11); tone(740,.5,'triangle',.09,.03); tone(1174,.42,'sine',.05,.03); }
function crashS(){ if(!audioOk||!soundOn||!AC)return;
try{ const dur=.5, buf=AC.createBuffer(1,AC.sampleRate*dur,AC.sampleRate), d=buf.getChannelData(0);
for(let i=0;i<d.length;i++)d[i]=(Math.random()*2-1)*Math.pow(1-i/d.length,2);
const src=AC.createBufferSource();src.buffer=buf;
const f=AC.createBiquadFilter();f.type='lowpass';f.frequency.value=900;
const g=AC.createGain();g.gain.value=.4;src.connect(f);f.connect(g);g.connect(AC.destination);src.start();
tone(88,.4,'square',.11);}catch(e){} }
/* ================= layout & background ================= */
function layout(){
dpr=Math.min(window.devicePixelRatio||1,2);
const availW=boardWrap.clientWidth;
const availH=Math.max(240, window.innerHeight - topsign.offsetHeight - deck.offsetHeight - 96);
cssW=clamp(Math.min(availW, 940, availH*COLS/ROWS), 240, 940);
cssH=cssW*ROWS/COLS; cell=cssW/COLS;
canvas.style.width=cssW+'px'; canvas.style.height=cssH+'px';
canvas.width=Math.round(cssW*dpr); canvas.height=Math.round(cssH*dpr);
buildBG();
}
function buildBG(){
bgCanvas.width=canvas.width; bgCanvas.height=canvas.height;
const b=bgCanvas.getContext('2d'); b.setTransform(dpr,0,0,dpr,0,0);
b.fillStyle='#9ecb68'; b.fillRect(0,0,cssW,cssH);
b.fillStyle='rgba(255,255,255,.055)';
for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++) if((x+y)%2===0) b.fillRect(x*cell,y*cell,cell,cell);
const rnd=mulberry(20240415);
// soft mottling
for(let i=0;i<26;i++){ b.fillStyle='rgba(96,148,64,.10)'; b.beginPath();
b.ellipse(rnd()*cssW,rnd()*cssH,cell*(0.8+rnd()*1.6),cell*(0.5+rnd()),rnd()*3,0,TAU); b.fill(); }
// flowers
const fcols=['#ffffff','#ffd6e0','#ffe08a','#cfe8ff'];
for(let i=0;i<26;i++){ const x=cell*(0.6+rnd()*(COLS-1.2)), y=cell*(0.6+rnd()*(ROWS-1.2)), c=pick(fcols);
for(let k=0;k<4;k++){ b.fillStyle=c; b.beginPath();
b.arc(x+Math.cos(k*TAU/4)*cell*0.055, y+Math.sin(k*TAU/4)*cell*0.055, cell*0.045,0,TAU); b.fill(); }
b.fillStyle='#f2b53c'; b.beginPath(); b.arc(x,y,cell*0.035,0,TAU); b.fill(); }
// grass tufts
b.strokeStyle='#6f9e4a'; b.lineWidth=Math.max(1,cell*0.035); b.lineCap='round';
for(let i=0;i<20;i++){ const x=cell*(0.5+rnd()*(COLS-1)), y=cell*(0.5+rnd()*(ROWS-1));
for(let k=-1;k<=1;k++){ b.beginPath(); b.moveTo(x+k*cell*0.05,y);
b.lineTo(x+k*cell*0.09, y-cell*0.14); b.stroke(); } }
// pebbles
for(let i=0;i<8;i++){ b.fillStyle='rgba(150,150,140,.55)'; b.beginPath();
b.ellipse(rnd()*cssW,rnd()*cssH,cell*0.08,cell*0.055,rnd()*3,0,TAU); b.fill(); }
// bushes near corners
const corners=[[1.1,1.1],[COLS-1.4,1.2],[1.2,ROWS-1.4],[COLS-1.5,ROWS-1.5]];
for(const [bx,by] of corners){ const x=bx*cell,y=by*cell;
b.fillStyle='#578f45';
b.beginPath();b.arc(x-cell*0.22,y,cell*0.22,0,TAU);b.fill();
b.beginPath();b.arc(x+cell*0.2,y+cell*0.06,cell*0.2,0,TAU);b.fill();
b.fillStyle='#6fae54'; b.beginPath();b.arc(x,y-cell*0.14,cell*0.2,0,TAU);b.fill(); }
// hedge edge
b.strokeStyle='rgba(72,110,50,.4)'; b.lineWidth=cell*0.32;
b.strokeRect(cell*0.14,cell*0.14,cssW-cell*0.28,cssH-cell*0.28);
b.fillStyle='rgba(93,143,69,.55)';
for(let x=cell*0.5;x<cssW;x+=cell*0.8){ b.beginPath();b.arc(x,cell*0.14,cell*0.11,0,TAU);b.fill();
b.beginPath();b.arc(x,cssH-cell*0.14,cell*0.11,0,TAU);b.fill(); }
for(let y=cell*0.5;y<cssH;y+=cell*0.8){ b.beginPath();b.arc(cell*0.14,y,cell*0.11,0,TAU);b.fill();
b.beginPath();b.arc(cssW-cell*0.14,y,cell*0.11,0,TAU);b.fill(); }
// vignette
b.strokeStyle='rgba(43,74,40,.16)'; b.lineWidth=cell*0.9;
b.strokeRect(-cell*0.2,-cell*0.2,cssW+cell*0.4,cssH+cell*0.4);
}
/* ================= game setup ================= */
function makePassenger(x,y){
return {x,y,coat:pick(COATS),skin:pick(SKINS),hat:Math.random()<0.6?pick(HATS):null,
name:pick(NAMES),suitcase:Math.random()<0.4,phase:Math.random()*TAU,born:timeNow};
}
function spawnPassenger(){
for(let tries=0;tries<90;tries++){
const x=(Math.random()*COLS)|0, y=(Math.random()*ROWS)|0;
if(snake.some(s=>s.x===x&&s.y===y))continue;
if(passengers.some(p=>p.x===x&&p.y===y))continue;
if(Math.abs(x-snake[0].x)+Math.abs(y-snake[0].y)<4)continue;
if(passengers.some(p=>Math.abs(p.x-x)+Math.abs(p.y-y)<2))continue;
passengers.push(makePassenger(x,y)); return;
}
}
function resetGame(){
clearTimeout(overlayTO); demoResetT=0;
snake=[{x:8,y:7},{x:7,y:7},{x:6,y:7}];
prevSnake=snake.map(s=>({x:s.x,y:s.y}));
dir={x:1,y:0}; angFrom=0; angTo=0;
score=0; crashed=false; paused=false; acc=0; stepCount=0;
stepMs=BASE_MS; particles=[]; shakeT=0; smokeAcc=0; spawnCd=0; inputQ=[];
passengers=[]; spawnPassenger(); spawnPassenger(); spawnPassenger();
hideOverlay();
bannerEl.classList.toggle('hidden', mode!=='demo');
syncHUD(); syncConsist();
}
function startGame(msg){
unlock(); mode='play'; resetGame(); whistleS(); if(msg)toast(msg);
}
/* ================= HUD ================= */
function syncHUD(){
scoreEl.textContent=score;
if(score>best)best=score;
bestEl.textContent=best;
const filled=clamp(1+Math.floor(score/4),1,8);
[...pipsEl.children].forEach((el,i)=>el.classList.toggle('on',i<filled));
}
function syncConsist(){
consistEl.innerHTML='';
const loco=document.createElement('span'); loco.className='chip loco'; consistEl.appendChild(loco);
for(let i=1;i<snake.length;i++){
const c=document.createElement('span'); c.className='chip';
c.style.background=CAR_COLORS[(i-1)%CAR_COLORS.length]; consistEl.appendChild(c);
}
}
function toast(msg){
toastEl.textContent=msg; toastEl.classList.remove('show');
void toastEl.offsetWidth; toastEl.classList.add('show');
clearTimeout(toastTO); toastTO=setTimeout(()=>toastEl.classList.remove('show'),1900);
}
function showOverlay(kind){
fullOverlay.classList.remove('hidden');
if(kind==='over'){
fullOverlay.innerHTML=`<div class="card"><div class="ov-kicker">Oh no…</div><h2>DERAILED!</h2>
<p class="ov-stats"><span><b>${score}</b> passenger${score===1?'':'s'} aboard</span><span>·</span>
<span>best <b>${best}</b></span></p>
<p class="ov-cta">Click or press any key to run again</p></div>`;
}else if(kind==='paused'){
fullOverlay.innerHTML=`<div class="card"><div class="ov-kicker">Taking on water</div><h2>PAUSED</h2>
<p class="ov-cta">Press P or click to continue</p></div>`;
}else if(kind==='win'){
fullOverlay.innerHTML=`<div class="card"><div class="ov-kicker">Incredible!</div><h2>FULL TRAIN!</h2>
<p class="ov-stats"><span>Every passenger in the meadow is aboard!</span></p>
<p class="ov-cta">Click or press any key for another run</p></div>`;
}
}
function hideOverlay(){ fullOverlay.classList.add('hidden'); }
/* ================= effects ================= */
function emitSmoke(){
const hx=(snake[0].x+0.5)*cell, hy=(snake[0].y+0.5)*cell;
const life=0.9+Math.random()*0.5;
particles.push({type:'smoke',x:hx+dir.x*cell*0.32+(Math.random()-0.5)*cell*0.08,
y:hy+dir.y*cell*0.32+(Math.random()-0.5)*cell*0.08,
vx:-dir.x*cell*0.1+(Math.random()-0.5)*cell*0.22, vy:-cell*(0.42+Math.random()*0.3),
size:cell*(0.10+Math.random()*0.05), life, maxLife:life});
}
function burst(px,py){
for(let i=0;i<16;i++){ const a=Math.random()*TAU, sp=cell*(0.5+Math.random()*1.6);
particles.push({type:'steam',x:px,y:py,vx:Math.cos(a)*sp*0.4,vy:Math.sin(a)*sp*0.4-cell*0.6,
size:cell*(0.12+Math.random()*0.12),life:0.8+Math.random()*0.5,maxLife:1.2}); }
for(let i=0;i<14;i++){ const a=Math.random()*TAU, sp=cell*(1.5+Math.random()*2.5);
particles.push({type:'spark',x:px,y:py,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp-cell*1.2,
color:pick(['#ffb03a','#ff7a3a','#ffe08a']),size:cell*0.05,life:0.7+Math.random()*0.4,maxLife:1.1}); }
for(let i=0;i<5;i++){ const a=Math.random()*TAU, sp=cell*(1+Math.random()*1.8);
particles.push({type:'star',x:px,y:py,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp-cell,
rot:Math.random()*TAU,size:cell*0.11,life:0.9,maxLife:0.9}); }
}
function eatFX(px,py,coat,name){
particles.push({type:'ring',x:px,y:py,r:cell*0.2,life:0.45,maxLife:0.45});
for(let i=0;i<9;i++){ const a=Math.random()*TAU, sp=cell*(0.8+Math.random()*1.6);
particles.push({type:'spark',x:px,y:py,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp-cell,
color:Math.random()<0.5?coat:'#ffe08a',size:cell*0.045,life:0.55+Math.random()*0.3,maxLife:0.85}); }
particles.push({type:'text',x:px,y:py-cell*0.4,str:'+1 '+name,life:1.15,maxLife:1.15});
}
function updateParticles(dt){
for(let i=particles.length-1;i>=0;i--){ const p=particles[i]; p.life-=dt;
if(p.life<=0){ particles.splice(i,1); continue; }
if(p.type==='smoke'||p.type==='steam'){ p.x+=p.vx*dt; p.y+=p.vy*dt; p.vy-=cell*0.15*dt; p.size+=dt*cell*0.35; }
else if(p.type==='spark'){ p.x+=p.vx*dt; p.y+=p.vy*dt; p.vy+=cell*4.5*dt; }
else if(p.type==='star'){ p.x+=p.vx*dt; p.y+=p.vy*dt; p.vy+=cell*2.5*dt; p.rot+=3*dt; }
else if(p.type==='ring'){ p.r+=cell*2.4*dt; }
else if(p.type==='text'){ p.y-=cell*0.72*dt; }
}
}
/* ================= core step ================= */
function doCrash(){
crashed=true; acc=stepMs; shakeT=1; crashS();
burst((snake[0].x+0.5)*cell,(snake[0].y+0.5)*cell);
if(mode==='play'){
mode='over'; if(score>best)best=score; syncHUD();
overlayTO=setTimeout(()=>showOverlay('over'),650);
}else{ demoResetT=1.7; }
}
function onEat(nx,ny){
const idx=passengers.findIndex(p=>p.x===nx&&p.y===ny);
if(idx<0)return;
const p=passengers[idx]; passengers.splice(idx,1);
score++; spawnCd=0.85; ding();
eatFX((nx+0.5)*cell,(ny+0.5)*cell,p.coat,p.name);
syncHUD(); syncConsist();
if(snake.length>=COLS*ROWS-8){ mode='win'; showOverlay('win'); }
}
function doStep(){
if(crashed)return;
let nd=null;
if(mode==='demo'){ nd=aiDir(); }
else{
while(inputQ.length){ const c=inputQ.shift();
if(c.x===-dir.x&&c.y===-dir.y)continue; nd=c; break; }
}
if(nd)dir=nd;
angFrom=angTo; angTo=Math.atan2(dir.y,dir.x);
const nx=snake[0].x+dir.x, ny=snake[0].y+dir.y;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS){ doCrash(); return; }
const willGrow=passengers.some(p=>p.x===nx&&p.y===ny);
const lim=snake.length-(willGrow?0:1);
for(let i=0;i<lim;i++){ if(snake[i].x===nx&&snake[i].y===ny){ doCrash(); return; } }
prevSnake=snake.map(s=>({x:s.x,y:s.y}));
snake.unshift({x:nx,y:ny});
if(willGrow)onEat(nx,ny); else snake.pop();
stepCount++;
stepMs=clamp(BASE_MS-score*2.2,MIN_MS,BASE_MS);
}
/* ================= demo AI ================= */
function flood(s,occ){
const seen=new Uint8Array(COLS*ROWS); const st=[s]; seen[s]=1; let n=0;
while(st.length&&n<400){ const cur=st.pop(); n++;
const cx=cur%COLS, cy=(cur/COLS)|0;
for(const d of DIRS){ const nx=cx+d.x, ny=cy+d.y;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS)continue;
const ni=ny*COLS+nx; if(!occ[ni]&&!seen[ni]){seen[ni]=1;st.push(ni);} } }
return n;
}
function aiDir(){
const head=snake[0];
const occ=new Uint8Array(COLS*ROWS);
for(let i=0;i<snake.length-1;i++)occ[snake[i].y*COLS+snake[i].x]=1;
const dist=new Int16Array(COLS*ROWS).fill(-1);
const first=new Int8Array(COLS*ROWS).fill(-1);
const q=new Int32Array(COLS*ROWS); let qh=0,qt=0;
const s=head.y*COLS+head.x; dist[s]=0; q[qt++]=s;
while(qh<qt){ const cur=q[qh++], cx=cur%COLS, cy=(cur/COLS)|0;
for(let di=0;di<4;di++){ const d=DIRS[di], nx=cx+d.x, ny=cy+d.y;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS)continue;
const ni=ny*COLS+nx; if(occ[ni]||dist[ni]!==-1)continue;
dist[ni]=dist[cur]+1; first[ni]=(dist[cur]===0)?di:first[cur]; q[qt++]=ni; } }
let bestT=-1,bd=1e9;
for(const p of passengers){ const ti=p.y*COLS+p.x;
if(dist[ti]>-1&&dist[ti]<bd){bd=dist[ti];bestT=ti;} }
if(bestT>-1)return DIRS[first[bestT]];
let bestD=null,bestA=-1e9;
for(let di=0;di<4;di++){ const d=DIRS[di], nx=head.x+d.x, ny=head.y+d.y;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS)continue;
const ni=ny*COLS+nx; if(occ[ni])continue;
let near=1e9; for(const p of passengers)near=Math.min(near,Math.abs(p.x-nx)+Math.abs(p.y-ny));
const sc=flood(ni,occ)*1000-near;
if(sc>bestA){bestA=sc;bestD=d;} }
return bestD||dir;
}
/* ================= drawing ================= */
function segPos(i,p){
const c=snake[i], pr=prevSnake[i]||c;
return {x:(lerp(pr.x,c.x,p)+0.5)*cell, y:(lerp(pr.y,c.y,p)+0.5)*cell};
}
function strokePts(c,pts){ c.beginPath(); c.moveTo(pts[0].x,pts[0].y);
for(let i=1;i<pts.length;i++)c.lineTo(pts[i].x,pts[i].y); }
function railOffsets(pts,off){ const out=[];
for(let i=0;i<pts.length;i++){ const p0=pts[Math.max(0,i-1)], p1=pts[Math.min(pts.length-1,i+1)];
let dx=p1.x-p0.x, dy=p1.y-p0.y; const m=Math.hypot(dx,dy)||1; dx/=m;dy/=m;
out.push({x:pts[i].x-dy*off, y:pts[i].y+dx*off}); }
return out; }
function drawTrack(posPx){
const pts=[];
if(posPx.length>=2){ const a=posPx[posPx.length-1], b=posPx[posPx.length-2];
let dx=a.x-b.x, dy=a.y-b.y; const m=Math.hypot(dx,dy)||1; dx/=m; dy/=m;
pts.push({x:a.x+dx*cell*0.55, y:a.y+dy*cell*0.55}); }
for(let i=posPx.length-1;i>=0;i--)pts.push(posPx[i]);
if(!crashed){
let hx=snake[0].x, hy=snake[0].y, steps=0;
while(steps<2){ const nx=hx+dir.x, ny=hy+dir.y;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS)break; hx=nx;hy=ny;steps++; }
if(steps>0){ const hd=posPx[0];
pts.push({x:hd.x+dir.x*cell*steps*0.9, y:hd.y+dir.y*cell*steps*0.9}); }
}
if(pts.length<2)return;
ctx.lineJoin='round'; ctx.lineCap='round';
strokePts(ctx,pts); ctx.strokeStyle='rgba(122,92,64,.30)'; ctx.lineWidth=cell*0.9; ctx.stroke();
let carry=cell*0.15;
for(let i=0;i<pts.length-1;i++){ const a=pts[i], b=pts[i+1];
const segLen=Math.hypot(b.x-a.x,b.y-a.y); if(segLen<0.001)continue;
const dx=(b.x-a.x)/segLen, dy=(b.y-a.y)/segLen; let d=carry;
while(d<segLen){ const x=a.x+dx*d, y=a.y+dy*d;
ctx.strokeStyle='rgba(107,79,54,.9)'; ctx.lineWidth=cell*0.11;
ctx.beginPath(); ctx.moveTo(x-dy*cell*0.33,y+dx*cell*0.33);
ctx.lineTo(x+dy*cell*0.33,y-dx*cell*0.33); ctx.stroke(); d+=cell*0.42; }
carry=d-segLen; }
for(const s of [1,-1]){ const rp=railOffsets(pts,s*cell*0.17);
strokePts(ctx,rp); ctx.strokeStyle='#4b545e'; ctx.lineWidth=cell*0.075; ctx.stroke();
strokePts(ctx,rp); ctx.strokeStyle='rgba(205,218,226,.55)'; ctx.lineWidth=cell*0.026; ctx.stroke(); }
}
function drawWagon(x,y,a,col){
ctx.save(); ctx.translate(x,y); ctx.rotate(a);
const L=cell*0.92, W=cell*0.62;
ctx.fillStyle='#33302c';
for(const sx of [-0.3,0.3])for(const sy of [-1,1]){
ctx.beginPath(); ctx.arc(sx*L,sy*(W/2+cell*0.02),cell*0.085,0,TAU); ctx.fill(); }
ctx.fillStyle='#3a362f'; ctx.fillRect(-L/2-cell*0.13,-cell*0.045,cell*0.17,cell*0.09);
rr(ctx,-L/2,-W/2,L,W,cell*0.13); ctx.fillStyle=col; ctx.fill();
ctx.lineWidth=Math.max(1.2,cell*0.03); ctx.strokeStyle=shade(col,0.6); ctx.stroke();
rr(ctx,-L/2+L*0.1,-W*0.27,L*0.8,W*0.54,cell*0.1); ctx.fillStyle=tint(col,0.28); ctx.fill();
ctx.strokeStyle='rgba(0,0,0,.18)'; ctx.lineWidth=cell*0.02; ctx.stroke();
ctx.fillStyle='rgba(255,248,224,.92)';
rr(ctx,-L*0.22,-W*0.16,L*0.18,W*0.32,cell*0.04); ctx.fill();
rr(ctx, L*0.05,-W*0.16,L*0.18,W*0.32,cell*0.04); ctx.fill();
ctx.fillStyle='#3a362f';
ctx.fillRect(L/2-cell*0.02,-W*0.32,cell*0.05,cell*0.09);
ctx.fillRect(L/2-cell*0.02,W*0.32-cell*0.09,cell*0.05,cell*0.09);
ctx.restore();
}
function drawLoco(x,y,a){
ctx.save(); ctx.translate(x,y); ctx.rotate(a);
const L=cell*1.0, W=cell*0.74;
ctx.fillStyle='#33302c';
for(const sx of [-0.32,0.32])for(const sy of [-1,1]){
ctx.beginPath(); ctx.arc(sx*L,sy*(W/2+cell*0.02),cell*0.09,0,TAU); ctx.fill(); }
ctx.fillStyle='#3a362f'; ctx.fillRect(-L/2-cell*0.13,-cell*0.05,cell*0.17,cell*0.1);
// cowcatcher
ctx.beginPath(); ctx.moveTo(L*0.46,-W*0.44); ctx.lineTo(L*0.78,0); ctx.lineTo(L*0.46,W*0.44); ctx.closePath();
ctx.fillStyle='#f2cf5b'; ctx.fill(); ctx.lineWidth=cell*0.03; ctx.strokeStyle='#8a5f1d'; ctx.stroke();
ctx.beginPath(); ctx.moveTo(L*0.52,-W*0.28); ctx.lineTo(L*0.67,-W*0.05);
ctx.moveTo(L*0.52,W*0.28); ctx.lineTo(L*0.67,W*0.05); ctx.stroke();
// body
rr(ctx,-L/2,-W/2,L,W,cell*0.14); ctx.fillStyle='#c8402f'; ctx.fill();
ctx.lineWidth=Math.max(1.2,cell*0.03); ctx.strokeStyle=shade('#c8402f',0.58); ctx.stroke();
// cab
rr(ctx,-L/2,-W*0.46,L*0.4,W*0.92,cell*0.1); ctx.fillStyle='#a93325'; ctx.fill();
rr(ctx,-L*0.46,-W*0.34,L*0.3,W*0.68,cell*0.08); ctx.fillStyle='#d95744'; ctx.fill();
ctx.fillStyle='rgba(255,248,224,.92)';
rr(ctx,-L*0.41,-W*0.22,L*0.09,W*0.18,cell*0.03); ctx.fill();
rr(ctx,-L*0.41, W*0.04,L*0.09,W*0.18,cell*0.03); ctx.fill();
// boiler
rr(ctx,-L*0.1,-W*0.4,L*0.56,W*0.8,cell*0.12); ctx.fillStyle='#d24f39'; ctx.fill();
ctx.strokeStyle='rgba(0,0,0,.22)'; ctx.lineWidth=cell*0.02; ctx.stroke();
ctx.strokeStyle='#e8b84b'; ctx.lineWidth=cell*0.035;
ctx.beginPath(); ctx.moveTo(L*0.04,-W*0.38); ctx.lineTo(L*0.04,W*0.38);
ctx.moveTo(L*0.22,-W*0.38); ctx.lineTo(L*0.22,W*0.38); ctx.stroke();
// dome
ctx.fillStyle='#e8b84b'; ctx.beginPath(); ctx.arc(-L*0.02,0,cell*0.07,0,TAU); ctx.fill();
ctx.strokeStyle='#8a5f1d'; ctx.lineWidth=cell*0.02; ctx.stroke();
// chimney
ctx.fillStyle='#2f2b27'; ctx.beginPath(); ctx.arc(L*0.3,0,cell*0.13,0,TAU); ctx.fill();
ctx.fillStyle='#151312'; ctx.beginPath(); ctx.arc(L*0.3,0,cell*0.08,0,TAU); ctx.fill();
ctx.strokeStyle='#e8b84b'; ctx.lineWidth=cell*0.028;
ctx.beginPath(); ctx.arc(L*0.3,0,cell*0.125,0,TAU); ctx.stroke();
// headlamp
ctx.fillStyle='#ffefad'; ctx.beginPath(); ctx.arc(L*0.47,0,cell*0.06,0,TAU); ctx.fill();
ctx.strokeStyle='#8a5f1d'; ctx.lineWidth=cell*0.02; ctx.stroke();
ctx.restore();
}
function drawPassenger(p,t){
const cx=(p.x+0.5)*cell, cy=(p.y+0.5)*cell;
rr(ctx,cx-cell*0.5,cy-cell*0.42,cell,cell*0.84,cell*0.12);
ctx.fillStyle='#cfc5b2'; ctx.fill(); ctx.lineWidth=1.4; ctx.strokeStyle='#a89c86'; ctx.stroke();
ctx.fillStyle='#e5c544';
for(let k=-1;k<=1;k++)ctx.fillRect(cx+k*cell*0.27-cell*0.07,cy-cell*0.36,cell*0.14,cell*0.07);
const head=snake[0];
const dist=Math.hypot(p.x-head.x,p.y-head.y);
const appear=clamp((timeNow-p.born)*4,0,1);
const bob=Math.sin(t*(dist<3.2?5:2.4)+p.phase)*cell*0.03;
const py=cy+bob+(1-appear)*cell*0.3;
ctx.fillStyle='rgba(60,70,45,.25)';
ctx.beginPath(); ctx.ellipse(cx,cy+cell*0.3,cell*0.17,cell*0.06,0,0,TAU); ctx.fill();
ctx.save(); ctx.globalAlpha=appear;
if(p.suitcase){ rr(ctx,cx+cell*0.18,py+cell*0.04,cell*0.17,cell*0.14,cell*0.03);
ctx.fillStyle='#7a4b2c'; ctx.fill(); ctx.strokeStyle='#4e2f18'; ctx.lineWidth=1; ctx.stroke();
ctx.beginPath(); ctx.arc(cx+cell*0.265,py+cell*0.03,cell*0.035,Math.PI,0); ctx.stroke(); }
// resting arm
ctx.strokeStyle=shade(p.coat,0.7); ctx.lineWidth=cell*0.06; ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(cx+cell*0.13,py-cell*0.06); ctx.lineTo(cx+cell*0.17,py+cell*0.08); ctx.stroke();
// waving arm
if(dist<3.2){ const wa=-1.9+Math.sin(t*12)*0.35;
const ax=cx-cell*0.13, ay=py-cell*0.06;
ctx.beginPath(); ctx.moveTo(ax,ay);
ctx.lineTo(ax+Math.cos(wa)*cell*0.18, ay+Math.sin(wa)*cell*0.18); ctx.stroke();
ctx.fillStyle=p.skin; ctx.beginPath();
ctx.arc(ax+Math.cos(wa)*cell*0.19,ay+Math.sin(wa)*cell*0.19,cell*0.04,0,TAU); ctx.fill(); }
// coat
rr(ctx,cx-cell*0.15,py-cell*0.16,cell*0.3,cell*0.34,cell*0.1);
ctx.fillStyle=p.coat; ctx.fill(); ctx.lineWidth=1.2; ctx.strokeStyle=shade(p.coat,0.62); ctx.stroke();
// head + hat
ctx.fillStyle=p.skin; ctx.beginPath(); ctx.arc(cx,py-cell*0.27,cell*0.115,0,TAU); ctx.fill();
ctx.fillStyle='#33302c';
ctx.beginPath(); ctx.arc(cx-cell*0.045,py-cell*0.28,cell*0.016,0,TAU); ctx.fill();
ctx.beginPath(); ctx.arc(cx+cell*0.045,py-cell*0.28,cell*0.016,0,TAU); ctx.fill();
if(p.hat){ ctx.fillStyle=p.hat;
ctx.beginPath(); ctx.arc(cx,py-cell*0.30,cell*0.115,Math.PI,0); ctx.fill();
ctx.fillRect(cx-cell*0.14,py-cell*0.315,cell*0.28,cell*0.03); }
// alert bubble
if(dist<2.2){ const bx=cx+cell*0.32, by=py-cell*0.5;
ctx.fillStyle='#fff'; ctx.beginPath(); ctx.arc(bx,by,cell*0.11,0,TAU); ctx.fill();
ctx.strokeStyle='#a89c86'; ctx.lineWidth=1; ctx.stroke();
ctx.fillStyle='#c8402f'; ctx.font=`700 ${cell*0.16}px ${'Trebuchet MS'}`;
ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillText('!',bx,by+cell*0.01); }
ctx.restore();
}
function drawParticles(){
for(const p of particles){
const a=clamp(p.life/p.maxLife,0,1);
if(p.type==='smoke'){ ctx.fillStyle=`rgba(243,246,242,${a*0.55})`;
ctx.beginPath(); ctx.arc(p.x,p.y,p.size,0,TAU); ctx.fill(); }
else if(p.type==='steam'){ ctx.fillStyle=`rgba(228,232,228,${a*0.7})`;
ctx.beginPath(); ctx.arc(p.x,p.y,p.size,0,TAU); ctx.fill(); }
else if(p.type==='spark'){ ctx.globalAlpha=a; ctx.fillStyle=p.color;
ctx.beginPath(); ctx.arc(p.x,p.y,p.size,0,TAU); ctx.fill(); ctx.globalAlpha=1; }
else if(p.type==='star'){ ctx.save(); ctx.translate(p.x,p.y); ctx.rotate(p.rot); ctx.globalAlpha=a;
ctx.fillStyle='#fff3c4'; ctx.strokeStyle='#e0aa4e'; ctx.lineWidth=1.2; ctx.beginPath();
for(let k=0;k<8;k++){ const r=(k%2===0)?p.size:p.size*0.42;
const an=k*Math.PI/4; ctx.lineTo(Math.cos(an)*r,Math.sin(an)*r); }
ctx.closePath(); ctx.fill(); ctx.stroke(); ctx.restore(); ctx.globalAlpha=1; }
else if(p.type==='ring'){ ctx.strokeStyle=`rgba(255,240,190,${a})`; ctx.lineWidth=cell*0.06;
ctx.beginPath(); ctx.arc(p.x,p.y,p.r,0,TAU); ctx.stroke(); }
else if(p.type==='text'){ ctx.font=`700 ${cell*0.34}px "Chalkboard SE","Comic Sans MS",cursive`;
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.lineWidth=4; ctx.strokeStyle=`rgba(63,36,18,${a*0.9})`;
ctx.strokeText(p.str,p.x,p.y); ctx.fillStyle=`rgba(255,248,234,${a})`;
ctx.fillText(p.str,p.x,p.y); }
}
}
function render(now){
const t=now/1000;
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.clearRect(0,0,cssW,cssH);
let sx=0,sy=0;
if(shakeT>0){ const amp=shakeT*shakeT*cell*0.4;
sx=(Math.random()*2-1)*amp; sy=(Math.random()*2-1)*amp; }
ctx.save(); ctx.translate(sx,sy);
ctx.drawImage(bgCanvas,0,0,cssW,cssH);
const active=!paused&&!crashed&&mode!=='over'&&mode!=='win';
const p=active?clamp(acc/stepMs,0,1):1;
const posPx=snake.map((s,i)=>segPos(i,p));
for(const pa of passengers)drawPassenger(pa,t);
drawTrack(posPx);
// shadows
for(let i=snake.length-1;i>=0;i--){
const q=posPx[i];
ctx.fillStyle='rgba(47,66,38,.3)'; ctx.beginPath();
ctx.ellipse(q.x+cell*0.05,q.y+cell*0.13,(i===0?cell*0.52:cell*0.47),cell*0.32,0,0,TAU); ctx.fill();
}
// wagons (tail → front)
for(let i=snake.length-1;i>=1;i--){
const q=posPx[i], ah=posPx[i-1];
const a=Math.atan2(ah.y-q.y,ah.x-q.x);
const phase=(stepCount+p)*1.35-i*0.85;
const off=crashed?0:Math.sin(phase)*cell*0.04;
drawWagon(q.x-Math.sin(a)*off, q.y+Math.cos(a)*off, a, CAR_COLORS[(i-1)%CAR_COLORS.length]);
}
const headAng=lerpAngle(angFrom,angTo,p);
drawLoco(posPx[0].x,posPx[0].y,headAng);
// headlamp glow
if(!crashed){ const hp=posPx[0];
const gx=hp.x+Math.cos(headAng)*cell*1.1, gy=hp.y+Math.sin(headAng)*cell*1.1;
const g=ctx.createRadialGradient(gx,gy,0,gx,gy,cell*1.1);
g.addColorStop(0,'rgba(255,236,160,.32)'); g.addColorStop(1,'rgba(255,236,160,0)');
ctx.globalCompositeOperation='lighter'; ctx.fillStyle=g;
ctx.beginPath(); ctx.arc(gx,gy,cell*1.1,0,TAU); ctx.fill();
ctx.globalCompositeOperation='source-over'; }
drawParticles();
ctx.restore();
}
/* ================= main loop ================= */
let last=performance.now();
function frame(now){
requestAnimationFrame(frame);
let dt=(now-last)/1000; last=now; if(dt>0.08)dt=0.08;
timeNow+=dt;
if(shakeT>0)shakeT=Math.max(0,shakeT-dt*2.2);
if(demoResetT>0){ demoResetT-=dt; if(demoResetT<=0)resetGame(); }
const active=!paused&&!crashed&&mode!=='over'&&mode!=='win';
if(active){
acc+=dt*1000; let guard=0;
while(acc>=stepMs&&guard++<4){
doStep(); acc-=stepMs;
if(crashed){ acc=stepMs; break; }
}
if(!crashed){
if(passengers.length<3){ spawnCd-=dt;
if(passengers.length===0)spawnCd=Math.min(spawnCd,0.1);
if(spawnCd<=0){ spawnPassenger(); spawnCd=0.9; } }
smokeAcc+=dt;
while(smokeAcc>0.09){ emitSmoke(); smokeAcc-=0.09; }
}
}
updateParticles(dt);
render(now);
}
/* ================= input ================= */
const KEYD={ArrowUp:[0,-1],ArrowDown:[0,1],ArrowLeft:[-1,0],ArrowRight:[1,0],
w:[0,-1],s:[0,1],a:[-1,0],d:[1,0],W:[0,-1],S:[0,1],A:[-1,0],D:[1,0]};
function queueDir(d){
if(inputQ.length&&inputQ[inputQ.length-1].x===d.x&&inputQ[inputQ.length-1].y===d.y)return;
if(inputQ.length<3)inputQ.push(d);
}
function togglePause(){
if(mode!=='play'||crashed)return;
paused=!paused;
if(paused)showOverlay('paused'); else{ hideOverlay(); acc=0; }
}
function toggleMute(){
soundOn=!soundOn; muteBtn.textContent=soundOn?'🔊':'🔇';
}
window.addEventListener('keydown',e=>{
const k=e.key;
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(k))e.preventDefault();
if(k==='m'||k==='M'){ toggleMute(); return; }
if(k==='p'||k==='P'){ togglePause(); return; }
if(mode==='demo'){ const d=KEYD[k]; startGame('Your train, engineer — pick up the passengers!');
if(d)queueDir({x:d[0],y:d[1]}); return; }
if(mode==='over'||mode==='win'){ startGame('Off we go again!'); return; }
if(k===' '){ togglePause(); return; }
if(paused){ togglePause(); return; }
const d=KEYD[k];
if(d&&!e.repeat)queueDir({x:d[0],y:d[1]});
});
let swStart=null;
frameEl.addEventListener('pointerdown',e=>{
if(mode==='demo'){ startGame('Your train, engineer — pick up the passengers!'); return; }
if(mode==='over'||mode==='win'){ startGame('Off we go again!'); return; }
if(paused){ togglePause(); return; }
swStart={x:e.clientX,y:e.clientY};
try{ frameEl.setPointerCapture(e.pointerId); }catch(err){}
});
frameEl.addEventListener('pointermove',e=>{
if(!swStart)return;
const dx=e.clientX-swStart.x, dy=e.clientY-swStart.y;
if(Math.hypot(dx,dy)>26){
queueDir(Math.abs(dx)>Math.abs(dy)?{x:Math.sign(dx),y:0}:{x:0,y:Math.sign(dy)});
swStart={x:e.clientX,y:e.clientY};
}
});
frameEl.addEventListener('pointerup',()=>swStart=null);
frameEl.addEventListener('pointercancel',()=>swStart=null);
muteBtn.addEventListener('click',e=>{ e.stopPropagation(); toggleMute(); });
document.addEventListener('visibilitychange',()=>{
if(document.hidden&&mode==='play'&&!paused&&!crashed)togglePause();
});
window.addEventListener('resize',layout);
/* ================= go! ================= */
layout();
resetGame();
requestAnimationFrame(frame);
</script>
</body>
</html>Reasoning trace (32,173 tokens)
The user wants a snake game where the snake is a little train — locomotive + colorful cars, picking up passengers waiting on platforms.
Let me think about the design:
**Core concept**: Snake game, but themed as a train:
- Head of snake = locomotive (drawn with smokestack, smoke puff, windows, cowcatcher)
- Body segments = colorful cars (passenger cars, each a different color, with windows, wheels)
- Food = passengers waiting on platforms. When the train reaches them, they "board" — a new car is added to the train.
- Grid = railway town: tracks, grassy areas, station platforms, trees, little houses, etc.
**Visual direction**: rich, lively, layered background. Warm toy-town aesthetic. Let me think palette: maybe a warm daytime meadow: soft green grass, dark green patches, brown/tan dirt, colorful train cars (red, blue, yellow, teal, orange). I want a distinctive display font plus a readable body font — but external fonts aren't allowed (no network requests except the explicitly allowed CDN URLs — nothing is permitted here). So I need to use system fonts creatively. For display I can use a stack like `"Chalkboard SE", "Comic Sans MS", "Segoe Print", cursive` for a playful train-town vibe, or system-ui with bold weight and letter-spacing. Pairing idea: display = chunky rounded stack ('Baloo 2' isn't available offline). System options: for playfulness `"Arial Rounded MT Bold", "Hiragino Maru Gothic ProN", "Yuanti SC", "Comic Sans MS"`. Body: `"Trebuchet MS", "Segoe UI", Verdana`. That pairing works.
Actually I could embed fonts via data URI but that's heavy. Better to use system stack.
**Game mechanics**:
- Grid-based snake, e.g. 24×15 cells. Canvas rendering.
- Locomotive head moves cell by cell with smooth interpolation for better look. Smooth motion looks much better. Interpolate between grid steps.
- Passenger appears at a random empty cell, drawn standing on a little platform tile. When the head reaches it, +1 car, score +1, passenger boarding animation (little figure pops and flies to the train, or simply a car appears with a puff).
- Multiple passengers at once? Classic snake has 1 food; but we can have up to 2-3 waiting at platforms at the same time, each with a patience timer? Simpler: one "active" passenger and a next one shown. Actually multiple passengers makes the screen livelier. Let's have up to 3 waiting passengers; eating any grows the train. Each passenger has a little idle bob animation, and maybe a speech bubble.
- Collide with wall or self → crash (steam burst, derailment shake), game over screen with "click/key to restart".
- Speed increases with score.
- Self-play demo mode: a simple AI plays behind a "Click or key to start" overlay. AI: greedy approach with safety check — pick direction that leads toward closest passenger while avoiding immediate collision, maybe simple BFS pathfinding for robustness. BFS to closest passenger avoiding snake body; if no path, pick a safe move that maximizes flood fill space. Keep it modest: BFS is fine on this grid size.
**Start overlay**: "🚂 CHOO-CHOO! Click or press any key to take the controls" — game runs in self-play demo behind it.
**HUD**: Score ("Passengers aboard"), Best (in-memory variable only, no localStorage — storage is blocked so keep best in JS variable), speed/line indicator. Top bar styled like a station departure board? Could be fun: a split-flap departure-board style header "PUFFINGTON EXPRESS — Platform 3".
**Layout / opening**: the game board itself should be the star, front and center. Small station-sign title header, board dominant, side panel (on wide screens) maybe has departure board showing passengers picked up (a list of colored "tickets"). Could get cluttered; keep it classy: header strip with title + score; board in middle; footer with control hints.
Make it a scene: full-page background of layered landscape (sky gradient, clouds, distant hills, sun) and a station plaza card containing the game board. Ambient animation: drifting clouds, birds? A small puffed smoke from decorative header locomotive? Maybe overkill; but an ambient background adds life. Page background: gentle sky with drifting clouds (CSS animation), subtle sun too. The board itself: grass texture pattern, track under snake? Fun idea: draw tracks that follow the snake's path (rails under the train!). That's a nice touch: render track segments along the snake's trail, fading behind. Feasible: for each body segment, draw track ties perpendicular to motion direction. Since cars follow the same path, drawing track under the whole train is simply drawing along the polyline of the snake path. We have the interpolated positions, so we can draw ties at regular intervals along the path polyline, plus two rails offset. That would look really great.
Simpler robust approach: for each segment position (interpolated), draw ties at intervals along the path. Compute polyline points = interpolated positions of all segments + head extension. Then draw rails as two parallel polylines using offsets from local direction; ties as short perpendicular lines. With round joins in Canvas. This is very achievable and the payoff is huge.
Actually simpler still: draw the track as a wide dark path (ballast) with ties, and rails as two strokes. Offset polylines: I can compute a normal at each point and build offset point arrays. Path length ~train length so it's manageable.
**Passengers**: drawn as small figures: round head, colored coat, tiny legs, standing on platform tile (grey rounded rect with yellow safety line). Idle bobbing animation, occasionally waving (arms up). When eaten: pop animation + floating "+1" score, and a new car joins with a little bounce.
Also a small "!" bubble when train is close? Cute but optional.
**Cars**: each car colored from a palette sequence, with roof, windows (one or two), wheels with rotating spokes? Wheels can rotate based on distance moved — nice touch. Keep wheels as circles + rotating cross-spokes. Couplings: small links between cars and locomotive.
**Locomotive**: body (e.g., deep red or dark green), smokestack with animated smoke puffs emitted on move (particles), dome, front face? Choo-choo face might be too much; keep it a clean toy look: cowcatcher (triangle stripes), smokestack, boiler, cab with window, side rods? Keep top-down or side-view? Hmm. Snake games are top-down. But cute train sprites are usually side view. Mixing: draw the train in a "flat top-down-ish" but stylized side silhouette rotated to direction? Common approach in cute snake games: draw the head as a front-ish shape rotating with direction. Draw each car as a rounded rectangle aligned to direction of travel (rotate canvas to direction of movement), with roof highlights and visible windows on the top — essentially a top-down toy look. Locomotive: rounded rect body, darker nose, chimney circle near front emitting smoke, side buffers. When rotated, the chimney smoke will appear at the front. This reads well from top-down.
Alternatively do a 3/4 look: shadow of body + roof gradient. Keep consistent top-down.
**Grid / board styling**: meadow: two tones of green checker (subtle), scattered decorations: deterministic (seeded) small trees, flowers, ponds? Ensure decorations don't collide visually with gameplay — draw them subtly, or place on fixed cells that aren't used by passenger spawns? Simplest: decorative sprites are just background texture drawn under everything (tiny flowers / grass tufts) — snake can pass over, that's fine. Add a border around the board like a hedge or fence — walls are fatal, so draw a fence/hedge wall.
**Extra juice**:
- Canvas particles for smoke from smokestack.
- Crash: shake + steam burst + "Derailed!" overlay.
- Sound? Can use WebAudio for little choo-choo and clickety-clack sounds — nice but optional; add simple beeps via WebAudio guarded by user gesture. A tiny "ding" when boarding, a low chug? Add minimal WebAudio: boarding chime, occasional whistle in demo? Autoplay policy: AudioContext needs to resume after user gesture; in demo mode sounds would be blocked until interaction anyway. Keep it: create context on first user interaction; then play a tiny ding on eat and a crash noise. Also "clickety" tick every cell? Might be noisy; use a soft click at low volume every cell step. Actually keep it minimal: ding + crash + start whistle. Good.
- Score popups "+1" floating.
- Departure board side panel listing "Next stop: ..."? Might be overkill. Instead, header shows PASSENGERS: n, BEST: n, SPEED increases with small gauge. Keep tight.
**Responsive**: 1280×800 target; scale down to 375px. Use canvas sized to container with devicePixelRatio scaling. Board aspect: 24 cols × 15 rows → 8:5. Container max-width ~ min(96vw, calc((100vh - header/footer) * 1.6)). Compute canvas CSS size in JS to fit.
Mobile: swipe input. Add touch swipe handling. Also on-screen dpad? Swipe should suffice; note in the hint.
**Controls**: arrows/WASD, swipe. Prevent 180° reverse. Input queue (buffer up to 2) for responsive controls.
**Self-play AI**: BFS to closest passenger; otherwise safe fallback with flood fill heuristic. Also avoid walls. In demo, AI shouldn't be perfect — that's fine, if it crashes, show "Crashed! restarting…" and auto-restart in demo mode after ~1.6s. Good: demo mode loop that keeps going behind overlay.
Speed: base step interval ~140ms, decreasing to ~70ms with score. Interpolate rendering between steps for smoothness.
Implementation details:
State:
- COLS=24, ROWS=16? Let's do 22×14 for more chunky cells. Cell size derived.
- snake: array of {x,y} grid positions, index 0 = head. Also previous positions for interpolation: keep a `trail` history of head path points (in grid coords including the interpolated head). Actually interpolation approach: keep `pathPoints`: each move, head enters new cell; store sequence of visited cells (path) with length >= snake length + buffer. Then position of segment i = point at index (pathIndex - i) interpolated. Standard technique: keep `trail` array, unshift head cell on each step. Position of segment i = trail[i], interpolated between trail[i] and trail[i+1] with t = stepProgress. Head is between trail[0] and... hmm let me define: after a step, trail[0] = new head. During a step from prev head to new head with progress p in [0,1], the position of each segment i is lerp(trail[i+1], trail[i], p)? Let me think: before step completes, head should move from trail[0] (current head cell) toward dir. Simplest: maintain `prevSnake` and `snake`; render each segment i = lerp(prevPos_i, currPos_i, p). Each segment moves one cell per step so this works. prevSnake = snake before last step (same length except on grow; on grow, new tail segment's prev = same as curr (appears in place) or equals previous tail). Handle: if length changed, use curr for missing prev (tail pops in place — fine since grow happens at head... actually grow: don't remove tail, so tail stays put for that step; prev length = curr-1; for extra index, use curr).
Draw order: track first, then tail→head cars, so head overlaps nicely.
Rotation of segments: angle from segment's prev→curr direction (or from position deltas). For smooth turning, compute angle from movement vector of that segment during step; cars rotate when traversing a turn. Compute per-segment angle from lerp(prev,curr) to lerp(prev_next?) — easier: angle = atan2(curr - prev) constant during step → car snaps on turns, slight snapping acceptable, or smooth with angle lerp. Can interpolate angle between prev's step direction and current step direction using shortest-angle lerp for smoothness. Store per-segment prevAngle? Keep it simple: compute from the actual rendered positions: position of segment i and segment i+1 (behind it): angle = atan2(pos_i - pos_{i+1}). This naturally gives smooth rotation as cars round corners. Head angle = atan2(currHead - prevHead) interpolated with previous head angle? Use head angle from direction of step with angle lerp over step progress — ok. Head angle = angleLerp(lastHeadAngle, newDirAngle, eased p). Store headAnglePrev and headAngleTarget.
Track drawing: polyline through rendered positions of all segments + extended at head (project forward one cell so track leads into new cell) + at tail maybe let track fade behind. Actually track should be where train has passed but is being built ahead of train (track forward?) — physically train rides existing track; visually, drawing track under whole train + a bit ahead looks right, fading behind tail. Track = segment path, extended a bit forward, fade alpha behind tail.
To render offset rails: build point list P[0..n] (head first), compute tangent at each point, normal, offset by railGap. Draw two polylines with lineJoin round; ties: short lines every k points from -w to +w. Points are ~per segment per frame so spacing = cell size; ties should be denser: can subdivide midpoints. With n ≤ snake length (~max 40), subdividing once gives ties every half cell — good.
Ballast: draw thick polyline beneath (darker, alpha ~0.25) width = cell*0.8, then ties, then rails. Should look nice.
Colors / theme:
Overall page: "Sunny Meadow Railway". Sky: soft teal to warm cream gradient. Clouds drifting as CSS blobs. Distant hills as CSS radial shapes or SVG background. Let's build the page background with layered CSS: sky gradient, drifting clouds (three divs), hills (two big rounded divs in muted green), maybe tiny sun. Subtle, not distracting.
Board frame: wooden station sign header: "PUFFER'S MEADOW RAILWAY" and small locomotive emoji 🚂? Prefer hand-drawn feel: use text with chunky display font. Header contents: title left, scoreboard right styled like brass plates. Also a small whistling train? Keep: title + "EST. 1897" mini.
Typography: display stack: `"Chalkboard SE", "Comic Sans MS", "Segoe Print", "Marker Felt", sans-serif`? Comic Sans might look childish but it's a toy train game — actually appealing here. But risky across platforms. Alternative: use bold rounded system: `"Arial Rounded MT Bold", "Verdana"`. Let's define --display: `"Chalkboard SE","Comic Sans MS","Segoe Print",cursive` (playful, matches toy trains) and body `"Trebuchet MS","Segoe UI",Verdana,sans-serif`. Distinctive display + readable body.
Board canvas colors:
- Grass: base #a8d16b? Two tone checker: #9cc85f / #a5d169 subdued. Maybe add subtle noise dots (deterministic flower dots) for texture.
- Hedge border: dark green frame with bumps.
- Track: ballast #7a5c43 alpha, ties #8b6a4f, rails #5b6770 with highlight.
- Locomotive: deep red #d94f3d? Or classic dark green #2f6f4f with brass. Let's do crimson locomotive (#c8402f) with dark roof, brass chimney tip. Headlamp yellow glowing circle at front.
- Cars palette cycle: ["#e0564b red", "#3f8fd2 blue", "#f2b53c yellow", "#4fae7d green", "#a06cd5 purple", "#ef8f4e orange", "#4fc3c8 teal", "#e77fb3 pink"]. Each car: colored body, lighter roof stripe, 2 cream windows, dark wheels.
- Passenger: random coat color, skin tones vary, stands on platform: grey slab with yellow edge stripe, plus a small suitcase maybe. Bobbing idle.
Board overlay (HTML on top of canvas):
- Start overlay: big badge "Click or press a key to drive!" with subtext "Demo engineer is on duty". Semi-transparent card, but not full-page glassmorphism — one card is fine. Style like a station sign: cream board, wooden posts? Card with wood frame, wobbling "DEMO — auto engineer driving" tag.
- Game over overlay: "Derailed!" + passengers saved count + restart prompt.
- Pause? P to pause. Nice to have; include quietly.
HUD top inside frame: "Passengers aboard: N" with train icon, best, speed indicator ("Line speed" with small dial or chevrons). Also show current "cargo" in HUD? Nope.
Side element: maybe a "departure board" showing a strip of colored tickets representing cars joined (mini-color-squares showing train composition). Fun and cheap: strip of colored dots = current cars. Place under board: "Consist:" chips. Could skip; but adds a bit of richness. Let's add compact strip: shows each car as a small colored rounded chip (update DOM on grow). Max ~40 chips; fine, wraps. Actually place inside header right? Keep under the board as a footer row with control hints on left, chips on right. Ok.
Canvas sizing: wrapper .board-frame with fixed aspect via CSS aspect-ratio: COLS/ROWS; canvas fills. DPR scaling; cell size computed from canvas.width/COLS.
Fonts on canvas: use px sizes relative to cell for "+1" popups and sign text maybe.
Passenger spawn: find empty cell not on snake, not adjacent to head maybe, at least 3 cells from head, not on existing passenger/platform. Platform drawn under passenger. Max 3 waiting. Spawn one ~every 4 seconds up to cap? For gameplay fairness, always keep at least 1. After eating, spawn replacement with small delay. Patience: passenger could wave arms as train gets close. Skip leaving (might frustrate). Instead, give each passenger a small "destination" flavor: random hat color.
Self-collision rule: standard; but do we ignore collision with tail cell that's about to move when not growing? Classic: allow moving into tail cell if not growing. To keep it simple, treat body including tail as blocked unless tail moves this step and no grow → allow. Implement standard check: new head vs segments [0..len-2] (exclude tail) when not growing; exclude tail; when growing, include tail.
Walls: hitting hedge = crash. Alternatively wrap? No, crash fits "derail".
AI demo:
```
function aiChoose(){
const targets = passengers;
BFS from head over grid, blocking = snake body minus tail (assume tail moves)...
choose first step toward nearest target; if none reachable, choose neighbor with max flood-fill area.
}
```
Implement BFS with parent map; pick target with smallest distance. Safety: after choosing next cell, ensure at least 1 safe continuation? Keep simple; demo crashes sometimes are fine and show crash + auto-restart.
Flood-fill fallback: compute empty area size for each valid neighbor; pick max.
Human input: set pendingDir queue; on step, pop next valid direction.
Step loop: use accumulator in rAF: acc += dt; while acc >= stepMs: doStep(). Render with p = acc/stepMs.
Pause on tab hidden: rAF stops naturally; clamp dt to 100ms to avoid jumps.
Growth animation: when eating, set carPulse on the new car (scale pop), spawn sparkle particles at passenger position, floating text "+1 aboard". Small screen shake too? Maybe not for eat; only on crash.
Crash sequence: state = 'crashed'; particle burst (steam + stars), shake amplitude decays, whistle sad? After 1.2s show overlay (human) / after 1.8s auto-restart (demo).
Smoke particles: while moving, emit from chimney world-position every ~90ms; drift up-left, grow, fade. Chimney position = head position + forward offset rotated.
Wheels: draw 2 circles per side per car? Top-down: wheels visible slightly on sides as dark stubs; simpler: draw small dark circles at corners under body — rotation not visible. To show rotation, draw side wheels as circles with a rotating spoke line — visible in top-down? Wheels are beneath; skip rotation, add subtle motion: bob the cars slightly (sin per distance)? Cars sway side-to-side slightly along the normal with sin(phase + i) — cute "clickety-clack" sway. Small amplitude 1-2px.
Headlamp glow: soft radial gradient ahead of locomotive. Nice on grass.
Sound (WebAudio), created on first gesture:
- ding: two quick sine notes (880 → 1320).
- whistle: triangle chord 523+659 with slight vibrato, 0.4s, on game start.
- crash: filtered noise burst.
- chug: soft low tick every 2 steps? Maybe skip to avoid annoyance; add quiet "click" on each cell at low gain (0.02), maybe too much. Include gentle clickety at very low volume, toggleable? Keep: only eat ding + start whistle + crash. Good.
Also a "boarded chime" variation in pitch with combo? No combo. Fine.
HTML structure:
```
<body>
<div class="sky"> clouds, sun, hills, birds? </div>
<main class="station">
<header class="signboard">
<div class="title">
<h1>Puffer's Meadow Railway</h1>
<p class="tag">The little train that could (collect everyone)</p>
</div>
<div class="gauges">
<div class="plaque"><span class="lbl">Aboard</span><span id="score">0</span></div>
<div class="plaque"><span class="lbl">Best</span><span id="best">0</span></div>
<div class="plaque speed"><span class="lbl">Speed</span><div id="speedDial">▮▮▮</div></div>
</div>
</header>
<div class="board-wrap">
<div class="board-frame">
<canvas id="game"></canvas>
<div id="overlay"> start / gameover contents </div>
</div>
</div>
<footer class="consist-row">
<div class="hint">…controls…</div>
<div class="consist" id="consist"></div>
</footer>
</main>
</body>
```
Signboard styling: wooden sign: warm wood gradient background, with dark border and nailheads (pseudo elements), hanging? Maybe two "chains" from top? Might skip chains. Big display font, cream text over dark wood? Strong contrast. Wooden sign with cream letters + brass score plates.
Actually, let me pick a bold palette direction: **vintage railway poster**: deep teal (#0f4c5c?) header, cream board, red accents. But sky background is bright. Header sign: dark walnut wood (#5b3a29 gradient) with cream text and red/green signal lamps. Score plates: brass (#d9a441) with dark digits. Feels like a railway.
Overlay card: cream enamel sign with red border, big text "ALL ABOARD!" and smaller "Click / tap or press any key to drive". In demo mode overlay shows continuously behind? Requirement: game starts in self-play demo behind "Click or press a key to start" message — so overlay should be shown while demo is playing. So overlay must not block view: make it a compact banner card positioned top-center of board (not full cover), demo visible around it. Good approach: float sign at top of board, gently wobbling, pointer-events none except when clicked anywhere to start. Click anywhere on board (or key) starts. After start, banner hides with pop animation.
Game over overlay: full-board semi-dim + card "DERAILED!" stats + "press any key".
Demo behavior details: while state==='demo', AI drives; overlay banner shown. On first click/key → start(): state='playing', reset train (fresh small train), whistle, banner hidden. If crashes during play → 'over' overlay; any key/click → restart to playing. If crashes during demo → quietly auto-reset after delay, keep demo.
Also idle: if user in play is idle >?, no auto pause needed.
P key toggles pause (playing only). Show small "PAUSED" overlay.
Mobile: swipe on canvas; also tap = start/restart. Show hint "Swipe to steer".
Consist chips: colored chips matching cars; on reset, 0 cars? Train starts with locomotive + 2 cars? Classic snake starts with length 3. Start with locomotive + 2 cars (looks like a train from the get-go). Score = passengers = cars beyond initial? Score counts passengers picked up; number of cars = 2 + score? Chips show cars after initial 2? Show all cars as chips; fine.
Edge: max length cap? If snake fills a lot, passenger spawn could fail → handle gracefully (win state? "Everyone rescued!" confetti) — unlikely but implement spawn-fail → win overlay. Rare. Add simple handling: if no free cell, declare "Grand Opening! Station full". Fine.
Now, drawing the sprites on canvas (top-down, rotate ctx by angle at segment position):
Draw car at (0,0) with length L=cell*0.92 in x direction, width W=cell*0.66:
- Shadow: dark alpha ellipse below, +2y.
- Coupling: dark line to previous segment (drawn between segments) — draw small dark rect behind.
- Body: roundRect(-L/2, -W/2, L, W, r). Fill color; then roof: brighter inner rounded stripe along the middle (roof ridge) lighter color; end caps darker.
- Windows: two cream squares on roof? In top-down windows aren't visible; but toy look: draw side hint with lighter top + 2 small window dots on each side? Simplest cute: draw a slightly inset rounded roof of lighter shade with 2 white "skylight" rounded rects. Reads as train car from top.
- Wheels: 4 small dark circles poking out on sides (at corners, x=±L*0.3, y=±(W/2+1.5)). Draw before body.
- Outline: subtle darker stroke.
Locomotive: L=cell*1.0, W=cell*0.72:
- Shadow
- Main body: dark crimson rounded rect for rear cab section + forward boiler narrower? Top-down: wide cab rect at back, narrower boiler forward, chimney circle near front with dark ring + inner, brass ring highlight.
- Cowcatcher: forward triangle (yellow/black stripes? maybe red/cream) — draw as fan of 3 lines or a striped triangle.
- Headlamp: small yellow circle at very front + glow drawn separately (screen blend).
- Cab roof: lighter rounded square at rear with 2 small windows.
- Smoke emitted from chimney position.
Roundrect: use ctx.roundRect (supported by latest Chrome). Fallback: implement helper rr() manually for safety — write helper anyway (zero error requirement; Chrome supports roundRect since 99, fine, but helper is cheap).
Passenger sprite (drawn without rotation):
- Platform: rounded rect ~1.4×1.1 cell, grey #cfcabc with yellow warning stripe edge facing track? Direction unknown; draw yellow border around? Cute: little platform square with doormat stripes.
- Person: shadow ellipse; body: coat-color rounded rect (w=0.34c, h=0.4c); head: skin-tone circle; hat: small arc cap in hat color; feet two small dark dots; arms: when train near (<3 cells) or random, wave occasionally → draw one arm raised line. Bob: y += sin(t*3+phase)*1.5.
- Occasionally a speech bubble "!"? Add tiny "zZ"? no. Skip.
Trees/flowers decor: precomputed random from a seed: ~14 flowers (small 3-petal dots), 6 grass tufts (3 short lines), 4 pebbles, placed avoiding? These are just texture; anything can overlap, train goes over flowers fine. Also maybe 2 bushes near corners outside play? Keep within but subtle. Also paint a subtle checkerboard.
Hedge border: outer frame within canvas? Keep: canvas covers only play area; frame border is CSS (wooden frame around canvas). Wall = canvas edge; draw a thin inner hedge strip (8px) inside canvas with leaf bumps so crash visually makes sense. Implement inset hedge thickness = cell*0.18 around; play cells drawn inside? Grid still spans full canvas; hedge drawn on top of outer margin cells — but snake occupies those cells visually below hedge… Better: keep grid full and draw hedge right at the edge (half on wall) so snake head touches and crashes. Simple visual: CSS frame does wall duty + draw soft vignette inner shadow inside canvas. Do both: thick wooden CSS frame + inner canvas shadow; also draw subtle dark green edge stripe.
Particles system: array of {x,y,vx,vy,life,maxLife,size,color,type:'smoke'|'spark'|'star'|'text'}. Update in render loop with dt. Text particles float up with fade (score popup). Steam is white puffs expanding. Crash: mix of grey steam + orange sparks + rotating "wheel" sprites? Fun: 2-3 wheel circles scatter. Keep sparks + steam + stars.
Shake: shakeT decays; offset = rand*amp*shakeT.
Render order per frame:
1. Clear; save; translate for shake.
2. Draw grass + decor (pre-render to offscreen canvas once per resize for perf!). Yes: render static background (grass checker, flowers, inner-edge shading) to offscreen bgCanvas on resize; drawImage each frame. Decor positions are a seed function of COLS/ROWS constant.
3. Draw platforms under passengers (draw together with passengers since dynamic count) — fine per frame.
4. Draw track polyline under train.
5. Draw train: cars from tail→head; shadow first? Draw shadow+body inline per segment.
6. Passengers (on top? Passenger stands beside track; train head reaching → eat. Draw passenger in front of train or behind? Train passing over platform eats immediately; draw passenger before train so train overlaps during pickup moment. But visually while waiting they shouldn't be covered unless train is adjacent. Draw passenger after track and before train. Good.
7. Particles.
8. Headlamp glow (additive) — draw before train? Glow is above grass, below train body but around head: draw after train with 'lighter' low alpha for halo effect; OK, after train with low alpha 'lighter'.
9. Vignette: subtle inner shadow via pre-rendered radial on bg? Add gradient overlay per frame is cheap: skip, include in bg offscreen (inner shadow rectangle).
Track fade behind tail: alpha gradient along path: draw rails in multiple strokes per subsegment varying alpha? Simpler: whole track alpha 0.9, and since it only extends train length + forward, it reads as "the track train is on" — no fade needed; maybe fade the last few ties. Keep constant; ends at tail. Also extend forward by 1.5 cells so track leads slightly ahead of train (suggests next destination) — but it should align with current direction; that's a cute "track lays ahead". Only forward extension when there's free space ahead? It may cross hedge visually at wall before crash — acceptable, or clamp extension length to distance to wall in that direction. Clamp: extend = min(1.5, cells to wall in dir)*cell. Cute detail, do it.
Interpolated points for track: positions of segments (head..tail) then at head add forward extension point. Subdivide each pair into 2 for ties spacing.
Rails: offset ±cell*0.16. Ties: length cell*0.55, width cell*0.12, every ~cell*0.45 along path (subdivided spacing). Color: tie #6e4f37, rail #3f4750 with thin lighter top #98a4ad? Two strokes: dark thick then lighter thin for gloss.
Performance: path length = snake length ≤ maybe 60; trivial.
Step logic doStep():
- Determine direction (player queue or AI).
- newHead = head + dir; if wall → crash; if body hit → crash.
- prevSnake = snake slice; unshift newHead; ate = passenger at newHead? if ate: keep tail (grow), handle passenger removal/spawn, score++, effect; else pop tail.
- Update headAngle: from prev head→newHead angle (target), store prevHeadAngle for lerp.
- Emit clickety sound? Skip. Smoke timer emits regardless (during render).
- Speed up: stepMs = clamp(base - score*2.5, min). Use current stepMs in accumulator.
AI doStep picks dir before moving.
Passenger management: keep array passengers [{x,y,coat,hat,skin,phase,spawnT}]. Target count = 3 (2 in demo? 3 is fine). Ensure spawn interval: after eaten, schedule spawn in 600ms (simple: each frame if passengers.length<3 and timeSinceLastSpawn>0.8s → spawn). Also initial spawn on reset: 3 immediately but not within 2 cells of head... initial spawn distance ≥4 from head.
Spawn cell constraints: empty (not in snake), not occupied by another passenger, manhattan distance ≥3 from head, maybe not directly adjacent to wall? Adjacent to wall is fine, platform drawn within cell.
Eat detection: passenger at newHead cell → remove, effects.
Also passengers shouldn't spawn on cells just behind head? Anywhere empty is fine.
Score display update via DOM on change.
Consist chips DOM: function syncConsist() rebuilds chips from snake length-1 cars (car colors determined by index: carColors[i % palette.length], stable as index fixed at creation order). Car color at segment i (i>=1): palette[(i-1) % len]. As train grows, new car is appended at tail — but tail is the last index; colors by index shift? Since snake array is head-first, new segment is appended at end: index = length-1. If color = f(index), existing cars keep their index → colors stable.
But note: during movement indices stable (head stays index 0). Yes.
Crash restart preserves best.
Now "speed" gauge: chevrons number = level = floor(score/5)+1 max 8, render as "▸▸▸". Or small dial? Chevrons simple: span filled/unfilled. Update DOM.
Best: variable only, update on crash / live? Live update best = max(best, score). Fine.
Pause overlay: small centered sign "Paused — P to resume". rAF continues but skip stepping; also pause demo? Pause only in playing.
visibilitychange: clamp dt anyway.
Now let me write out AI BFS:
```
function aiDir(){
const head = snake[0];
const blocked = new Set(snake slice(0, -1) ... ) // body excluding tail? assume tail moves (won't grow if target nearby?) include whole body except tail.
// BFS on grid avoiding blocked; target = passenger cells.
// Return first move direction; fallback: safest neighbor by flood count; fallback keep dir.
}
```
BFS from head; standard queue with prev pointer; find closest passenger; reconstruct first step. If none found (all blocked), fallback: for each of 4 dirs in order of preference toward nearest passenger (manhattan), if valid (in-bounds and not in body excluding tail), compute flood fill size from that neighbor (with blocked, cap 400), pick max. Tiebreak toward passenger.
Implement flood with reused array and Int arrays for perf (grid small, either way).
Direction encoding: {x,y}; list DIRS.
Reverse prevention for player: if candidate dir = -currentDir and queue is empty, ignore. Use last applied dir as currentDir.
Initial snake: horizontal center, facing right: cells [(8,7),(7,7),(6,7)] with COLS 22 ROWS 14, head at 8. dir = right.
Board COLS=22, ROWS=14 → aspect 22/14 = 1.571. Canvas CSS width constrained: min(92vw, (availableH)*1.571, 980px). Compute in JS on resize: fit to .board-wrap box.
Simpler: .board-frame {width: min(100%, calc((100dvh - 220px)*1.57)); } maybe fragile; JS: on resize, compute size = min(maxW, (vh - headerFooter)*ratio). Let's do JS sizing: function layout(): compute available width (wrap.clientWidth) and height budget (window.innerHeight - header/footer approx measure offsets from DOM: header.offsetHeight + footer.offsetHeight + margins). Set frame style width accordingly. Use ResizeObserver? Window resize is enough.
Cell = canvas pixel width / COLS (device px) — compute all drawing from cell. DPR: canvas.width = cssW*dpr; ctx.setTransform(dpr...). Keep everything in CSS px coord (scale via transform) → simpler.
Font in canvas: `${cell*0.5}px 'Trebuchet MS'`.
Sky background CSS: body gradient #bfe3f2 → #eaf6e0? Add sun: radial circle top right with glow, animation gentle pulse. Clouds: 3 white round blobs (divs with ::before bumps) drifting at different speeds via translateX animation looping. Hills: two overlapping wide radial-gradient divs positioned bottom, muted greens; small station clock too? Skip. Also floating hot air balloon? Could add a small drifting balloon for charm — one div, cheap. OK, add balloon slowly ascending loop.
Also maybe add tiny bird (2 arcs)? Skip.
Reduced motion: prefers-reduced-motion → disable cloud animation? Add media query pausing animations. Nice touch, low cost.
Footer hint: "← ↑ ↓ → / WASD to drive · P pause · swipe on touch". Style as small enamel tag.
Overlay banner (demo): position absolute top-center of board, wooden sign: "🚂 DEMO RUN — Click / tap or press any key to drive!" gently swaying (rotate ±1.5deg). Subtle dim: no full dim (demo should be visible). Add small "SELF-DRIVING" tag. pointer-events: none (clicks handled on frame/window level).
Game over overlay: absolute full, bg rgba dark with slight blur? Use solid translucent dark teal; card: cream with red border "DERAILED!" big; row of stats: passengers aboard N, best B; "Click or press any key to restart". Card pops in with scale animation.
Start (human) after demo: small "TOOT TOOT!" toast? Whistle sound + maybe a "Your train, engineer!" toast fading — cute. Add toast element.
Audio implementation:
```
let AC=null, soundOn=true;
function ac(){ if(!AC){ AC = new (window.AudioContext||window.webkitAudioContext)(); } if(AC.state==='suspended') AC.resume(); return AC; }
function tone(f,dur,type='sine',gain=0.15,delay=0,slide=0){...}
ding(): tone(784,.12,'triangle',.2); tone(1175,.18,'triangle',.18,.08)
whistle(): two tones 620 and 780? Train whistle chord: 466+587? Use 523.25+659.25 triangle, 0.5s with slight detune vibrato via LFO? Simple: two oscillators with gain envelope, plus a bit of noise? Keep two triangles + one sine octave.
crash(): noise buffer 0.4s low-pass + thud tone 90Hz.
```
Guard everything with try/catch.
Mute toggle button in header (🔊/🔇) — nice addition.
DOM ids: score, best, speed, consist, overlay states, muteBtn, toast.
Now let me code the game loop carefully.
Global constants:
```
const COLS=22, ROWS=14;
const CAR_COLORS = ['#e2574c','#3e8fd6','#f3b53f','#4fae7d','#9a6bd6','#ee8f4e','#45bfc4','#e77fb3'];
```
State machine: mode: 'demo' | 'play' | 'over' | 'win'; paused boolean.
reset(modeKeep): snake, dir, score=0, sync UI, spawn passengers, clear particles, timing reset, headAngle.
Main loop rAF(t): dt=clamp; if(!paused) acc+=dt; while(acc>=stepMs && mode not over) doStep(); render(t, p=min(1,acc/stepMs)).
Actually in demo/over we still render; over: freeze train (skip stepping), particles update.
Demo auto-restart: when crashes during demo: mode='demo' but crashed flag with timer → after 1600ms reset().
During 'over' (human), wait for input to reset to 'play'.
doStep():
```
if(mode==='demo') chooseAI();
else apply player queue.
compute next cell; collision → doCrash(); return.
prev = snake.map(clone); move; check eat.
```
Save prevPositions = old rendered grid positions (array of cells) for interpolation: store this.prevSnake.
Render position of segment i: lerp(prevSnake[i] ?? snake[i], snake[i], ease(p))? Use linear p for constant speed; ease adds jerk. Use p linear.
Head angle: prevHeadAng → newAng lerp with p (angleLerp shortest).
Wheels sway: sway = sin(dist*0.9 + i*1.3)*cell*0.03 in normal direction of travel, where dist = total distance moved (stepCounter + p).
Smoke emit: timer in render: every 0.09s of movement, add particle at chimney position with slight backward velocity.
Chimney world position: head render pos + forward dir rotated by headAngle * cell*0.28.
Headlamp glow: radial gradient ahead at cell*0.9 radius, alpha 0.25 yellow, composite 'lighter'.
Eat effect: at passenger position: 8 sparkle particles (star type: draw small plus/circle), text particle "+1" cream with dark stroke, ding sound, chip update, maybe platform remains empty and fades — platform removed with passenger (just remove). Also "boarded" flash ring: expanding circle particle.
Crash effect: big steam + sparks at head, shake=1, crash sound, mode='over' (if play): show overlay after 500ms; if demo: schedule reset.
Also on crash: locomotive tilts? Add headAngle += 0.3 and slight offset? Skip; particles convey it.
Win condition: if snake length > COLS*ROWS - 10 → win overlay (rare). Implement minimal check on spawn failure with no free cells: if cannot spawn passenger and passengers empty → win. Keep code small.
Passenger proximity wave: compute distance to head each frame; if ≤3 raise arm and slight faster bob. Cute feedback.
Now background offscreen render (bg): fill grass base; alternate cells subtle two tones; decor: seeded RNG (mulberry32) places ~30 items: flowers (white/pink/yellow dots with stems?), grass tufts darker strokes, pebbles, 4 small bushes at random edges? Also inner shadow edges: draw semi-transparent dark inset stroke. Draw platforms? No (dynamic). Also draw subtle "station yard" area? Skip.
Wait — platforms: drawn per frame below passenger (rounded slab). Slight shadow. OK.
Also draw start station decoration: little "PUFFER'S MEADOW" signpost on bg at a corner — cute: draw small sign at top-left cell area on bg. Sure: tiny post + board with 6px text might be unreadable; draw simple red flag? Draw small station hut on bg at top-right corner area (2x1 cells): roof rect, body, door — but play passes over. Decorative at corner with lower saturation; overlap is fine. Add a small hut at top-left inside hedge, drawn subtly.
Hmm, but snake might crash at that corner; hut visually overlaps snake? Draw hut below (under) train. Fine.
Seeded RNG for reproducibility across resizes: fixed seed constant → decor same layout; positions scale by cell.
Passenger name flavor: on eat, toast "Lucy aboard!"? Fun: random name array; show floating text "+1 · Zoë"? Keep "+1" only, plus occasional toast? Add toast message per pickup: "Pip aboard!" — charming and cheap: reuse a toast element with queue fade. But frequent toasts might clutter; instead include name in floating text: "+1 Pip". Cute. Name list ~24 short names.
Departure-board header idea: plaque in header is enough.
Now handle inputs:
```
window.addEventListener('keydown', e=>{
const k=e.key;
if(['ArrowUp',...,'w','a','s','d',' '].includes) e.preventDefault();
start on any key if demo/over; else queue direction; 'p' pause; 'm' mute;
});
frame.addEventListener('pointerdown', e=>{ if(demo||over) start/restart; else swipe start point; });
pointermove/up compute swipe delta → direction.
```
Also allow tap during play without swipe to do nothing.
Start flow: startGame(): mode='play'; reset(); hide banner; whistle; toast "Your train, engineer!".
Restart from over: reset(); mode='play'; hide overlay; whistle short.
Keydown during play pushes to input queue only if mode==='play'.
Queue: inputQueue array max 3; in doStep shift until valid (not reverse).
Pause: if mode==='play' toggle; show/hide pauseEl.
Mute button toggles soundOn; icon swap.
Now sizing/layout JS:
```
function layout(){
const wrap = document.getElementById('boardWrap');
const availW = wrap.clientWidth;
const headH = header.offsetHeight, footH = footer.offsetHeight;
const availH = window.innerHeight - headH - footH - margins(~140);
let w = Math.min(availW, 980, availH * (COLS/ROWS));
w = Math.max(w, 280);
frame.style.width = w+'px';
resizeCanvas(w);
}
```
Use CSS flex column centered; header and footer shrink. Body min-height 100dvh with sky fixed background.
Canvas resize: set canvas.style.width 100%; canvas.height set in px computed from width*(ROWS/COLS); canvas.width=cssW*dpr etc.; rebuild bg offscreen.
Note: rebuild bg uses cell size → regenerate on resize.
Render function details — drawTrack(points):
points: array of {x,y} in px starting at head forward extension point? Build tail→head so forward extension is at end: pts = [tail...head, forward]. Compute offset polylines:
```
function offsetPath(pts, off){ // returns array
for each i, tangent = normalize(pts[min(i+1)] - pts[max(i-1)]); normal=(-ty,tx); push p+normal*off
}
```
Draw rails: strokePath(offsetPath(+d)) and (-d): strokeStyle '#4a5560', lineWidth cell*0.07, round caps/joins; then a thin highlight stroke '#aab6bd' width cell*0.025 offset? Simpler: single dark rail stroke + small light dash? Keep dark single stroke; ties provide contrast on grass.
Ties: iterate cumulative distance; every cell*0.5 place a tie: interpolate position + tangent between pts; draw a line from p - n*cell*0.34 to p + n*cell*0.34, strokeStyle '#7b5b40', width cell*0.13, alpha .85. Ballast: first stroke a thick path along pts: strokeStyle 'rgba(120,94,70,.28)', width cell*0.95, round joins. That grounds the train.
Forward extension tangent uses current head direction.
Also when crash, hide track extension.
drawTrain(p): compute positions array pos[i] in px. For i from len-1 down to 1: angle_i = atan2(pos[i-1]-pos[i]); draw car at pos[i] with angle (plus sway offset along normal). Head at pos[0] with headAngle(p). Wait, angle of car uses next segment ahead: good.
But interpolation of position when growing: prevSnake shorter; fallback new tail segment = pos uses current only (prev = current) → static for 1 step, fine.
Also ensure pos smoothing: use px = lerp(prev,curr,p)*cell + cell/2.
Sway: apply vertical offset: n = (-sin a, cos a); pos += n*swayAmp*sin(phase). Sway phase based on global travel distance s = (stepCount + p) - i*0.9 → wave passes through train. swayAmp = cell*0.035.
Drawing the car with color c = CAR_COLORS[(i-1)%8]:
```
ctx.save(); translate(x,y); rotate(a);
// wheels
dark #2e2a26 circles at (±L*0.32, ±(W/2-1)) r=cell*0.09 — actually wheels are below body edges: y=±W/2*0.95.
// coupling behind: rect from -L/2-cell*0.12 length cell*0.14 h cell*0.08 dark.
// body shadow: draw before rotation? Shadow inside rotation is fine: ellipse (2.5,3) dark alpha.
Actually draw shadow first for entire train: loop and draw shadow then loop bodies to avoid shadow overlap onto cars. Two-pass: shadows pass, then bodies.
// body: rr(-L/2,-W/2,L,W,r=cell*0.14) fill c; stroke darker (shade(c)) lw 2.
// roof: rr(-L/2+L*0.12, -W*0.26, L*0.76, W*0.52, r) fill lighten(c, .18)
// roof ridge line darker alpha.
// skylights: 2 white rounded rects on roof at x=-L*0.16, +L*0.16 size L*0.16 x W*0.3 alpha .85.
// end buffers: small rects at both ends? Small dark rects at corners of front and rear edges.
ctx.restore();
```
shade()/lighten(): precompute pairs: each color has {main, dark, light} — compute manually or via simple hex manipulation function. Write small helpers hexShade(hex, f).
Locomotive drawing (i=0): L=cell*1.02, W=cell*0.78:
```
Cowcatcher: triangle at front: moveTo(L*0.5, -W*0.45)→(L*0.82,0)→(L*0.5,W*0.45) fill #f2e6c9? Classic red/white stripes: draw yellow with dark stripes → 3 lines. Keep: fill '#f7d154' stroke dark, plus 2 dark chevrons.
Body: rr main '#c8402f' (crimson) full L W.
Boiler front section: forward 55% narrower: overlay slightly lighter rounded rect? Draw: cab = rear 40%: rr(-L/2, -W/2, L*0.42, W) fill dark '#a93325'; cab roof lighter rr inset with 2 small windows (cream squares at rear top? Top-down windows on cab roof = skylights).
Boiler: forward: rr(-L*0.08, -W*0.42, L*0.6, W*0.84) fill lighter red; band: 2 thin brass vertical lines (#e8b84b) crossing boiler.
Chimney: circle at x=L*0.30: outer dark ring #2e2a26 r cell*0.13, inner #1c1a18 r*0.7, brass rim stroke.
Dome: small brass circle at x=L*0.05 r cell*0.08.
Headlamp: circle at x=L*0.5+2 r cell*0.06 fill '#ffe9a3' stroke brass; glow separately.
Cab window: small cream rect near rear top edge? 2 tiny side windows on cab: small rects at (−L*0.38, ±W*0.32). ok.
```
Also front buffer beam: dark red rect at front edge.
Shadow pass: for each segment, ellipse rgba(40,50,30,.28) radius L*0.52/W*0.55 offset +2.5y.
Passenger draw:
```
platform: rr at cell center size cell*1.35 x cell*1.15 fill '#cbbfae' stroke '#a99d8b', plus warning stripe: small yellow dashes along edge? draw 3 yellow ticks at front. Simple: inner dashed border '#e7c94c'.
shadow small.
person with bob: body rr w=cell*0.30 h=cell*0.34 coat color; head circle r=cell*0.13 skin; hat: arc fill hat color + brim line; suitcase: small rect at side (some have it, random), arm raised on wave: line from shoulder rotated.
```
Wave arm: raise arm when dist<=3.2, or sin(t*2+phase)>0.93 random wave bursts. Implementation: waving = dist<=3.2; angle = sin(t*10)*0.3.
Also show a small "!" speech bubble when train very close (<=2)? Cute: small white bubble with "!"; OK, draw a circle + tail when dist<2.2.
Floating text particle: {type:'text', str, x,y, vy:-0.5, life 1.2}: font bold cell*0.34 Trebuchet; strokeText dark + fillText cream, alpha by life.
Sparks: small circle color orange/yellow with gravity.
Stars: draw 4-point star rotating, color cream.
Steam: circle expanding r: size*(1+ (1-life)*1.5), alpha life*0.5, color '#f4f6f4' slight grey.
Confetti on win? Minimal.
Now HTML/CSS for chrome (frame around game):
- .station: max-width 1060px margin auto padding.
- Header signboard: wooden: background linear-gradient(#7a4b2c,#5f3820); border 4px #3f2513; border-radius 14px; box-shadow large; position relative; add nails: 4 radial dots via ::before/::after? Use two pseudo circles each side. Title font display size clamp(28px,4vw,44px), cream #f7ecd7 with slight text-shadow. Red signal lamp dots on the sign? Add 2 small circles left/right that alternate glow (like signals) — animated red/green. Cute: signal light cycling animation every 3s.
Actually, header layout: left title block (name + tagline), right plaques row (ABOARD / BEST / SPEED chips + mute). On narrow, stack.
- Plaque: brass gradient bg, dark brown text, riveted borders. Numbers big in display font.
- Speed pips: 8 small dots, filled with amber.
- board-frame: padding 10px; background wood grain gradient (#8a5a33→#6b421f) with border radius 18px; deep box-shadow; inside canvas border-radius 10px; inner dark inset shadow. Add screws in corners via pseudo elements? Use 4 span.screw absolute positioned.
- Overlay banner (demo) .banner: absolute top:14px centered; background cream enamel with red border, rotate -2deg animation wobble 3s; big display text "ALL ABOARD!" and smaller "Demo engineer driving — click / tap or press any key to take the wheel". Add small blinking "DEMO" badge. pointer-events none.
- .overlay-full (game over/pause/win): absolute inset 0 within frame; display flex center; background rgba(23,42,38,.45); card cream with red border, pop animation.
- Footer: flex between: controls hint chips (kbd styled) + consist chips container.
- Toast: absolute bottom center of frame, cream pill, fade in/out.
- Sky: fixed inset 0 z -1: gradient + sun + clouds + hills + balloon.
Cloud CSS: .cloud{position:absolute; background:#fff; border-radius:50px; opacity:.85; filter: drop-shadow?} width varies; drift animation linear infinite translateX from -20% to 120vw. Use ::before/::after bumps.
Hills: bottom:0 width:140% height:220px background: radial-gradient(ellipse at center, ...) positioned; simpler: two divs with border-radius 50% scaled, color #b9d98a / #a3cd79, positioned partially off-screen.
Balloon: small div 26px circle red with basket, animation float 40s loop rising diagonally; subtle.
Reduced motion: pause all animations.
Fonts:
--display: "Chalkboard SE","Comic Sans MS","Segoe Print","Comic Neue",cursive;
--body: "Trebuchet MS","Segoe UI",Verdana,sans-serif;
Title letter-spacing slight.
Ensuring first paint meaningful: demo runs immediately + banner visible + HUD.
Edges: iframe keyboard needs click: banner tells so. Also canvas click gives focus anyway; attach listener on window and document — window keydown won't fire in iframe until focus; clicking anywhere gives focus on iframe body → then keys work. Good.
Now let me write BFS AI carefully:
```
function chooseAIDir(){
const head=snake[0];
const occ = new Uint8Array(COLS*ROWS);
for(let i=0;i<snake.length-1;i++) occ[snake[i].y*COLS+snake[i].x]=1; // exclude tail (moves) — unless eat pending? Approx ok.
const dist=new Int16Array(COLS*ROWS).fill(-1);
const prevDir=new Int8Array(COLS*ROWS).fill(-1); // first step direction index for this cell
const q=[head.y*COLS+head.x]; dist[start]=0;
BFS: for each cell pop, for each of 4 dirs: if inbounds, !occ, dist==-1 → set dist, prevDir[n] = (parent is head? dirIndex : prevDir[cur]);
Targets: passengers indices → pick min dist. If found return DIRS[prevDir[target]].
Fallback: evaluate each valid neighbor: floodSize from that cell (respecting occ, maybe excluding tail) → pick max; tiebreak by closer manhattan to nearest passenger; if none → keep dir.
}
```
Flood capped at 300.
Also safety tweak: avoid moves that lead to dead end if options exist — flood covers this.
Note: occ excludes tail → AI may step onto tail cell; legal only when not growing; if passenger adjacent AI may eat and grow → tail doesn't move → possible crash; rare and fine (demo).
Now code structure inside single file:
```
<!DOCTYPE html><html><head><meta charset><meta viewport><title>...</title><style>...</style></head>
<body>
sky divs
main.station
header#topsign ...
div.board-wrap > div#frame.board-frame > canvas#game + #banner + #fullOverlay + #toast
footer ...
<script>...</script>
```
Wait: frame contains canvas + overlays; sizing of frame is set via JS; overlays absolute inside.
Also add subtle grass border outside canvas inside frame? Frame is wood; fine.
Write JS with clear sections. Must be zero console errors; careful with audio context creation before gesture (don't create until gesture).
Angle lerp helper:
```
function lerpAngle(a,b,t){ let d=(b-a)%(Math.PI*2); if(d>Math.PI)d-=2PI; if(d<-PI)d+=2PI; return a+d*t;}
```
hex shade:
```
function shade(hex,f){ parse rrggbb; each = clamp(round(c*f)); return rgb string; }
function tint(hex,f){ mix toward white }
```
Track forward extension clamp:
```
let ext=0; const d=curDir; let cx=head.x, cy=head.y, steps=0; while(steps<2){nx=cx+d.x...; if(out)break; cx=nx... steps++} extSteps=steps; // 0..2
forward point = headPixel + dir*extSteps*cell*0.8? Just use extSteps*cell.
```
But head pixel is interpolated; extension from current head position fine.
Actually better forward extension = min(2 cells, available) * cell * (something) — also animate extension shrinking as we approach wall to prevent pop? Overkill; ties spacing will mask.
Track start: last point = tail position maybe extend backward by cell*0.5 opposite tail angle so track sticks out behind tail slightly? Tail position moves; tail-behind extension gives continuity. Add backward extension point = tail + tailDir*cell*0.6, tailDir = direction from tail to segment before it → backward = pos[len-1] + (pos[len-1]-pos[len-2]) normalized *0.6cell. Sure.
Now platform drawing: platform slightly larger than person but within cell → size cell*1.0? Person small. Let's do platform cell*1.05 x cell*0.9. Rounded 4. Yellow stripe along top edge? Direction-agnostic: draw dashed border inside.
Cell centers: px = (x+0.5)*cell.
Passenger shadow small.
Name list: ['Pip','Momo','June','Ozzy','Bea','Tito','Nia','Gus','LuLu','Ivo','Suki','Remy','Ada','Bo','Cleo','Dot','Ezra','Faye','Hugo','Iris','Juno','Kai','Milo','Nell'].
Coat colors: bright set distinct from grass: ['#d94f7a','#4c6ef5','#f59f00','#0ca678','#e8590c','#7048e8','#12b886','#f06595'] skin ['#f5c9a2','#e0a377','#b97a50','#8a5a3b'], hat ['#2f3e46','#c92a2a','#1864ab','#5f3dc4','#e67700','#087f5b'] or maybe no hat (30%).
Toast on pickup: show name via floating text instead; skip toast for pickup (reduce noise), only use toast for start / pause messages.
Also small "conductor" on locomotive? Skip.
Ensure no horizontal scroll: body overflow hidden? Allow vertical scroll on small screens if content taller than viewport; overflow-x hidden on html,body. Sky fixed.
Small screens: header stacks, plaques smaller; frame width ~ min(94vw, ...). Footer wraps.
Height budget: on phone, board may exceed height minus header; layout() ensures fit using innerHeight. Compute: availH = innerHeight - headerRect.height - footerRect.height - gaps(~70). Use offsetHeight after DOM ready. Call layout on load + resize + orientationchange.
Also cap frame width to 920 for aesthetics.
Canvas height = width*ROWS/COLS. If availH smaller, width = availH*COLS/ROWS.
Guarantee minimum too 260.
Now let me write rendering of speed pips: id speedPips container with 8 spans; update: filled = min(8, 1+floor(score/4)).
Best updates live.
Pause element reuses fullOverlay with different content — implement showOverlay(kind) that sets innerHTML.
FullOverlay kinds: 'over' (title DERAILED!, stats, prompt), 'paused' (PAUSED, P to resume), 'win'.
Keep overlay hidden during demo; banner is the demo message.
OK — also click on frame during demo to start; during over to restart; during play: begin swipe.
Swipe: on pointerdown record; on pointermove if distance>24 pick dominant axis direction and set as single queued direction (replace queue), only once per gesture? Allow continuous: on each move crossing threshold from start point recompute, reset start point to current so repeated swipes chain. Implement.
Prevent scrolling on touch drag: touch-action none on frame.
Now let me write doCrash():
```
mode stays; crashed=true; spawn particles at head px: 16 steam, 12 sparks, 5 stars; shake=1; sound crash;
if playing → mode='over'; update best; show overlay after 600ms via overlayTimer.
if demo → demoResetTimer=1600.
```
During crashed render: train static (p frozen): stop acc accumulation (mode over/demo-crashed skip step). Render with last p.
Reset(): clear crashed, timers.
Whistle also on demo start? No audio before gesture; on demo start silent fine.
Another live detail: signal lamps on header animate; banner wobbles; consist chips pop-in animation (keyframes scale).
Also add small "km" counter? No.
Time to write the actual code meticulously. Aim for clean, commented sections. Let me draft key snippets in my head then produce final.
Potential pitfalls:
- ctx.roundRect exists; still define fallback rr(ctx,x,y,w,h,r){ if r>min/2 clamp; path manual }. Use helper everywhere for safety.
- Angle for head after reset set to dir angle.
- On reset during render loop ensure prevSnake = snake copy to avoid lerp jump.
- When growing, prevSnake shorter; in render posAt(i): prev = prevSnake[i] || snake[i].
Wait prevSnake captured before move: prevSnake = snake.map before unshift/pop. After move snake length = prev length +1 (grow) or same. For grow: snake indices 0..n; prev 0..n-1; segment i: prev[i] exists for i<n; new tail i=n uses snake[n] for both → static. Correct since grow means tail didn't move.
- Head angle continuity: store headAngle (rendered), target on step = atan2(dir); headAngle = lerpAngle(headAngleStart, target, p) where headAngleStart captured at step time (= headAngle at step moment). Save angFrom, angTo.
Track points use rendered positions; forward extension only when !crashed.
Passenger spawn timer: spawnCooldown.
doStep also increments stepCount for sway phase.
Smoke only emit if mode is moving (not crashed/over/pause) — tie to render time accumulation while stepping (mode active).
Particle update uses dt seconds.
Text particle vy is px/s relative to cell: use cell scale: vy=-cell*0.9 per second.
Canvas font strings use px number.
Also draw small direction "puff" on step? No.
Edge: passenger spawn cell shouldn't be within 2 cells in front of head on next path? Fine.
Also multiple passengers: ensure spacing between passengers ≥2.
Win check: after grow, if snake.length >= COLS*ROWS-6 → show win overlay 'Station festival! You collected everyone'. Also spawn fails: if passengers empty & cannot spawn → also win. Implement: after eat, if snake.length > COLS*ROWS-8 → win mode (show overlay, stop stepping). Rare; code small.
Best persists across rounds within session (variable).
Now let me write. Also don't forget meta viewport, title.
Canvas text uses display font for "+1 Pip": `'Chalkboard SE','Comic Sans MS',cursive`.
Another consideration: canvas letter rendering each frame is minimal — fine.
Sky decorations also z-index behind station: sky position fixed z 0; station z 1.
Balloon structure: .balloon{width:34px;height:44px} balloon circle via ::before (radial gradient red/white stripes? simple red with highlight), basket via ::after small brown square connected by line? Keep it minimal: balloon circle + 6px basket; animation: rising from below viewport to above, 55s, slight sway. Low opacity 0.9. Position left: 12%.
Clouds: 3 of them, size 120-220px, top 8-30%, animation duration 60-110s, use negative delay to spread at load: yes use negative delay so visible immediately.
Hills: bottom fixed; 2 ellipse divs.
Subtle grain? Skip.
Also ensure body margin 0.
Finalize header markup:
```
<header id="topsign">
<div class="sign-left">
<div class="lamp lamp-l"></div>
<div class="title-block">
<h1>Puffer's Meadow Railway</h1>
<p class="tag">Pick up every passenger — don't derail your own wagons!</p>
</div>
<div class="lamp lamp-r"></div>
</div>
<div class="sign-right">
<div class="plaque"><span class="pl">Aboard</span><strong id="score">0</strong></div>
<div class="plaque"><span class="pl">Best</span><strong id="best">0</strong></div>
<div class="plaque wide"><span class="pl">Speed</span><span class="pips" id="pips"></span></div>
<button id="muteBtn" title="Sound">🔊</button>
</div>
</header>
```
Signal lamps: small circles alternately red/green animated via keyframes (lamp-l red→green cycle, lamp-r opposite).
Footer:
```
<footer id="deck">
<div class="hint"><kbd>←↑↓→</kbd>/<kbd>WASD</kbd> drive · <kbd>P</kbd> pause · swipe on touch</div>
<div class="consist-wrap"><span class="cw-label">Consist</span><div id="consist"></div></div>
</footer>
```
Banner:
```
<div id="banner">
<span class="demo-badge">DEMO RUN</span>
<strong>All aboard!</strong>
<span>Click / tap or press any key to drive the train</span>
</div>
```
Toast div.
Full overlay div with inner card (built in JS or static parts). Keep #fullOverlay div; JS sets content.
OK time to write final code. Pay great attention to syntax. Let me write with careful attention to syntax.
Also note: exactly one fenced code block, no extra text.
Let me write the JS fully:
```js
'use strict';
/* ---------- helpers ---------- */
const $=id=>document.getElementById(id);
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const lerp=(a,b,t)=>a+(b-a)*t;
function lerpAngle(a,b,t){let d=(b-a)%(Math.PI*2);if(d>Math.PI)d-=Math.PI*2;if(d<-Math.PI)d+=Math.PI*2;return a+d*t;}
function shade(hex,f){const n=parseInt(hex.slice(1),16);let r=(n>>16)&255,g=(n>>8)&255,b=n&255;r=clamp(Math.round(r*f),0,255);g=clamp(Math.round(g*f),0,255);b=clamp(Math.round(b*f),0,255);return `rgb(${r},${g},${b})`;}
function tint(hex,f){const n=parseInt(hex.slice(1),16);let r=(n>>16)&255,g=(n>>8)&255,b=n&255;r=Math.round(r+(255-r)*f);g=Math.round(g+(255-g)*f);b=Math.round(b+(255-b)*f);return `rgb(${r},${g},${b})`;}
function rr(c,x,y,w,h,r){r=Math.min(r,w/2,h/2);c.beginPath();c.moveTo(x+r,y);c.arcTo(x+w,y,x+w,y+h,r);c.arcTo(x+w,y+h,x,y+h,r);c.arcTo(x,y+h,x,y,r);c.arcTo(x,y,x+w,y,r);c.closePath();}
function mulberry(seed){return function(){seed|=0;seed=seed+0x6D2B79F5|0;let t=Math.imul(seed^seed>>>15,1|seed);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296;}}
```
Constants, state vars, canvas setup, layout, bg build, reset, spawnPassenger, step, AI, crash, particles, render pieces, loop, input, audio, UI sync.
Passenger object: {x,y,coat,skin,hat,phase,name,suitcase:boolean, t:spawnTime}.
Particles array; addSmoke etc.
Render(tGlobal, p):
```
ctx.clearRect... drawImage(bg)
drawPlatforms+passengers (person after platform)
drawTrack(positions)
drawTrain(...)
headlamp glow
particles
shake applied around all: ctx.save(); translate(shakeX,shakeY) at start; restore at end.
```
Shake: if shakeT>0: shakeT-=dt; amp=shakeT*cell*0.35; offset rand.
Passenger render also computes waving.
Draw positions computed once per frame into array `posPx`.
drawTrack(points): if points.length>=2.
Let me code track construction:
```
const pts=[];
// backward extension
if(posPx.length>=2){ const a=posPx[posPx.length-1], b=posPx[posPx.length-2];
let dx=a.x-b.x, dy=a.y-b.y; const m=Math.hypot(dx,dy)||1; dx/=m;dy/=m;
pts.push({x:a.x+dx*cell*0.55, y:a.y+dy*cell*0.55}); }
for(const q of posPx) pts.push(q);
if(!crashed && curDir){ // forward clamp
let hx=snake[0].x, hy=snake[0].y, steps=0;
while(steps<2){ const nx=hx+curDir.x, ny=hy+curDir.y; if(nx<0||ny<0||nx>=COLS||ny>=ROWS)break; hx=nx;hy=ny;steps++; }
if(steps>0){ const hd=posPx[0]; pts.push({x:hd.x+curDir.x*cell*steps*0.9, y:hd.y+curDir.y*cell*steps*0.9}); }
}
```
Note: posPx head is at index 0; forward point appended at end after iterating head..tail? I'm pushing tail..head order? Let me define posPx array order: index 0=head. Track construction: start from behind tail, then tail..head, then forward → iterate posPx reversed: `for(let i=posPx.length-1;i>=0;i--)pts.push(posPx[i])`. Then forward. Yes.
Offset polyline function:
```
function railOffsets(pts,off){ const out=[]; for(let i=0;i<pts.length;i++){ const p0=pts[Math.max(0,i-1)], p1=pts[Math.min(pts.length-1,i+1)]; let dx=p1.x-p0.x, dy=p1.y-p0.y; const m=Math.hypot(dx,dy)||1; dx/=m;dy/=m; out.push({x:pts[i].x-dy*off, y:pts[i].y+dx*off}); } return out;}
function strokePts(c,pts){c.beginPath();c.moveTo(pts[0].x,pts[0].y);for(let i=1;i<pts.length;i++)c.lineTo(pts[i].x,pts[i].y);}
```
Track draw:
```
ctx.lineJoin='round'; ctx.lineCap='round';
// ballast
strokePts(ctx,pts); ctx.strokeStyle='rgba(122,92,64,0.30)'; ctx.lineWidth=cell*0.9; ctx.stroke();
// ties: iterate segments with cumulative
let acc2=cell*0.15;
for(let i=0;i<pts.length-1;i++){ const a=pts[i],b=pts[i+1]; const segLen=Math.hypot(b.x-a.x,b.y-a.y); const dx=(b.x-a.x)/segLen, dy=(b.y-a.y)/segLen; let d=acc2; while(d<segLen){ const x=a.x+dx*d, y=a.y+dy*d; ctx.strokeStyle='rgba(107,79,54,0.9)'; ctx.lineWidth=cell*0.11; ctx.beginPath(); ctx.moveTo(x-dy*cell*0.33, y+dx*cell*0.33); ctx.lineTo(x+dy*cell*0.33, y-dx*cell*0.33); ctx.stroke(); d+=cell*0.42;} acc2 = d-segLen; }
```
Wait acc2 carry: after loop d ≥ segLen; next segment starts at d-segLen from next a. Initialize acc2 offset cell*0.15 for good spacing continuity. Edge case segLen 0 → skip (guarded).
Then rails:
```
for(const s of [1,-1]){ const rp=railOffsets(pts, s*cell*0.17); strokePts(ctx,rp); ctx.strokeStyle='#4b545e'; ctx.lineWidth=cell*0.075; ctx.stroke(); strokePts(ctx,rp); ctx.strokeStyle='rgba(200,214,222,0.5)'; ctx.lineWidth=cell*0.028; ctx.stroke(); }
```
Fine, per-frame cost small.
drawWagon(x,y,a,color,swayY?) — pass sway applied before calling as offset? Apply sway in position calc: pos += normal*sin. Compute in train loop.
Passenger platform:
```
function drawPassenger(p, t){
const cx=(p.x+0.5)*cell, cy=(p.y+0.5)*cell;
// platform
rr(ctx, cx-cell*0.5, cy-cell*0.42, cell, cell*0.84, cell*0.12); fill '#cfc5b2'; stroke '#a89c86' lw1.5;
// hazard stripes on top edge: 3 dashes
ctx.fillStyle='#e5c544'; for(k -1..1) rect(cx+k*cell*0.26- cell*0.07, cy-cell*0.36, cell*0.14, cell*0.07);
// person bob
const bob=Math.sin(t*2.4+p.phase)*cell*0.03;
const py=cy+bob;
shadow ellipse
// suitcase
if(p.suitcase){ rr(cx+cell*0.18, py+cell*0.02, cell*0.16, cell*0.13,2); fill '#7a4b2c'; stroke darker; handle arc }
// coat body
rr(cx-cell*0.15, py-cell*0.16, cell*0.3, cell*0.34, cell*0.1); fill p.coat; stroke shade
// arms: if waving draw left arm raised
// head
circle(cx, py-cell*0.26, cell*0.115) fill skin; stroke shade(.8)
// hat if any: arc on top half fill hat + brim rect
// eyes: 2 dark dots? tiny: fill '#333' circle r cell*0.015 ±0.04 — cute
}
```
Waving arm: if dist<3.2: angle=sin(t*12)*0.35-0.9; line from shoulder (cx-cell*0.15, py-cell*0.08) with length cell*0.16, stroke coat dark width cell*0.06 round cap. Plus small hand circle skin. Also draw static arm on opposite side? Small stub on other side always: line down.
Exclamation bubble when dist<2.2: white circle r cell*0.11 at (cx+cell*0.28, py-cell*0.42), '!' text dark.
Distance computed from snake[0] grid manhattan → use Math.hypot(p.x-head.x, p.y-head.y).
OK now let me finalize doStep details:
```
function doStep(){
if(crashed||paused) return;
// direction
let d=null;
if(mode==='demo'){ d=aiDir(); } else {
while(inputQ.length){ const c=inputQ.shift(); if(!(c.x===-dir.x&&c.y===-dir.y)){ d=c; break; } }
d=d||dir;
}
dir=d; angFrom=headAng; angTo=Math.atan2(dir.y,dir.x);
const nx=snake[0].x+dir.x, ny=snake[0].y+dir.y;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS){ crash(); return; }
const willGrow = passengers.some(p=>p.x===nx&&p.y===ny);
const lim = snake.length-(willGrow?0:1);
for(let i=0;i<lim;i++){ if(snake[i].x===nx&&snake[i].y===ny){ crash(); return; } }
prevSnake=snake.map(s=>({x:s.x,y:s.y}));
snake.unshift({x:nx,y:ny});
if(willGrow){ onEat(nx,ny); } else snake.pop();
stepCount++;
// speed ramp
stepMs = clamp(BASE_MS - score*2.2, MIN_MS, BASE_MS);
}
```
Wait self collision check should exclude tail only when not growing (tail moves). When growing, tail stays → include tail: lim=len covers all. When not growing, lim=len-1 excludes tail. ✓.
onEat: find passenger idx, remove, score++, best=max, floating text + sparkles at cell, ring particle, ding sound, syncHUD, syncConsist, toast? None. spawnCooldown=0.9. Check win.
crash(): crashed=true; fx; sound; if(mode==='play'){ mode='over'; showOverlay('over') after 650ms via setTimeout stored; update best } else { demoTimer=1.7 }
Also update best display immediately.
Reset(mode): clear timers, overlay hide, banner visibility controlled by mode==='demo'.
startGame(): ac(); whistle(); reset(); mode='play'; banner hide; toast('Your train, engineer! 🎩');
Restart from over: same but toast 'Off we go again!'.
Pause toggle only when mode==='play' && !crashed: paused=!paused; overlay 'paused' show/hide.
Win: mode='win'; overlay 'win'.
Overlay builder:
```
function showOverlay(kind){ fullOverlay.style.display='flex';
if(kind==='over'){ fullOverlay.innerHTML=`<div class="card"><div class="ov-kicker">Oh no!</div><h2>DERAILED!</h2><p class="ov-stats"><span>${score} passenger${score===1?'':'s'} aboard</span><b>·</b><span>best ${best}</span></p><p class="ov-cta">Click or press any key to run again</p></div>`;}
paused: card 'Paused' ...
win: ...
}
```
Hide: display none.
Banner element toggled via classList hidden.
Toast: function toast(msg){ toastEl.textContent=msg; toastEl.classList.remove('show'); void toastEl.offsetWidth; toastEl.classList.add('show'); }
CSS .toast show animation with setTimeout 1800ms auto hide via opacity transition: remove class on setTimeout. Manage timer variable.
Input handlers:
```
const KEYD={ArrowUp:[0,-1],ArrowDown:[0,1],ArrowLeft:[-1,0],ArrowRight:[1,0],w:[0,-1],s:[0,1],a:[-1,0],d:[1,0],W...,}
window.addEventListener('keydown',e=>{
const k=e.key;
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(k)) e.preventDefault();
ensureAudio maybe not until gesture — keydown is gesture: ac() allowed here if soundOn? Only create on start.
if(k==='p'||k==='P'){ togglePause(); return;}
if(k==='m'||k==='M'){ toggleMute(); return;}
const d=KEYD[k];
if(mode==='demo'){ startGame(); return; } // any key starts (even arrow?) yes, but also queue that arrow? if arrow and started, then queue:
if(mode==='over'||mode==='win'){ startGame(); return; }
if(paused){ togglePause(); }
if(d && mode==='play'){ queueDir({x:d[0],y:d[1]}); }
});
```
Hmm: pressing arrow during demo should start and apply that direction: startGame then queue. Handle: if(mode==='demo'){ const d=KEYD[k]; startGame(); if(d) queueDir(...); return; }
Space during play: nothing? maybe pause too. Keep space = pause toggle? Add space → pause toggle. ok add.
queueDir: if(inputQ.length<3) push; prevent consecutive duplicate/reverse relative to last queued or current dir.
Pointer:
```
let swStart=null;
frame.addEventListener('pointerdown',e=>{ ac? none; if(mode==='demo'||mode==='over'||mode==='win'){ startGame(); return;} if(paused){togglePause(); return;} swStart={x:e.clientX,y:e.clientY}; frame.setPointerCapture(e.pointerId); });
frame.addEventListener('pointermove',e=>{ if(!swStart)return; const dx=e.clientX-swStart.x, dy=e.clientY-swStart.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); swStart={x:e.clientX,y:e.clientY}; } });
pointerup/cancel: swStart=null.
```
Mute button click: toggle; stopPropagation to avoid starting game? muteBtn inside header not frame — fine. But keydown M also.
Also prevent double-trigger: keydown fires repeatedly on hold: guard e.repeat on direction? Hold arrow shouldn't spam queue — actually allow repeat? Queue limited to 3; skip if e.repeat.
visibilitychange: on hidden if playing, auto pause: paused=true show overlay. Nice touch.
dt clamp 80ms.
Smoke spawn in render: smokeAcc += dt*(moving?1:0); while(smokeAcc>0.1){spawn; smokeAcc-=0.1}. Moving = !crashed && !paused && mode!=='over'/'win'. Reset smokeAcc on reset.
Chimney px: head pos + forward vector of headAng * cell*0.30, plus small sway? Fine.
Smoke particle: vx = -headDirX*cell*0.15 + rand*..., vy = -cell*(0.35+rand*0.3) drifting up; life 0.9-1.4; size cell*0.12 growing. Color rgba(245,247,245,α). Slight wind: global wind = sin(t*0.3)*cell*0.1 on x.
Crash particles as described.
Ring particle: type 'ring' expanding stroke circle.
Now HUD update function updateHUD(): score textContent, best, pips fill count.
syncConsist(): rebuild: for i=1..snake.length-1 create span.chip style background CAR_COLORS[(i-1)%N]. Only call on change (eat/reset). Also add pop class to last one.
Pips: build 8 spans once; update class 'on' for first k.
Initial call after DOM ready: build pips, layout, reset (demo), loop.
bg offscreen: document.createElement('canvas') size = canvas.width/height device px; draw with same dpr transform. Elements:
```
function buildBG(){
bgCanvas.width=canvas.width; bgCanvas.height=canvas.height; const b=bgCanvas.getContext('2d'); b.setTransform(dpr,0,0,dpr,0,0);
// base
b.fillStyle='#9ecb68'; fillRect all.
// checker
b.fillStyle='rgba(255,255,255,0.06)'; for cells where (x+y)%2 fill cell rect.
// mottling: seeded random darker patches: 26 ellipses rgba(90,140,60,0.10)
// decor seeded:
const rnd=mulberry(20240415);
flowers 26: pick pos; color from ['#ffffff','#ffd6e0','#ffe08a','#c9e7ff']; draw 4 petal circles r cell*0.05 around center + center dot.
grass tufts 18: 3 short stroke arcs color '#6f9e4a'.
pebbles 8: small grey round.
bushes 4 near corners: cluster of 3 dark green circles + lighter top.
// inner edge shading: inset rounded rect stroke rgba(0,0,0,0.10) width cell*0.5? draw 4 gradient strips.
// vignette: b.fillStyle gradient? Simpler: b.strokeStyle='rgba(43,74,40,0.18)'; lineWidth cell*0.9; strokeRect slightly outside → soft inner ring.
}
```
Decorative positions avoid exact edges? Within margin 0.5..COLS-0.5 fine.
Also draw station hut on bg? Add one: at cell (COLS-3.2, 0.5)? Might clash with gameplay visibility; make flat and muted: small roof '#b3552e'? draw in top-right corner area tiny hut ~1.6x1.2 cell: base cream, roof dark red ridge, door. Below train is fine. Sure include, at (COLS-2.6cell, 0.6cell) area. Hmm but passenger spawn might overlap hut → platform on top of hut, looks weird visually. Solution: keep hut outside playfield: not possible. Alternative: skip hut, keep just flowers/bushes. Skip hut for cleanness.
Now demo AI: ensure it starts moving right; ok.
Also initial passengers count 3.
Let me think about difficulty: BASE_MS 150 → MIN 72, -2.2 per score. Nice.
AI in demo with 150ms step: fine.
Now banner overlay CSS z above canvas; toast z above.
Full overlay bg: rgba(31,52,44,0.5); card pop.
Banner wobble keyframes rotate -2deg↔2deg.
Also add slight canvas image-rendering default (smooth).
Edge: on small screens hide consist label, wrap.
prefers-reduced-motion: turn off cloud/balloon/banner animations.
Now count DOM ids used in JS: game, frame, banner, fullOverlay, toast, score, best, pips, consist, muteBtn, topsign, deck.
I'll write CSS with custom properties.
Also focus outline for accessibility on button; add :focus-visible.
Font sizes clamp.
Also ensure overlay inside frame doesn't overflow radius: overflow hidden on frame.
Now let me produce the final file. Write carefully double-checking syntax as I go. Note: template literals inside single-quote JS are fine.
Also note: `ctx` after resize needs setTransform(dpr,...). On resize do: canvas.width=..., ctx.setTransform(dpr,0,0,dpr,0,0).
All drawing is CSS px coord: cell = cssW/COLS. Store cssW.
render():
```
function render(now){
const t=now/1000;
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.clearRect(0,0,cssW,cssH);
// shake
let sx=0,sy=0; if(shakeT>0){ const a=shakeT*shakeT*cell*0.4; sx=(Math.random()*2-1)*a; sy=(Math.random()*2-1)*a; }
ctx.save(); ctx.translate(sx,sy);
ctx.drawImage(bgCanvas,0,0,cssW,cssH);
...
ctx.restore();
}
```
bgCanvas drawn with dw/dh cssW,cssH (device scaled by transform). bgCanvas internal device size equals canvas so drawImage(bg,0,0,cssW,cssH) is fine.
p for interpolation: p = crashed||paused ? 1 : clamp(acc/stepMs,0,1).
Wait after crash we freeze at p of the crash moment — but crash happens at step boundary so p ≈ 0 (acc reset?). Use acc-=stepMs in while loop; after crash acc is small; prevSnake might be stale → with p small renders near previous state. Actually crash before mutating snake so snake unchanged, prevSnake from last successful step; rendering with p (acc small) shows between prev and current — that's the position where train was right before crash — fine, and particles at head current position. But we want the train to visually "hit": maybe advance p to 1 quickly: on crash set acc=stepMs so p=1 → train at current cells (touching wall/body). Do: on crash acc=stepMs.
Headlamp: after train:
```
if(!crashed){ const hp=posPx[0]; const gx=hp.x+Math.cos(headAng)*cell*1.1, gy=hp.y+Math.sin(headAng)*cell*1.1; const g=ctx.createRadialGradient(gx,gy,0,gx,gy,cell*1.1); g.addColorStop(0,'rgba(255,236,160,0.32)'); g.addColorStop(1,'rgba(255,236,160,0)'); ctx.globalCompositeOperation='lighter'; ctx.fillStyle=g; ctx.beginPath(); ctx.arc(gx,gy,cell*1.1,0,7); ctx.fill(); ctx.globalCompositeOperation='source-over'; }
```
Also headlamp dot drawn as part of locomotive.
Particle draw switch by type.
Star draw: rotate, 4 spikes path.
Now let me write train draw functions.
drawWagonBody(color):
```
function drawWagon(x,y,a,col){
ctx.save(); ctx.translate(x,y); ctx.rotate(a);
const L=cell*0.92, W=cell*0.62;
// wheels
ctx.fillStyle='#33302c';
for(const sx of [-0.3,0.3]) for(const sy of [-1,1]){ ctx.beginPath(); ctx.arc(sx*L, sy*(W/2+cell*0.02), cell*0.085,0,7); ctx.fill(); }
// coupling
ctx.fillStyle='#3a362f'; ctx.fillRect(-L/2-cell*0.13, -cell*0.045, cell*0.16, cell*0.09);
// body
rr(ctx,-L/2,-W/2,L,W,cell*0.13); ctx.fillStyle=col; ctx.fill(); ctx.lineWidth=Math.max(1.2,cell*0.03); ctx.strokeStyle=shade(col,0.62); ctx.stroke();
// roof
rr(ctx,-L/2+L*0.10,-W*0.27,L*0.8,W*0.54,cell*0.1); ctx.fillStyle=tint(col,0.28); ctx.fill();
ctx.strokeStyle='rgba(0,0,0,0.18)'; ctx.lineWidth=cell*0.02; ctx.stroke();
// skylights
ctx.fillStyle='rgba(255,248,224,0.92)';
rr(ctx,-L*0.22,-W*0.16,L*0.18,W*0.32,cell*0.04); ctx.fill();
rr(ctx, L*0.05,-W*0.16,L*0.18,W*0.32,cell*0.04); ctx.fill();
// buffers on end
ctx.fillStyle='#3a362f'; ctx.fillRect(L/2-cell*0.02,-W*0.32,cell*0.05,cell*0.09); ctx.fillRect(L/2-cell*0.02,W*0.32-cell*0.09,cell*0.05,cell*0.09);
ctx.restore();
}
```
rr then fill then stroke with another rr? rr defines path; call rr then fill and stroke on same path.
drawLoco similarly more elaborate.
Shadow pass:
```
function drawShadow(x,y,len,wid){ ctx.fillStyle='rgba(47,66,38,0.30)'; ctx.beginPath(); ctx.ellipse(x+cell*0.05,y+cell*0.12,len,wid,0,0,7); ctx.fill(); }
```
Ellipse rotation 0 fine.
posPx compute:
```
function segPos(i,p){
const c=snake[i]; const pr=prevSnake[i]||c;
return { x:(lerp(pr.x,c.x,p)+0.5)*cell, y:(lerp(pr.y,c.y,p)+0.5)*cell };
}
```
Head angle: headAng = crashed? angTo : lerpAngle(angFrom,angTo,p). Actually save angFrom/angTo on each step; initial both right.
Sway: for segment i>0: angle from posPx[i-1] to posPx[i] (vector ahead minus self): a=atan2(posPx[i-1].y-posPx[i].y, posPx[i-1].x-posPx[i].x). Locomotive uses headAng. Sway offset: phase = (stepCount+p)*1.35 - i*0.85; off=Math.sin(phase)*cell*0.04; apply along normal of a: x+= -sin(a)*off? Normal = (−sin a, cos a): x += -Math.sin(a)*off; y += Math.cos(a)*off. Also skip sway when crashed maybe, either way fine.
Drawing order: shadows loop i=len-1..0 then bodies loop i=len-1..1, then loco.
Couplings automatically via each car's behind stub (i≥1) — head also has behind stub.
Now the AI functions as planned.
DIRS = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}].
aiDir():
```
const head=snake[0];
const occ=new Uint8Array(COLS*ROWS);
for(let i=0;i<snake.length-1;i++){occ[snake[i].y*COLS+snake[i].x]=1;}
// BFS
const dist=new Int16Array(COLS*ROWS).fill(-1);
const first=new Int8Array(COLS*ROWS).fill(-1);
const q=new Int32Array(COLS*ROWS); let qh=0,qt=0;
const s=head.y*COLS+head.x; dist[s]=0; q[qt++]=s;
while(qh<qt){ const cur=q[qh++]; const cx=cur%COLS, cy=(cur/COLS)|0;
for(let di=0;di<4;di++){ const d=DIRS[di]; const nx2=cx+d.x, ny2=cy+d.y; if(nx2<0||ny2<0||nx2>=COLS||ny2>=ROWS)continue; const ni=ny2*COLS+nx2; if(occ[ni]||dist[ni]!==-1)continue; dist[ni]=dist[cur]+1; first[ni]= dist[cur]===0? di : first[cur]; q[qt++]=ni; } }
let bestT=-1,bd=1e9;
for(const p of passengers){ const ti=p.y*COLS+p.x; if(dist[ti]>-1&&dist[ti]<bd){bd=dist[ti];bestT=ti;} }
if(bestT>-1) return DIRS[first[bestT]];
// fallback flood
let bestD=dir, bestA=-1;
for(let di=0;di<4;di++){ const d=DIRS[di]; const nx2=head.x+d.x, ny2=head.y+d.y; if(nx2<0||ny2<0||nx2>=COLS||ny2>=ROWS)continue; const ni=ny2*COLS+nx2; if(occ[ni])continue; const area=flood(ni,occ);
let near=1e9; for(const p of passengers){near=Math.min(near,Math.abs(p.x-nx2)+Math.abs(p.y-ny2));}
const score2=area*1000-near; if(score2>bestA){bestA=score2;bestD=d;} }
return bestD;
```
flood(startIdx, occ): BFS count capped at 400.
```
function flood(s,occ){ const seen=new Uint8Array(COLS*ROWS); const st=[s]; seen[s]=1; let n=0; while(st.length&&n<400){ const cur=st.pop(); n++; const cx=cur%COLS, cy=(cur/COLS)|0; for(...dirs){ if in bounds && !occ[ni]&&!seen[ni] {seen[ni]=1;st.push(ni);} } } return n; }
```
Good.
spawnPassenger():
```
function spawnPassenger(){
for(let tries=0;tries<80;tries++){
const x=(Math.random()*COLS)|0, y=(Math.random()*ROWS)|0;
if(snake.some(s=>s.x===x&&s.y===y))continue;
if(passengers.some(p=>p.x===x&&p.y===y))continue;
if(Math.abs(x-snake[0].x)+Math.abs(y-snake[0].y)<4)continue;
if(passengers.some(p=>Math.abs(p.x-x)+Math.abs(p.y-y)<2))continue;
passengers.push(makePassenger(x,y)); return;
}
}
```
makePassenger: pick colors, name, phase, hat chance, suitcase chance 0.35.
Reset: passengers=[]; then spawnPassenger x3 with snake length 3 fine.
spawnCooldown handled in update: if(!crashed && passengers.length<3){ spawnCd-=dt; if(spawnCd<=0){spawnPassenger(); spawnCd=0.9;} } but initial spawn at reset sets 3 → cooldown only relevant after eat: on onEat set spawnCd=0.8.
Ensure at least one quickly: if passengers.length===0 then spawnCd=Math.min(spawnCd,0.1).
onEat also: if snake.length+? after grow snake.length≥COLS*ROWS-8 win check: mode='win', overlay.
Now loop:
```
let last=performance.now();
function frame(now){
requestAnimationFrame(frame);
let dt=(now-last)/1000; last=now; if(dt>0.08)dt=0.08;
timeNow+=dt;
if(shakeT>0)shakeT=Math.max(0,shakeT-dt*2.2);
if(demoResetT>0){ demoResetT-=dt; if(demoResetT<=0){ resetGame(); } }
const active = !paused && !crashed && mode!=='over' && mode!=='win';
if(active){ acc+=dt*1000; let guard=0; while(acc>=stepMs&&guard++<4){ if(crashed)break; doStep(); acc-=stepMs; if(crashed){acc=stepMs;break;} } }
// spawn cooldown and smoke only when active
if(active){ spawn logic; smokeAcc+=dt; while(smokeAcc>0.09){emitSmoke(); smokeAcc-=0.09;} }
updateParticles(dt, active);
render(now);
}
```
Smoke also emit lightly when idle? Only when moving.
updateParticles handles text float etc.
emitSmoke needs headAng and posPx — compute fresh: head cell → px using current p... use snake[0] cell center + dir offset: simpler: smoke from current head center + dir*cell*0.3. Since emit during movement, fine.
Render p computed inside: const p = (!active)?1:clamp(acc/stepMs,0,1). After doStep inside while, prevSnake updated; p small → smooth.
Wait: after final doStep in frame, acc is residual (0..stepMs) and prevSnake corresponds to that step → p=acc/stepMs correct.
During pause: active false → p=1 → train rests at current cells. Good.
Crash: acc=stepMs set in crash() → p=1.
Demo overlay hidden; banner visible when mode==='demo': classList toggles.
startGame also handles from over/win: hide fullOverlay.
resetGame():
```
snake=[{x:8,y:7},{x:7,y:7},{x:6,y:7}]; dir={x:1,y:0}; angFrom=0;angTo=0; prevSnake=snake.map(...); score=0; crashed=false; acc=0; stepMs=BASE; particles=[]; shakeT=0; smokeAcc=0; passengers=[]; spawnCd=0; spawnPassenger();x3; inputQ=[]; demoResetT=0; clear overlayTimer; stepCount=0; syncHUD(); syncConsist(); hide fullOverlay; banner visibility = mode==='demo';
```
COLS=22 → head x 8 near left middle; ROWS=14 y 7 fine.
updateHUD also sets best.
Now sound:
```
let AC=null, soundOn=true;
function ac(){ try{ if(!AC) AC=new (window.AudioContext||window.webkitAudioContext)(); if(AC.state==='suspended') AC.resume(); return AC;}catch(e){return null;} }
function env(g,t0,a,d,peak){ g.gain.setValueAtTime(0,t0); g.gain.linearRampToValueAtTime(peak,t0+a); g.gain.exponentialRampToValueAtTime(0.0001,t0+a+d); }
function tone(f,dur,type,peak,delay=0){ const c=ac(); if(!c||!soundOn)return; const t0=c.currentTime+delay; const o=c.createOscillator(), g=c.createGain(); o.type=type; o.frequency.value=f; o.connect(g).connect(c.destination); env(g,t0,0.01,dur,peak); o.start(t0); o.stop(t0+dur+0.1); }
function ding(){ tone(880,0.14,'triangle',0.16); tone(1318,0.2,'triangle',0.14,0.07); }
function whistleS(){ tone(587,0.5,'triangle',0.12); tone(740,0.5,'triangle',0.10,0.02); tone(1174,0.4,'sine',0.05,0.02);}
function crashS(){ const c=ac(); if(!c||!soundOn)return; const t0=c.currentTime; const dur=0.5; const buf=c.createBuffer(1,c.sampleRate*dur,c.sampleRate); const d=buf.getChannelData(0); for(let i=0;i<d.length;i++)d[i]=(Math.random()*2-1)*Math.pow(1-i/d.length,2); const src=c.createBufferSource(); src.buffer=buf; const f=c.createBiquadFilter(); f.type='lowpass'; f.frequency.value=900; const g=c.createGain(); g.gain.value=0.4; src.connect(f).connect(g).connect(c.destination); src.start(t0); tone(90,0.4,'square',0.12); }
```
ac() called inside tone only when needed; but creating AudioContext without gesture warns — tone called only after user gesture (startGame/ding on eat during play; demo eat triggers ding before gesture! Guard: in ding/crash check `if(!AC)return` unless forced? Simplest: sound functions require `audioUnlocked` flag set on first user gesture. Until unlocked, no-ops.
```
let audioOk=false;
function unlock(){ if(audioOk)return; audioOk=true; ac(); }
```
Call unlock() on startGame and pointerdown handler. tone checks `if(!audioOk||!soundOn)return;`.
Demo remains silent until interaction.
Mute button text 🔊/🔇.
Toast messages.
Pause overlay content.
I think I'm ready. Also add a little "engineer hint" footer? Enough.
One more: requirement says meaningful on first paint; demo starts immediately — yes.
Double-check no localStorage etc. Best in variable only.
Also title tag "Puffer's Meadow Railway 🚂".
Let me write the final HTML now, keeping it rich.
CSS plan (write carefully):
:root colors: --wood:#6b4423; --wood-d:#4a2d14; --cream:#f7ecd7; --red:#c8402f; --brass:#e0aa4e; --ink:#3b2a1a;
body{margin:0;font-family:var(--body);color:var(--ink);overflow-x:hidden;min-height:100dvh;}
.sky fixed z:0; .station position relative z:1.
Sun: .sun{position:absolute;top:36px;right:12%;width:90px;height:90px;border-radius:50%;background:radial-gradient(circle at 35% 35%, #fff7d6, #ffd95e 60%, #f7b733); box-shadow:0 0 60px 20px rgba(255,214,100,.45); animation: sunPulse 6s ease-in-out infinite;}
Clouds: base class; 3 ids or nth-child sizes.
Hills: .hill{position:absolute;bottom:-60px;width:60vw;height:260px;border-radius:50%;}
h1: left:-10vw bg #b7da8b; h2: right:-15vw bottom:-90px bg #a8d17b height 300.
Also lower grass strip: .ground{position:absolute;bottom:0;height:120px;width:100%;background:linear-gradient(#9ecb68,#8bbd58);} to blend below hills? hills on top of ground. Order: ground then hills. ok skip ground, hills enough; but sky→hill transition bottom corner gap: use 2 hills + one wide: add .hill3 centered width 120vw height 200 bottom -110 bg #c3e296. Layered rolling hills.
Balloon CSS:
.balloon{position:absolute;left:10%;top:110%;width:30px;height:56px;animation:floatUp 48s linear infinite;z-index:0;}
.balloon i (envelope): display block width30 h36 border-radius 50% 50% 46% 54%/ 55% 55% 45% 45%; background: radial-gradient(circle at 35% 30%, #ff8f7d, #d63f2e 70%); plus stripes? Keep. ::after basket: bottom 8x6 brown centered; connector line via border. floatUp keyframes: translateY(-130vh) with slight translateX sway in additional keyframe percentages.
Simplify: keyframes floatUp {0%{transform:translate(0,0)}50%{transform:translate(40px,-65vh)}100%{transform:translate(-20px,-130vh)}} linear.
.station{max-width:1040px;margin:0 auto;padding:14px 16px 18px;display:flex;flex-direction:column;align-items:center;gap:12px;}
Header: width:100%; display flex; justify-content:space-between; align-items:center; gap 14px; background: linear-gradient(#7c4d2a,#5e371c); border:3px solid #3f2412; border-radius:16px; padding:10px 18px; box-shadow: 0 8px 0 #3f2412, 0 14px 24px rgba(40,25,10,.35); position:relative;
Nails: ::before/::after small brass circles positioned left/right centered.
h1{font-family display; font-size clamp(22px,3.6vw,40px); color:var(--cream); margin:0; text-shadow:0 2px 0 rgba(0,0,0,.35); letter-spacing:.5px;}
.tag{margin:2px 0 0;color:#e8cfa6;font-size:clamp(11px,1.4vw,14px);font-style:italic;}
Lamps: .lamp{width:14px;height:14px;border-radius:50%;border:2px solid #2c1a0c;} lamp-l background red with glowPulseA 2.6s infinite; lamp-r green glowPulseB. Keyframes toggle box-shadow.
.sign-right{display:flex;gap:10px;align-items:center;flex-wrap:wrap;justify-content:flex-end;}
.plaque{background:linear-gradient(#f2cd7e,#d9a441);border:2px solid #8a5f1d;border-radius:10px;padding:4px 12px;text-align:center;box-shadow: inset 0 2px 0 rgba(255,255,255,.5), 0 3px 0 #8a5f1d; min-width:64px;}
.pl{display:block;font-size:10px;letter-spacing:1.5px;text-transform:uppercase;color:#6b4a12;font-weight:700;}
.plaque strong{font-family:display;font-size:24px;color:#402c08;line-height:1;}
.pips{display:flex;gap:3px;padding:6px 2px;}
.pips i{width:9px;height:9px;border-radius:50%;background:#8a5f1d33;border:1px solid #8a5f1d;}
.pips i.on{background:#ff9d2e;box-shadow:0 0 6px #ff9d2e;}
#muteBtn{...brass round button}
.board-wrap{width:100%;display:flex;justify-content:center;}
.board-frame{position:relative;background:linear-gradient(#8a5a33,#654022);border:3px solid #3f2412;border-radius:18px;padding:10px;box-shadow:0 10px 0 #3f2412, 0 18px 30px rgba(40,25,10,.35); touch-action:none;}
Screws: 4 span.screw inside frame absolute at corners: brass circles with slot line (linear-gradient). Add manually in HTML.
canvas{display:block;border-radius:10px;box-shadow: inset 0 0 0 3px rgba(43,66,36,.5), inset 0 6px 18px rgba(0,0,0,.25); background:#9ecb68;}
.banner{position:absolute;top:12px;left:50%;transform:translateX(-50%);background:var(--cream);border:3px solid var(--red);border-radius:12px;padding:10px 18px 12px;text-align:center;box-shadow:0 8px 18px rgba(30,20,10,.35); animation:wob 3.2s ease-in-out infinite; pointer-events:none; z-index:5; max-width:88%;}
.banner strong{display:block;font-family:display;font-size:clamp(18px,3vw,26px);color:var(--red);}
.banner span.sub{font-size:clamp(11px,1.6vw,14px);color:#6b4a12;}
.demo-badge{display:inline-block;background:var(--red);color:#fff;font-size:10px;font-weight:700;letter-spacing:2px;padding:2px 8px;border-radius:20px;margin-bottom:4px;animation:blink 1.4s steps(2) infinite;}
wob keyframes: rotate translate combo: keyframes must include translateX(-50%): 0%{transform:translateX(-50%) rotate(-1.6deg)}50%{... rotate(1.6deg)}.
.hidden{display:none!important}
#fullOverlay{position:absolute;inset:10px;border-radius:10px;background:rgba(26,44,36,.55);display:flex;align-items:center;justify-content:center;z-index:6;}
.card{background:var(--cream);border:4px solid var(--red);border-radius:16px;padding:22px 34px;text-align:center;box-shadow:0 14px 30px rgba(0,0,0,.4); animation:pop .35s cubic-bezier(.2,1.6,.4,1);}
.card h2{font-family:display;color:var(--red);font-size:clamp(26px,4vw,40px);margin:2px 0 8px;}
.ov-kicker{letter-spacing:3px;font-size:11px;font-weight:700;color:#8a5f1d;text-transform:uppercase;}
.ov-stats{font-size:16px; display flex gap 8 justify center}
.ov-cta{margin-top:10px;font-weight:700;color:#402c08;animation:blink 1.6s steps(2) infinite;}
#toast{position:absolute;bottom:14px;left:50%;transform:translateX(-50%) translateY(8px);background:rgba(63,36,18,.92);color:var(--cream);padding:7px 16px;border-radius:20px;font-size:13px;opacity:0;transition:all .3s;z-index:7;pointer-events:none;white-space:nowrap;}
#toast.show{opacity:1;transform:translateX(-50%) translateY(0);}
footer#deck{width:100%;display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;}
.hint{font-size:12px;color:#54654a? maybe brown: #5d4a2f;}
kbd{background:#fff8ea;border:1px solid #c9b489;border-bottom-width:3px;border-radius:5px;padding:1px 6px;font-family:var(--body);font-size:11px;color:#6b4a12;}
.consist-wrap{display:flex;align-items:center;gap:8px;}
.cw-label{font-size:10px;letter-spacing:2px;text-transform:uppercase;color:#8a5f1d;font-weight:700;}
#consist{display:flex;gap:4px;flex-wrap:wrap;max-width:60vw;justify-content:flex-end;}
.chip{width:14px;height:10px;border-radius:3px;border:1px solid rgba(0,0,0,.35);animation:chipIn .3s cubic-bezier(.2,1.8,.4,1);}
Engine chip: .chip.loco{width:18px;background:#c8402f!important;border-radius:3px 6px 3px 3px;} add chimney dot? ::after small dark square above. cute.
Media max-width 700px: header column center; sign-right row; .tag hidden? Keep small. #consist max-width 100%.
Keyframes: pop, wob, blink, chipIn, sunPulse, drift, floatUp, glowA, glowB.
Reduced motion: disable drift/floatUp/wob/blink? keep blink (small) — disable all: * {animation-duration:0.001s?} simpler: inside media .cloud,.balloon,.banner,.demo-badge,.ov-cta,.lamp{animation:none;}
Drift keyframes: 0%{transform:translateX(-30vw)}100%{transform:translateX(110vw)} — each cloud animated with different duration and negative delay; top set inline via nth-child CSS.
.cloud{position:absolute;height:34px;background:#fff;border-radius:40px;opacity:.9;animation:drift linear infinite;}
.cloud::before,.cloud::after{content:'';position:absolute;background:#fff;border-radius:50%;}
.cloud::before{width:46%;height:160%;left:12%;bottom:20%;}
.cloud::after{width:34%;height:120%;right:14%;bottom:30%;}
Sizes: .c1{width:150px;top:8%;animation-duration:75s;animation-delay:-20s}.c2{width:200px;top:16%;animation-duration:105s;animation-delay:-60s;opacity:.75}.c3{width:110px;top:26%;animation-duration:60s;animation-delay:-5s;opacity:.8}
Now HTML body assembly, then script.
Script: wrap in IIFE? Top-level fine.
Also `const canvas=$('game')`.
Note: element id 'pips' and variable name pips? Use pipsEl.
Note about stepMs ramp: stepMs recomputed in doStep after score change: stepMs=clamp(150-score*2.2,72,150).
Demo uses same speed.
updateParticles(dt): iterate backwards; per-type behavior:
- smoke: x+=vx*dt,y+=vy*dt; vx*=0.98... life-=dt; size+=dt*cell*0.35.
- spark: gravity vy+=cell*4*dt; life.
- star: rotation += 3*dt; slight drift.
- ring: r+=cell*2.2*dt.
- text: y-=cell*0.7*dt.
Remove when life<=0.
drawParticles: switch:
smoke: fill rgba(240,244,240, life*0.5?) use alpha=clamp(life/maxLife,0,1)*0.55; arc size.
spark: color pick; small circle.
star: save translate rotate path 4 spikes fill '#fff3c4' stroke '#e0aa4e'.
ring: stroke circle width cell*0.06 alpha.
text: font `700 ${cell*0.34}px display...`; textAlign center; lineWidth 4 strokeStyle 'rgba(63,36,18,.9)' strokeText; fillStyle '#fff8ea' fillText.
emitSmoke(): compute head px from snake[0] cell center + dir*0.3cell (using current grid dir) plus small rand; particle vx = -dir*cell*0.1 + rand*0.2cell, vy=-cell*(0.4+rand*0.3); size cell*0.10+rand*0.05; life 0.9+rand*0.5; maxLife=life.
On crash: 14 steam bigger + 12 sparks color ['#ffb03a','#ff7a3a','#ffe08a'] random + 5 stars + 1 ring.
Eat: 8 sparks small star? use color from coat of passenger for sparks: cute — sparks of passenger coat color + ring + text.
Now passenger draw function with details above; compute head distance.
Also passenger idle slight hop on spawn: scale pop via age = timeNow-p.born; s=min(1, age*4) ease — apply as translate/scale to person only.
Include born field.
OK final check, win overlay text: "FULL TRAIN!" with confetti-like particles star burst.
Also on win: mode='win' stop stepping (active false since mode win) show overlay; any key restarts (keydown handles over/win).
Ensure keydown during 'over' also triggers restart via startGame().
startGame():
```
function startGame(fresh){
unlock();
mode='play'; resetGame(); bannerEl.classList.add('hidden'); hideOverlay();
whistleS(); toast(fresh? 'Your train, engineer — pick up the passengers!' : 'Off we go again!');
}
```
resetGame keeps mode (set before calling). Demo init: mode='demo'; resetGame(); banner visible.
Demo reset after crash: resetGame() (mode still demo) → banner still visible (classList already). ok.
Overlay timer on human crash: overlayTO=setTimeout(()=>showOverlay('over'),650). Clear on resetGame.
Also ensure input ignored during crash in play (doStep guarded; queueDir allow but harmless).
togglePause(): if(mode!=='play'||crashed)return; paused=!paused; if paused showOverlay('paused') else hideOverlay(); acc=0 on unpause? Set acc=0 to avoid jump: on unpause acc=0.
visibilitychange: if(document.hidden && mode==='play' && !paused) togglePause().
Space key: preventDefault already via arrow list; add ' ' → togglePause (only in play).
Edge: keydown 'Enter' during over → start. Any key: in keydown handler, if mode over/win → startGame(false) on any key except pure modifiers? Trivial; allow all; even if k is 'Shift' ok.
But note: keydown handler calls startGame on any key including 'm' during demo → mute intent turns into start; acceptable.
Order: handle mute key before start check? Put M/P check first then mode check. Good.
One more small thing: prevent arrow key page scroll — done.
Now let me write bg decor code with cell-relative sizes. Also mottling patches.
buildBG also draws subtle checker highlight.
Also draw fence posts around inner edge? Hedge strip: draw rounded inner border: stroke rect inset cell*0.15 with color rgba(63,94,47,0.35) width cell*0.3 → soft hedge edge. Add leaf bumps: circles along edge every cell*0.8: color '#5d8f45' alpha .5 r cell*0.12 — place inside edge lines. Draw loop along 4 sides. Nice.
Time to output. Let me now write out the whole file coherently in my head. Ensure no syntax errors by being careful:
- Template literals for overlay HTML with ${score}.
- Careful: rr() uses c parameter name; ensure no collision.
- Use `const TAU=Math.PI*2` for arc end angle.
Also double-check drawImage of bgCanvas with transform: bgCanvas.width set to canvas.width (device). b.setTransform(dpr...) draws in CSS px. Then main ctx (transform dpr) drawImage(bgCanvas,0,0,cssW,cssH): scales device-sized source to cssW,cssH under dpr transform → device pixels map 1:1. Good.
render() sets transform per frame anyway.
On layout(): cssW computed; cssH=cssW*ROWS/COLS; canvas.style.width=cssW+'px'; height auto via style height cssH px; canvas.width=Math.round(cssW*dpr) etc; cell=cssW/COLS; buildBG();
dpr = Math.min(window.devicePixelRatio||1, 2).
Frame width: frameEl.style.width = (cssW+padding*2+border)px — simpler: set inner canvas size and let frame wrap: canvas width set via style; frame is inline-block so it shrinks: .board-frame{display:inline-block}. Then frame width auto. Set canvas.style.width/height explicitly.
Layout compute available width: boardWrap.clientWidth (which is station width minus padding). availH: window.innerHeight - topsign.offsetHeight - deck.offsetHeight - 90 (padding+gaps). Clamp cssW = min(availW, availH*COLS/ROWS, 940); min 240.
Call layout after fonts? No external fonts; call on load and resize.
Initial render before layout? Call layout() then resetGame() then rAF.
Now pips init: for i<8 add i elements.
syncConsist(): consistEl.innerHTML=''; loco chip first then cars count = snake.length-1; cap display chips maybe 40 fine.
Note: DOM rebuild each eat is fine.
updateHUD(): scoreEl.textContent=score; bestEl... pips: filled=clamp(1+Math.floor(score/4),1,8).
OK also add a small detail: on each eat show name in floating text: `+1 ${p.name}`.
Toast on start references engineer.
I think ready to write. Also include `html,body{height:100%}`? not needed.
Also ensure z-index: .sky{position:fixed;inset:0;z-index:0;overflow:hidden} .station{position:relative;z-index:1}.
Let me write the code.
Additional check — drawTrack when snake length small (3): pts length 3 + extensions fine.
Angle for car i uses posPx[i-1] (ahead). For i=1..len-1. OK.
headAng rendering: const hp = lerpAngle(angFrom, angTo, active? p : 1). Store global headAng per render for smoke? smoke uses dir directly, fine.
Loco drawing passes headAng.
Edge case: initial prevSnake=copy of snake; p=... active true, acc 0 p 0 → position = prev = fine.
doStep sets prevSnake before mutating: prevSnake=snake.map(clone). Then unshift/pop.
But interpolation per segment: prevSnake[i] to snake[i]: after unshift, snake[i] corresponds to prevSnake[i-1]... which is exactly correct: each segment moves from previous cell to next. prevSnake[i] is old position of segment that is now at index i? after unshift, segment at index i was at index i-1 before (its old cell = prevSnake[i-1]) except tail. Hmm careful!
snake array represents positions per segment index; segments are not persistent entities — the snake "moves" by adding head and dropping tail; segment index i at new state occupies the cell that segment index i-1 occupied previously. For rendering smooth motion, we want each drawn car (index i) to move from its previous rendered position to the new position. Previous rendered position of index i = old snake[i] (where car i was last step). Car i now at new snake[i] = old snake[i-1]. So lerp(old snake[i], new snake[i]) = movement forward one cell — exactly right! So prevSnake captured before mutation (old array) and prevSnake[i] works: car i moves from old[i] to new[i]. ✓ New tail index on grow: new length n+1, prevSnake has only n entries; index n → fallback new (static) ✓.
Great.
Angle: car i direction = vector from pos[i] to pos[i-1] (toward head) ✓.
Now write everything. Also small guard: while loop guard in frame prevents spiral.
Also `inputQ` dedupe: on push, if last queued equals d skip; if d is reverse of last queued or dir skip? doStep filters reverse relative to current dir only; also filter reverse relative to previous queued in doStep (better): in doStep while queue: candidate c; reference = lastApplied dir (starts as current dir); if c is not reverse of dir → apply, dir=c, break. Implement:
```
let nd=null;
while(inputQ.length){const c=inputQ.shift(); if(c.x===-dir.x&&c.y===-dir.y)continue; nd=c;break;}
dir = nd||dir;
```
This handles chained turns correctly.
queueDir: if last in queue equals d return; push if length<3.
Also don't queue while paused? Allow queueing during pause is fine.
Write the final file now. Ensure no console errors: e.g., ctx.ellipse exists.
Audio unlock also triggered by canvas click? covered by pointerdown handler on frame → unlock.
For keydown, call unlock() on startGame (which has gesture). Pause toggle doesn't need audio.
OK writing. Also add aria labels? aria-label on mute button.
One more design flourish for the header: a small train icon on the sign drawn in CSS? Use emoji 🚂 next to title — simple and charming: include in tag? Keep clean typography only; signal lamps provide charm.
Finalize names and colors array.
Now let me write the whole file, complete and careful.
(One more consideration: iframe sandbox autoplay — no audio until gesture, handled.)
Also "best" plate updates on crash and also live: updateHUD sets best=max.
Writing keyframes:
@keyframes drift{from{transform:translateX(-30vw)}to{transform:translateX(115vw)}}
@keyframes floatUp{0%{transform:translate(0,0)}45%{transform:translate(50px,-60vh)}100%{transform:translate(-30px,-135vh)}}
@keyframes wob{0%,100%{transform:translateX(-50%) rotate(-1.5deg)}50%{transform:translateX(-50%) rotate(1.5deg)}}
@keyframes blink{0%,55%{opacity:1}56%,100%{opacity:.35}}
@keyframes pop{from{transform:scale(.6);opacity:0}}
@keyframes chipIn{from{transform:scale(0)}}
@keyframes sunPulse{0%,100%{box-shadow:0 0 50px 16px rgba(255,214,100,.4)}50%{box-shadow:0 0 70px 26px rgba(255,214,100,.55)}}
@keyframes glowA{0%,45%{background:#e23b2e;box-shadow:0 0 8px #e23b2e}55%,100%{background:#4f7a34;box-shadow:0 0 8px #6fae4a}} — hmm steps: alternate each half: lampA: 0-49% red, 50-100 green; lampB reverse. Use keyframes above with duration 3s.
OK. Let me write it out. Also include `role="img"`? skip.
Also verify: no external fonts, network zero. Emoji ok.
Potential subtle bug: `ctx.arc(...,0,7)` 7 > 2π fine.
In railOffsets, if pts length 1 → strokePts moveTo only; guard pts>=2 earlier.
drawTrack also skipped during crashed? Keep showing track (static) — fine, shows where derailment happened.
Passengers during crashed keep bobbing; fine.
Also hide banner on human start: yes.
After human crash → show overlay 'over'; on key → startGame(false) resets and banner stays hidden (mode play). ✓
Demo crash: no overlay; brief steam visible; auto reset.
Time to write the final. Also add `meta name=color-scheme light`.
Length is fine. Write everything now carefully.
While writing drawLoco, keep numbers consistent.
drawLoco(x,y,a):
```
ctx.save();translate;rotate(a);
const L=cell*1.0, W=cell*0.74;
// wheels
ctx.fillStyle='#33302c'; arc at ±L*0.32, ±(W/2+0.02cell) r cell*0.09
// coupling behind
fillRect(-L/2-cell*0.13,-cell*0.05,cell*0.16,cell*0.1)
// cowcatcher
ctx.beginPath();moveTo(L*0.46,-W*0.44);lineTo(L*0.78,0);lineTo(L*0.46,W*0.44);closePath;fill '#f2cf5b'; stroke '#8a5f1d' lw cell*0.03; stripes: 2 lines from (L*0.5,±W*0.28) to (L*0.66,±W*0.1)? Draw simply: strokeStyle '#b3552e'? use dark #7a4b16: beginPath moveTo(L*0.52,-W*0.30) lineTo(L*0.68,-W*0.06); move mirrored; stroke.
// main body base
rr(-L/2,-W/2,L,W,cell*0.14); fill '#c8402f'; stroke shade(#c8402f,.6)
// cab (rear)
rr(-L/2, -W*0.46, L*0.40, W*0.92, cell*0.1); fill '#a93325'; stroke rgba(0,0,0,.25)
// cab roof
rr(-L*0.46, -W*0.34, L*0.30, W*0.68, cell*0.08); fill '#d95744';
// cab skylights: 2 small cream rr
// boiler (front)
rr(-L*0.10, -W*0.40, L*0.56, W*0.80, cell*0.12); fill '#d24a36'? slightly lighter than base: '#d24f39'; stroke rgba dark.
// brass bands: 2 vertical lines at x=L*0.02 and L*0.22: stroke '#e8b84b' lw cell*0.035 from -W*0.38 to W*0.38.
// chimney
circle at (L*0.30,0): fill '#2f2b27' r cell*0.13; inner '#151312' r*0.62; brass ring stroke '#e8b84b' lw cell*0.03 r*0.95.
// dome
circle at (L*0.02,0)? that's under band; put dome at -L*0.02: fill '#e8b84b' r cell*0.07 + shade stroke.
// headlamp
circle at (L*0.47,0) r cell*0.06 fill '#ffefad' stroke '#8a5f1d'.
// side windows on cab: 2 small cream rr at (-L*0.38, ±W*0.30) size L*0.1 x W*0.16.
restore.
```
Enough.
Also draw a small engineer dot in cab skylight: small dark circle in the right skylight? cute: add skin dot + hat in the rear skylight. Minor: skip.
Passenger colors:
COATS=['#d64570','#4c6ef5','#f59f00','#0ca678','#e8590c','#7048e8','#12b886','#f06595','#3b82c4','#8bc34a']? keep bright against grass; greenish may blend; remove green: use ['#d64570','#4c6ef5','#f59f00','#e8590c','#7048e8','#0d9bb0','#f06595','#c2255c','#5f3dc4','#e67700'].
SKINS=['#f6cba4','#e2a878','#b97a50','#8a5a3b'].
HATS=['#2f3e46','#c92a2a','#1864ab','#5f3dc4','#e67700','#087f5b','#a61e4d'].
Name list as above.
Now final code writing. Let me just write it out now — this is the actual output. Keep comments concise.
Also add `overflow:hidden` to body? content should fit viewport at 1280x800 with layout fitting; small screens might scroll vertically slightly — allow. Set body overflow-x hidden only.
Final consistency check on frame inner overlay inset: padding 10px so inset:10px aligns with canvas.
Banner top might overlap train on small screens — acceptable (pointer-events none, semi informative). Position top:10px.
Also add `user-select:none` on station.
Writing now.
Also remember: exactly one fenced html code block, no other text.