Train snakeOne-line
MiniMax M3.1 Flash
by MiniMaxReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 6 min 17 s
- Cost
- $0.031
- Output tokens
- 51,832
- Spent thinking
- 38,240 tokens
- Speed
- 137.5 tok/s
- Console errors
- 0
- First token after
- 444 ms
- First visible text after
- 5 min 11 s
- Prompt tokens
- 545
- Code size
- 35.5 KB
- Finish reason
- stop
- Served by
- Parasail
- Model id
minimax/minimax-m3.1-flash- Reasoning sent
{"effort":"max"}- Max output
- 64,000 tokens
- Generated
- Nov 11, 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 · 786 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>Platform Express — Train Snake</title>
<style>
:root{ --ink:#eef2ff; --muted:#98a3c0; --accent:#ffd166; --accent2:#4ecdc4; }
*{box-sizing:border-box;}
html,body{margin:0;padding:0;}
body{
background:
radial-gradient(1100px 620px at 50% -12%, #2a3663 0%, rgba(16,19,34,0) 62%),
linear-gradient(180deg,#171d33 0%,#0c0f1b 100%);
color:var(--ink);
font-family:"Trebuchet MS","Segoe UI",system-ui,-apple-system,sans-serif;
min-height:100vh; display:flex; align-items:center; justify-content:center;
padding:14px; overflow-x:hidden;
}
.wrap{width:100%; max-width:800px; display:flex; flex-direction:column; gap:10px;}
header{display:flex; align-items:flex-end; justify-content:space-between; gap:12px; flex-wrap:wrap;}
.title{font-size:23px; letter-spacing:3px; margin:0; text-transform:uppercase; font-weight:bold;}
.title span{color:var(--accent);}
.sub{font-size:11px; color:var(--muted); letter-spacing:2px; text-transform:uppercase; margin-top:2px;}
.hud{display:flex; gap:7px; flex-wrap:wrap;}
.chip{background:rgba(255,255,255,.055); border:1px solid rgba(255,255,255,.12); border-radius:10px;
padding:5px 11px; display:flex; flex-direction:column; min-width:64px;}
.chip i{font-style:normal; font-size:9px; letter-spacing:1.4px; color:var(--muted); text-transform:uppercase;}
.chip b{font-size:17px; color:var(--accent); line-height:1.15;}
.chip.b b{color:var(--accent2);}
.stage{position:relative; width:100%; max-width:768px; margin:0 auto; border-radius:16px; overflow:hidden;
border:1px solid rgba(255,255,255,.14); box-shadow:0 22px 54px rgba(0,0,0,.5); background:#10141f;}
canvas{display:block; width:100%; height:auto; touch-action:none;}
.overlay{position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
background:rgba(7,9,18,.55); backdrop-filter:blur(2px); transition:opacity .3s; padding:10px;}
.overlay.hidden{opacity:0; pointer-events:none;}
.card{background:rgba(20,25,42,.93); border:1px solid rgba(255,255,255,.16); border-radius:16px;
padding:18px 24px; text-align:center; max-width:90%; box-shadow:0 14px 44px rgba(0,0,0,.55);}
.card h2{margin:0 0 6px; font-size:25px; letter-spacing:2px; text-transform:uppercase;}
.card p{margin:5px 0; font-size:12.5px; color:var(--muted); line-height:1.55;}
.card p b{color:var(--accent);}
.cta{margin-top:13px; font-size:13px; letter-spacing:2px; color:#141a2c; background:var(--accent);
padding:9px 18px; border-radius:999px; display:inline-block; font-weight:bold; animation:blink 1.35s infinite;}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.5}}
.hint{font-size:11.5px; color:var(--muted); text-align:center; line-height:1.75; letter-spacing:.3px;}
.hint b{color:var(--accent2);}
kbd{background:#2a3350;border:1px solid #4a5680;border-bottom-width:2px;border-radius:5px;padding:1px 6px;font-size:11px;color:#cfd8f2;}
.pad{display:none; grid-template-columns:repeat(3,56px); grid-template-rows:repeat(2,46px); gap:8px; justify-content:center;}
.pad button{background:rgba(255,255,255,.08); border:1px solid rgba(255,255,255,.18); color:var(--ink);
border-radius:11px; font-size:17px; cursor:pointer;}
.pad button:active{background:rgba(255,209,102,.35);}
.pad .u{grid-column:2;grid-row:1;} .pad .l{grid-column:1;grid-row:2;}
.pad .d{grid-column:2;grid-row:2;} .pad .r{grid-column:3;grid-row:2;}
@media (max-width:720px){ .pad{display:grid;} .title{font-size:18px;letter-spacing:2px;} .chip{min-width:52px;padding:4px 8px;} .chip b{font-size:14px;} }
</style>
</head>
<body>
<div class="wrap">
<header>
<div>
<h1 class="title">Platform <span>Express</span></h1>
<div class="sub">a little train that picks up people</div>
</div>
<div class="hud">
<div class="chip"><i>Score</i><b id="uiScore">0</b></div>
<div class="chip b"><i>Riders</i><b id="uiRiders">0</b></div>
<div class="chip"><i>Train</i><b id="uiLen">2</b></div>
<div class="chip"><i>Level</i><b id="uiLevel">1</b></div>
<div class="chip"><i>Best</i><b id="uiBest">0</b></div>
</div>
</header>
<div class="stage" id="stage">
<canvas id="game" width="768" height="512"></canvas>
<div class="overlay" id="overlay">
<div class="card">
<h2 id="ovTitle">Platform Express</h2>
<p id="ovText">You are the locomotive. Stop at the <b>top platform</b> to pick up waiting passengers,
then run down to the <b>delivery depot</b> on the bottom platform to drop them off for bonus points.</p>
<div class="cta" id="ovCta">CLICK OR PRESS ANY KEY TO START</div>
</div>
</div>
</div>
<div class="hint">
<kbd>←</kbd><kbd>↑</kbd><kbd>↓</kbd><kbd>→</kbd> or <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> to drive ·
pick up on <b>Platform 1</b> (top, +10) · deliver on <b>Platform 2</b> (bottom, +25) ·
dodge the express train and the scenery!
</div>
<div class="pad">
<button class="u" data-dir="up">▲</button>
<button class="l" data-dir="left">◀</button>
<button class="d" data-dir="down">▼</button>
<button class="r" data-dir="right">▶</button>
</div>
</div>
<script>
(()=>{
'use strict';
/* ------------------------------------------------------------------ setup */
const COLS=24, ROWS=16, CELL=32, W=COLS*CELL, H=ROWS*CELL;
const PICK_ROW=1, DELIVER_ROW=ROWS-2, EXP_ROW=5;
const canvas=document.getElementById('game'), ctx=canvas.getContext('2d');
const bg=document.createElement('canvas'); bg.width=W; bg.height=H;
const bctx=bg.getContext('2d');
const DIRS={up:{x:0,y:-1},down:{x:0,y:1},left:{x:-1,y:0},right:{x:1,y:0}};
const DL=[DIRS.up,DIRS.down,DIRS.left,DIRS.right];
const WAGON=['#ff6b6b','#4ecdc4','#ffd93d','#c084fc','#7bd389','#ff9f45','#60a5fa','#f472b6'];
const SKINS=['#ffd9b3','#f0b183','#d79a6a','#a9673c'];
const HAIRS=['#2f2a33','#6b3f21','#c98b3a','#1f1b28','#8d3b3b','#3b2a4d'];
const WALL=(x,y)=> x<0||y<0||x>=COLS||y>=ROWS||x===0||y===0||x===COLS-1||y===ROWS-1;
const K=(x,y)=>y*COLS+x;
/* --------------------------------------------------------------- state */
let obstacles=new Map();
const BASE_OBST=[
{x:5,y:8,type:'tower'},{x:18,y:3,type:'crate'},{x:9,y:11,type:'box'},
{x:15,y:9,type:'tower'},{x:20,y:12,type:'crate'},{x:7,y:4,type:'box'}
];
let snake=[], riders=[], waiting=[], score=0, level=1, picked=0, best=0;
let dir=DIRS.right, pending=DIRS.right, mode='demo';
let particles=[], floaters=[], ghosts=[];
let express={x:3.2,d:1};
let wheelAngle=0, time=0, last=performance.now(), acc=0;
let demoResetAt=0, crashFlash=0, wagonIdx=0;
const el=id=>document.getElementById(id);
const overlay=el('overlay'), ovTitle=el('ovTitle'), ovText=el('ovText'), ovCta=el('ovCta');
/* ------------------------------------------------------------ tiny audio */
let actx=null;
function initAudio(){ if(!actx){ try{ actx=new (window.AudioContext||window.webkitAudioContext)(); }catch(e){ actx=null; } }
if(actx&&actx.state==='suspended'){ try{actx.resume();}catch(e){} } }
function blip(f,dur,type,vol){ if(!actx) return; try{
const o=actx.createOscillator(),g=actx.createGain();
o.type=type||'square'; o.frequency.value=f;
g.gain.value=vol||0.05; o.connect(g); g.connect(actx.destination);
o.start(); g.gain.exponentialRampToValueAtTime(0.0001,actx.currentTime+dur);
o.stop(actx.currentTime+dur);}catch(e){} }
/* --------------------------------------------------------------- helpers */
function rr(c,x,y,w,h,r){ c.beginPath(); c.moveTo(x+r,y);
c.arcTo(x+w,y,x+w,y+h,r); c.arcTo(x+w,y+h,x,y+h,r);
c.arcTo(x,y+h,x,y,r); c.arcTo(x,y,x+w,y,r); c.closePath(); }
function lighten(hex,amt){ const n=parseInt(hex.slice(1),16);
const r=Math.min(255,(n>>16)+amt*255), g=Math.min(255,((n>>8)&255)+amt*255), b=Math.min(255,(n&255)+amt*255);
return 'rgb('+Math.round(r)+','+Math.round(g)+','+Math.round(b)+')'; }
/* ------------------------------------------------------ static background */
const speckles=[]; (function(){ let s=987654321; const rnd=()=>{ s=(s*1103515245+12345)&0x7fffffff; return s/0x7fffffff; };
for(let i=0;i<1100;i++) speckles.push({x:rnd()*W,y:rnd()*H,s:1+rnd()*2.2,a:0.03+rnd()*0.08}); })();
function faintTrack(g,row,alpha){
const y=row*CELL+CELL/2, x0=CELL, x1=W-CELL;
g.strokeStyle='rgba(8,10,16,'+(alpha*1.6)+')'; g.lineWidth=9;
g.beginPath(); g.moveTo(x0,y); g.lineTo(x1,y); g.stroke();
g.strokeStyle='rgba(120,95,70,'+(alpha*1.5)+')'; g.lineWidth=1.6;
for(let x=x0+3;x<x1;x+=8){ g.beginPath(); g.moveTo(x,y-5.5); g.lineTo(x,y+5.5); g.stroke(); }
g.strokeStyle='rgba(215,226,240,'+alpha+')'; g.lineWidth=1.3;
for(const o of [-4,4]){ g.beginPath(); g.moveTo(x0,y+o); g.lineTo(x1,y+o); g.stroke(); }
}
function expressLane(g){
g.fillStyle='rgba(255,255,255,.035)'; g.fillRect(CELL,EXP_ROW*CELL,W-2*CELL,CELL);
const y=EXP_ROW*CELL+CELL/2, x0=CELL, x1=W-CELL;
g.strokeStyle='rgba(10,12,18,.5)'; g.lineWidth=10; g.beginPath(); g.moveTo(x0,y); g.lineTo(x1,y); g.stroke();
g.strokeStyle='rgba(120,95,70,.45)'; g.lineWidth=1.7;
for(let x=x0+3;x<x1;x+=8){ g.beginPath(); g.moveTo(x,y-6); g.lineTo(x,y+6); g.stroke(); }
g.strokeStyle='rgba(255,214,120,.34)'; g.lineWidth=1.5;
for(const o of [-4.5,4.5]){ g.beginPath(); g.moveTo(x0,y+o); g.lineTo(x1,y+o); g.stroke(); }
g.fillStyle='rgba(255,209,102,.20)';
for(let x=x0+10;x<x1-10;x+=46){ g.beginPath();
g.moveTo(x,EXP_ROW*CELL+9); g.lineTo(x+11,EXP_ROW*CELL+16); g.lineTo(x,EXP_ROW*CELL+23);
g.lineTo(x+5,EXP_ROW*CELL+16); g.closePath(); g.fill(); }
}
function platformRow(g,row,tile,stripe,stripeTop){
const y0=row*CELL;
g.fillStyle=tile; g.fillRect(CELL,y0,W-2*CELL,CELL);
g.fillStyle='rgba(255,255,255,.22)'; g.fillRect(CELL,y0,W-2*CELL,3);
g.strokeStyle='rgba(0,0,0,.13)'; g.lineWidth=1;
for(let x=CELL;x<W-CELL;x+=CELL){ g.beginPath(); g.moveTo(x+.5,y0); g.lineTo(x+.5,y0+CELL); g.stroke(); }
g.beginPath(); g.moveTo(CELL,y0+.5); g.lineTo(W-CELL,y0+.5); g.stroke();
const sy = stripeTop ? y0+2 : y0+CELL-6;
g.fillStyle=stripe; g.fillRect(CELL,sy,W-2*CELL,4);
g.fillStyle='rgba(0,0,0,.22)';
for(let x=CELL;x<W-CELL;x+=14) g.fillRect(x,sy,7,4);
g.fillStyle='rgba(0,0,0,.18)'; g.fillRect(CELL,stripeTop?y0+CELL-3:y0,W-2*CELL,3);
}
function station(g){
g.fillStyle='#7c4034'; g.fillRect(0,0,W,CELL);
g.strokeStyle='rgba(0,0,0,.18)'; g.lineWidth=1;
for(let y=5;y<CELL;y+=6){ g.beginPath(); g.moveTo(0,y+.5); g.lineTo(W,y+.5); g.stroke(); }
g.fillStyle='#40201c'; g.fillRect(0,0,W,9); g.fillStyle='#5e302a'; g.fillRect(0,9,W,3);
for(let x=1;x<COLS-1;x+=3){
const wx=x*CELL+9, wy=14, ww=15, wh=13;
g.fillStyle='#2b1a17'; g.fillRect(wx-1,wy-1,ww+2,wh+2);
g.fillStyle='#ffd97d'; g.fillRect(wx,wy,ww,wh);
g.fillStyle='rgba(255,255,255,.28)'; g.fillRect(wx+1,wy+1,ww-2,4);
}
// clock
const cx=6*CELL+14, cy=17;
g.fillStyle='#3b2b26'; g.beginPath(); g.arc(cx,cy,9,0,Math.PI*2); g.fill();
g.fillStyle='#f7f1de'; g.beginPath(); g.arc(cx,cy,7.5,0,Math.PI*2); g.fill();
g.strokeStyle='#3b2b26'; g.lineWidth=1.6; g.beginPath(); g.moveTo(cx,cy); g.lineTo(cx,cy-5); g.stroke();
g.lineWidth=1.2; g.beginPath(); g.moveTo(cx,cy); g.lineTo(cx+4,cy+2); g.stroke();
// main sign
g.fillStyle='#f4e7c1'; rr(g,W/2-118,2,236,17,4); g.fill();
g.strokeStyle='#a8794a'; g.lineWidth=1.6; rr(g,W/2-118,2,236,17,4); g.stroke();
g.fillStyle='#5b3a22'; g.font='bold 12px "Trebuchet MS",Verdana,sans-serif';
g.textAlign='center'; g.textBaseline='middle'; g.fillText('CENTRAL STATION',W/2,11);
// pick up sign
g.fillStyle='#2b3a55'; rr(g,CELL+6,10,72,17,4); g.fill();
g.fillStyle='#ffd166'; g.font='bold 10px "Trebuchet MS",Verdana,sans-serif'; g.fillText('PICK UP ▼',CELL+42,19);
}
function depot(g){
const y0=(ROWS-1)*CELL;
g.fillStyle='#54403a'; g.fillRect(0,y0,W,CELL);
g.strokeStyle='rgba(0,0,0,.2)'; g.lineWidth=1;
for(let y=y0+5;y<y0+CELL;y+=6){ g.beginPath(); g.moveTo(0,y+.5); g.lineTo(W,y+.5); g.stroke(); }
g.fillStyle='#332521'; g.fillRect(0,y0,W,6);
// roller doors
for(let x=2;x<COLS-2;x+=5){
g.fillStyle='#3c3330'; g.fillRect(x*CELL+5,y0+11,52,18);
g.strokeStyle='rgba(255,255,255,.09)'; g.lineWidth=1;
for(let i=0;i<4;i++){ g.beginPath(); g.moveTo(x*CELL+6,y0+13+i*4); g.lineTo(x*CELL+56,y0+13+i*4); g.stroke(); }
}
// sign
g.fillStyle='#f4e7c1'; rr(g,W/2-108,y0+2,216,17,4); g.fill();
g.strokeStyle='#a8794a'; g.lineWidth=1.6; rr(g,W/2-108,y0+2,216,17,4); g.stroke();
g.fillStyle='#3f5c4a'; g.font='bold 11px "Trebuchet MS",Verdana,sans-serif';
g.textAlign='center'; g.textBaseline='middle'; g.fillText('▲ DELIVERY DEPOT',W/2,y0+11);
}
function hedges(g){
for(const x of [0,COLS-1]) for(let y=1;y<ROWS-1;y++){
const cx=x*CELL+CELL/2, cy=y*CELL+CELL/2;
g.fillStyle='#1f4632'; g.beginPath(); g.ellipse(cx,cy,CELL*0.66,CELL*0.58,0,0,Math.PI*2); g.fill();
g.fillStyle='#2d6346'; g.beginPath(); g.ellipse(cx-3,cy-3,CELL*0.44,CELL*0.38,0,0,Math.PI*2); g.fill();
g.fillStyle='#3a7a57'; g.beginPath(); g.ellipse(cx-6,cy-6,CELL*0.2,CELL*0.16,0,0,Math.PI*2); g.fill();
}
}
function obstacle(g,o){
const cx=o.x*CELL+CELL/2, cy=o.y*CELL+CELL/2;
g.fillStyle='rgba(0,0,0,.32)'; g.beginPath(); g.ellipse(cx,cy+8,13,6,0,0,Math.PI*2); g.fill();
if(o.type==='tower'){
g.fillStyle='#5b636e'; g.beginPath(); g.arc(cx,cy,13,0,Math.PI*2); g.fill();
g.fillStyle='#78828f'; g.beginPath(); g.arc(cx-2,cy-2,10,0,Math.PI*2); g.fill();
g.strokeStyle='#3c434c'; g.lineWidth=2; g.beginPath(); g.arc(cx,cy,13,0,Math.PI*2); g.stroke();
g.fillStyle='#3c434c'; g.beginPath(); g.arc(cx,cy,4,0,Math.PI*2); g.fill();
g.strokeStyle='rgba(0,0,0,.3)'; g.lineWidth=1; g.beginPath(); g.moveTo(cx-11,cy-6); g.lineTo(cx+11,cy+6); g.stroke();
} else if(o.type==='crate'){
g.fillStyle='#8a5a34'; rr(g,cx-12,cy-12,24,24,3); g.fill();
g.strokeStyle='#5e3c21'; g.lineWidth=2; g.stroke();
g.strokeStyle='#a97b4b'; g.lineWidth=2; g.beginPath(); g.moveTo(cx-11,cy-11); g.lineTo(cx+11,cy+11); g.stroke();
g.beginPath(); g.moveTo(cx+11,cy-11); g.lineTo(cx-11,cy+11); g.stroke();
} else {
g.fillStyle='#b9c0cb'; rr(g,cx-11,cy-9,22,18,3); g.fill();
g.fillStyle='#e0554e'; g.fillRect(cx-11,cy-9,22,5);
g.fillStyle='#2c3440'; g.fillRect(cx-7,cy-2,5,5); g.fillRect(cx+2,cy-2,5,5);
g.fillStyle='#f2c94c'; g.beginPath(); g.arc(cx,cy-13,3.4,0,Math.PI*2); g.fill();
}
}
function renderBackground(){
const g=bctx;
g.clearRect(0,0,W,H);
g.fillStyle='#28313e'; g.fillRect(0,0,W,H);
for(const s of speckles){ g.fillStyle='rgba(255,255,255,'+s.a+')'; g.fillRect(s.x,s.y,s.s,s.s); }
for(let y=2;y<=13;y++){ if(y===EXP_ROW) continue; faintTrack(g,y,0.09); }
expressLane(g);
platformRow(g,PICK_ROW,'#c9cfd9','#f2c94c',false);
platformRow(g,DELIVER_ROW,'#b7c4d4','#4ecdc4',true);
station(g); depot(g); hedges(g);
for(const o of obstacles.values()) obstacle(g,o);
}
/* ------------------------------------------------------------ game reset */
function occupied(x,y){
for(const s of snake) if(s.x===x&&s.y===y) return true;
for(const p of waiting) if(p.x===x&&p.y===y) return true;
return obstacles.has(K(x,y));
}
function spawnWaiting(n){
let guard=0;
while(waiting.length<n && guard++<80){
const x=1+Math.floor(Math.random()*(COLS-2));
if(occupied(x,PICK_ROW)) continue;
waiting.push({x,y:PICK_ROW,color:WAGON[Math.floor(Math.random()*WAGON.length)],
skin:SKINS[Math.floor(Math.random()*SKINS.length)],hair:HAIRS[Math.floor(Math.random()*HAIRS.length)],
phase:Math.random()*6.28});
}
}
function reset(m){
mode=m;
obstacles=new Map(); BASE_OBST.forEach(o=>obstacles.set(K(o.x,o.y),{x:o.x,y:o.y,type:o.type}));
snake=[{x:3,y:9,color:null,rider:null},{x:2,y:9,color:WAGON[1],rider:null}];
riders=[]; waiting=[]; score=0; level=1; picked=0; wagonIdx=1;
particles=[]; floaters=[]; ghosts=[];
express={x:3.2,d:1}; dir=DIRS.right; pending=DIRS.right; crashFlash=0; acc=0;
spawnWaiting(3); renderBackground(); updateHUD();
}
function addObstacle(){
if(obstacles.size>=10) return;
const head=snake[0];
for(let t=0;t<70;t++){
const x=1+Math.floor(Math.random()*(COLS-2));
const y=2+Math.floor(Math.random()*(ROWS-4));
if(y===EXP_ROW) continue;
const k=K(x,y); if(obstacles.has(k)) continue;
if(Math.abs(x-head.x)+Math.abs(y-head.y)<5) continue;
const types=['tower','crate','box'];
obstacles.set(k,{x,y,type:types[Math.floor(Math.random()*types.length)]});
renderBackground(); return;
}
}
/* ----------------------------------------------------------- express AI */
function expressCells(){
const out=[]; for(let k=0;k<3;k++) out.push({x:Math.round(express.x-express.d*k),y:EXP_ROW});
return out;
}
function expressHits(x,y){
if(y!==EXP_ROW) return false;
for(let k=0;k<3;k++){ if(Math.abs(x-(express.x-express.d*k))<0.8) return true; }
return false;
}
function expressHitsTrain(){
for(const s of snake) if(expressHits(s.x,s.y)) return true;
return false;
}
/* --------------------------------------------------------------- the AI */
function bfs(sx,sy,blocked){
const dist=new Int16Array(COLS*ROWS).fill(-1), prev=new Int16Array(COLS*ROWS).fill(-1);
const q=new Int32Array(COLS*ROWS); let h=0,t=0;
const s=K(sx,sy); dist[s]=0; q[t++]=s;
while(h<t){
const cur=q[h++], cx=cur%COLS, cy=(cur/COLS)|0, d=dist[cur]+1;
for(let i=0;i<4;i++){
const dd=DL[i], nx=cx+dd.x, ny=cy+dd.y;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
const k=K(nx,ny);
if(blocked[k]||dist[k]>=0) continue;
dist[k]=d; prev[k]=cur; q[t++]=k;
}
}
return {dist,prev};
}
function field(){
const blocked=new Uint8Array(COLS*ROWS);
for(let y=0;y<ROWS;y++) for(let x=0;x<COLS;x++) if(WALL(x,y)) blocked[K(x,y)]=1;
for(const k of obstacles.keys()) blocked[k]=1;
for(const c of expressCells()) blocked[K(c.x,c.y)]=1;
for(const s of snake) blocked[K(s.x,s.y)]=1;
return bfs(snake[0].x,snake[0].y,blocked);
}
function freeArea(nx,ny){
const blocked=new Uint8Array(COLS*ROWS);
for(let y=0;y<ROWS;y++) for(let x=0;x<COLS;x++) if(WALL(x,y)) blocked[K(x,y)]=1;
for(const k of obstacles.keys()) blocked[k]=1;
for(const c of expressCells()) blocked[K(c.x,c.y)]=1;
for(let i=0;i<snake.length-1;i++) blocked[K(snake[i].x,snake[i].y)]=1;
const seen=new Uint8Array(COLS*ROWS); const q=[K(nx,ny)]; seen[q[0]]=1;
let h=0,count=0;
while(h<q.length){
const cur=q[h++]; count++;
const cx=cur%COLS, cy=(cur/COLS)|0;
for(let i=0;i<4;i++){
const dd=DL[i], ax=cx+dd.x, ay=cy+dd.y;
if(ax<0||ay<0||ax>=COLS||ay>=ROWS) continue;
const k=K(ax,ay);
if(seen[k]||blocked[k]) continue;
seen[k]=1; q.push(k);
}
}
return count;
}
function safeStep(nx,ny){
if(WALL(nx,ny)) return false;
if(obstacles.has(K(nx,ny))) return false;
if(expressHits(nx,ny)) return false;
for(const s of snake) if(s.x===nx&&s.y===ny) return false;
return true;
}
function aiDir(){
const head=snake[0], {dist,prev}=field();
const carrying=riders.length>0;
let target=null;
const wantDeliver = carrying && snake.length>=6;
if(!wantDeliver){
let bd=Infinity;
for(const p of waiting){ const d=dist[K(p.x,p.y)]; if(d>=0&&d<bd){bd=d;target={x:p.x,y:p.y};} }
}
if(wantDeliver || !target){
let bt=null,bd=Infinity;
if(head.y===DELIVER_ROW){
const along = dir.x!==0?dir.x:(snake.length>1?(snake[1].x>=snake[0].x?1:-1):1);
bt={x:Math.max(1,Math.min(COLS-2,head.x+along*3)),y:DELIVER_ROW};
}else{
for(let x=1;x<COLS-1;x++){ const d=dist[K(x,DELIVER_ROW)]; if(d>=0&&d<bd){bd=d;bt={x,y:DELIVER_ROW};} }
}
if(bt&&(!target||wantDeliver)) target=bt;
}
let pref=null;
if(target){
let k=K(target.x,target.y); const chain=[];
const headK=K(head.x,head.y);
if(dist[k]>=0){ while(k>=0){ chain.push(k); if(k===headK) break; k=prev[k]; }
if(chain.length>=2){ const n=chain[chain.length-2]; pref={x:n%COLS,y:(n/COLS)|0}; } }
}
const cand=[];
if(pref) cand.push(pref);
for(const d of DL){
if(d===pref) continue;
if(d.x===-dir.x&&d.y===-dir.y) continue;
cand.push(d);
}
let fallback=null,fallArea=-1;
for(const d of cand){
const nx=head.x+d.x, ny=head.y+d.y;
if(!safeStep(nx,ny)) continue;
const area=freeArea(nx,ny);
if(d===pref && area>=snake.length+3) return d;
if(area>fallArea){ fallArea=area; fallback=d; }
}
if(pref){ const nx=head.x+pref.x, ny=head.y+pref.y; if(safeStep(nx,ny)) return pref; }
if(fallback) return fallback;
return pref||dir;
}
/* ------------------------------------------------------------ main logic */
function burst(cx,cy,color,n,speed){
for(let i=0;i<n;i++){
const a=Math.random()*Math.PI*2, s=(speed||3)*(0.4+Math.random());
particles.push({x:cx*CELL+CELL/2,y:cy*CELL+CELL/2,vx:Math.cos(a)*s,vy:Math.sin(a)*s-0.8,
life:1,dec:0.018+Math.random()*0.02,r:2+Math.random()*3,color});
}
}
function crash(){
if(mode!=='play'&&mode!=='demo') return;
crashFlash=1; burst(snake[0].x,snake[0].y,'#ff6b6b',34,5); blip(150,0.35,'sawtooth',0.07);
if(mode==='demo'){
mode='demoCrash'; demoResetAt=performance.now()+1500;
}else{
mode='over';
if(score>best) best=score;
ovTitle.textContent='CRASHED!';
ovText.innerHTML='The train came off the rails.<br>Final score: <b>'+score+'</b> · best: <b>'+best+'</b>';
ovCta.textContent='CLICK OR PRESS R TO DRIVE AGAIN';
overlay.classList.remove('hidden');
}
updateHUD();
}
function tickInterval(){ return Math.max(78,165-(level-1)*12); }
function step(){
if(mode!=='play'&&mode!=='demo') return;
if(mode==='demo'){ const d=aiDir(); if(d) dir=d; }
else dir=pending;
pending=dir;
const head=snake[0], n=snake.length;
const nh={x:head.x+dir.x,y:head.y+dir.y};
let pi=-1;
for(let i=0;i<waiting.length;i++) if(waiting[i].x===nh.x&&waiting[i].y===nh.y){pi=i;break;}
const grow = pi>=0;
for(let i=0;i<n-(grow?0:1);i++) if(snake[i].x===nh.x&&snake[i].y===nh.y){ crash(); return; }
if(WALL(nh.x,nh.y)){ crash(); return; }
if(obstacles.has(K(nh.x,nh.y))){ crash(); return; }
if(expressHits(nh.x,nh.y)){ crash(); return; }
const ns=[{x:nh.x,y:nh.y,color:null,rider:null}];
for(let i=1;i<n;i++) ns.push({x:snake[i-1].x,y:snake[i-1].y,color:snake[i].color,rider:snake[i].rider});
if(grow){
const tail=snake[n-1], p=waiting[pi];
const col=WAGON[wagonIdx%WAGON.length]; wagonIdx++;
ns.push({x:tail.x,y:tail.y,color:col,rider:{skin:p.skin,hair:p.hair}});
waiting.splice(pi,1);
riders.push({skin:p.skin,hair:p.hair});
score+=10; picked++;
floaters.push({x:nh.x*CELL+CELL/2,y:nh.y*CELL-6,text:'+10',life:1,color:'#ffd166'});
burst(nh.x,nh.y,'#ffd166',16,3.4); blip(660,0.12,'triangle',0.06);
spawnWaiting(3);
if(picked%4===0){
level++; addObstacle();
floaters.push({x:W/2,y:H/2-30,text:'LEVEL '+level,life:1.5,color:'#4ecdc4',big:true});
blip(880,0.2,'square',0.05);
}
}
snake=ns;
if(nh.y===DELIVER_ROW && snake.length>=3){
const car=snake[snake.length-1];
if(car.rider){
snake.pop(); riders.pop();
ghosts.push({x:car.x,y:car.y,color:car.color,rider:car.rider,life:1});
score+=25;
floaters.push({x:nh.x*CELL+CELL/2,y:nh.y*CELL-6,text:'+25',life:1.1,color:'#7bd389'});
burst(nh.x,nh.y,'#7bd389',20,3.8); blip(990,0.14,'triangle',0.06);
}
}
wheelAngle+=0.35;
updateHUD();
}
/* ------------------------------------------------------------- rendering */
function drawTrack(c){
c.lineCap='round';
for(let i=1;i<snake.length;i++){
const a=snake[i], b=snake[i-1];
const ax=a.x*CELL+CELL/2, ay=a.y*CELL+CELL/2;
const bx=b.x*CELL+CELL/2, by=b.y*CELL+CELL/2;
const dx=bx-ax, dy=by-ay, len=Math.hypot(dx,dy)||1;
const ux=dx/len, uy=dy/len, px=-uy, py=ux;
c.strokeStyle='rgba(10,9,12,.85)'; c.lineWidth=13;
c.beginPath(); c.moveTo(ax,ay); c.lineTo(bx,by); c.stroke();
c.strokeStyle='#5c4632'; c.lineWidth=2.6;
for(let s=2;s<len;s+=7){
const sx=ax+ux*s, sy=ay+uy*s;
c.beginPath(); c.moveTo(sx+px*6.5,sy+py*6.5); c.lineTo(sx-px*6.5,sy-py*6.5); c.stroke();
}
c.strokeStyle='#c3cedd'; c.lineWidth=1.8;
for(const o of [-3.4,3.4]){
c.beginPath(); c.moveTo(ax+px*o,ay+py*o); c.lineTo(bx+px*o,by+py*o); c.stroke();
}
}
}
function wheels(c){
const pts=[[-10,-11],[10,-11],[-10,11],[10,11]];
for(const p of pts){
c.fillStyle='#12151b'; c.beginPath(); c.arc(p[0],p[1],3.4,0,Math.PI*2); c.fill();
c.strokeStyle='#5b6572'; c.lineWidth=1; c.beginPath();
const a=wheelAngle+(p[0]>0?0:1.1);
c.moveTo(p[0]-Math.cos(a)*2.2,p[1]-Math.sin(a)*2.2);
c.lineTo(p[0]+Math.cos(a)*2.2,p[1]+Math.sin(a)*2.2); c.stroke();
}
}
function drawLoco(c){
c.fillStyle='rgba(0,0,0,.30)'; c.beginPath(); c.ellipse(0,2,15,10,0,0,Math.PI*2); c.fill();
c.fillStyle='#161a21'; rr(c,-15,-9,30,18,4); c.fill();
c.fillStyle='#2f4a72'; rr(c,-15,-10,12,20,4); c.fill();
c.fillStyle='#3f6196'; rr(c,-16,-11,13,22,5); c.fill();
c.fillStyle='#ffd97d'; rr(c,-12,-6,6,12,2); c.fill();
c.fillStyle='rgba(255,255,255,.35)'; c.fillRect(-11,-5,2,4);
c.fillStyle='#d8453f'; rr(c,-6,-10,21,20,7); c.fill();
c.fillStyle='rgba(255,255,255,.16)'; rr(c,-4,-8,17,7,4); c.fill();
c.fillStyle='#ffe3a3'; c.fillRect(6,-10,3,20);
c.fillStyle='#a9302c'; c.beginPath(); c.ellipse(14,0,3.6,9,0,0,Math.PI*2); c.fill();
c.fillStyle='rgba(255,230,128,.28)'; c.beginPath(); c.arc(12,0,6.5,0,Math.PI*2); c.fill();
c.fillStyle='#ffe680'; c.beginPath(); c.arc(12,0,3.1,0,Math.PI*2); c.fill();
c.fillStyle='#3a3f4b'; c.beginPath(); c.arc(6.5,-5,3.7,0,Math.PI*2); c.fill();
c.fillStyle='#626b79'; c.beginPath(); c.arc(6.5,-5,2,0,Math.PI*2); c.fill();
c.fillStyle='#fff'; c.beginPath(); c.arc(8.6,-4,2.5,0,Math.PI*2); c.fill();
c.beginPath(); c.arc(8.6,4,2.5,0,Math.PI*2); c.fill();
c.fillStyle='#20242c'; c.beginPath(); c.arc(9.3,-4,1.25,0,Math.PI*2); c.fill();
c.beginPath(); c.arc(9.3,4,1.25,0,Math.PI*2); c.fill();
c.strokeStyle='#20242c'; c.lineWidth=1.4; c.beginPath(); c.arc(13.2,0,3.1,-0.85,0.85); c.stroke();
wheels(c);
}
function drawWagon(c,col,rider){
c.fillStyle='rgba(0,0,0,.28)'; c.beginPath(); c.ellipse(0,2,15,9,0,0,Math.PI*2); c.fill();
c.fillStyle='#161a21'; rr(c,-15,-9,30,18,3); c.fill();
c.fillStyle=col; rr(c,-14,-9,28,18,5); c.fill();
c.fillStyle=lighten(col,0.22); rr(c,-12,-7,24,13,4); c.fill();
c.fillStyle='rgba(255,255,255,.33)'; c.fillRect(-12,-1.6,24,3.2);
c.fillStyle='rgba(0,0,0,.18)'; c.fillRect(-13,6.5,26,2);
if(rider){
c.fillStyle=rider.hair; c.beginPath(); c.arc(0,-1,5,0,Math.PI*2); c.fill();
c.fillStyle=rider.skin; c.beginPath(); c.arc(1.2,-0.4,3.7,0,Math.PI*2); c.fill();
c.fillStyle='#20242c'; c.beginPath(); c.arc(2,-1,0.85,0,Math.PI*2); c.fill();
}
wheels(c);
}
function drawTrain(c){
for(let i=snake.length-1;i>=0;i--){
const s=snake[i];
let ang;
if(i===0) ang=Math.atan2(dir.y,dir.x);
else ang=Math.atan2(snake[i-1].y-s.y,snake[i-1].x-s.x);
c.save();
c.translate(s.x*CELL+CELL/2,s.y*CELL+CELL/2);
c.rotate(ang);
if(i===0) drawLoco(c); else drawWagon(c,s.color,s.rider);
c.restore();
}
}
function drawWaiting(c,t){
for(const p of waiting){
const bob=Math.sin(t*3+p.phase)*1.4;
const cx=p.x*CELL+CELL/2, cy=p.y*CELL+CELL/2+2+bob;
c.fillStyle='rgba(0,0,0,.28)'; c.beginPath(); c.ellipse(cx,cy+10,7.5,3,0,0,Math.PI*2); c.fill();
c.fillStyle='#39404f'; c.fillRect(cx-4.5,cy+1,3.4,9); c.fillRect(cx+1.1,cy+1,3.4,9);
c.fillStyle=p.color; c.beginPath();
c.moveTo(cx-6.5,cy+4); c.lineTo(cx+6.5,cy+4); c.lineTo(cx+4.5,cy-3.5); c.lineTo(cx-4.5,cy-3.5);
c.closePath(); c.fill();
c.strokeStyle=p.skin; c.lineWidth=2.6; c.lineCap='round';
c.beginPath(); c.moveTo(cx-5.5,cy-2); c.lineTo(cx-9,cy+3); c.stroke();
const w=Math.sin(t*7+p.phase);
c.beginPath(); c.moveTo(cx+5.5,cy-2); c.lineTo(cx+9,cy-4.5-w*3.5); c.stroke();
c.fillStyle=p.skin; c.beginPath(); c.arc(cx,cy-8,5.6,0,Math.PI*2); c.fill();
c.fillStyle=p.hair; c.beginPath(); c.arc(cx,cy-9,5.6,Math.PI,0); c.fill();
c.fillStyle='#20242c'; c.beginPath(); c.arc(cx-2.1,cy-7,0.95,0,Math.PI*2); c.fill();
c.beginPath(); c.arc(cx+2.1,cy-7,0.95,0,Math.PI*2); c.fill();
c.strokeStyle='#20242c'; c.lineWidth=1; c.beginPath(); c.arc(cx,cy-5.5,2.4,0.25,Math.PI-0.25); c.stroke();
// little suitcase
c.fillStyle='#8a5a34'; rr(c,cx+8,cy+2,8,7,1.6); c.fill();
c.strokeStyle='#5e3c21'; c.lineWidth=1; c.beginPath(); c.arc(cx+12,cy+2,2.2,Math.PI,0); c.stroke();
}
}
function drawExpress(c){
const y=EXP_ROW*CELL+CELL/2;
c.save(); c.translate(express.x*CELL+CELL/2,y); c.scale(express.d,1);
// speed lines
c.strokeStyle='rgba(255,255,255,.18)'; c.lineWidth=2; c.lineCap='round';
for(let i=0;i<3;i++){ c.beginPath(); c.moveTo(-46-i*4,-7+i*7); c.lineTo(-58-i*5,-7+i*7); c.stroke(); }
// coaches
for(let i=2;i>=1;i--){
const x=-16*i;
c.fillStyle='#3b4657'; rr(c,x-14,-9,28,18,4); c.fill();
c.fillStyle='#4d5b70'; rr(c,x-12,-7,24,12,3); c.fill();
c.fillStyle='rgba(255,255,255,.30)';
for(let k=0;k<3;k++) c.fillRect(x-9+k*7,-4,4,7);
c.fillStyle='#12151b';
for(const p of [[x-9,-11],[x+9,-11],[x-9,11],[x+9,11]]){ c.beginPath(); c.arc(p[0],p[1],3,0,Math.PI*2); c.fill(); }
}
// loco
c.fillStyle='#1c2028'; rr(c,-15,-9,30,18,4); c.fill();
c.fillStyle='#c8322f'; rr(c,-6,-10,21,20,7); c.fill();
c.fillStyle='rgba(255,255,255,.15)'; rr(c,-4,-8,16,7,4); c.fill();
c.fillStyle='#ffd166'; c.fillRect(5,-10,3,20);
c.fillStyle='#8f201e'; c.beginPath(); c.ellipse(14,0,3.4,9,0,0,Math.PI*2); c.fill();
c.fillStyle='rgba(255,240,180,.35)'; c.beginPath(); c.arc(12,0,6.5,0,Math.PI*2); c.fill();
c.fillStyle='#fff6cf'; c.beginPath(); c.arc(12,0,3,0,Math.PI*2); c.fill();
c.fillStyle='#12151b';
for(const p of [[-10,-11],[10,-11],[-10,11],[10,11]]){ c.beginPath(); c.arc(p[0],p[1],3,0,Math.PI*2); c.fill(); }
c.restore();
}
function render(t){
ctx.clearRect(0,0,W,H);
ctx.drawImage(bg,0,0);
drawExpress(ctx);
drawTrack(ctx);
drawWaiting(ctx,t);
// ghosts of delivered cars
for(const g of ghosts){
ctx.save(); ctx.globalAlpha=Math.max(0,g.life);
ctx.translate(g.x*CELL+CELL/2,g.y*CELL+CELL/2);
drawWagon(ctx,g.color,g.rider); ctx.restore();
}
drawTrain(ctx);
for(const p of particles){
ctx.globalAlpha=Math.max(0,Math.min(1,p.life));
ctx.fillStyle=p.color;
if(p.steam){ ctx.beginPath(); ctx.arc(p.x,p.y,p.r*(2.2-p.life),0,Math.PI*2); ctx.fill(); }
else { ctx.beginPath(); ctx.arc(p.x,p.y,p.r,0,Math.PI*2); ctx.fill(); }
}
ctx.globalAlpha=1;
for(const f of floaters){
ctx.globalAlpha=Math.max(0,Math.min(1,f.life));
ctx.fillStyle=f.color; ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.font=(f.big?'bold 30px ':'bold 17px ')+'"Trebuchet MS",Verdana,sans-serif';
ctx.strokeStyle='rgba(0,0,0,.5)'; ctx.lineWidth=3;
ctx.strokeText(f.text,f.x,f.y-(1-f.life)*22); ctx.fillText(f.text,f.x,f.y-(1-f.life)*22);
}
ctx.globalAlpha=1;
if(crashFlash>0){
ctx.fillStyle='rgba(255,60,60,'+(crashFlash*0.38)+')'; ctx.fillRect(0,0,W,H);
}
if(mode==='demoCrash'){
ctx.fillStyle='rgba(10,12,22,.72)'; ctx.fillRect(0,H/2-38,W,76);
ctx.fillStyle='#ffd166'; ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.font='bold 26px "Trebuchet MS",Verdana,sans-serif';
ctx.fillText('CRASHED!',W/2,H/2-8);
ctx.fillStyle='#cbd3ea'; ctx.font='13px "Trebuchet MS",Verdana,sans-serif';
ctx.fillText('demo restarting...',W/2,H/2+18);
}
if(mode==='demo'&&time>1){
ctx.fillStyle='rgba(0,0,0,.35)'; rr(ctx,W/2-58,8,116,22,11); ctx.fill();
ctx.fillStyle='#4ecdc4'; ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.font='bold 12px "Trebuchet MS",Verdana,sans-serif';
ctx.fillText('DEMO MODE',W/2,20);
}
}
/* -------------------------------------------------------------- HUD/DOM */
const ui={score:el('uiScore'),riders:el('uiRiders'),len:el('uiLen'),level:el('uiLevel'),best:el('uiBest')};
function updateHUD(){
const set=(n,v)=>{ if(n.textContent!==String(v)) n.textContent=v; };
set(ui.score,score); set(ui.riders,riders.length); set(ui.len,snake.length);
set(ui.level,level); set(ui.best,best);
}
function showOverlay(title,text,cta){
ovTitle.textContent=title; ovText.innerHTML=text; ovCta.textContent=cta;
overlay.classList.remove('hidden');
}
function hideOverlay(){ overlay.classList.add('hidden'); }
function startPlay(){
initAudio();
if(mode==='play') return;
reset('play'); hideOverlay();
if(score>best) best=score;
}
/* ------------------------------------------------------------- controls */
function setDir(d){
if(!d) return;
if(d.x===-dir.x&&d.y===-dir.y) return;
if(d.x===-pending.x&&d.y===-pending.y) return;
pending=d;
}
document.addEventListener('keydown',e=>{
const k=e.key.toLowerCase();
if(['arrowup','arrowdown','arrowleft','arrowright',' '].includes(k)) e.preventDefault();
if(mode!=='play'){
if(k==='r'||mode!=='demo'||k===' '||k.startsWith('arrow')||k==='enter'||k==='w'||k==='a'||k==='s'||k==='d') startPlay();
return;
}
if(k==='arrowup'||k==='w') setDir(DIRS.up);
else if(k==='arrowdown'||k==='s') setDir(DIRS.down);
else if(k==='arrowleft'||k==='a') setDir(DIRS.left);
else if(k==='arrowright'||k==='d') setDir(DIRS.right);
});
stage.addEventListener('pointerdown',e=>{ if(mode!=='play') startPlay(); });
document.querySelectorAll('.pad button').forEach(b=>{
b.addEventListener('pointerdown',e=>{
e.preventDefault();
if(mode!=='play') startPlay();
setDir(DIRS[b.dataset.dir]);
});
});
let tsx=0,tsy=0;
canvas.addEventListener('pointerdown',e=>{ tsx=e.clientX; tsy=e.clientY; });
canvas.addEventListener('pointerup',e=>{
if(mode!=='play'){ startPlay(); return; }
const dx=e.clientX-tsx, dy=e.clientY-tsy;
if(Math.abs(dx)>20&&Math.abs(dx)>Math.abs(dy)) setDir(dx>0?DIRS.right:DIRS.left);
else if(Math.abs(dy)>20) setDir(dy>0?DIRS.down:DIRS.up);
});
canvas.addEventListener('contextmenu',e=>e.preventDefault());
/* ------------------------------------------------------------ main loop */
function frame(now){
requestAnimationFrame(frame);
const dt=Math.min(120,now-last); last=now; time+=dt/1000;
if(mode==='play'||mode==='demo'||mode==='demoCrash'){
express.x += express.d*(0.85+level*0.06)*dt/1000;
if(express.x>COLS-2.7){ express.x=COLS-2.7; express.d=-1; }
if(express.x<2.7){ express.x=2.7; express.d=1; }
}
// steam from the chimney
if(mode==='play'||mode==='demo'){
const a=Math.atan2(dir.y,dir.x), h=snake[0];
const cx=h.x*CELL+CELL/2, cy=h.y*CELL+CELL/2;
const c=Math.cos(a), s=Math.sin(a);
const px=cx+6.5*c+5*s, py=cy+6.5*s-5*c;
if(Math.random()<0.55)
particles.push({x:px,y:py,vx:-dir.x*0.35+Math.random()*0.3,vy:-dir.y*0.35-0.35,
life:1,dec:0.011,r:2.6+Math.random()*1.6,color:'rgba(235,240,250,0.75)',steam:true});
}
for(let i=particles.length-1;i>=0;i--){
const p=particles[i];
p.x+=p.vx*dt/1000*14; p.y+=p.vy*dt/1000*14;
if(p.steam){ p.vy-=0.004*dt/16; }
p.life-=p.dec*dt/16;
if(p.life<=0) particles.splice(i,1);
}
for(let i=ghosts.length-1;i>=0;i--){ ghosts[i].life-=0.012*dt/16; if(ghosts[i].life<=0) ghosts.splice(i,1); }
for(let i=floaters.length-1;i>=0;i--){ floaters[i].life-=0.012*dt/16; if(floaters[i].life<=0) floaters.splice(i,1); }
if(crashFlash>0) crashFlash=Math.max(0,crashFlash-0.02*dt/16);
acc+=dt;
const iv=tickInterval(); let guard=0;
while(acc>=iv&&guard++<4){ acc-=iv; step(); if(mode==='over'||mode==='demoCrash') break; }
if((mode==='play'||mode==='demo')&&expressHitsTrain()) crash();
if(mode==='demoCrash'&&performance.now()>demoResetAt){ reset('demo'); }
render(time); updateHUD();
}
reset('demo');
showOverlay('Platform Express',
'You are the locomotive. Stop at the <b>top platform</b> to pick up waiting passengers, then run down to the <b>delivery depot</b> on the bottom platform to drop them off for bonus points.',
'CLICK OR PRESS ANY KEY TO START');
requestAnimationFrame(frame);
})();
</script>
</body>
</html>Reasoning trace (38,240 tokens)
The user wants a snake game where the snake is a train with locomotive and colorful wagons, picking up passengers waiting on the platform.
Let me design:
- Grid-based game, e.g., 24x16 cells.
- Snake = train: locomotive at head, wagons behind in colorful linked cars.
- Food = passengers waiting on platform (little people figures) — pick up by eating.
- Trains travel on tracks... maybe rails everywhere. Actually a snake game on a grid, but themed as train: the train moves along grid cells, rails drawn behind.
- Passengers spawn on platform edges maybe — station platform tiles. Let's make passengers spawn anywhere but on platform rows (top and bottom rows) look like platforms. Hmm, but then picking up passengers would be constrained.
Better: passengers appear anywhere on the track grid; the edges (top row) is a platform with station. Let's make passengers spawn at random cells, and some at platform positions. Train wagons fill in as passengers collected — that's a nice mechanic: each passenger picked up adds a colorful wagon to the train. Train length grows like snake.
- Score = passengers delivered? Maybe passengers get picked up and then you must bring them to the station/depot to drop off (like a delivery bonus). That adds depth. Let's do: passengers are waiting (stationary) → train collects them → they ride in the wagons → deliver them at the station (highlighted platform area) to score points, and the wagon detaches after delivery. That keeps train length manageable and gives a goal.
Actually to keep it snake-like, growing is standard. But the delivery mechanic is more "train"-like and interesting. Let's do both: picking up passenger adds a wagon (grow) and gives small points; delivering at station gives bigger points and removes the last wagon (like shrinking). Hmm, might be confusing. Let's do: passenger pickup → new colorful wagon added + 10 points. Delivering to station platform → +25 points per passenger and converts... Hmm, complicated with the snake-length mechanic.
Simpler and clean:
- Passengers wait on the platform (station cells along the edges, say top row and bottom row are platforms with benches, sign).
- Actually spawn passengers only on platform cells? Then the train must reach the platform to pick them up — but the snake moves freely on grid. Hmm, that works: passengers appear on platform tiles (marked with a station platform look: tiled ground). Train drives over the cell and picks them up → a new wagon is added. Train length grows.
But then the danger (self-collision) grows and game becomes hard — good, standard snake.
Let's add: score = passengers collected; also a depot at the right side where you can drop off? Let's keep the delivery idea but simple: passengers ride on the wagon (visible little heads popping out of wagon tops), and when you reach the station platform (bottom row marked "DEPARTURES"), passengers hop off and you get bonus points, and the wagon is removed (train shortens). That gives player agency: risk of staying long vs. delivering.
Hmm, but if wagons removed, then the train could shrink to 1 and become too easy. That's fine — like a risk/reward. Maybe the minimum length is 3 (locomotive + 2 wagons)? Let's allow shrinking down to locomotive + 1 car. Actually let's make it: dropping off passengers gives bonus and shrinks; score keeps counting. Good.
Also obstacles: on a train theme, maybe some cells are "crossings"/"cranes"/"tunnel walls" that kill the train. Let's add a few static obstacles (like water tanks, signal boxes) that cause a crash. And an express train that runs along a track and kills on collision? Maybe too much. Let's include:
- Obstacles: signal boxes / water towers (static), randomly placed but consistent, maybe a few.
- Hmm, random obstacles could make it messy. Let's add 4-6 fixed obstacles.
Also add: level progression, speed increases.
Visual style: pixel-art-ish / vector cute. Use CSS + canvas? Canvas is best for grid game. Draw everything with canvas 2D: rails, sleepers, locomotive with wheels, colorful wagons, passengers (little people with waving arms), station platform, particles, score HUD in HTML overlay.
Canvas size: 24 cols × 16 rows with cell size 32 → 768×512. Good fit in 1280×800. Maybe use cell 30 → 720×480. Let's do a 22×16 grid at 32px = 704×512? Let's pick 24 x 16 cells of 30px = 720 x 480. Hmm, I want the canvas to look good. Let's do 25 cols x 16 rows, cell 28 → 700×448. Eh.
Let's just do cell size 32, grid 24×16 → 768×512 canvas. Plus HUD above. Total page height ~ 512 + header ~ 600. Fits in 800 viewport.
Width 768 centered on 1280 → fine. At 375px wide, we need to scale down canvas: use CSS `width:100%; max-width:768px; aspect-ratio` — canvas with `width:100%` and height auto scales. But then the drawing coordinates need to be scaled — canvas internal resolution stays 768×512 and CSS scales it. That works fine (slightly blurry but ok). Let's set canvas width=768 height=512 attributes and CSS `width:100%; height:auto; display:block;`.
Game features:
1. Start screen overlay: "CLICK OR PRESS ANY KEY TO START" with title "PLATFORM PICKUP — Express Snake" or something cute. And AI demo mode plays behind it (self-playing demo).
2. Demo AI: simple greedy/safe pathfinding. Implement a simple AI: move toward nearest passenger while avoiding collisions; use BFS safety check. Let me implement a reasonably safe AI: BFS from head to target over free cells; choose first step of shortest path; but also avoid getting trapped. A common approach: BFS to target, take first step, but verify that after moving there's still a path to the tail (or that the new head can reach the target). To keep it simple and robust: compute BFS path to target avoiding obstacles and body; if found, take the first step; also check that the resulting position isn't a dead end (head's reachable region size >= some threshold). Implement a safety check: after candidate move, compute the number of reachable free cells from the new head; if that reachable count < body length + 5, don't move (pick another safe direction). This is decent.
3. Real play: arrow keys / WASD. Touch controls for mobile: swipe or on-screen dpad buttons.
4. Elements:
- Locomotive: drawn as a cute engine with chimney, cab, headlight, wheels.
- Wagons: colorful (cycle palette) with passenger heads visible on top, different types (coach, flatcar with luggage, etc.)
- Passengers: little people standing on platform, waving, with speech bubble/emoji.
- Platform: station platforms along top and bottom rows? If passengers stand only on platform rows, they never block the interior → hmm, they do occupy a cell. Let's place platforms along the left and right edges? Actually classic snake on grid: let's make the outer border be "platform" — the top row is the station platform (with benches, a station sign, a clock tower) and the bottom row is another platform (departure). Passengers spawn on either platform row, waiting. Train runs on the rails in the interior grid. The train can enter the platform rows to pick up passengers (the track runs along the platform).
Wait, but the border is the wall — in snake, hitting the wall = death. So if the platform is on the border, then reaching the platform = death at the border. Hmm. Let's make the grid have a 1-cell margin wall (fence) and platform rows inside the wall? Then the train can run over the platform cells to pick up passengers, and the wall beyond kills.
Layout: grid 24×16. Row 0 = wall/fence (top), Row 15 = wall (bottom). Row 1 = north platform (station, benches, sign). Row 14 = south platform (depot). Columns 0 and 23 = walls (signal boxes, tunnel entrances). Interior rows 2..13 and cols 1..22 = main track area.
Passengers spawn on platform rows (1 and 14) at random columns — cute: they wait for the train. Train must go up to row 1 or down to row 14 to collect. That's a nice constraint and makes the "delivery" idea natural: pick up at one platform, deliver at the other? Let's do:
- North platform = "PICK-UP" station: passengers waiting there. Collecting gives +10 and adds a wagon.
- South platform = "DEPARTURES" / depot: delivering passengers (from wagons) gives +25 each and removes the wagon (train shrinks by one, min 1 wagon).
But how does the train know which passengers are in the wagon? Each wagon carries a passenger; when the head enters a south-platform cell, drop off the last passenger (i.e., pop the last wagon)? Hmm, physically the head arrives first; passengers would hop off at the front. Let's make the drop-off remove the wagon closest to the head (the first wagon) — that's natural: the front wagon unloads at the platform. Then the train shrinks from the front.
That means order of collection is reversed. Fine — implement: passengers array in order [wagon1...wagonN], drop-off removes index 0 (front wagon).
Hmm, but then wagon colors cycle and passengers ride in wagons, with heads popping up.
Actually, wait: simpler mechanic could be confusing. Let me reconsider: maybe passengers appear on the platform, and the train collects them (grow). Score = passengers. That's clean, classic snake with a theme. The delivery part is a bonus layer. I think adding the depot gives the game depth and a reason to traverse, and it's easy to explain: "Pick up passengers at the north platform, deliver them to the south platform for bonus points."
Risk: player just picks up and the train gets long → crash. That's snake.
Let me include obstacles: a few "signal gantry"/"water tower" cells in the interior (static), and also level increases add more. Also maybe a roaming "express train" hazard on the bottom row? Let's skip the roaming hazard for clarity; instead, obstacles + walls.
Actually a moving hazard would be exciting: a rival train running along a fixed track row (say row 7) — you must dodge it. Hmm, it could be annoying. Let's include a slowly-moving "express" on the horizontal center line that moves back and forth, drawn as a red express locomotive. Colliding = crash. It's visually interesting and adds tension. But the demo AI must handle it (avoid). I'll include it but make it slow and only appear after score threshold (level 2)? Hmm, complexity. Let's include it as a hazard that appears always but moves slowly (1 cell per 6 ticks) along row 8 (a straight track). AI pathfinding needs to treat its position as blocked. That's fine — I'll add the express's current cell as blocked in BFS.
I need to make sure BFS can still find a path. The express moves along a single row, so it's a temporary blockage. OK.
Let's keep hazard optional: maybe simpler to just have static obstacles (water towers, signal boxes) that increase with level. I'll do static obstacles + a moving express. Let's carefully implement the AI to handle both.
Risk of getting stuck: the AI might be trapped with no safe move; then it dies — that's fine, demo shows a crash and restarts. Actually crashing in the demo looks bad? It's fine — it's action. But let's make the AI good enough.
Let me think about the AI implementation:
State: body array of {x,y} cells, head at index 0, direction.
Grid blocked: walls (outside grid? use walls as cells with type 'wall'), obstacles, body cells (except tail? use all except the tail cell if not growing), express cells.
AI algorithm each tick:
1. Compute BFS distance map from head over free cells (not walls, not obstacles, not body except the tail's current cell... careful). Find nearest passenger (or delivery target). Target selection: if train has passengers and it's near depot? Prefer picking up if close, else deliver? Let's: compute target list of passenger cells and depot cells (south platform). Choose the nearest reachable passenger by BFS distance; if none reachable, head toward the depot; if the train has passengers and depot is closer than the nearest passenger, go to depot. Simplify: compute BFS distance field from head; for each passenger, if reachable, candidate distance; for depot cells, distance; choose min among passengers; if no passenger reachable, target the nearest depot cell. But delivering gives points only if you have passengers.
Also: if carrying passengers, maybe prefer delivering when train length is long (>=6) to shorten. That's smart play: deliver when long. Let's do: if length >= 6 and have passengers → target depot; else target nearest passenger; if no passengers reachable → target depot (to reposition) — but going to depot with no passengers is wasted; instead just wander safely. Let's: if no passengers and none reachable, do a safe wander (pick a direction maximizing free space).
2. Get BFS path to target, take the first step direction.
3. Safety: simulate the move; compute reachable free-cell count from new head (flood fill, considering the body after the move). If count < needed (body length), try alternate directions: iterate over candidate directions sorted by BFS-path-first, then others, and pick the first that is safe (new head not colliding, and flood-fill reachable area from new head >= body length + 2, or new head can reach target). Standard approach.
Also demo: after a crash, show "CRASHED" briefly and restart automatically? For a demo, auto-restart after ~1.5s is nice so the screen always has action. Let's do: in demo mode, on crash, flash, then respawn after 1.2s. Also don't show the game-over overlay in demo; instead show "DEMO" badge.
Start: show overlay "TRAIN TIME EXPRESS / Click or press any key to start" and the demo AI plays behind. When user presses a key or clicks, switch to player mode (reset game) and hide overlay.
Let me now design visuals in canvas:
Palette: warm, cozy, vibrant.
- Background: grass/ground dark teal? Let's do a top-down view of a rail yard: background ballast (grey-brown with noise dots), rails drawn on each grid cell as two parallel lines with sleepers.
- Actually, drawing rails everywhere is heavy but fine: for each cell draw a sleeper (horizontal brown dash) and two vertical rails (dark grey lines with highlight). Since the train travels on a grid, rails along vertical direction look fine; top-down rail lines along both axes: draw a full vertical rail line in each cell plus horizontal? Overlapping makes it messy. Simplify: draw a track grid: vertical rail lines at each cell boundary? Hmm.
Alternative simpler approach: background = dark green grass / gravel texture with subtle grid; train draws its own track? Let's draw rails as horizontal lines spanning the full width for each row of cells (like a set of parallel horizontal tracks), plus vertical short connectors? For a snake that turns, top-down view rails should be a lattice.
I think the cleanest: draw ballast texture (dotted noise), then draw the grid with faint lines, and draw "sleepers + rails" per cell as a small icon: two parallel vertical dark lines within the cell (like the track goes vertically), and for the head direction... nah.
Better approach: draw the track network as continuous lines: For each cell, draw rail lines as an L or straight through, based on the train's path? Too complex.
Compromise: Draw a horizontal railway line for each row: a light rail pair (two thin lines) with sleepers, spanning full width. The train travels along rows mostly, and when it moves vertically it crosses. Visually, the wagons drawn as cars will look oriented by direction anyway. Many games do this. But then the background would be all horizontal tracks which looks like a railyard with many parallel tracks — actually that looks great and thematic! Like a big yard with parallel sidings. The train hops between rows though, which is physically impossible but it's a game.
Alternative: make the movement feel like a train on a grid of rails where each cell contains a crossing. Hmm.
Let me just do: dark ballast background with subtle speckles; faint grid; and draw a "track" under the train: the train's body path drawn as a continuous chain of rail segments (sleepers + two rails) connecting each car to the next, oriented along the segment direction. That gives a nice "snake of track" look: a snaking railway line. Plus static sidings? The passengers stand on platforms.
That's elegant: draw track under the snake body as a continuous line: for each consecutive pair of body cells, draw a thick dark rail line with sleeper ticks perpendicular. Then the train cars sit on it. Since the track follows the train, it's always visible where the train is. Elsewhere, the background could show faint "yard" hints (some decorative parallel tracks in the far background at low opacity).
Yes! Let's do that: background = ballast speckles + faint decorative tracks (horizontal lines at low alpha) + platforms (top & bottom rows drawn as tiled platform with edge stripe yellow) + station building at the north side? The north platform row (row 1) spans all columns: draw a platform strip with tiles and a yellow safety line, benches, a station sign "PICK-UP PLATFORM", a clock. South platform row (row 14) similarly with a "DELIVERY / DEPOT" sign and a train shed?
Wait, but if the whole row is platform, the train driving along row 1 is on the platform — the rails run through the platform. Fine, draw platform tiles at the row edges.
Passengers drawn standing on the platform cell with a little shadow, waving animation, and a small emoji-ish face. Use simple vector shapes: head circle, body trapezoid, arms, legs, hat in different colors.
Locomotion: draw the head as locomotive from top-down? Or side view? Top-down is consistent with the grid. Top-down locomotive: boiler rectangle with rounded front, chimney circle at front, cab at back, wheels visible on sides, headlight. Wagons: rounded rectangle with colored roof, coupling, wheels, passenger heads popping out of the roof (two per wagon maybe).
Actually, side-view for a train with a top-down grid is a mix. Let's keep everything top-down (bird's eye): locomotive = rounded rectangle boiler (green/red), circular front plate with headlight, a cab box at the back with a window, wheels as small dark circles at corners, chimney puff particles. Wagons = rounded rect with a colorful roof and stripe, small passengers' heads (circles) peeking. That reads well top-down.
Hmm, top-down with passengers visible inside wagons: we can draw the passengers' heads in the wagon.
Alternatively, a slightly "2.5D" cute style: cars drawn as rounded rectangles with the front car having a face (eyes) — like a cartoon train face. That's charming. Let's give the locomotive a face on the front (eyes + smile) — cartoon style, front-facing when moving right/left, but for top-down let's just always draw the same and rotate the whole thing by direction. Rotating means the face rotates — cute, works.
Let's draw the train in a "top-down-ish" style but keep it upright for readability? Simplest robust approach: draw each car as a rounded rect oriented along the travel axis, with rotation applied for the locomotive's front. I'll draw cars in a fixed orientation (always horizontal, long axis horizontal) and rotate only when the direction is vertical? Hmm.
Actually many snake games with trains just draw cars as horizontal rectangles always (the train looks like it's driving sideways). Let's orient cars along the direction of movement segment: each car (except head) is oriented along the vector from its cell to the previous cell. That looks natural.
I'll implement drawing functions with a rotation context (ctx.translate to cell center, ctx.rotate(angle), then draw car along +x axis).
Locomotive drawing (along +x, front at +x):
- Chassis: rounded rect length 0.86*cell, height 0.62*cell, dark grey.
- Body: rounded rect red/maroon with darker outline.
- Boiler: circle/oval at front (x from 0.05 to 0.55) in a bright color.
- Cab: rect at back (x -0.4 to -0.1) with windows.
- Chimney: small circle at front top.
- Headlight: yellow circle at front.
- Wheels: 4 dark circles along the bottom edges? Top-down: wheels on both sides → draw dark ellipses at the four corners-ish positions (top and bottom edges of the body).
- Face: two white eyes with pupils on the front of the boiler + smile (cartoon). Yes, cute.
Wagon drawing (along +x):
- Rounded rect with roof color from palette [coral, teal, yellow, purple, lime, orange].
- A roof stripe/panel lighter.
- Wheels: dark circles.
- Coupling: small dark bar between cars.
- Passenger heads: one or two small circles (skin tone) with hair, drawn on top of the roof to indicate riders. Only if the wagon carries a passenger.
Passenger (waiting on platform):
- small body (rounded), head circle with hair, arms waving (animated), maybe a suitcase, a thought bubble with "!" or a train emoji.
Particles: steam puffs from the chimney, sparkle when collecting a passenger, hearts when delivering.
HUD (HTML): Title "PLATFORM EXPRESS", score (Passengers), length (cars), level/speed, best (session-based, in-memory only; no localStorage!). Instructions.
Game over overlay: "SERVICE TERMINATED" with final score and "Press R / click to restart".
Level: speed increases every 5 passengers? Level = 1 + floor(picked/4). Speed = base ticks per second.
Let me write the tick rate: base 160ms at level 1, decreasing to 80ms at high levels. Let's do interval = max(80, 170 - (level-1)*10).
Express hazard: appears from level 2. It runs along row 8 (middle horizontal track), moving left-right, 1 cell per N ticks. It has a body of 3 cells (loco + 2 coaches). Collision with it = crash. Draw as a red express loco with speed lines.
Wait: the express along row 8 crossing the field. The player's train may travel along row 8 too. BFS must treat express cells as blocked (temporarily). Fine.
Actually, one issue: the express moving could pass through the player's train in a way that feels unfair. But it's visible and slow, so OK.
Let me also ensure demo AI can survive reasonably: it should treat express cells as blocked; when the express is adjacent, the AI might get cornered. Let's make the express move slowly (1 cell per 8 ticks ~ 1.3s) and be visually distinct. Also make the express only appear at level ≥2 (after 4 passengers). Demo will reach that.
Hmm, risk: the demo AI might frequently crash, causing flicker. I'll make the AI decent with the flood-fill safety check.
Let me think about the AI safety check more concretely.
Grid: W=24, H=16. Cells indexed [x][y].
blockedStatic(x,y): wall (x==0||x==W-1||y==0||y==H-1) → actually let's set walls as the outer ring? Then platforms at row 1 and 14, and the interior rows 2..13. Wait with H=16: outer ring y=0 and y=15, x=0 and x=23 are walls. Platform rows: y=1 (north) and y=14 (south). Interior: y in 2..13, x in 1..22.
Hmm, with the outer ring as walls, the walls are drawn as a fence/tunnel. Let's draw the outer ring as a stone wall / hedge / tunnel edge. North side (y=0): the station building roof with a sign "GRAND CENTRAL" — the north platform row y=1 below it. South side (y=15): a river or a fence; y=14 is the delivery platform.
Actually let's make it visually strong:
- y=0 row: station building (brick) with windows, a clock, sign "CENTRAL STATION" spanning the middle; the ends are tunnel mouths (dark arch).
- y=1 row: platform with light grey tiles, yellow tactile strip along the bottom edge, benches, station clock, "PLATFORM 1 — PICK UP" sign.
- y=14 row: platform with blue-ish tiles, "PLATFORM 2 — DELIVER" sign, lamp posts, a departure board.
- y=15 row: a fence / dark hedge with trees? Let's make it a low stone wall with bushes.
Walls (fence) on x=0 and x=23: hedges/bushes or signal posts.
Interior: ballast ground (grey-brown with speckles) with the faint decorative tracks.
Obstacles: interior cells containing "water tower" / "signal box" / "cargo crates" — drawn as small 3D-ish icons. Let's have 6 fixed positions, e.g., (5,5), (18,4), (9,9), (15,11), (3,12), (20,7) — but ensure they don't block platform access entirely. Passengers spawn on platform rows so obstacles inside don't block that.
Also add more obstacles as level increases? Static is fine; maybe add one more at each new level at a random free cell. Let's add obstacles: start with 6, add one each level (max 12). Ensure not on platform rows or on the player's spawn area.
Player spawn: head at (3, 8) heading right? Let's spawn at (2,8) facing right (east) — clear of obstacles. Actually x=2 is interior (x in 1..22). Ensure obstacles don't spawn near (2,8) and (3,8).
Demo AI spawn: same.
Passengers: spawn on platform rows y=1 and y=14 at random x in 1..22, avoiding occupied cells (train body, obstacles). Max concurrent waiting passengers: 3 (or 4). Each passenger has a color and a countdown? Maybe they have patience — after some time they leave? Adds pressure. Let's give each passenger a patience bar (they wait). If patience runs out, they leave (no penalty) — but that reduces the chance to score. Maybe simpler: unlimited patience, but the number of waiting passengers is capped at 3, so collecting them keeps the flow going. Add a little "!" bubble to indicate waiting. Keep it simple: no patience timer, cap 3 waiting.
Hmm, but then the train should beeline for the platform. Good.
Scoring:
- Pick up passenger: +10, add a wagon (with that passenger inside), particle burst, sound? no audio (could add WebAudio — but sandbox may block? AudioContext is generally allowed but requires user gesture; skip, or add simple WebAudio blips on user interaction. Let's add tiny WebAudio beeps activated on first click/keypress — low risk. Actually might throw errors in sandbox; I'll wrap in try/catch and only create on user gesture. Let's include it, it's a nice touch but keep it optional and safe.)
- Deliver at south platform (y=14) while carrying passengers: for each passenger aboard... Let's design: when the head is on a south platform cell, one passenger (the front wagon) is dropped off per tick? That'd be a cascade. Let's drop all aboard? No — dropping all shrinks the train a lot. Let's drop one per tick (the front wagon) → the train shrinks gradually as it drives along the platform. Actually that is elegant: drive along the delivery platform and passengers hop off one by one, each giving +25. But the train might drive onto the platform and then leave quickly. Hmm, let's make it: each time the head enters a south-platform cell, drop the front passenger (+25 points). So driving along the platform row drops them one per cell.
But then the wagon count could drop below 1 → limit: keep min 2 cars total (locomotive + 1 wagon)? If you have 1 wagon and drop it, train = locomotive only. Let's allow min 1 wagon? Let's keep min total length 2 (locomotive + one carriage). If length would drop below 2, don't drop.
Wait, but is the delivery mechanic too complex to convey? The HUD can show: "Riders aboard: N — drive to PLATFORM 2 to deliver (+25 each)". Show an arrow/marker pointing to the delivery platform when carrying riders. I'll add a HUD hint and a highlight glow on the delivery platform when carrying riders.
Alternatively, make it simpler: the passenger you're carrying is displayed on the wagon; delivering happens when the head is on the delivery platform row. Yes, as described.
Both pickup and delivery happen when the head is on a platform cell. Pickup: if the head enters a cell with a waiting passenger → collect. Also on north platform row (y=1). Should pickup also be allowed on the south platform? Let's say waiting passengers spawn on both platforms? No — waiting passengers spawn on the north platform (pick-up, "PICK UP" sign) and delivering happens at the south platform ("DELIVER"). Clean.
Hmm, but the demo AI: it needs to route to north platform to collect, then to the south to deliver when carrying. Implement AI target selection:
- If carrying >= 1 and (train length >= 6 or no reachable waiting passenger) → target delivery platform (a random cell in y=14, ideally near the train's current x).
- Else target the nearest reachable waiting passenger (BFS distance).
- If no passengers reachable (blocked) → target delivery/wander.
I'll implement a BFS distance field from the head over free cells, then pick the best passenger by distance (tie-break by y). Then path to it. Then step.
Also the AI should avoid reversing into the body (can't reverse anyway in snake).
Let's write the flood-fill safety:
```
function reachableCount(nx, ny, bodyAfterMove) {
// BFS from (nx,ny) over cells not blocked by walls/obstacles/bodyAfterMove
}
```
bodyAfterMove = body array after the move (new head + rest). For the safety check, treat all body cells except the tail as blocked (the tail moves away if not growing). Simpler: treat all body cells as blocked except the new head and the last cell (tail) if not growing. Let's compute `blockedSet` = walls ∪ obstacles ∪ expressCells ∪ bodyCells(excluding new head, excluding tail if !growing). Then BFS from new head. If count >= length of train (body length) → safe-ish. Standard heuristic: if reachable area >= train length, the snake can survive.
Let's implement candidate ordering:
1. The direction from the BFS path to the target (if reachable).
2. Other directions that don't collide, sorted by resulting reachable count (descending).
Pick the first safe one; if none safe, pick the one with max reachable count (or just any non-colliding move).
Express cells: the express occupies 3 cells along row 8 (the "express lane"). Let's fix the lane at y=8? The train spawns at y=8 too... Let's put the express lane at y=4 or y=11 to avoid the spawn area. Let's use y=11? Obstacles at (15,11) — adjust. Express lane: y=5. Obstacles: avoid row 5 near the express? The express moves over the whole row; obstacles on row 5 would be hit by the express — that looks odd. Let's make row 5 a dedicated "express line" with no obstacles and no passengers, and mark it visually (a highlighted track). Actually, drawing a distinct line for the express is nice: draw a brighter rail line across row 5.
Let me set:
- Express lane row y=5.
- Obstacles in rows 2,3,4,6,7,8,9,10,11,12,13 but not row 5.
- Spawn: head at (2,9)? Let's set spawn head at (3,9) heading east (dx=1,dy=0). Make sure (3,9),(4,9),(5,9) are free of obstacles.
Hmm wait, the express lane row 5 and the player spawn row 9. OK.
Now, the demo AI needs to not be trapped by the express which blocks the row for 3 cells. The BFS handles it.
Let me now also handle the case where the AI has no valid move at all → it dies. Fine.
Let me write the code.
Structure:
HTML:
- .wrap: header (title + HUD stats), canvas area, controls hint, mobile dpad.
- Overlay div on top of canvas for start/gameover messages.
I'll draw the canvas game, and use an HTML overlay for the start screen and game over, so text is crisp.
Let me write CSS: dark background with a radial gradient, rounded card container, canvas with border-radius and shadow, HUD chips.
Responsive: at max-width 760px, stack HUD, shrink fonts, show touch dpad (always show dpad on touch devices; simplest: show dpad buttons at the bottom on small screens, hidden on large). I'll use a media query (max-width: 700px) to show the dpad, and also handle touch via swipe on canvas.
Now the JS game code.
Constants:
```
const COLS = 24, ROWS = 16, CELL = 32;
const W = COLS*CELL, H = ROWS*CELL;
```
Canvas attribute width=W height=H; CSS width:100%; max-width: W px; height:auto.
State:
```
let snake; // array of {x,y}, head first
let dir, nextDir;
let passengers = []; // {x,y,color,phase, ...}
let riders = []; // passengers aboard (in wagons) — colors
let score, level, ticks, best;
let mode = 'demo' | 'play' | 'over';
```
Colors palette for wagons: ['#ff6b6b','#4ecdc4','#ffd93d','#a06cd5','#6bcB77','#ff9f45','#4d96ff','#ff7bac'].
Game loop: use requestAnimationFrame with an accumulator, tick at current interval.
Render each frame (smooth animations: passenger waving, steam, wheels spin).
Drawing functions:
drawBackground():
- fill '#2b2b3a'? Let's make a nicer palette: ballast = '#3a3a4a' with speckles. Hmm, the theme is cute; let's use a warm dusk palette:
- ground: '#2e3440'? Let's do '#323a4a' base with speckles of '#3b4356'.
Actually, let's make the yard greenish-grey: base '#37424f'.
Let me define:
sky/outer: page bg gradient (#101322 → #1b2038).
yard ground: '#2f3a46' with speckles '#384654' and occasional '#4a5a68'.
rails: '#5c6775' with dark outline.
Platform north (y=1): tiles '#d9d2c3' with grout lines, a yellow line strip '#f6c445' at the bottom edge, benches.
Platform south (y=14): tiles '#cfd8e3' with a blue strip '#4d96ff' or yellow too. Let's do both yellow for safety lines but the signs differ.
Station building (y=0): brick '#8c4a3f' with roof '#5b2f2a', windows glowing '#ffd97d', a clock, and a sign "CENTRAL STATION" in cream letters.
South row y=15: a fence: dark green hedge '#2f5d3f' with lighter blobs, plus a few trees.
Left/right columns x=0 and x=23: hedges/bushes as well (to look continuous). But the north building and south hedge span full width including x=0..23. Corners: just draw the building across the full top row and hedge across the full bottom row; the side columns drawn as hedge for y>=1.
Hmm, the platform rows are y=1 and y=14. So at the corners (x=0,y=1) we have hedge; (x=0,y=0) building. Fine.
Draw decorative tracks: for each interior row (2..13 excluding 5), draw faint horizontal rail lines at low alpha, to suggest a railyard. And row 5 drawn brighter as the express line.
Actually if we draw horizontal rails in every row, the train driving vertically will look off. But as background decoration it's ok, it looks like a rail yard. I'll keep them faint (alpha 0.18) and the train's own track drawn brightly under it.
Draw track under the train: for each consecutive pair, draw a segment: a dark thick line (width ~10px) plus two thin rails (width 2) in light grey, with sleeper ticks perpendicular every 8px. Simpler: draw a thick line '#232a35' width 12 (the ballast/ties) then a line '#8b95a5' width 3.5. That gives a nice track look. With rounded caps/joins.
Train drawing:
```
function drawCar(ctx, x, y, angle, color, isLoco, riderColor)
```
Local coords: cell center at 0,0; car length L=0.92*CELL along x, height Wd=0.66*CELL.
Loco:
- shadow ellipse under.
- chassis rounded rect (-L/2, -Wd/2, L, Wd) fill '#20242c' (the underframe).
- body: boiler: rounded rect from -L/2+2 to +L/2-2? Let's structure: cab at back (x from -L/2 to -L/2+0.34L), boiler from -L/2+0.34L to +L/2.
- boiler fill: '#e14b4b'? Let's do a classic: boiler green '#3fa34d'? Hmm, with a cartoon face let's do: boiler teal '#2ec4b6'? Let's pick: loco body '#ef476f'?
Let me choose a palette that pops against the dark yard: locomotive = deep red '#d64545' boiler with a cream '#ffe8b0' stripe, cab '#2b3a55' navy with yellow windows. Wagon colors from the bright palette. Good contrast.
- front plate: ellipse at x=+L/2 (a vertical rounded nose) with a headlight (circle '#ffe680' + glow).
- chimney: small circle at front on top? Top-down, the chimney appears as a circle on the boiler near the front. Draw circle radius 0.12*CELL at (L/2 - 0.12*CELL, 0)? That's in the middle of the boiler. Fine, plus steam puffs emitted from it.
- cab roof: rounded rect at back with windows (two small squares '#ffe8b0').
- face: two eyes on the front part of the boiler (white circles with dark pupils) and a smile arc. Position at x = L/2 - 0.15*CELL, y = ±0.13*CELL. Eyes + smile.
- wheels: 4 dark circles r=0.09*CELL at corners of the chassis (top and bottom edges).
- cowcatcher? skip.
Wagon:
- chassis dark rounded rect.
- body rounded rect with the bright color, roof lighter shade, a darker stripe.
- passenger heads: 1-2 circles (skin '#ffd9b3') with hair, on the roof, maybe with a tiny hat in a color.
- wheels.
Passenger (waiting):
- shadow ellipse.
- body: rounded trapezoid (dress color), head circle with hair, arms waving (animated by sin), legs, maybe a suitcase (small rounded rect) or a book.
- A "!" bubble above? Draw a small speech bubble with a train icon or a "🚂"? Canvas emoji works in Chrome (uses system emoji font). Emojis in canvas may render as monochrome or colored depending on the platform; in Chrome on Linux they might render as tofu. Risky — avoid emoji in canvas. Draw simple shapes instead.
HUD: HTML elements updated each frame: passengers picked, riders aboard, score, level, best.
Also draw on-canvas floating score popups when picking up/delivering.
Let me now write the actual code carefully.
```js
const COLS=24, ROWS=16, CELL=32;
const canvas=document.getElementById('game');
const ctx=canvas.getContext('2d');
const W=COLS*CELL, H=ROWS*CELL;
// world helpers
const isWall=(x,y)=> x<0||y<0||x>=COLS||y>=ROWS || (x===0||x===COLS-1||y===0||y===ROWS-1);
```
Wait: I want the outer ring to be walls. Let me define wall cells: x===0||x===COLS-1||y===0||y===ROWS-1. And the platforms are y===1 and y===ROWS-2 (14). The interior playable area is x 1..22, y 2..13, plus the platform rows 1 and 14.
So `isWall(x,y)` = outer ring. Then the train can move into the platform rows (1 and 14) but not the outer ring.
Obstacles: set of cells inside interior (y 2..13, x 1..22) excluding the express lane row 5.
Wait — should the express lane be row 5? The player can also travel on row 5; the express occupies 3 cells there at times. Fine.
Express: {x: float cell position, dir: 1|-1}. Let's implement it with a fractional position for smooth motion: express.x in cell units (float), moving at speed 0.9 cells/sec? Let's make it move 1 cell per (interval*4) ticks. Simpler: keep an integer cell position and move every K ticks, with interpolation for drawing.
Express occupies cells: [ex, ex+1, ex+2]? If the express is moving right, the head is at the rightmost. Let's store expressHead (float) and direction. Occupied cells: floor(head), floor(head)-1, floor(head)-2.
Express bounces between x=1 and x=COLS-2.
Draw: red locomotive + 2 coaches, with motion blur lines.
Now, scoring & level:
- pickUp: score += 10; riders.push(color); passengers.remove; snake grows by 1 (append a cell at the tail end); sparkles.
- deliver: if snake.length > 2 (i.e., more than loco + 1 wagon): pop the front wagon → remove the second element of the snake array? Careful: the snake array order: index0 = head, index1 = next car, ... last = tail. If we remove the front wagon (index 1), we must remove snake[1] and shift. Then riders.shift(). But this changes the train geometry: the remaining cars keep their positions, so the gap is filled naturally (the train just gets shorter). That's fine visually (the remaining cars still occupy their cells; the connection to the head is still adjacent since snake[1] was adjacent to head and snake[2] is also adjacent to snake[1]... is snake[2] adjacent to head? No. Removing snake[1] leaves the head adjacent to... nothing. The train would have a disconnected link!
Hmm. In snake, the body is a chain; removing a middle car breaks adjacency with the head.
Alternative: remove the LAST car (tail) when delivering — that preserves the chain. So delivering removes the tail car and its rider. Physically: the last car is uncoupled at the platform. That's a bit odd but visually fine, and the train stays connected.
Alternatively, keep the train length and don't shrink; the rider leaves the wagon but the wagon stays (empty). Then no geometry issue at all! Delivery = +25 and the passenger leaves the wagon (wagon stays but empty, no rider head). But then the train never shrinks → classic snake growth only. That's simpler and safe.
But then "deliver" has no cost/benefit other than points; the train keeps growing → eventually dies. Which is classic snake. Hmm, but the delivery mechanic then has no strategic depth.
Alternative that keeps the chain valid: deliver removes the tail car (the last one). Visually: the tail car is dropped at the platform — like the train is being cut off. Odd but understandable? Actually, think of it as: the train arrives at the depot and the rear cars are left behind... no, then they'd be left on the track.
Better idea: Delivery removes the LAST car and it "drives away"/fades out. I think it's acceptable: "drop off the last carriage at the depot". Many games do similar (e.g., snake eating and shrinking). Let's just do it and describe it in the HUD as "Deliver riders at PLATFORM 2 to earn +25 and cut a carriage".
Hmm, another option: delivery keeps length but marks the wagon as empty (no rider head). Then the visual of riders aboard shows fewer passengers while the train stays long. The strategic choice: deliver for points (no strategic tradeoff) vs. keep riders for... nothing. So no reason not to deliver. That makes it just bonus points with a journey — fine, it's a light bonus. But I like the shrink mechanic for balance.
I'll implement: delivering pops the LAST car (tail) and the last rider. Requires snake.length >= 3 to keep at least loco + 1 car. When delivering, the tail car animates away (fades + slides) for visual polish.
Also add: when the tail is removed, the head keeps moving so the chain stays connected.
Now, when the train grows (pickup), we add a cell at the tail — standard snake growth (don't move the tail this tick).
Let me write the tick:
```js
function tick(){
if(mode!=='play' && mode!=='demo') return;
// compute direction (AI in demo)
if(mode==='demo') dir = aiDirection();
// move
const head = snake[0];
const nh = {x:head.x+dir.x, y:head.y+dir.y};
// collision
if(isWall(nh.x,nh.y) || obstacles.has(key(nh)) || hitsExpress(nh) || hitsBody(nh)) => crash
...
}
```
hitsBody: check against snake cells. Note: when not growing, the tail cell will be vacated — standard snake allows moving into the tail cell. I'll implement: check body excluding the last element if not growing.
Growth: if the new head cell contains a waiting passenger → grow.
Wagon colors: each car has a color. Let's store the snake as objects {x,y,color} where color is for wagons; head color fixed. When growing, the new tail car gets a color from the palette and a rider color. Actually simpler: store `cars` array where each entry {x,y,kind:'loco'|'wagon', color, rider}. The riders list corresponds to wagons (index in snake: index>=1).
Let me define snake entries: {x,y,color,rider} where index 0 = loco (color = null, rider=null), indices 1.. = wagons with color from palette and rider = {color, skin} or null.
On pickup: append {x: tail.x, y: tail.y, color: nextPalette, rider: {hair: color, skin}} and grow=true (don't remove the tail this tick).
Wait: in snake, growth means the tail stays. Implementation: compute newHead; unshift; if(!grow) pop. So on growth, the old tail stays and we don't pop — but we need to add a car with the color for the new tail cell. Since the tail cell stays at the same place and becomes a new car, the new car is at the old tail position with the NEW color, and the previous tail becomes... hmm.
Standard snake: body = [head, ... , tail]. After moving: newBody = [newHead, ...body]; if growing, keep the last element; else remove it. The colors shift with the cells since each cell carries its color (the color moves with the car). Wait — in a train, the cars are the entities, and the head is the loco. When the train moves forward, each car moves to the cell the car ahead was in. So the cell contents shift: newBody[0]=newHead(loco), newBody[1]=old body[0] position with body[1]'s car identity...
Let me think: cars array = [loco, wagon1, wagon2, ...] with positions p0 (loco), p1 (wagon1), etc. After moving forward, loco → newHead; wagon1 → p0 (old loco position); wagon2 → p1; ... So new positions array = [newHead, p0, p1, ..., p_{n-1}] and if growing we add a new wagon at p_{n-1}... but then p_{n-1} is taken by the previous last wagon. Ugh.
Correct approach: positions after the move = [newHead, p0, p1, ..., p_{n-2}] (n cars → n positions), and if growing, add a new car at the end with position p_{n-2}? No...
Let's carefully do it: Before the move, n cars at positions p0..p_{n-1}. After moving forward one cell (all cars move to the cell ahead), the loco is at newHead (= p0 + dir). Wagon1 must be at p0. Wagon2 at p1. ... Wagon_k at p_{k-1}. So the new positions for the existing n cars are [newHead, p0, p1, ..., p_{n-2}]. The position p_{n-1} (old tail cell) is now free.
If growing, we add a new wagon at the old tail cell p_{n-1} (so it appears as a new car coupled at the back). So new positions = [newHead, p0, ..., p_{n-2}, p_{n-1}] with n+1 cars.
If not growing, the last car is removed: positions = [newHead, p0, ..., p_{n-2}] with n cars (the old wagon_{n-1} disappears).
So implement with a positions array and separate cars array (colors/riders), where cars[i] corresponds to positions[i]. Easier: keep snake as array of objects each with x,y,color,rider, in order head→tail. On move:
```
const newSnake = [{x:nh.x,y:nh.y,color:LOCO,rider:null}, ...snake.slice(0, -1)]; // wait, slice(0,-1) drops the old tail; the cars identities shift!
```
Hmm: snake.slice(0,-1) gives [old head, wagon1, ..., wagon_{n-2}] which after moving become [wagon1 at old head pos...]. No — positions shift: wagon1 should move to old head's position but keep wagon1's identity (color). So:
newSnake[0] = loco at newHead.
newSnake[1] = wagon1 with position = old p0.
newSnake[2] = wagon2 with position = old p1.
...
newSnake[k] = wagon_k with position = old p_{k-1}.
So newSnake = [loco(newHead), ...for i in 1..n-1: {x: snake[i-1].x, y: snake[i-1].y, color: snake[i].color, rider: snake[i].rider}].
And if growing: push {x: old p_{n-1}, y: old p_{n-1}, color: newColor, rider}. Wait, the old tail position p_{n-1}: after the move, the last existing car (wagon_{n-1}) is at p_{n-2}. The new car goes at p_{n-1}. Yes.
Hmm, careful: in the loop above, newSnake has n entries (indices 0..n-1), with newSnake[n-1] = wagon_{n-1} at p_{n-2}. The freed cell is p_{n-1}. So growing appends the new wagon at p_{n-1}. Good.
If not growing: the old wagon_{n-1} simply disappears (uncoupled at the back) — positions = n entries as computed.
Also the direction of each car for drawing: car i's orientation = from its position toward the position of the car ahead (i.e., newSnake[i] → newSnake[i-1]), except the loco which uses `dir`.
That works.
Delivery (removing the tail car): pop the last snake entry (and its rider). The chain stays connected.
Now the AI needs the snake array in the same format. Fine.
Collision with body: newHead collides if it equals any snake cell that remains after the move. Snake body cells after the move (excluding the loco) = positions of newSnake[1..]. Also, the new head could be at the position of the old tail (p_{n-1}) when growing — but that cell is now occupied by the new wagon! Careful: when growing, the new head can't be at p_{n-1} because that cell now holds the new wagon. Let's do the collision check before computing the new snake:
Collision check: newHead must not be in the set of cells occupied by cars that remain: that's snake[0..n-2] positions (all but the old tail), because the old tail's cell becomes the new wagon if growing, or is vacated if not growing... wait, if not growing the old tail cell is vacated (the tail car disappears). Hmm, but in classic snake, the tail moves forward — the tail car is at p_{n-1} and after moving, the tail car (if kept) would be at p_{n-2}; the cell p_{n-1} becomes free.
Hold on, I think I mis-assigned. Let's re-derive with a concrete example. Snake of 3 cars: loco at (5,5), w1 at (4,5), w2 at (3,5), dir=+x. After the move: loco at (6,5), w1 at (5,5), w2 at (4,5). Cell (3,5) is free. Yes, p_{n-1} = (3,5) is freed. Correct as I had.
So the new head may move into the old tail cell (3,5) — but if growing, the new wagon would be placed at (3,5) — conflict! So when growing, the new head cannot be at the old tail cell. Handle: if growing and newHead == old tail position → that's a conflict. Rare edge case; I'll just disallow: check collision against all snake cells including the tail when growing. Actually in classic snake growth happens when eating food; the food can't be under the tail (food doesn't spawn under the snake). Our food (passengers) spawns on the platform, not under the train (we avoid spawning on the train). So the edge case: the head moves onto the tail cell and simultaneously eats? The passenger would have to be at the tail cell — impossible since passengers spawn away from the train. But the train might pass over... we remove a passenger when the head reaches it. The tail cell could have a passenger? No, passengers are removed when collected and never spawn under the train. But the tail cell could coincide with a passenger's cell if the passenger spawned there and the train's tail later moved onto it... The head collects it when it reaches it. The tail cell might be on a passenger's cell if the train drove over and... no, when the head passes over the passenger cell, it's collected. So the tail can only be on a passenger cell if the passenger spawned there after the train was there — we avoid spawning passengers on train cells. OK.
So the collision rule: newHead collides if it equals any cell in snake[0..n-2] (i.e., all but the old tail), or a wall/obstacle/express. If it equals the old tail cell, that's allowed (classic), and then the tail cell is vacated. But careful: if growing, the new car goes to the old tail cell → conflict. To be safe: if growing and newHead == oldTail → treat as collision (extremely rare). Or place the new car elsewhere? Just treat as collision/crash? Better: if growing, don't allow entering the tail cell — instead, no growth. Simplest: check collision with all snake cells when growing.
Alright.
Now the AI direction function.
```js
function aiDirection(){
const head = snake[0];
// build blocked map
// BFS from head
const dist = bfs(head.x, head.y, blockedForAI);
// choose target
...
}
```
blockedForAI: walls, obstacles, express occupied cells, and the snake body excluding the head and excluding the tail (if not growing — but the AI doesn't know if it will grow; treat the tail as free, since the AI might eat. Standard: treat the tail cell as free). Also treat the snake cells as blocked for the path (can't pass through the body).
Hmm, but for the AI's own body, the cells of snake[1..n-1] are blocked. Then BFS from the head over free cells gives distances.
Target selection:
- If riders.length > 0 && (snake.length >= 7 || no reachable passenger): target = delivery platform cell in row 14 nearest to the head (by BFS distance, tie-break by |x - head.x|).
- Else: nearest reachable passenger (min dist). If none reachable → if riders>0 target the depot, else target = a "wander" cell (the free cell with the largest distance/most open space) or just pick the safe direction maximizing space.
Then path = reconstruct from BFS parents. Take the first direction. Then apply the safety check: for each candidate direction (path dir first, then the other 3 non-reverse dirs), simulate and compute the reachable area from the new head; if the area >= snake.length + 3 (or the new head can reach the target), accept. If none pass, take the path dir anyway (or the max-area one).
Let me implement `simulateAndCount(nx,ny)`: BFS from (nx,ny) over cells blocked by walls/obstacles/express and the snake body after the move (snake[1..n-1] positions shifted). Simpler approximation: blocked = the snake's cells (snake[0..n-2] positions after move) — approximated by snake[1..n-1] + the new head. I'll build a Set of occupied cells after the move: newHead + snake[0..n-2] positions (the same set as the body except the tail). Plus walls/obstacles/express.
Then flood-fill from newHead over cells not in that set, and count.
Also: the new head must be adjacent to the current head obviously (one step).
Also the AI must not move into the express.
Let me write the BFS on a flat array for speed: use a typed array of size COLS*ROWS with -1 for unvisited.
bfs(startX,startY, blockedSet) returns {dist: Int16Array, prev: Int16Array}.
Since the grid is small (384 cells), it's cheap.
Express handling: the express occupies 3 cells; treat them as blocked.
Now the demo also needs to handle the "deliver" step: the AI should ride along the platform. When the head reaches row 14, one rider is delivered per tick. The AI's target is a delivery cell; it will move along the platform and deliver riders. Good.
Also, note: dropping riders reduces the snake length, so the AI must be careful.
Also the demo needs to keep going indefinitely: on crash, reset after a delay and continue. In demo mode, no game-over overlay; instead show a "CRASH! Demo restarting..." toast.
Let me now write the render function.
```js
function render(){
drawYard();
drawPlatforms();
drawExpress();
drawTrack();
drawPassengers();
drawTrain();
drawParticles();
drawFloaters();
}
```
Draw order: yard → obstacles → platforms → express → track → passengers → train → particles.
Hmm, the passengers stand on the platform; the train overlaps them when collecting. Fine.
Details for the yard: draw a base fill, then speckles (precomputed random array for consistency), then faint horizontal rail lines per row, then the express lane highlighted.
Let me precompute speckles: an array of {x,y,r,color}.
Faint tracks: for rows 2..13 (excluding 5?), draw at rowY = row*CELL + CELL/2 a line from x=1*CELL to (COLS-1)*CELL with color rgba(255,255,255,0.05), width 2, plus a dashed darker line? Simple: draw a 6px dark line + 2px lighter line at alpha 0.12. And sleepers: short perpendicular ticks every 10px at alpha 0.07. That's a lot of draw calls but only 12 rows × ~70 ticks = 840 ops — fine at 60fps? It's fine, but we could pre-render the static background to an offscreen canvas and blit it. Let's pre-render the background (yard + platforms + obstacles + walls) to an offscreen canvas once (and re-render when obstacles change). Then each frame: drawImage(bg), then dynamic stuff.
Let's do that: `bgCanvas` = document.createElement('canvas'); renderBackground() draws everything static; called on init and when obstacles change (level up).
Then the frame: ctx.drawImage(bgCanvas,0,0); then express, track, passengers, train, particles.
Nice and fast.
Platform drawing details (in bg):
North platform row y=1 (cells from x=0..23 but the outer ring x=0,23 are walls): draw the platform tiles for x=1..22 at row 1. Then the station building at row 0 spans the full width.
Let me structure the bg drawing:
1. Fill the entire canvas with the ground color (ballast).
2. Draw the interior yard region: the whole canvas is ground except the outer ring which is wall.
3. Outer ring:
- Top row (y=0): station building. Draw a brick facade spanning the full width: dark red roof band at the very top, brick body, windows every 3 cells, a clock at the center, a sign board with "CENTRAL STATION" text.
- Bottom row (y=ROWS-1): hedge/fence: green bushes along the bottom.
- Left column (x=0, y=1..14): hedge (green) to continue the fence, or a row of signal posts. Let's draw hedges.
- Right column (x=COLS-1, y=1..14): also hedge. Hmm, maybe the right side could be a tunnel mouth / water tower. Keep hedges for symmetry.
4. Platform rows:
- Row 1 (y=1): light tile strip (x=1..22). Add a yellow safety line along the bottom edge of the row (i.e., at y = 2*CELL - 4). Add benches and a sign "▲ PICK-UP PLATFORM" — draw text in the cell row? The row is only 32px tall. Put the sign on the station building row: "PLATFORM 1 · PICK UP" in a sign board at row 0 with a small arrow down.
- Row 14: tiles with a blue safety line, and a sign board at row 15: "PLATFORM 2 · DELIVER". But row 15 is the hedge/fence. Let's make the bottom row (15) a depot building? Hmm. Let's do: row 15 = a low brick wall with a sign board "DELIVERY DEPOT" mounted, and lamps. That reads better than a hedge for the bottom. And the sides remain hedges.
Actually symmetry isn't required; a station at the top and a depot at the bottom is thematic. Let's do:
- Top row (0): station building with a sign "CENTRAL STATION" and a clock.
- Row 1: Platform 1 (pick-up), tiles with yellow line; benches; a "PICK UP" sign hanging; passengers wait here.
- Row 14: Platform 2 (deliver), tiles with a green/blue line; a departure board; a little trolley.
- Row 15: depot wall / fence with the sign "DEPOT — DELIVER HERE" and a shed roof? Let's draw a row of dark brick with lamps on top.
- Left/right columns: hedges (green bushes) so the train can't leave.
5. Obstacles in the interior: water towers, signal boxes, cargo crates. Draw as icons with a shadow.
6. Express lane (row 5): draw a distinct track: a brighter rail line across, with a yellow-black hazard stripe on the edges? Let's draw a slightly lighter ballast strip and a bright rail line with "express" arrows. Also maybe small "FAST LINE" markers.
Let me also add background decorative buildings? No, keep it clean.
Now, the train rendering with rotation: each car is drawn in its own transform.
```js
function drawCar(entry, angle, isLoco){
ctx.save();
ctx.translate(entry.x*CELL+CELL/2, entry.y*CELL+CELL/2);
ctx.rotate(angle);
// draw along +x axis, with the front at +x
ctx.restore();
}
```
The angle for car i (i>=1) = atan2( (snake[i-1].y - snake[i].y), (snake[i-1].x - snake[i].x) ). For the loco, the angle = atan2(dir.y, dir.x).
Car body length: 0.94*CELL = 30px, height 0.68*CELL ≈ 22px. With CELL=32, the car is 30×22 — reasonable.
Since the cell is 32 and the car is 30 long, adjacent cars will overlap slightly when in a straight line (30px long each, 32px spacing → 2px gap). Good.
Loco drawing (local coords, origin at center, x from -15 to +15, y from -11 to +11):
- shadow: ellipse (0, 2, 15, 10) rgba(0,0,0,0.25)
- chassis: roundRect(-15, -10, 30, 20, 4) fill '#1b1f27'
- boiler: roundRect(-4, -9, 19, 18, 6) fill '#d94b4b' — front at +15.
- boiler stripe: roundRect(6,-9,3,18) fill '#ffe8b0'
- cab: roundRect(-14, -10, 11, 20, 4) fill '#2f4a72'; windows: two small rects '#ffd97d'
- cab roof: roundRect(-15,-11,12,22,4) fill '#3a5b8c'? Let's keep simpler.
- front plate: ellipse at (13,0) rx 4 ry 9 fill '#b93b3b'
- headlight: circle at (12,0) r 3 fill '#ffe680' + glow (shadowBlur).
- chimney: circle at (10,0)? That's on the front. Hmm, in top-down the chimney is on top of the boiler, so it appears as a circle on the boiler, offset to one side maybe. Let's place the chimney circle at (7,-5) r=3.5 fill '#3a3f4b' with a lighter ring. Steam puffs emit from the chimney's world position (transform the local point).
- face: eyes at (11,-4) and (11,4)? A face on the front: two eyes at (10,-4.5) and (10,4.5) — but the front plate is narrow. Let's instead put the face on the boiler's front half: eyes at (8,-4) and (8,4), r=2.2 white with dark pupils, and a smile arc under the front plate. Hmm, in top-down view a face on the boiler facing forward looks like eyes on the top surface — cartoonish and cute. Let's do: eyes at (7,-5) and (7,5) (i.e., the left/right sides of the boiler, which when facing right appear above and below), pupils offset forward. And a smile arc near the front plate at (12,0) curving.
Actually when the train faces right, the "face" should be at the front. Let's draw: two eyes at (9,-5),(9,5) and a smile arc from (11,-3) to (11,3) bulging forward. Combined with the headlight at the very front, it reads as a cute face. Good.
- wheels: 4 circles r=2.8 at (-10,-10),(10,-10),(-10,10),(10,10) fill '#15181e' with a hub '#4a5260'. Since the body is 20 tall (±10), the wheels at ±10 stick out slightly. Good.
Wagon drawing:
- chassis roundRect(-15,-10,30,20,3) '#1b1f27'
- body roundRect(-14,-9,28,18,5) color
- roof highlight: roundRect(-12,-7,24,14,4) lighter shade (use a lighter tint function)
- stripe: a vertical band
- wheels: 4 circles like the loco.
- rider heads: if entry.rider, draw 1-2 head circles on the roof: circle(0,0,4.5) skin + hair arc + maybe a hat. Place 2 heads at (-5,0) and (5,0) if the wagon has 2 riders; each wagon carries exactly 1 rider (since 1 rider per pickup = 1 car). So 1 head, maybe with a hat.
Hmm: 1 rider per wagon → riders count = wagons count. Fine.
Passenger (waiting) drawing, cell-centered:
- shadow ellipse at (0, 10)
- legs: two small rects
- body: a rounded trapezoid or a simple dress shape: path from (-5,10) to (5,10) to (3,0) to (-3,0) close → fill color.
- head: circle at (0,-6) r 5.5 fill skin; hair: arc on top; eyes: 2 dots.
- arms: waving — draw a line from the shoulder to a hand position that oscillates. Left arm down, right arm waving up.
- a "waiting" indicator: a small speech bubble above with a tiny train shape? Or a bobbing exclamation mark. Let's draw a small rounded bubble with a tiny "🚂"? No emoji. Draw a tiny train silhouette (rect + circle). Or simply a small circle with an arrow. Let's draw a small white bubble (rounded rect) at the top with a tiny dark train icon: a mini loco shape (rect + chimney). Cute.
Also, when a passenger is near the train's head, no change needed.
Particle system: {x,y,vx,vy,life,maxLife,color,size,type}. Types: 'spark' (circle), 'steam' (circle, growing, fading), 'heart' (heart shape) on delivery.
Floating text: {x,y,text,life,color} — drawn with ctx.font.
Steam: emitted from the locomotive's chimney each frame (only in play/demo), drifting backwards with a slight upward drift, growing and fading. Cute.
Now the HUD in HTML:
```html
<div class="hud">
<div class="stat"><span class="label">Passengers</span><span class="value" id="score">0</span></div>
<div class="stat"><span class="label">Riders</span><span class="value" id="riders">0</span></div>
<div class="stat"><span class="label">Train</span><span class="value" id="length">1</span></div>
<div class="stat"><span class="label">Level</span><span class="value" id="level">1</span></div>
<div class="stat"><span class="label">Best</span><span class="value" id="best">0</span></div>
</div>
```
Best stored in a JS variable (no localStorage).
Also a status/hint line: "Pick up passengers on Platform 1 (top) · Deliver on Platform 2 (bottom) for +25".
Overlay:
```html
<div id="overlay" class="overlay">
<div class="panel">
<h1>PLATFORM EXPRESS</h1>
<p>...</p>
<div class="cta">CLICK OR PRESS ANY KEY TO START</div>
</div>
</div>
```
The overlay is semi-transparent over the canvas so the demo is visible behind it. "Games must start in a self-playing demo mode behind a 'Click or press a key to start' message". So the overlay should not fully hide the canvas. Use rgba(10,12,20,0.55) backdrop with a panel card. The demo plays behind.
On start (click/key), hide the overlay, reset the game in play mode.
Game over: show the overlay with "CRASHED!" + score + "Click or press R to restart". Then a click restarts.
Keyboard: arrows/WASD. Prevent default scrolling for arrows/space.
Touch: swipe on the canvas; plus an on-screen D-pad for small screens.
Also the canvas click should start the game (any click anywhere in the wrapper starts).
Now, the demo AI: it plays in the background even before the user interacts. The overlay is displayed on top. Good.
Let me write the code now, being careful about correctness.
```js
// ---------- setup
const COLS = 24, ROWS = 16, CELL = 32;
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const W = COLS*CELL, H = ROWS*CELL;
canvas.width = W; canvas.height = H;
const key = (x,y)=> y*COLS+x;
const WALL = (x,y)=> x===0||y===0||x===COLS-1||y===ROWS-1;
const PLAT_PICK = 1; // north platform row
const PLAT_DELIVER = ROWS-2; // 14
const EXPRESS_ROW = 5;
```
Obstacles as a Map key→type.
Levels: obstacles added progressively.
Let me define the initial obstacles:
```js
const baseObstacles = [
{x:5,y:8,type:'tower'},
{x:18,y:3,type:'crate'},
{x:9,y:11,type:'box'},
{x:15,y:9,type:'tower'},
{x:20,y:12,type:'crate'},
{x:7,y:4,type:'box'},
];
```
Ensure none are on row 5 (express lane) and none are in the spawn area (x 2..6, y 8..10)? The spawn head at (3,9) facing east: cells (3,9),(4,9),(5,9) must be free. (5,8) tower is fine (not in the path). Let me double check (9,11) 'box' - fine.
Hmm, also make sure obstacles don't block the platform rows — they're all in interior rows.
Wait, interior rows are 2..13 and the express row is 5. The obstacles listed have y=3,4,8,9,11,12 → ok.
Express lane row 5: no obstacles.
Additional obstacles per level: pick random interior cells (y 2..13 except 5, x 1..22) not adjacent to the spawn, not on the train, not too close to the platform rows? Fine.
Actually to avoid the AI getting boxed in, cap the total obstacles at 10 and keep a minimum distance of 3 from the train head at the time of adding. Also ensure the added obstacle isn't in the same row as the head at a nearby x... Let's simply require the cell to be at least 4 cells away (Manhattan) from the head and not in the express row.
Now let's write the game state:
```js
let snake, dir, riders, waiting, score, level, best=0, tickCount, mode, overlayState;
let express = {x: 2, dir: 1, speedTick: 0, t: 0};
```
Express motion: let's store a float position `ex` (in cells) and a direction. Update per frame with dt: ex += exDir * speed * dt (speed in cells/sec, e.g., 0.8). Bounce at x=1 and x=COLS-2 (head stays inside). The occupied cells: floor(ex), floor(ex)-1, floor(ex)-2 (for a right-moving train) — actually the express train's head is at ex and the tail trails behind by 2 cells. If moving right, the tail is at ex-2. If moving left, the head is at ex and the tail is at ex+2? Let's define ex as the head cell position (float) and the direction of travel; the tail is behind: if dir=+1, tail at ex-1, ex-2; if dir=-1, tail at ex+1, ex+2.
Hmm, but if the head is at ex and moving left, the front is to the left, and the body extends to the right. Yes: tail cells = ex + dirOpposite*k? Let's define: bodyOffset = -dir (behind is opposite to travel direction). So cells: ex, ex - dir*1, ex - dir*2.
For drawing, we draw the loco at the head and coaches behind.
Collision with the express: check if the new head cell equals any of the 3 express cells (rounded).
Also the express passes over obstacles? Its lane (row 5) has no obstacles. Good.
Express drawing: a red streamlined loco + 2 dark coaches, with speed lines, drawn on row 5.
Now let's write the AI.
```js
function blockedForPath(){
const b = new Uint8Array(COLS*ROWS);
for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++){ if(WALL(x,y)) b[key(x,y)]=1; }
for(const k of obstacles.keys()) b[k]=1;
// express cells
for(const c of expressCells()) b[key(c.x,c.y)]=1;
// snake body (excluding head and tail)
for(let i=1;i<snake.length-1;i++) b[key(snake[i].x,snake[i].y)]=1;
return b;
}
```
Hmm, we should exclude the tail so the AI can path into the tail's cell (classic). But also exclude the head's own cell (the BFS start).
BFS:
```js
function bfs(startX,startY,blocked){
const dist = new Int16Array(COLS*ROWS).fill(-1);
const prev = new Int16Array(COLS*ROWS).fill(-1);
const q = new Int32Array(COLS*ROWS);
let head=0,tail=0;
const s=key(startX,startY);
dist[s]=0; q[tail++]=s;
while(head<tail){
const cur=q[head++];
const cx=cur%COLS, cy=(cur/COLS)|0;
const d=dist[cur]+1;
for(const [dx,dy] of DIRS){
const nx=cx+dx, ny=cy+dy;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
const k=key(nx,ny);
if(blocked[k]||dist[k]!==-1) continue;
dist[k]=d; prev[k]=cur; q[tail++]=k;
}
}
return {dist,prev};
}
```
Note: the start cell might be "blocked" by nothing (the head isn't in the blocked set) — but the snake body cells are blocked; the head cell is excluded from the body set. Good.
Reconstruct the path: from the target back via prev to the start, then reverse → the first step gives the direction.
Target selection:
```js
function aiChooseDir(){
const blocked = blockedForPath();
const {dist,prev} = bfs(snake[0].x, snake[0].y, blocked);
// candidates
let best=null, bestScore=Infinity;
const carrying = riders.length>0;
for(const p of waiting){
const d=dist[key(p.x,p.y)];
if(d<0) continue;
const s = d + (carrying && snake.length<6 ? 0 : 0);
if(s<bestScore){bestScore=s;best={x:p.x,y:p.y,type:'pick'};}
}
if(carrying && (snake.length>=6 || !best)){
// target the delivery platform
let bd=Infinity,bt=null;
for(let x=1;x<COLS-1;x++){
const d=dist[key(x,PLAT_DELIVER)];
if(d>=0 && d<bd){bd=d;bt={x,y:PLAT_DELIVER};}
}
// prefer to head to the platform at the x closest to the current head's x, but nearest by BFS is fine
if(bt && (!best || bd < bestScore*0.9)) { best=bt; }
}
...
}
```
Hmm, the priority logic: if carrying and length>=6 → deliver. If carrying and length<6 → pick up more, but if no passenger is reachable → deliver.
Let me restructure:
```
if (carrying && snake.length >= 7) target = depot;
else target = nearest reachable waiting passenger;
if (!target && carrying) target = depot;
if (!target) target = wander (open area);
```
The depot target: choose the delivery platform cell that minimizes BFS distance; tie-break by minimizing |x - head.x|? Let's just pick the min distance cell.
Actually, when the head is already on the delivery platform row, we want it to keep moving along the row to deliver more riders. The BFS distance to the nearest delivery cell from a cell on row 14 would be 0 (the cell itself) → the target is the current cell → the path is empty → no direction. Need a special case: if the head is on the delivery platform, continue moving along the platform row toward the direction with the most space (or just continue in the current direction). Let's handle: if the head is on the delivery row and riders>0: choose the direction along the row (±x) that is safe and continues; prefer the current dir if safe.
Simplify: when the head is on the delivery platform row, target = the platform cell farthest along the current direction? Let's define: if the head's y === PLAT_DELIVER, target = a delivery cell at x = min(COLS-2, head.x + 4*dir.x) if valid else the other direction. Then the path leads along the row. Good.
Similarly, when the head is on the pick-up platform row and there's a passenger further along the row, the BFS handles it.
The "wander" fallback: if no target, choose the direction that maximizes the reachable area (safety) and moves toward open space. I'll implement: try each non-reverse direction, compute the reachable count, pick the max.
Safety check for the chosen direction:
```js
function simulateArea(nx,ny){
// blocked after moving: walls, obstacles, express cells, snake body cells snake[0..len-2] (positions)
// plus newHead
}
```
After the move, the body occupies: newHead + old snake[0..n-2] positions (since the old tail cell is freed, or occupied by a new car when growing). Approximation: blocked = snake[0..n-2] positions ∪ {newHead}. Note snake[0] is the old head position which becomes wagon1's position — it is occupied. Yes.
So the area BFS from (nx,ny) counting cells not in (blocked ∪ obstacles ∪ walls ∪ express). If area >= snake.length (the number of cars) → safe.
Then choose:
```js
const candidates = [];
if (pathDir) candidates.push(pathDir);
for (const d of DIRS) if (d !== reverse && !candidates.includes(d)) candidates.push(d);
// order the rest by resulting area desc
```
For each candidate: check that the new head is valid (not a wall/obstacle/body/express) — body check: the new head must not be in snake[0..n-2] (except when not growing and the cell is the old tail → allowed). Let's just check against all snake cells except the tail, to be safe.
Then compute the area; accept if area >= needed (snake.length + 2?). Accept the first candidate with area >= needed. Otherwise pick the candidate with the max area.
OK. Now the tick and the game flow.
```js
function update(dt){
if(mode==='play'||mode==='demo'){ ... }
}
```
Game loop with an accumulator:
```js
let acc=0, last=performance.now();
function frame(now){
const dt=Math.min(100, now-last); last=now;
acc+=dt;
const interval = tickInterval();
while(acc>=interval){ acc-=interval; step(); }
updateDynamic(dt); // express motion, particles, animations
render();
requestAnimationFrame(frame);
}
```
The express should move smoothly with dt, but its collision cells change continuously. That's fine.
Hmm: the express moving at 0.8 cells/sec means it moves 1 cell every 1.25s. Good.
Let's define the express update with dt: ex += expressDir * 0.85 * dt/1000. Bounce: if ex > COLS-2.2 → expressDir=-1; if ex < 2.2 → expressDir=1.
Express cells: head at floor(ex) roughly. Let's compute the occupied cells as: for k in 0..2: cell = round(ex - expressDir*k) → hmm, when moving right, the tail is at ex-1, ex-2. Use Math.round(ex - expressDir*k).
Collision check with the player's head: if the new head cell equals any express cell → crash. But the express moves in between ticks; a more accurate check would use a bounding box. Since the player's head moves in discrete cell steps and the express moves slowly, the cell-based check is adequate, but the express might pass "through" the player's head between ticks without a cell match. Let's also check the distance: if the player's head cell is within the express's occupied cell list → crash. Since the express moves < 1 cell per tick interval, the cell list updates each frame but the collision is only checked on ticks. Let's also perform an express-vs-train check each frame (in updateDynamic) to be more accurate: compute the express's occupied cells each frame and check the player's body cells (excluding the head? no, including) → if any overlap → crash. Do this check every frame for fairness. But then the crash might happen mid-way between ticks, which is fine — crash immediately.
Let's implement `checkExpressCollision()` called each frame: for each snake cell (all cars), check if it equals an express cell (or is adjacent to the express's body cells, since the express is a long train — it occupies 3 cells). Use exact cell equality — the express's cell list is derived from the float position, so it's approximate. Good enough.
Hmm, one problem: the express occupies 3 cells in row 5, and the player's train may cross row 5 vertically. The check happens each frame, so a crossing will likely be caught if the player's head cell coincides with an express cell at some frame. Since the player's train moves 1 cell per tick (~150ms) and the express occupies 3 cells, the head will be in row 5 for at least one tick → it will be caught. Good.
But there's a subtlety: the player's head could pass through the express's lane at a cell that's not exactly one of the express's 3 cells at the moment of the tick — but the per-frame check catches it as the head passes through the row... Actually the head jumps discretely between cells, so the head is only at row 5 for the duration between two ticks. The per-frame check happens every frame (~16ms), so during that ~150ms the head sits at the row-5 cell and the express may or may not be there. If the express is 2 cells away at that time, the player passes safely. It's a fair dodge.
To be safer/fairer, let's make the express's collision check use a slightly wider body (its 3 cells) and also make it visible with a warning. OK.
Now, the score/pickup logic:
```js
function step(){
if(mode==='demo') dir = aiChooseDir();
// clamp the direction to prevent 180° turns: store pendingDir
...
}
```
For the player, I'll use a `nextDir` queue that the player sets, and the step applies it if it's not a reverse.
Pickup: after moving the head, check if there's a waiting passenger at the new head cell → collect.
Delivery: if the new head's y === PLAT_DELIVER and riders.length>0 and snake.length>=3 → deliver the last car: snake.pop(), riders.pop(), score += 25, spawn heart particles + floating "+25".
Wait, careful with the pop: the snake array's last element is the tail car. Popping it is the "uncouple the last carriage". OK.
But hold on: the tail car is at the old tail position which is NOT the head's position, so the delivered passenger "hops off" at the platform... visually the tail car disappears from the track (fades out). I'll add a fade-out animation: keep a "ghost" copy of the popped car that fades. Good.
Pickup: score += 10, riders.push(passenger), add a new wagon car at the tail with a color and the rider; grow.
Level: level = 1 + Math.floor(pickedTotal/4). On level up: speed up, add an obstacle, flash a "LEVEL 2" floater.
Score display: show total score. Also show "Best" (session best).
Game over (crash): mode='over', show the overlay with the score, best, and a restart prompt. In demo mode, crash → show a brief "CRASH — restarting" banner and reset after 1.2s (keeping mode='demo').
Let me implement `reset(mode)`.
Now the start overlay: initially mode='demo' with the overlay showing "CLICK OR PRESS ANY KEY TO START". When the user clicks or presses a key → reset('play'), hide the overlay.
Note: "The iframe only receives keyboard events after the user clicks it" — so we show the prompt. Also handle the click on the document.
Careful: the overlay should be clickable to start. Attach the listener to the document/wrapper.
Let me write the overlay content dynamically for the different states.
Now let's write the drawing code in detail.
Background render (offscreen):
```js
const bg = document.createElement('canvas');
bg.width=W; bg.height=H;
const bctx = bg.getContext('2d');
function renderBackground(){
bctx.clearRect(0,0,W,H);
// ground
bctx.fillStyle='#2c3542'; bctx.fillRect(0,0,W,H);
// speckles
for(const s of speckles){ bctx.fillStyle=s.c; bctx.fillRect(s.x,s.y,s.s,s.s); }
// faint yard tracks for rows 2..13
for(let y=2;y<=13;y++){
if(y===EXPRESS_ROW) continue;
drawFaintTrack(bctx, y);
}
// express lane
drawExpressLane(bctx);
// platforms
drawPickPlatform(bctx);
drawDeliverPlatform(bctx);
// station building (row 0)
drawStation(bctx);
// depot (row 15)
drawDepot(bctx);
// hedges on the sides
drawHedges(bctx);
// obstacles
for(const [k,o] of obstacles) drawObstacle(bctx,o);
}
```
Order: the station building row 0 is drawn over the ground; the platform row 1 next to it.
Let me define the draw functions with nice detail.
drawFaintTrack(ctx,row): y = row*CELL + CELL/2. Draw a 10px dark line (rgba(0,0,0,0.18)) and a 2px light line (rgba(255,255,255,0.07)) plus sleepers: short vertical ticks (1.5px wide, 10px tall) every 9px with rgba(0,0,0,0.12). Might be heavy but it's a one-time render. Fine.
drawExpressLane: a strip of slightly lighter ballast across the full width at row 5: fillRect(0, 5*CELL, W, CELL) with 'rgba(255,255,255,0.04)'; then a brighter rail: two 2px lines at y=5*CELL+12 and y=5*CELL+20? Let's draw a proper track: sleepers + 2 rails like the faint tracks but brighter (rgba(255,220,120,0.25)). Plus small "FAST" chevrons.
Platforms:
drawPickPlatform: for x=1..22, fill the cell rect with '#cdd3dc' (concrete tiles) with grout lines; add a yellow safety stripe at the bottom (y from row bottom-5 to row bottom) '#f2c94c' with darker dashes; add small tile speckles. Then benches: at a few x positions, draw a bench (brown seat) near the top of the row; and a sign hanging from the station? Let's put a sign board at the left end of the row (x=1..3) overlapping upward? Keep the platform clean: draw benches at x=4, 10, 16 on the upper half of the row, and a luggage trolley at x=19.
The passengers will stand on the row; they'll be drawn on top of the platform.
drawDeliverPlatform: tiles '#bfc9d6' with a blue safety stripe '#4d96ff'; a departure board (dark rect with tiny colored lines) at x=2..5 on the upper half; lamps.
drawStation (row 0):
- Fill the row with brick '#7b3b32' from x=0..W, y=0..CELL.
- A roof band at the top 8px: '#4a2622' with a lighter edge.
- Windows: every 3 cells (x=2,5,8,...) a lit window '#ffd97d' 18x14 with a frame, except the center where the clock is.
- A sign board in the center: a rounded rect '#f4e7c1' with the text "CENTRAL STATION" in dark brown, with small "PLATFORM 1 · PICK UP" underneath? The row is 32px tall; the sign can be 22px tall, 200px wide, centered. Text 14px bold. And a small board on the left: "PICK UP ▲" with an arrow pointing down, and on the right: "DELIVER ▼"? The deliver sign should be at the bottom row.
- A clock: circle at the center-left with hands.
Let me do: the station row has the central sign "CENTRAL STATION" and a clock at x=3 cells. Also small lamps.
drawDepot (row 15):
- A brick wall '#5a4038' with a dark cap, and a sign board "DELIVERY DEPOT" in cream with an arrow up, plus lamps and a "shutter door" pattern (vertical lines).
drawHedges (left/right columns for rows 1..14): green bushes: draw rounded blobs in '#2f6b45' with lighter '#3f8a58' highlights, spaced every CELL. Actually the platform rows at the ends: x=0 and x=23 columns for rows 1 and 14 are also hedge. OK.
Obstacles: drawObstacle:
- 'tower' (water tower): a grey cylinder: circle base + a tall tank (ellipse + rect) with a ladder.
- 'crate': a brown crate with cross planks.
- 'box' (signal box): a small red/white striped barrier post with a light.
Draw a shadow ellipse first.
Now the dynamic drawing.
drawExpress: compute the head float position; draw the loco + 2 coaches at row 5, oriented horizontally, with the direction flipped when moving left. Add motion lines behind.
drawTrack (under the train): for i in 1..snake.length-1: draw a segment from snake[i] to snake[i-1]. Draw as a thick dark line (8px) + a lighter rail line (2.5px). Also draw sleepers: small perpendicular ticks every 6px along the segment.
Simplify: for each segment, compute the angle, draw a rounded line. Then sleepers: iterate along the segment at 6px intervals, drawing a perpendicular line of length 8px in a slightly lighter color.
This gives a nice continuous track following the train.
drawPassengers: for each waiting passenger: bobbing animation (sin(t*3 + phase)), waving arm.
drawTrain: for each car, compute the angle and draw.
Particles & floaters.
Let me now write all the code concretely. I'll write it carefully and completely.
Also, the length of the code is substantial but let's go.
One more consideration: performance of drawing sleepers per segment every frame — the train can be long (say 20 segments × ~5 ticks each = 100 line ops). Fine.
Let me write the code.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>Platform Express — Train Snake</title>
<style>
:root{ --bg1:#0d1020; --bg2:#1a2038; --panel:#171c2e; --ink:#e8ecf7; --muted:#9aa4bf; --accent:#ffd166; --accent2:#4ecdc4;}
*{box-sizing:border-box;}
html,body{margin:0;height:100%;}
body{
background: radial-gradient(1200px 700px at 50% -10%, #263055 0%, var(--bg1) 60%),
linear-gradient(180deg, var(--bg2), var(--bg1));
color:var(--ink);
font-family: 'Trebuchet MS', 'Segoe UI', system-ui, sans-serif;
display:flex; align-items:center; justify-content:center;
min-height:100vh; padding:12px; overflow-x:hidden;
}
.wrap{width:100%; max-width:820px; display:flex; flex-direction:column; gap:10px;}
header{display:flex; align-items:baseline; justify-content:space-between; gap:12px; flex-wrap:wrap;}
h1{font-size:22px; letter-spacing:2px; margin:0; text-transform:uppercase;}
h1 span{color:var(--accent);}
.subtitle{font-size:12px;color:var(--muted); letter-spacing:1px;}
.hud{display:flex; gap:8px; flex-wrap:wrap;}
.chip{background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.12); border-radius:10px; padding:5px 10px; display:flex; gap:6px; align-items:baseline; min-width:72px;}
.chip b{font-size:16px; color:var(--accent);}
.chip i{font-style:normal;font-size:10px;letter-spacing:1px;color:var(--muted);text-transform:uppercase;}
.stage{position:relative; border-radius:16px; overflow:hidden; border:1px solid rgba(255,255,255,.14); box-shadow:0 20px 50px rgba(0,0,0,.45); background:#10141f;}
canvas{display:block; width:100%; height:auto; touch-action:none;}
.overlay{position:absolute; inset:0; display:flex; align-items:center; justify-content:center; background:rgba(8,10,20,.6); backdrop-filter: blur(2px); transition:opacity .25s; }
.overlay.hidden{opacity:0; pointer-events:none;}
.card{background:rgba(23,28,46,.92); border:1px solid rgba(255,255,255,.16); border-radius:16px; padding:20px 26px; text-align:center; max-width:86%; box-shadow:0 12px 40px rgba(0,0,0,.5);}
.card h2{margin:0 0 6px; font-size:26px; letter-spacing:2px;}
.card p{margin:6px 0; font-size:13px; color:var(--muted); line-height:1.5;}
.cta{margin-top:14px; font-size:14px; letter-spacing:2px; color:#0f1322; background:var(--accent); padding:9px 16px; border-radius:999px; display:inline-block; font-weight:bold; animation:blink 1.4s infinite;}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.55}}
.hint{font-size:12px; color:var(--muted); text-align:center; line-height:1.6;}
kbd{background:#2a3350;border:1px solid #46527a;border-radius:5px;padding:1px 6px;font-size:11px;}
.pad{display:none; grid-template-columns:repeat(3,54px); grid-template-rows:repeat(2,46px); gap:8px; justify-content:center; margin-top:4px;}
.pad button{background:rgba(255,255,255,.08); border:1px solid rgba(255,255,255,.18); color:var(--ink); border-radius:10px; font-size:18px;}
.pad .u{grid-column:2;}
.pad .l{grid-row:2;grid-column:1}
.pad .d{grid-row:2;grid-column:2}
.pad .r{grid-row:2;grid-column:3}
@media (max-width:700px){ .pad{display:grid;} h1{font-size:18px} .chip{min-width:60px;padding:4px 8px} .chip b{font-size:14px} }
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Platform <span>Express</span></h1>
<div class="hud">
<div class="chip"><i>Score</i><b id="uiScore">0</b></div>
<div class="chip"><i>Riders</i><b id="uiRiders">0</b></div>
<div class="chip"><i>Train</i><b id="uiLen">1</b></div>
<div class="chip"><i>Level</i><b id="uiLevel">1</b></div>
<div class="chip"><i>Best</i><b id="uiBest">0</b></div>
</div>
</header>
<div class="stage" id="stage">
<canvas id="game" width="768" height="512"></canvas>
<div class="overlay" id="overlay">
<div class="card" id="card">
<h2 id="ovTitle">Platform Express</h2>
<p id="ovText">You are the train. Collect waiting passengers from the top platform, then deliver them to the depot below for bonus points.</p>
<div class="cta" id="ovCta">CLICK OR PRESS ANY KEY TO START</div>
</div>
</div>
</div>
<div class="hint">
<kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd> or <kbd>WASD</kbd> to drive · pick up on <b>Platform 1 (top)</b> · deliver on <b>Platform 2 (bottom)</b> for +25 · avoid the express and the scenery!
</div>
<div class="pad">
<button class="u" data-dir="up">▲</button>
<button class="l" data-dir="left">◀</button>
<button class="d" data-dir="down">▼</button>
<button class="r" data-dir="right">▶</button>
</div>
</div>
<script> ... </script>
</body>
</html>
```
Note the canvas width=768 height=512 matches COLS=24, ROWS=16, CELL=32.
Now the JS. Let me write it fully.
```js
(()=>{
'use strict';
const COLS=24, ROWS=16, CELL=32, W=COLS*CELL, H=ROWS*CELL;
const canvas=document.getElementById('game');
const ctx=canvas.getContext('2d');
const PICK_ROW=1, DELIVER_ROW=ROWS-2, EXP_ROW=5;
const WAGON_COLORS=['#ff6b6b','#4ecdc4','#ffd93d','#c084fc','#7bd389','#ff9f45','#60a5fa','#f472b6'];
const SKIN=['#ffd9b3','#f2b48c','#c68642','#8d5524'];
const DIRS={up:{x:0,y:-1},down:{x:0,y:1},left:{x:-1,y:0},right:{x:1,y:0}};
const DIR_LIST=[DIRS.up,DIRS.down,DIRS.left,DIRS.right];
```
State variables and functions... Let me write them all out.
I need to be careful with the `reset` function initializing obstacles: obstacles should persist per level? On restart, reset obstacles to the base set.
Let me write:
```js
let obstacles=new Map();
const BASE_OBSTACLES=[...];
let snake=[], riders=[], waiting=[], score=0, level=1, picked=0, mode='demo';
let dir=DIRS.right, pending=DIRS.right;
let best=0, runTime=0;
let particles=[], floaters=[], ghosts=[];
let express={x:2.5, d:1, phase:0};
let demoRestart=0, crashFlash=0;
```
reset(newMode):
```js
function reset(m){
mode=m;
obstacles=new Map();
BASE_OBSTACLES.forEach(o=>obstacles.set(o.y*COLS+o.x,o));
snake=[{x:3,y:9,color:null,rider:null},{x:2,y:9,...},{x:1,y:9,...}];
```
Wait, the spawn should have the loco at (3,9) and maybe 1 wagon at (2,9) → length 2 minimum? Let's start with the loco only at (3,9) plus one wagon at (2,9) so it looks like a train from the start. Snake length 2.
Hmm, but (2,9) and (1,9) — x=1 is interior (not a wall since the wall is x=0). OK.
Actually, let's start with the loco + 1 coach: snake=[{3,9},{2,9}].
riders=[], waiting=[], score=0, level=1, picked=0, particles=[], floaters=[], ghosts=[];
express={x:2.5,d:1};
pending=dir=DIRS.right;
spawnWaiting(2);
renderBackground();
```
spawnWaiting(n): add n waiting passengers at random valid platform cells.
```js
function spawnWaiting(n){
while(waiting.length<n && attempts<50){
const row = Math.random()<0.75?PICK_ROW:DELIVER_ROW; // hmm, waiting passengers should only be at the pick-up platform
```
Decision: waiting passengers spawn only on PICK_ROW (the north platform). The south platform is the delivery depot. Clear.
Hmm, but then the top platform gets crowded with 3 passengers — fine.
```js
const x = 1+Math.floor(Math.random()*(COLS-2));
if (isOccupied(x,row)) continue;
waiting.push({x,y:row, color: pick, skin: pick, phase: Math.random()*6.28, bob: Math.random()*6.28});
}
}
```
isOccupied: check the snake cells, obstacles, other waiting.
Also make sure the passenger doesn't spawn under the express (row 5 isn't a platform, so fine).
Now the main step function.
```js
function step(){
if(mode!=='play' && mode!=='demo') return;
if(mode==='demo'){ const d=aiDir(); if(d) dir=d; }
else { dir=pending; }
const head=snake[0];
const nh={x:head.x+dir.x, y:head.y+dir.y};
// collision
const n=snake.length;
// body collision: all cells except the last (tail) unless growing
let grow=false;
// is there a passenger at nh?
const pIdx = waiting.findIndex(p=>p.x===nh.x&&p.y===nh.y);
if(pIdx>=0) grow=true;
for(let i=0;i<n-(grow?0:1);i++){ if(snake[i].x===nh.x&&snake[i].y===nh.y){ crash(); return; } }
if(WALL(nh.x,nh.y)){ crash(); return; }
if(obstacles.has(nh.y*COLS+nh.x)){ crash(); return; }
if(expressHits(nh.x,nh.y)){ crash(); return; }
// build new snake
const newSnake=[];
newSnake.push({x:nh.x,y:nh.y,color:null,rider:null});
for(let i=1;i<n;i++){
newSnake.push({x:snake[i-1].x,y:snake[i-1].y,color:snake[i].color,rider:snake[i].rider});
}
if(grow){
const tail=snake[n-1];
const p=waiting[pIdx];
newSnake.push({x:tail.x,y:tail.y,color:nextWagonColor(),rider:{skin:p.skin,hair:p.hair,hat:p.hat}});
waiting.splice(pIdx,1);
riders.push(...);
score+=10; picked++;
floaters.push({x:nh.x*CELL+CELL/2,y:nh.y*CELL,text:'+10',life:1,color:'#ffd166'});
burst(nh.x,nh.y,'#ffd166',14);
spawnWaiting(1); // keep up to 3 waiting
if(picked%4===0){ level++; addObstacle(); floater('LEVEL '+level); }
}
snake=newSnake;
// delivery
if(nh.y===DELIVER_ROW && riders.length>0 && snake.length>=3){
const car=snake.pop();
riders.pop();
ghosts.push({...car, life:1});
score+=25;
floaters.push(...'+25', color '#7bd389');
burst(nh.x,nh.y,'#7bd389',18);
}
updateHUD();
}
```
Wait, there's an issue with the "grow" collision check: `for(let i=0;i<n-(grow?0:1);i++)` — if growing, check all n cells (including the tail); if not growing, check n-1 cells (excluding the tail). Correct as written: n-(grow?0:1) = n when growing, n-1 when not.
But careful: the check includes snake[0] (the old head) — the new head can't be the old head (it moved). Fine.
Also: the delivery requires snake.length>=3 (loco + 2 cars) so that after popping we still have loco + 1 car. Good.
Also, when delivering, should the score popper remove the last rider? Yes, riders.pop().
Hmm: riders and snake cars must stay in sync: each wagon (index>=1) may have a rider. riders.length should equal the number of wagons with riders. Initially 0 wagons have riders, and the start has 1 wagon with no rider → riders.length=0 but snake.length-1=1. So riders.length != snake.length-1. That's fine; riders is a separate list. But when popping the tail car, we should pop a rider only if that car had one. Let's handle: if the popped car had a rider, riders.pop() (the last rider). Since riders are appended in order and cars are appended in order, the last rider corresponds to the last car with a rider. But the tail car might have no rider (the initial coach). Let's implement: `if(car.rider) riders.pop();` — but that assumes the tail car's rider is the last in the riders list, which is true only if the tail car is the last car with a rider. If the tail car has no rider, riders stays. But what if the tail car has a rider but it's not the last rider? Since riders are only added when a car is added and cars are only removed from the tail, the rider order matches the car order. The last car with a rider is the last rider in the list. If we pop a car with a rider, it's the last car with a rider → riders.pop() is correct.
So: `if(car.rider){ riders.pop(); }`.
Now, the delivery check requires riders.length>0 — but what if the tail car has no rider while others do? Then popping the tail car doesn't deliver anyone but we still add 25 points. Let's instead deliver only when the popped car has a rider: condition `snake.length>=3 && riders.length>0`. If the tail car has no rider (only the initial coach at the start), popping it gives no points. Hmm, but the initial coach is at the tail and would be popped first — the player would lose their starting coach without points. Let's handle: only pop if the tail car has a rider OR ... let's simplify: give the starting coach a "conductor" rider? Then riders.length=1 at the start, which conflicts with "riders aboard" showing 1 at the start. That's fine actually — the conductor counts as a rider? It might confuse the score but whatever.
Cleaner: make the starting snake = loco only (length 1) plus... a train with just a loco looks fine. Then every wagon has a rider. Let's start with the loco + 1 coach with no rider, and handle the delivery rule as: pop the tail car only if it has a rider; otherwise no delivery happens. Hmm, then at the start, if the player drives onto the delivery platform with riders=0, nothing happens. Fine.
So the delivery condition: `if(nh.y===DELIVER_ROW && snake.length>=3){ const car=snake[snake.length-1]; if(car.rider){ pop and deliver } }`. And the tail car without a rider blocks delivery until it's... wait, the tail car is the LAST one; if the last car has no rider, but earlier cars do, then we can't deliver because we'd have to pop a car without a rider. That's an edge case only at the very start (the starting coach). Let's give the starting coach a rider = the conductor (a passenger color). Then riders.length=1 at the start and the HUD shows "Riders 1". Hmm, but delivering the conductor for +25 at the start is fine.
Actually, let's simplify: start with the loco only (no coach). The train looks like a lone locomotive — acceptable and clean. Then every coach has a rider, and the delivery logic is consistent. But visually a train with just a loco is a bit sparse. I'll start with the loco + 1 coach WITH a rider (the conductor). The HUD "Riders: 1" — it's a passenger aboard from the start. Fine, and it gives the player a reason to visit the depot immediately for +25. Actually that's a nice tutorial moment! But wait, it might be confusing: "Riders 1" at the start without picking anyone up. I'll label the HUD "Riders" and it starts at 1 with the conductor. Hmm.
Alternative: start with loco + 1 coach with no rider, and the delivery rule: pop the tail car and give +25 if that car has a rider; if it doesn't, don't pop. So the player must pick up a passenger first (which adds a new car at the tail WITH a rider), then deliver: the tail car now has a rider → pop → +25. The starting coach stays until then, and after the first pickup the tail is the new rider car. But after delivering that, the tail is the starting coach again (no rider) → no more deliveries until another pickup. Hmm, that means after each pickup+delivery cycle, the train is back to loco+coach. That's a reasonable loop actually! But it means the train never grows long, which removes the snake challenge.
Wait no: the order matters. The tail is the newest car. When you pick up, a new car is added at the tail. When you deliver, the tail car is removed. So the length oscillates. The snake challenge (self-collision) only arises if you pick up multiple passengers before delivering. That's a legit strategy: accumulate riders (train grows, riskier) then deliver a batch (train shrinks, bonus points). That's actually a GOOD game design: risk/reward! The player chooses when to deliver.
So the delivery rule: pop the tail car if it has a rider (+25). If the tail car has no rider, no pop. With the starting coach (no rider) at the tail, after picking up 2 passengers, the tail is a rider car → deliver pops it (+25), then the tail is the other rider car → next tick on the platform, pop it (+25).
So the player accumulates passengers at the top platform, then drives along the bottom platform to unload them one by one, each +25 and shrinking the train. And the score comes from both. The risk: a long train is harder to maneuver.
Let's keep the starting coach with no rider.
Now, `riders` list = passengers aboard; HUD shows riders.length.
Let me also reconsider: should the train grow beyond the riders? Yes, the snake grows with each pickup and shrinks with each delivery.
Now the AI: the AI should pick up until the train is long (say length>=6), then deliver. Let's set the AI's threshold at length>=6.
Now the crash function:
```js
function crash(){
crashFlash=1;
burst(snake[0].x,snake[0].y,'#ff6b6b',30);
if(mode==='demo'){ demoRestart=performance.now()+1200; mode='demoCrash'; }
else { mode='over'; showOverlay('CRASHED!', ...); }
}
```
In mode 'demoCrash', the step() does nothing but the demo restart timer resets the game. Let's use a separate flag `demoResetAt`.
Overlay states:
- 'start' (initial): shown.
- 'over': shown with the score.
- hidden while playing.
showOverlay(title, text, cta) sets the text and removes the 'hidden' class.
On start: hide the overlay.
Click/key handler:
```js
function startPlay(){
if(mode==='play') return;
audioInit();
reset('play');
hideOverlay();
}
document.addEventListener('keydown', e=>{
// prevent default for arrows/space
...
if(mode==='start'||mode==='over'||mode==='demo'){ startPlay(); return; }
// steering
});
```
Hmm, careful: in demo mode, any key starts the game. But we also want arrow keys to steer in play mode. Since the first key press starts the game (and is consumed), that's fine.
Also the click handler: clicking the stage or the document starts the game. But clicking the D-pad buttons shouldn't start the game? It doesn't matter much — clicking a D-pad button would start the game and also steer. Let's make the D-pad buttons call startPlay() then set the direction. Fine.
Let's implement the pointer/touch swipe on the canvas: on pointerdown record the start; on pointerup, if the distance > 24px, determine the direction.
Now the AI function. Let me write it:
```js
function aiDir(){
const head=snake[0];
const blocked=new Uint8Array(COLS*ROWS);
// walls
for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++) if(WALL(x,y)) blocked[y*COLS+x]=1;
for(const k of obstacles.keys()) blocked[k]=1;
const ex=expressCells();
for(const c of ex) blocked[c.y*COLS+c.x]=1;
for(let i=1;i<snake.length-1;i++) blocked[snake[i].y*COLS+snake[i].x]=1;
const {dist,prev}=bfs(head.x,head.y,blocked);
// choose target
let target=null, bestD=Infinity;
const carrying=riders.length>0;
const wantDeliver = carrying && snake.length>=6;
if(!wantDeliver){
for(const p of waiting){
const d=dist[p.y*COLS+p.x];
if(d>=0 && d<bestD){bestD=d;target={x:p.x,y:p.y};}
}
}
if(wantDeliver || !target){
// depot target
let bd=Infinity,bt=null;
if(head.y===DELIVER_ROW){
// already on the platform: move along it
const along = dir.x!==0? dir.x : (snake.length>1 ? (snake[1].x>=snake[0].x?1:-1):1);
const tx=Math.max(1,Math.min(COLS-2, head.x+along*3));
bt={x:tx,y:DELIVER_ROW};
} else {
for(let x=1;x<COLS-1;x++){
const d=dist[DELIVER_ROW*COLS+x];
if(d>=0&&d<bd){bd=d;bt={x,y:DELIVER_ROW};}
}
}
if(bt && (!target || (wantDeliver && bd<bestD))){ target=bt; }
}
...
}
```
Hmm, this is getting messy. Let me restructure clearly:
```js
function aiDir(){
const head=snake[0];
const {dist,prev}=computeField();
const carrying=riders.length>0;
let target=null;
const longTrain = snake.length>=6;
if(carrying && longTrain){
target=depotTarget(dist);
} else {
target=nearestWaiting(dist);
if(!target && carrying) target=depotTarget(dist);
}
let pref=null;
if(target){
const path=pathTo(prev,target);
if(path && path.length) pref=path[0]; // a direction object
}
const candidates=[];
if(pref) candidates.push(pref);
for(const d of DIR_LIST){
if(d===pref) continue;
if(isReverse(d,dir)) continue;
candidates.push(d);
}
// evaluate
let bestC=null,bestArea=-1;
for(const d of candidates){
const nx=head.x+d.x, ny=head.y+d.y;
if(!safeStep(nx,ny)) continue;
const area=freeArea(nx,ny);
if(area>=snake.length+2){ return d; } // take the first safe one
if(area>bestArea){bestArea=area;bestC=d;}
}
if(bestC) return bestC;
// nothing safe: try reversing? Not allowed. Pick any direction that doesn't immediately crash? Return pref or dir.
return pref || dir;
}
```
Wait, the ordering: candidates[0] is the preferred (target-seeking) direction; if it's safe with a large enough area, return it. Otherwise, other directions are evaluated in DIR_LIST order (up,down,left,right) — which is arbitrary. Better: sort the non-preferred candidates by their resulting free area descending, and take the best that is safe. Let me restructure: evaluate all candidates (including pref) with their areas; if pref is safe and area>=needed → return pref; else choose the candidate with the max area among safe ones (preferring pref on ties). But we want the AI to still head toward the target when safe. Let's do:
1. Compute the area for each valid candidate.
2. If pref is valid and area(pref) >= needed → pref.
3. Else pick the valid candidate with the max area; tie-break by preferring pref.
That's good.
safeStep(nx,ny): not a wall, not an obstacle, not an express cell, and not in the snake body (except the tail cell which is allowed if not growing — the AI doesn't know about growth; allow the tail cell but then the new car would conflict... the AI path might target the tail cell; rare. Let's disallow the tail cell for the AI to be safe).
Hmm, but the BFS path might route through the tail cell since we excluded the tail from blocked. Let's just also block the tail in the BFS for the AI (slightly conservative). Then the AI never moves into the tail cell. Fine.
freeArea(nx,ny): flood-fill from (nx,ny) counting cells that are not walls, obstacles, express cells, or body cells (snake[0..n-2] positions, i.e., excluding the tail which moves away). Plus the new head cell is the start.
Let me write:
```js
function freeArea(nx,ny){
const blocked=new Uint8Array(COLS*ROWS);
for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++) if(WALL(x,y)) blocked[y*COLS+x]=1;
for(const k of obstacles.keys()) blocked[k]=1;
for(const c of expressCells()) blocked[c.y*COLS+c.x]=1;
for(let i=0;i<snake.length-1;i++) blocked[snake[i].y*COLS+snake[i].x]=1;
// BFS count from (nx,ny)
let count=0; const seen=new Uint8Array(COLS*ROWS);
const q=[ny*COLS+nx]; seen[q[0]]=1; let h=0;
while(h<q.length){
const cur=q[h++]; count++;
const cx=cur%COLS, cy=(cur/COLS)|0;
for(const d of DIR_LIST){
const ax=cx+d.x, ay=cy+d.y;
if(ax<0||ay<0||ax>=COLS||ay>=ROWS) continue;
const k=ay*COLS+ax;
if(seen[k]||blocked[k]) continue;
seen[k]=1; q.push(k);
}
}
return count;
}
```
Note: the snake body cells include the old head position (snake[0]) which the new head is adjacent to; after the move, the old head cell becomes the first coach's cell → still blocked. Correct.
But: the tail cell (snake[n-1]) is excluded → free. After the move, the tail cell is either vacated (not growing) or occupied by the new car (growing). Conservative enough.
Hmm, but there's a subtlety: excluding the tail cell from the blocked set means the flood fill can "escape" through the tail cell and count a lot of area. It's a standard approximation. OK.
Now, the BFS `prev` and path reconstruction. pathTo(prev, target): walk back from the target to the start, collecting cells, then reverse → an array of cells; the first step direction = the difference between path[0] and path[1] (or the head).
Let me define pathTo returns an array of cells starting from the target's neighbor... Let's do: build the chain from the target back to the start (the head), reverse it → [target, ..., neighborOfHead, head]. The first move direction = neighborOfHead - head.
OK.
Now the demo will play. Let's also ensure the demo doesn't cheat by being too good — it's fine.
Now, an important consideration: the demo AI in the beginning — the train spawns at (3,9) facing right. The nearest passenger is on row 1 (the top platform). The AI will path there. Fine.
Now let me handle the `waiting` passengers on the pick platform (row 1). The player must drive to row 1. Row 1 is adjacent to the wall row 0 — the train can be on row 1 and must not go up into row 0.
OK.
Another thought: the demo might be boring if the AI crashes often. Let's test mentally... it's hard to guarantee. I'll add the safety check which should be decent.
Let me also add a subtle but important thing: the AI must not reverse into its own body. safeStep checks the body cells (snake[0..n-2] — the neck is snake[1], so moving back into the neck is blocked). Good.
Now the rendering functions. Let me write them.
```js
function roundRect(c,x,y,w,h,r){
c.beginPath();
c.moveTo(x+r,y);
c.arcTo(x+w,y,x+w,y+h,r);
c.arcTo(x+w,y+h,x,y+h,r);
c.arcTo(x,y+h,x,y,r);
c.arcTo(x,y,x+w,y,r);
c.closePath();
}
```
drawBackground (offscreen bgCanvas):
```js
const bg=document.createElement('canvas'); bg.width=W; bg.height=H; const b=bg.getContext('2d');
const speckles=[];
(function(){ let s=12345; const rnd=()=>{s=(s*1103515245+12345)&0x7fffffff; return s/0x7fffffff;};
for(let i=0;i<900;i++){ speckles.push({x:rnd()*W,y:rnd()*H,s:rnd()*2+1,a:0.04+rnd()*0.09}); }
})();
```
Ground color: '#2a3340'. Speckles in a slightly lighter color with low alpha.
Let me now write the platform drawing.
```js
function drawPlatformRow(g,row, tileColor, stripeColor, isPick){
const y0=row*CELL;
g.fillStyle=tileColor; g.fillRect(CELL, y0, W-2*CELL, CELL);
// tile grout
g.strokeStyle='rgba(0,0,0,.12)'; g.lineWidth=1;
for(let x=CELL; x<W-CELL; x+=CELL){ g.beginPath(); g.moveTo(x+.5,y0); g.lineTo(x+.5,y0+CELL); g.stroke(); }
g.beginPath(); g.moveTo(CELL,y0+.5); g.lineTo(W-CELL,y0+.5); g.stroke();
// safety stripe at the bottom edge of the row
g.fillStyle=stripeColor; g.fillRect(CELL, y0+CELL-5, W-2*CELL, 4);
g.fillStyle='rgba(0,0,0,.25)';
for(let x=CELL;x<W-CELL;x+=12) g.fillRect(x, y0+CELL-5, 6, 4);
// upper edge shadow
g.fillStyle='rgba(0,0,0,.18)'; g.fillRect(CELL, y0, W-2*CELL, 3);
}
```
Benches on the pick platform: at x = 4*CELL, 10*CELL, 16*CELL: draw a bench: a dark brown seat rect (24x8) at the top of the row with legs. Actually the row is 32px tall; a bench would occupy much of it. The passengers stand in the row too. Overlap is okay-ish. Let's put the benches at the top edge of the platform row (a 10px tall band) and have the passengers stand slightly lower (drawn at the row center). It might overlap visually. Let's skip benches and instead add small details: a station clock pole, a lamp post, a trash bin — drawn as small items near the top edge of the row (8px tall). Keep them small so they don't clutter.
Actually simpler: draw the platform clean with a few painted details (a yellow crosswalk, arrows) and put the decorative props on the station building row. Let's keep the platforms relatively clean so the passengers are readable.
I'll add: on the pick platform, small painted numbers "1" on the tiles every few cells? Eh. Let's add a sign hanging from the station building: a small board "PICK UP" with a down arrow, drawn at x=3*CELL in row 0-1. And on the depot row, a sign "DELIVER" with an up arrow.
The depot row (15) is a wall with a sign. Let's draw the depot as a dark brick wall with a roller door and a big sign board "DELIVERY DEPOT" plus a green arrow pointing up toward the platform. And lamps.
Station row (0): brick facade with a roof, windows, a clock, and a sign "CENTRAL STATION" plus a "PICK UP" arrow.
Let me write it.
```js
function drawStation(g){
const h=CELL;
// facade
g.fillStyle='#7a3f35'; g.fillRect(0,0,W,h);
// brick lines
g.strokeStyle='rgba(0,0,0,.18)'; g.lineWidth=1;
for(let y=4;y<h;y+=6){g.beginPath();g.moveTo(0,y+.5);g.lineTo(W,y+.5);g.stroke();}
// roof
g.fillStyle='#3f211d'; g.fillRect(0,0,W,9);
g.fillStyle='#5c2f28'; g.fillRect(0,9,W,3);
// windows
for(let x=1;x<COLS-1;x+=3){
const wx=x*CELL+8, wy=13, ww=16, wh=13;
g.fillStyle='#2a1a17'; g.fillRect(wx-1,wy-1,ww+2,wh+2);
g.fillStyle='#ffd97d'; g.fillRect(wx,wy,ww,wh);
g.fillStyle='rgba(255,255,255,.25)'; g.fillRect(wx+1,wy+1,ww-2,4);
}
// sign
...
}
```
But the sign would overlap the windows. Let me place the sign in the middle covering a couple of windows: draw the sign after the windows so it covers them. The sign: a rounded rect 220x22 centered at the top row, cream with dark text "CENTRAL STATION". Also smaller boards "PICK UP ▼" at the left (x=2*CELL) and the clock at the right.
Since the row is 32px, a 22px sign fits.
Text: g.font='bold 13px Trebuchet MS, sans-serif'; g.textAlign='center'; g.textBaseline='middle'; fillText.
The bottom depot row similarly: fill '#4a3a33' brick, a dark cap, a sign "DELIVERY DEPOT ▼"? The arrow should point up (toward the platform which is above). "▲ DELIVER".
Side hedges (x=0 and x=COLS-1 columns, rows 1..14): draw green bushes:
```js
function drawHedges(g){
for(const x of [0, COLS-1]){
for(let y=1;y<ROWS-1;y++){
const cx=x*CELL+CELL/2, cy=y*CELL+CELL/2;
g.fillStyle='#25543a';
g.beginPath(); g.ellipse(cx,cy,CELL*0.62,CELL*0.55,0,0,Math.PI*2); g.fill();
g.fillStyle='#31684a';
g.beginPath(); g.ellipse(cx-3,cy-3,CELL*0.42,CELL*0.36,0,0,Math.PI*2); g.fill();
}
}
}
```
Hmm, ellipse is fine.
Express lane row 5: draw a distinct strip.
Obstacles: drawObstacle with a shadow + the object.
Water tower: a circle tank on 4 legs (top-down: a big circle with a radial gradient and a small circle in the middle + a ladder line).
Crate: a rounded square with plank lines.
Signal box: a small hut with a striped pole.
Let's write them compactly.
Now the dynamic render:
```js
function render(t){
ctx.clearRect(0,0,W,H);
ctx.drawImage(bg,0,0);
drawExpress(ctx);
drawTrack(ctx);
drawWaiting(ctx,t);
drawTrain(ctx,t);
drawGhosts(ctx);
drawParticles(ctx);
drawFloaters(ctx);
if(crashFlash>0){ ctx.fillStyle='rgba(255,60,60,'+(crashFlash*0.4)+')'; ctx.fillRect(0,0,W,H); }
}
```
drawTrack: segments between consecutive cars.
```js
function drawTrack(ctx){
ctx.lineCap='round';
for(let i=1;i<snake.length;i++){
const a=snake[i], b=snake[i-1];
const ax=a.x*CELL+CELL/2, ay=a.y*CELL+CELL/2;
const bx=b.x*CELL+CELL/2, by=b.y*CELL+CELL/2;
// ballast
ctx.strokeStyle='rgba(18,22,30,.85)'; ctx.lineWidth=13;
ctx.beginPath(); ctx.moveTo(ax,ay); ctx.lineTo(bx,by); ctx.stroke();
// rails
ctx.strokeStyle='#9aa5b5'; ctx.lineWidth=3;
ctx.beginPath(); ctx.moveTo(ax,ay); ctx.lineTo(bx,by); ctx.stroke();
// sleepers
...
}
}
```
The sleepers: compute the length L, the angle; step every 7px; draw a perpendicular line of length 13, color 'rgba(60,45,35,.9)' width 2.5. Draw the sleepers first, then the rails on top. Let me do: for each segment, first the ballast line, then the sleepers, then the rails.
Wait, the ballast line width 13 in dark + the sleepers on top + rails. Let's do:
1. ballast: rgba(20,16,14,0.8), width 12
2. sleepers: color '#5b4636', width 2.5, perpendicular ticks of length 12, every 7px
3. rails: '#c9d3e0' width 2.2 (a single line down the middle) — for realism, two parallel rails 6px apart. Let's do two rails: offset ±3.5 perpendicular, each width 1.8, color '#b8c2d0'. That looks like a real track from above.
That'll look nice.
drawTrain: for each car, compute the angle and draw the car.
The loco angle: atan2(dir.y, dir.x). Car i angle: atan2(snake[i-1].y - snake[i].y, snake[i-1].x - snake[i].x).
Draw the cars from the tail to the head (so the head is drawn on top).
Now the loco drawing function:
```js
function drawLoco(ctx){
// local: x along the direction of travel, front at +x
const L=15, Hh=10;
// shadow
ctx.fillStyle='rgba(0,0,0,.28)';
ctx.beginPath(); ctx.ellipse(0,2,15,10,0,0,Math.PI*2); ctx.fill();
// chassis
ctx.fillStyle='#161a21'; roundRect(ctx,-15,-9,30,18,4); ctx.fill();
// cab
ctx.fillStyle='#2f4a72'; roundRect(ctx,-15,-10,11,20,4); ctx.fill();
ctx.fillStyle='#3d5f92'; roundRect(ctx,-16,-11,13,22,5); ctx.fill();
// cab windows
ctx.fillStyle='#ffd97d'; roundRect(ctx,-13,-6,6,12,2); ctx.fill();
ctx.fillStyle='rgba(255,255,255,.35)'; ctx.fillRect(-12,-5,2,4);
// boiler
ctx.fillStyle='#d8453f'; roundRect(ctx,-6,-10,21,20,7); ctx.fill();
// boiler highlight
ctx.fillStyle='rgba(255,255,255,.18)'; roundRect(ctx,-4,-8,17,7,4); ctx.fill();
// stripe
ctx.fillStyle='#ffe3a3'; ctx.fillRect(6,-10,3,20);
// front plate
ctx.fillStyle='#a9302c'; ctx.beginPath(); ctx.ellipse(14,0,3.5,9,0,0,Math.PI*2); ctx.fill();
// headlight
ctx.fillStyle='#ffe680'; ctx.beginPath(); ctx.arc(12,0,3,0,Math.PI*2); ctx.fill();
ctx.fillStyle='rgba(255,230,128,.25)'; ctx.beginPath(); ctx.arc(12,0,6,0,Math.PI*2); ctx.fill();
// chimney
ctx.fillStyle='#3a3f4b'; ctx.beginPath(); ctx.arc(7,-5,3.6,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#5a6270'; ctx.beginPath(); ctx.arc(7,-5,2,0,Math.PI*2); ctx.fill();
// face
ctx.fillStyle='#fff'; ctx.beginPath(); ctx.arc(9,-4,2.4,0,Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(9,4,2.4,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#20242c'; ctx.beginPath(); ctx.arc(9.6,-4,1.2,0,Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(9.6,4,1.2,0,Math.PI*2); ctx.fill();
ctx.strokeStyle='#20242c'; ctx.lineWidth=1.4; ctx.beginPath(); ctx.arc(13.5,0,3.2,-0.9,0.9); ctx.stroke();
// wheels
wheels(ctx);
}
```
Hmm, the face: eyes at (9,-4) and (9,4) are on the boiler's sides (top and bottom when facing right) → they'd look like eyes above and below the center line, which is wrong for a face. A cartoon train face facing forward (in the direction of travel) in top-down would have eyes side by side across the width: when facing right, "across the width" = the y axis (up/down). So the eyes at (9,-4) and (9,4) are correct for a top-down face! Yes: the face points along +x, and the eyes are separated along y. Good. And the smile arc at the front (x≈13) spanning y -3..3. Good.
Wheels: in top-down, the wheels are on the sides of the vehicle: at y=±10, x=±10. Draw 4 small dark circles r=2.6 at (-10,-10),(10,-10),(-10,10),(10,10). But they'd be hidden under the body (the body is ±10). Let's put the wheels slightly outside: y=±11.5, r=3. Then they're visible as bumps on the sides. But the cell is 32 and the car is 30 wide (±15) — wheels at ±11.5 with r=3 → up to 14.5, within the cell. Good.
Actually with rotation, wheels might overlap the adjacent cell slightly; acceptable.
Let me also animate the wheels (rotating spokes) — a subtle rotation based on the distance traveled. I'll keep a global `wheelAngle` that increases with movement and draw the wheel hubs with a rotating line. Nice detail.
The wagon drawing:
```js
function drawWagon(ctx,color,rider){
// chassis
ctx.fillStyle='#161a21'; roundRect(ctx,-15,-9,30,18,3); ctx.fill();
// body
ctx.fillStyle=color; roundRect(ctx,-14,-9,28,18,5); ctx.fill();
// roof lighter
ctx.fillStyle=lighten(color,0.18); roundRect(ctx,-12,-7,24,13,4); ctx.fill();
// stripe
ctx.fillStyle='rgba(255,255,255,.35)'; ctx.fillRect(-12,-1.5,24,3);
// rider head
if(rider){ ... }
wheels(ctx);
}
```
lighten: a helper that converts hex to rgb and mixes with white.
Rider head: a circle at (0,-1) r=4.2 with skin color, hair as an arc on one side, and a hat in a color. Let's draw: hair = a half-circle covering the top half (relative to the car orientation, but the rider faces "up" out of the roof — in top-down, a head is just a circle with hair around it). Draw the hair as an arc covering ~60% of the circle, then the face on the rest. Let's simply draw: a circle in the hair color (r=4.6), then a smaller circle in the skin color offset by (1,-1) (r=3.4) → looks like a head with hair. Plus two tiny eyes. Good.
The passenger (waiting) drawing:
```js
function drawWaiting(ctx,p,t){
const bob=Math.sin(t*3+p.phase)*1.5;
const cx=p.x*CELL+CELL/2, cy=p.y*CELL+CELL/2+bob;
// shadow
ctx.fillStyle='rgba(0,0,0,.25)'; ctx.beginPath(); ctx.ellipse(cx,cy+10,7,3,0,0,Math.PI*2); ctx.fill();
// legs
ctx.fillStyle='#3b4252'; ctx.fillRect(cx-4,cy+2,3,8); ctx.fillRect(cx+1,cy+2,3,8);
// body
ctx.fillStyle=p.color; ctx.beginPath();
ctx.moveTo(cx-6,cy+4); ctx.lineTo(cx+6,cy+4); ctx.lineTo(cx+4,cy-3); ctx.lineTo(cx-4,cy-3); ctx.closePath(); ctx.fill();
// arms
ctx.strokeStyle=p.skin; ctx.lineWidth=2.4; ctx.lineCap='round';
// left arm down
ctx.beginPath(); ctx.moveTo(cx-5,cy-2); ctx.lineTo(cx-8,cy+4); ctx.stroke();
// right arm waving
const w=Math.sin(t*7+p.phase);
ctx.beginPath(); ctx.moveTo(cx+5,cy-2); ctx.lineTo(cx+8,cy-4-w*3); ctx.stroke();
// head
ctx.fillStyle=p.skin; ctx.beginPath(); ctx.arc(cx,cy-7,5.4,0,Math.PI*2); ctx.fill();
// hair
ctx.fillStyle=p.hair; ctx.beginPath(); ctx.arc(cx,cy-8,5.4,Math.PI,0); ctx.fill(); // top half
// eyes
ctx.fillStyle='#20242c'; ctx.beginPath(); ctx.arc(cx-2,cy-6,0.9,0,Math.PI*2); ctx.fill(); ctx.beginPath(); ctx.arc(cx+2,cy-6,0.9,0,Math.PI*2); ctx.fill();
// smile
ctx.strokeStyle='#20242c'; ctx.lineWidth=1; ctx.beginPath(); ctx.arc(cx,cy-4,2.2,0.2,Math.PI-0.2); ctx.stroke();
// bubble
...
}
```
The hair as an arc from PI to 0 (the top half in canvas coords: angle 0 = right, PI/2 = down since y is flipped... In canvas, the angle increases clockwise with y down. So arc(cx,cy-8, r, Math.PI, 0) goes from PI (left) to 0 (right) counterclockwise? With the default anticlockwise=false, arc(cx,cy,r,PI,0) draws from PI to 0 going clockwise → PI → 3PI/2 → 2PI (0) which is the TOP half (since y-down means PI/2 is the bottom). Let me verify: angle 0 = (cx+r, cy) = right. angle PI/2 = (cx, cy+r) = below (y-down). angle PI = left. angle 3PI/2 = above. So going from PI to 2PI (clockwise, increasing) passes 3PI/2 = above. So arc(cx,cy-8,r,PI,0) with anticlockwise=false draws from PI increasing to 2PI (equivalent to 0) → the upper half.
The bubble: a small white rounded rect above the head with a mini train icon, bobbing. Let's draw a small circle with a mini train shape: a tiny rect + a tiny chimney circle, in dark blue. Place it at (cx+9, cy-14)? It might collide with the row above (the station building). Row 1's passengers are on the pick platform; the bubble would overlap the station building row. That's OK visually (like a speech bubble over the building). But it might look cluttered. Alternatively, put a small "waiting" icon under the passenger (a suitcase) and skip the bubble. Let's draw a small suitcase next to them: a rounded rect 7x6 in a brown color with a handle. Cute and doesn't clutter.
Actually, a thought bubble adds charm. Let's put the bubble above-left and keep it small (r=6 circle) with a mini train glyph. Since the passengers are on row 1, the bubble will overlap the station facade, which is fine (it's drawn on top).
Hmm, but the bubble would also overlap the platform sign. Let's skip the bubble and use the suitcase + a slight bob. Simpler and cleaner.
Express drawing:
```js
function drawExpress(ctx){
const cells=expressCells();
const hx=express.x; // float cell position
const y=EXP_ROW*CELL+CELL/2;
// draw coaches then loco
ctx.save();
ctx.translate(hx*CELL+CELL/2, y);
ctx.scale(express.d,1); // flip when going left
// loco
...
ctx.restore();
}
```
The express loco: a streamlined red/dark shape with a yellow stripe and 2 coaches. Plus speed lines behind.
Let me draw the express as: at local x=0 the loco (front at +x), the coaches at x=-16, -32.
I'll write a simplified drawExpressCar(ctx,color) — a rounded rect with windows.
Particles:
```js
function burst(cx,cy,color,n){ for(let i=0;i<n;i++){ const a=Math.random()*Math.PI*2, s=1+Math.random()*3; particles.push({x:cx*CELL+CELL/2, y:cy*CELL+CELL/2, vx:Math.cos(a)*s, vy:Math.sin(a)*s-1, life:1, dec:0.02+Math.random()*0.02, r:2+Math.random()*3, color}); } }
```
Steam from the chimney: spawn at the loco's chimney world position, drifting backwards.
To compute the chimney's world position: the loco is at snake[0] with angle θ; the chimney local (7,-5) → world = center + rotate(local, θ).
```js
const c=Math.cos(a), s=Math.sin(a);
const wx = cx + 7*c - (-5)*s, wy = cy + 7*s + (-5)*c;
```
Steam particles: {x,y,vx,vy,r,life,type:'steam'} → grow and fade.
Floaters: text that rises and fades.
Now the HUD update and the level/speed.
tickInterval: `Math.max(75, 165 - (level-1)*12)`.
Level up: every 4 pickups → level++. Add an obstacle (max 10). Also the express speed increases slightly with the level.
Let's write the updateHUD function to update the DOM elements.
Now the audio: let's add a tiny WebAudio blip for pickups/deliveries/crash. Must be created after a user gesture. I'll implement:
```js
let audioCtx=null;
function initAudio(){ if(!audioCtx){ try{audioCtx=new (window.AudioContext||window.webkitAudioContext)();}catch(e){} } if(audioCtx&&audioCtx.state==='suspended'){audioCtx.resume();} }
function blip(freq,dur,type,vol){ if(!audioCtx) return; try{ const o=audioCtx.createOscillator(), g=audioCtx.createGain(); o.type=type||'square'; o.frequency.value=freq; g.gain.value=vol||0.05; o.connect(g); g.connect(audioCtx.destination); o.start(); g.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime+dur); o.stop(audioCtx.currentTime+dur);}catch(e){} }
```
In demo mode there's no user gesture, so no audio until the user interacts. That's fine.
Now the demo reset behavior: after a crash in demo mode, show a brief banner "CRASHED — DEMO RESTARTING" (a floater or an overlay element?) and reset after ~1.4s. The overlay should stay hidden during the demo crash, but on the initial screen the overlay shows the start message. If the demo crashes before the user starts, the overlay still shows the start message on top — fine.
I'll implement the demo crash with a "banner" drawn on the canvas (a translucent band with text) instead of the HTML overlay.
Let me now also double check the "express" collision check per frame, which might cause the player's train to be hit by the express while it's stationary... The train always moves, so it's fine.
Potential issue: the express passes through the player's train body. The per-frame check catches it → crash. Fair.
Another potential issue: the express's cells include cells where obstacles are? No, the express lane has no obstacles.
Let me now write the final code. I'll be careful with the details.
One more: the initial overlay must be shown, and the demo must run behind it. The demo AI starts immediately at load.
Let's also handle the window resize — the canvas is scaled by CSS, so no handling needed.
Let me write the complete script now.
Also: `updateHUD` should be called each frame or on changes. Let's call it in the render loop (cheap enough — 5 textContent assignments; only update if changed to avoid layout thrash). I'll just set textContent each frame; it's fine, but let's compare to avoid unnecessary updates.
Let me write the code.
```js
// ====== AI ======
function computeField(){
const blocked=new Uint8Array(COLS*ROWS);
for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++) if(WALL(x,y)) blocked[y*COLS+x]=1;
for(const k of obstacles.keys()) blocked[k]=1;
for(const c of expressCells()) blocked[c.y*COLS+c.x]=1;
for(let i=0;i<snake.length;i++) blocked[snake[i].y*COLS+snake[i].x]=1; // includes the head (the start is allowed anyway) and the tail (conservative)
return bfs(snake[0].x,snake[0].y,blocked);
}
```
Wait: if the head's cell is blocked in the array, the BFS start is still fine because we set dist[start]=0 explicitly. But the tail being blocked is conservative → the AI never routes through the tail. That's fine.
Hmm, but there's a subtlety: blocking the tail means the path can't go through the tail cell. Good.
bfs returns {dist, prev}.
Let me write bfs with a plain array queue.
```js
function bfs(sx,sy,blocked){
const dist=new Int16Array(COLS*ROWS).fill(-1);
const prev=new Int16Array(COLS*ROWS).fill(-1);
const q=new Int32Array(COLS*ROWS);
let head=0,tail=0;
const s=sy*COLS+sx; dist[s]=0; q[tail++]=s;
while(head<tail){
const cur=q[head++]; const cx=cur%COLS, cy=(cur/COLS)|0; const d=dist[cur]+1;
for(let i=0;i<4;i++){
const d2=DIR_LIST[i]; const nx=cx+d2.x, ny=cy+d2.y;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
const k=ny*COLS+nx;
if(blocked[k]||dist[k]>=0) continue;
dist[k]=d; prev[k]=cur; q[tail++]=k;
}
}
return {dist,prev};
}
```
pathTo(prev, target):
```js
function pathTo(prev,tx,ty){
const chain=[]; let k=ty*COLS+tx;
if(prev[k]<0 && k!==snake[0].y*COLS+snake[0].x) return null;
while(k>=0){ chain.push(k); if(k===headK) break; k=prev[k]; }
chain.reverse(); // [target,...,neighbor, head]
// the first step = chain[chain.length-2] - head
if(chain.length<2) return null;
const n=chain[chain.length-2];
return {x:n%COLS, y:(n/COLS)|0};
}
```
Good.
Now let me make sure the AI's chosen direction respects the no-reverse rule: the candidates exclude the reverse of the current dir. The path might require a reversal (going back the way it came) — the path's first step would be the neck cell which is blocked, so the BFS wouldn't route through it. Good.
Let me now finalize the code and write it out.
Additional detail: the `dir` object is a shared reference to DIRS.*. `pending` for the player input. When the player presses a key, we set pending; but we should only allow one queued turn per tick to prevent double-turns. Simple approach: set pending = the new dir if it's not the reverse of the current dir. If the player presses two keys quickly (e.g., up then left), the second would be applied relative to `dir` (the current), which could cause a 180° reversal: e.g., dir=right, press up → pending=up; press left → left is not the reverse of right → pending=left; then the step applies left → that's a 180° turn from right → instant death. To avoid this, compare against `pending` instead of `dir`: only accept a direction that isn't the reverse of `pending`. Let's do: if (d is reverse of pending) ignore; else pending = d. Since pending starts as right, pressing left first is ignored (good). Then pending=up, then left is not the reverse of up → pending=left. The train then turns up, then on the next tick turns left. That's a smooth double turn. But wait: the step applies pending each tick, so after the first tick dir=up, pending becomes... we should reset pending=dir after applying. Then pressing left after: pending is up (not yet applied?) Let's manage: in step(), dir = pending; pending = dir (reset). But if the player presses two keys between ticks, pending changes twice with the comparison against the updated pending — that works as described.
Hmm, but if pending is reset to dir after each tick and the player presses left while dir=right, left is the reverse → ignored. Good.
Let me implement: `setDir(d)`: if (d.x === -dir.x && d.y === -dir.y) return; // reverse of the current dir → ignore. Also compare with pending: if (d.x===-pending.x && d.y===-pending.y) return; pending=d;
Both checks.
Now the code for the swipe input: track pointerdown x,y; pointerup: if |dx|>18 → setDir(dx>0?right:left); else if |dy|>18 → setDir(dy>0?down:up).
Also prevent the default touch behavior: CSS touch-action:none on the canvas. Done.
Alright, let's write everything out. I'll also include a `resize` no-op.
Let me now write the final HTML file.
One more check on the layout at 375px: the canvas scales to 375-24=351px wide → the height = 351*512/768 = 234px. The header, HUD, hint, and d-pad stack below. Total height ~ 234+60+60+120 = 474 < 800. Good.
At 1280: the canvas is 768 wide (max-width 820 for the wrap) → the wrap is 820 wide but the canvas is 768 → centered. Let's set the wrap's max-width to 768 so the canvas fills it. But the HUD might be wider — it's flexible. Set the wrap max-width to 800 and the canvas max-width to 768, margin auto. Let's set the stage's max-width:768px; margin:0 auto; width:100%.
Let me write the CSS accordingly:
```
.stage{position:relative; width:100%; max-width:768px; margin:0 auto; ...}
```
Now writing the final code. Let me be thorough and write it in one go.
Additional: the level-up obstacle addition should avoid placing an obstacle on the platform rows and the express row, and not too close to the train.
```js
function addObstacle(){
if(obstacles.size>=10) return;
const head=snake[0];
for(let tries=0;tries<60;tries++){
const x=1+Math.floor(Math.random()*(COLS-2));
const y=2+Math.floor(Math.random()*(ROWS-4)); // 2..13
if(y===EXP_ROW) continue;
const k=y*COLS+x;
if(obstacles.has(k)) continue;
if(Math.abs(x-head.x)+Math.abs(y-head.y)<5) continue;
// avoid adjacent to existing obstacles? not needed
obstacles.set(k,{x,y,type:['tower','crate','box'][Math.floor(Math.random()*3)]});
break;
}
renderBackground();
}
```
y range: 2..13 → `2+Math.floor(Math.random()*12)` gives 2..13. ROWS-4 = 12 → 2..13. OK, use `2+Math.floor(Math.random()*(ROWS-4))` = 2..13.
Wait ROWS=16, so ROWS-4=12 → 2+0..11 = 2..13. Good.
Now let me write out the whole thing.
For the ghost (fading car) rendering: draw the wagon with a globalAlpha = life.
Let me write the code for drawGhosts.
Also floaters: {x,y,text,life,color} → draw the text at (x,y - (1-life)*20) with alpha=life.
Let me write the final code.
Also, I should handle the case where the game is over and the user presses a key → restart. The overlay shows "PRESS R OR CLICK TO RESTART".
Let me make sure that in the 'over' state, the demo isn't running. The overlay is shown.
Alright, writing now.
One more consideration about `spawnWaiting`: we want 2-3 waiting passengers at the start. Let's spawn 3.
Also the waiting passengers should have distinct colors.
Let me write the code for the passenger object: {x,y,color,skin,hair,phase}.
Now, the final code:
I'll write it carefully with comments.
Let me also double check the delivery platform row = ROWS-2 = 14 and the bottom wall row = 15. The depot sign is drawn on row 15 (the wall). Good.
Also the pick platform row = 1 and the top wall row = 0 (the station).
Let me write the drawPlatformRow with the stripe at the bottom for the pick platform (row 1) and at the top for the deliver platform (row 14) (facing the track). For row 14, the track is above (rows 2..13), so the safety line should be at the TOP of row 14. For row 1, the track is below, so the line should be at the BOTTOM of row 1. Good, I'll parametrize.
Now the code. Let me write it.
Also: draw the express lane strip only in the interior (x from CELL to W-CELL).
I think we're ready. Let me write the final answer.
For the HUD, the "Train" value = snake.length (cars). "Riders" = riders.length.
Let me write the code for the level floater: floaters.push({x: W/2, y: H/2, text:'LEVEL '+level, life:1.4, color:'#ffd166', big:true}).
Let me write the code now, final.
```js
function frame(now){
requestAnimationFrame(frame);
const dt=Math.min(120, now-last); last=now;
time+=dt/1000;
// express motion
if(mode==='play'||mode==='demo'||mode==='demoCrash'){
express.x += express.d * (0.8+level*0.05) * dt/1000;
if(express.x>COLS-2.6){express.x=COLS-2.6; express.d=-1;}
if(express.x<2.6){express.x=2.6; express.d=1;}
}
// steam
...
// particles
...
// ticks
acc+=dt;
const iv=tickInterval();
let guard=0;
while(acc>=iv && guard++<4){ acc-=iv; step(); }
// express collision check per frame
if(mode==='play'||mode==='demo'){ if(expressHitsTrain()) crash(); }
render(time);
updateHUD();
}
```
Careful: step() may change the mode to 'over'; the while loop should break. The guard handles the loop; step() returns early if the mode isn't play/demo.
Express vs train collision per frame: check the snake cells against the express cells.
Hmm, but the express's float position means the express's head is between cells; the occupied cells are computed by rounding. Let's define expressCells():
```js
function expressCells(){
const out=[];
const hx=express.x;
for(let k=0;k<3;k++){
const cx=Math.round(hx - express.d*k);
out.push({x:cx,y:EXP_ROW});
}
return out;
}
```
If express.d=1 (moving right), the head is at hx and the tail is at hx-1, hx-2. Correct.
But if the express is at hx=2.6 and d=1, the cells are 3, 2, 1 → x=1 is the wall column (x=0 is the wall; x=1 is interior). OK, the tail at x=1 is fine.
The drawing should use the float position for smoothness: the loco at hx, the coaches at hx-d*1, hx-d*2 (float).
Collision check with the player's head cell: exact integer match with one of the express cells. Since the express moves continuously, it might visually overlap the player's cell without an exact match. To be a bit more forgiving/accurate, let's also check the distance from the express's head float to the player's cell center: if |expressHeadX - playerCellX| < 0.6 and the same row → crash. Let's implement a check: for each snake cell, if the cell's y === EXP_ROW and the cell's x is within 0.75 of any express float position (hx, hx-d, hx-d*2) → crash. That handles the overlap better.
expressHits(x,y): y===EXP_ROW && (|x-hx|<0.8 || |x-(hx-d)|<0.8 || |x-(hx-2d)|<0.8).
Let's use 0.8 for a slight forgiveness.
Hmm, but for the AI's pathfinding we use the integer cells. Fine.
Now the AI's blocked set uses expressCells() (integer) — a slight mismatch with the 0.8 threshold. OK.
Let's write the final code. I'll now produce the complete file.
One more detail: `updateHUD` compares values.
Finally, let me double check the initial overlay and the demo: mode='demo' at load, so the AI plays. The overlay is displayed with the "CLICK OR PRESS ANY KEY TO START" CTA.
When the user clicks anywhere → startPlay() → reset('play') → the overlay hides.
I should also make sure the click that starts the game doesn't immediately steer.
Let me write the code.
Also for mobile: the D-pad buttons use data-dir attributes; attach listeners:
```js
document.querySelectorAll('.pad button').forEach(btn=>{
btn.addEventListener('pointerdown', e=>{ e.preventDefault(); const d=btn.dataset.dir; setDir(DIRS[d]); startPlay(); });
});
```
But startPlay() resets the snake, so the direction set before the reset is lost. Let's call startPlay() first, then setDir. But startPlay resets pending to right. Then setDir(up) → pending=up. Fine. But the direction from the button press would be lost anyway since the train resets facing right. Let's make the reset preserve the requested direction: startPlay(d) → reset('play') then if d, set pending.
I'll implement: pointerdown → if mode!=='play' { startPlay(); } then setDir(...). Since startPlay resets, set the dir after. OK, my order above handles it.
Let me write the final file now.
For the "demo" banner on crash: draw a translucent band across the canvas center with "DEMO CRASH — RESTARTING…" for the demoCrash mode.
Let me write it all out now.
Final check on the canvas drawing of the station sign text: use a font that exists: 'bold 12px "Trebuchet MS", Verdana, sans-serif'.
Let me write everything.
Also, one potential issue: `ctx.ellipse` is supported in modern Chrome. Fine.
Writing the final answer now.
Let me carefully write the drawBackground function with all the pieces:
```js
function renderBackground(){
const g=bg.getContext('2d');
g.clearRect(0,0,W,H);
// ground
g.fillStyle='#29323f'; g.fillRect(0,0,W,H);
// speckles
for(const s of speckles){ g.fillStyle='rgba(255,255,255,'+s.a+')'; g.fillRect(s.x,s.y,s.s,s.s); }
// faint yard tracks
for(let y=2;y<=13;y++){ if(y===EXP_ROW) continue; faintTrack(g,y,0.10); }
// express lane
expressLane(g);
// platforms
platformRow(g,PICK_ROW,'#c8ced8','#f2c94c',false);
platformRow(g,DELIVER_ROW,'#b9c6d6','#4ecdc4',true);
// station facade
station(g);
// depot wall
depot(g);
// hedges
hedges(g);
// obstacles
for(const o of obstacles.values()) obstacle(g,o);
}
```
faintTrack(g,row,alpha): draws the ties and rails across the interior width.
```js
function faintTrack(g,row,alpha){
const y=row*CELL+CELL/2;
const x0=CELL, x1=W-CELL;
g.save();
g.strokeStyle='rgba(10,12,18,'+(alpha*1.4)+')'; g.lineWidth=9;
g.beginPath(); g.moveTo(x0,y); g.lineTo(x1,y); g.stroke();
g.strokeStyle='rgba(255,255,255,'+alpha+')'; g.lineWidth=1.2;
for(const off of [-4,4]){ g.beginPath(); g.moveTo(x0,y+off); g.lineTo(x1,y+off); g.stroke(); }
g.strokeStyle='rgba(90,70,55,'+(alpha*1.1)+')'; g.lineWidth=1.6;
for(let x=x0+3;x<x1;x+=8){ g.beginPath(); g.moveTo(x,y-6); g.lineTo(x,y+6); g.stroke(); }
g.restore();
}
```
expressLane(g): a brighter version + chevrons.
OK writing the answer now. I'll produce the complete file.
Let me make sure I handle `obstacles` as a Map from key → {x,y,type}.
Also `for(const o of obstacles.values())` works.
Let me write out everything now, final answer.