Train snakeOne-line
Mistral Large 4
by MistralReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 8 min 16 s
- Cost
- $0.105
- Output tokens
- 45,245
- Spent thinking
- 35,301 tokens
- Speed
- 91.4 tok/s
- Console errors
- 0
- First token after
- 1.3 s
- First visible text after
- 6 min 39 s
- Prompt tokens
- 375
- Code size
- 26.0 KB
- Finish reason
- stop
- Served by
- Mistral
- Model id
mistralai/mistral-large-4-0- Reasoning sent
model default- Max output
- 262,144 tokens
- Generated
- Nov 12, 2026
What it was asked
The prompt
“make me a snake game where the snake is a little train with a locomotive and colorful wagons, picking up passengers waiting on the platform”
System prompt, sent with every prompt
You are generating a self-contained web demo. It will be rendered automatically in a sandboxed iframe and compared side by side with the output of other AI models given the exact same task. Output rules: - Reply with exactly ONE fenced code block containing the complete file: ```html (or ```svg when the task asks for an SVG). No text before or after the code block. - Everything must be inline: CSS in <style>, JavaScript in <script>. No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task. - Do not use localStorage, sessionStorage, cookies, IndexedDB, alert(), confirm(), prompt() or window.open(): they are blocked in the sandbox and will throw. - The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar. - Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction. - Everything that makes the demo interesting must be visible within the first seconds: nothing hidden behind a high score, a later level or a long play session. - Games must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, so the game can be judged in action without playing. - The iframe only receives keyboard events after the user clicks it: if the demo uses the keyboard, display a clear "Click or press a key to start" message. - The code must run with zero errors in the console of the latest Chrome.
Code run-1.html · 659 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Choo-Choo Express — Train Snake</title>
<style>
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{
min-height:100vh;
font-family:"Segoe UI","Trebuchet MS",Verdana,system-ui,sans-serif;
background:radial-gradient(1200px 700px at 50% -10%, #26305a 0%, #171d38 45%, #0c101f 100%);
color:#ece9df;
overflow-x:hidden;
display:flex; justify-content:center;
}
.wrap{width:min(960px,100%); padding:14px 12px 22px;}
.hud{display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:10px; margin-bottom:4px;}
.brand{font-size:clamp(18px,3vw,24px); font-weight:800; letter-spacing:.5px; text-shadow:0 2px 8px rgba(0,0,0,.5);}
.stats{display:flex; flex-wrap:wrap; gap:8px;}
.chip{background:rgba(255,255,255,.07); border:1px solid rgba(255,255,255,.14); border-radius:999px; padding:6px 13px; font-size:14px; white-space:nowrap;}
.chip b{color:#ffd76a; margin-left:3px;}
.stage{position:relative; width:min(904px,100%); margin:8px auto 0; touch-action:none;}
.frame{background:linear-gradient(180deg,#3b404d,#262a34); border:1px solid rgba(255,255,255,.09); border-radius:18px; padding:0 10px 10px; box-shadow:0 18px 50px rgba(0,0,0,.55);}
.hazard{height:12px; margin:0 -10px 10px; border-radius:17px 17px 0 0; background:repeating-linear-gradient(-45deg,#f2c230 0 14px,#23232a 14px 28px);}
canvas{display:block; width:100%; height:auto; border-radius:0 0 9px 9px; background:#2a3a30;}
.overlay{position:absolute; inset:0; display:flex; align-items:center; justify-content:center; pointer-events:none; background:rgba(8,11,22,.42); backdrop-filter:blur(3px); border-radius:18px; padding:12px;}
.panel{background:rgba(15,19,35,.93); border:1px solid rgba(255,214,106,.4); border-radius:16px; padding:20px 26px; max-width:540px; text-align:center; box-shadow:0 12px 40px rgba(0,0,0,.6);}
.panel h1{margin:6px 0 10px; font-size:clamp(20px,3.4vw,30px);}
.panel p{margin:8px 0; line-height:1.45; font-size:clamp(13px,2vw,15px);}
.pill{display:inline-block; background:#ffd76a; color:#23232a; font-weight:800; font-size:11px; letter-spacing:1.2px; padding:4px 12px; border-radius:999px;}
.cta{color:#ffd76a; font-weight:800; font-size:clamp(14px,2.2vw,17px) !important;}
.keys{opacity:.75; font-size:12px !important;}
.blink{animation:blink 1.15s ease-in-out infinite;}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.3}}
.hidden{display:none !important;}
.hint{margin:12px auto 0; width:min(904px,100%); text-align:center; font-size:12.5px; opacity:.7; line-height:1.5;}
@media (max-width:520px){
.chip{font-size:12px; padding:4px 10px;}
.panel{padding:14px 16px;}
}
</style>
</head>
<body>
<div class="wrap">
<header class="hud">
<div class="brand">🚂 Choo-Choo Express</div>
<div class="stats">
<div class="chip">⭐ Score <b id="score">0</b></div>
<div class="chip">🧍 Aboard <b id="picked">0</b></div>
<div class="chip">🚃 Length <b id="len">4</b></div>
<div class="chip">🏆 Best <b id="best">0</b></div>
</div>
</header>
<div class="stage">
<div class="frame">
<div class="hazard"></div>
<canvas id="game" width="884" height="612"></canvas>
</div>
<div id="startOverlay" class="overlay">
<div class="panel">
<div class="pill">DEMO MODE — THE AI IS DRIVING</div>
<h1>🚂 Choo-Choo Express</h1>
<p>Steer the little train through the station and run the <b>locomotive</b> over the platform slabs where <b>passengers</b> are waiting — each one hops aboard and adds a colorful wagon! Gold <b>VIP</b> passengers add three wagons.</p>
<p class="cta blink">▶ Click or press any key to start ◀</p>
<p class="keys">← ↑ ↓ → / WASD to steer • P pause • R restart • swipe on touch screens</p>
</div>
</div>
<div id="overOverlay" class="overlay hidden">
<div class="panel">
<h1 id="overTitle">💥 Crash!</h1>
<p id="overText"></p>
<p class="cta blink">Click or press R to ride again</p>
</div>
</div>
<div id="pauseOverlay" class="overlay hidden">
<div class="panel">
<h1>⏸ Paused</h1>
<p class="cta">Press P to continue</p>
</div>
</div>
</div>
<footer class="hint">Pick up passengers standing on the platform slabs • avoid pillars, benches, your own wagons and the station edges • every passenger = one more colorful wagon!</footer>
</div>
<script>
"use strict";
/* ---------- setup ---------- */
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const scoreEl = document.getElementById('score');
const pickedEl = document.getElementById('picked');
const lenEl = document.getElementById('len');
const bestEl = document.getElementById('best');
const startOverlay = document.getElementById('startOverlay');
const overOverlay = document.getElementById('overOverlay');
const overTitle = document.getElementById('overTitle');
const overText = document.getElementById('overText');
const pauseOverlay = document.getElementById('pauseOverlay');
const COLS = 26, ROWS = 18, CELL = 34, W = COLS*CELL, H = ROWS*CELL;
const BASE_INT = 150, MIN_INT = 85;
const dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = W*dpr; canvas.height = H*dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const DIRS = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
const PALETTE = ['#e74c3c','#f39c12','#f1c40f','#2ecc71','#16a085','#3498db','#9b59b6','#e67e22','#fd79a8','#00cec9'];
const SHIRTS = ['#e74c3c','#3498db','#2ecc71','#9b59b6','#f39c12','#fd79a8','#1abc9c','#e67e22'];
const KEYMAP = {
ArrowUp:{x:0,y:-1}, ArrowDown:{x:0,y:1}, ArrowLeft:{x:-1,y:0}, ArrowRight:{x:1,y:0},
w:{x:0,y:-1}, s:{x:0,y:1}, a:{x:-1,y:0}, d:{x:1,y:0}
};
let snake, dir, queue, pendingGrowth, score, picked, best = 0, interval, tickCount;
let passengers, obstacles, smoke, pops, boom;
let mode = 'demo', over = false, paused = false;
let acc = 0, wheelAngle = 0, shakeT = 0, smokeAcc = 0, lastT = 0, demoRestartT = null;
let AC = null;
/* ---------- helpers ---------- */
function rnd(n){ return Math.floor(Math.random()*n); }
function pick(arr){ return arr[Math.floor(Math.random()*arr.length)]; }
function shade(hex, f){
const n = parseInt(hex.slice(1), 16);
const r = Math.min(255, Math.round(((n>>16)&255)*f));
const g = Math.min(255, Math.round(((n>>8)&255)*f));
const b = Math.min(255, Math.round((n&255)*f));
return 'rgb('+r+','+g+','+b+')';
}
const key = (x,y) => x+','+y;
const isOpp = (a,b) => a.x === -b.x && a.y === -b.y;
function rr(x,y,w,h,r){
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(x,y,w,h,r); else ctx.rect(x,y,w,h);
}
function passengerAt(c){ for (const p of passengers) if (p.x===c.x && p.y===c.y) return p; return null; }
function obstacleAt(c){ for (const o of obstacles) if (o.x===c.x && o.y===c.y) return true; return false; }
/* ---------- audio (lazy, only after user gesture) ---------- */
function initAudio(){
if (!AC){ try { AC = new (window.AudioContext || window.webkitAudioContext)(); } catch(e){ AC = null; } }
if (AC && AC.state === 'suspended'){ AC.resume().catch(()=>{}); }
}
function tone(f, d, type, v, delay){
if (!AC) return;
try{
const t0 = AC.currentTime + (delay||0);
const o = AC.createOscillator(), g = AC.createGain();
o.type = type; o.frequency.setValueAtTime(f, t0);
g.gain.setValueAtTime(v, t0);
g.gain.exponentialRampToValueAtTime(0.001, t0+d);
o.connect(g); g.connect(AC.destination);
o.start(t0); o.stop(t0+d+0.05);
}catch(e){}
}
/* ---------- world ---------- */
function updateHUD(){
scoreEl.textContent = score;
pickedEl.textContent = picked;
lenEl.textContent = snake.length;
bestEl.textContent = best;
}
function cellFree(x,y,avoidPass){
if (x<0||y<0||x>=COLS||y>=ROWS) return false;
for (const s of snake) if (s.x===x && s.y===y) return false;
if (obstacleAt({x,y})) return false;
if (avoidPass && passengerAt({x,y})) return false;
return true;
}
function freeCell(minDist, avoidPass){
for (let i=0;i<500;i++){
const x = rnd(COLS), y = rnd(ROWS);
if (!cellFree(x,y,avoidPass)) continue;
const h = snake[0];
if (Math.abs(x-h.x)+Math.abs(y-h.y) < minDist) continue;
return {x,y};
}
return null;
}
function spawnObstacles(){
obstacles = [];
for (let i=0;i<8;i++){
const c = freeCell(6, false);
if (c) obstacles.push({x:c.x, y:c.y, type: Math.random()<0.5 ? 'pillar' : 'bench'});
}
}
function spawnPassenger(){
if (passengers.length >= 4) return;
const c = freeCell(6, true);
if (!c) return;
passengers.push({x:c.x, y:c.y, vip: Math.random()<0.12, shirt: pick(SHIRTS), seed: Math.random()*10, side: rnd(4)});
}
function resetRun(){
snake = [{x:11,y:9},{x:10,y:9},{x:9,y:9},{x:8,y:9}];
dir = {x:1,y:0}; queue = []; pendingGrowth = 0;
score = 0; picked = 0; interval = BASE_INT; tickCount = 0; acc = 0;
passengers = []; obstacles = []; smoke = []; pops = []; boom = [];
over = false; paused = false; shakeT = 0;
pauseOverlay.classList.add('hidden');
spawnObstacles();
for (let i=0;i<4;i++) spawnPassenger();
updateHUD();
}
/* ---------- input ---------- */
function queueDir(d){
const last = queue.length ? queue[queue.length-1] : dir;
if (isOpp(d,last)) return;
if (d.x===last.x && d.y===last.y) return;
if (queue.length >= 3) queue.shift();
queue.push(d);
}
window.addEventListener('keydown', e => {
const k = e.key;
if (k === ' ' || k.indexOf('Arrow') === 0) e.preventDefault();
if (mode === 'demo') startGame();
let d = KEYMAP[k];
if (!d && k.length === 1) d = KEYMAP[k.toLowerCase()];
if (d && mode === 'play' && !over) queueDir(d);
if (k === 'r' || k === 'R'){ if (mode === 'play'){ resetRun(); overOverlay.classList.add('hidden'); } }
if (k === 'p' || k === 'P'){ if (mode === 'play' && !over){ paused = !paused; pauseOverlay.classList.toggle('hidden', !paused); } }
});
window.addEventListener('pointerdown', () => {
if (mode === 'demo') startGame();
else if (over){ resetRun(); overOverlay.classList.add('hidden'); }
});
let tsx = 0, tsy = 0;
window.addEventListener('touchstart', e => { tsx = e.touches[0].clientX; tsy = e.touches[0].clientY; }, {passive:true});
window.addEventListener('touchend', e => {
const dx = e.changedTouches[0].clientX - tsx, dy = e.changedTouches[0].clientY - tsy;
if (mode === 'demo'){ startGame(); return; }
if (over) return;
if (Math.abs(dx) < 24 && Math.abs(dy) < 24) return;
const d = Math.abs(dx) > Math.abs(dy) ? {x: dx>0?1:-1, y:0} : {x:0, y: dy>0?1:-1};
if (mode === 'play' && !over) queueDir(d);
}, {passive:true});
function startGame(){
if (mode !== 'demo') return;
mode = 'play';
clearTimeout(demoRestartT);
if (over) resetRun();
over = false;
startOverlay.classList.add('hidden');
overOverlay.classList.add('hidden');
initAudio();
if (AC){ tone(523,.08,'triangle',.15); tone(784,.12,'triangle',.13,.08); }
}
/* ---------- AI (demo driver) ---------- */
function flood(sx,sy,blocked){
const seen = new Set([key(sx,sy)]);
const q = [[sx,sy]];
let c = 0;
while (q.length && c < 600){
const cur = q.shift(); c++;
for (const d of DIRS){
const nx = cur[0]+d.x, ny = cur[1]+d.y;
if (nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
const k = key(nx,ny);
if (seen.has(k) || blocked.has(k)) continue;
seen.add(k); q.push([nx,ny]);
}
}
return c;
}
function aiDir(){
const h = snake[0];
const blocked = new Set();
for (const o of obstacles) blocked.add(key(o.x,o.y));
for (let i=0;i<snake.length-1;i++) blocked.add(key(snake[i].x, snake[i].y));
// BFS toward nearest passenger
const prev = new Map();
prev.set(key(h.x,h.y), null);
const q = [[h.x,h.y]];
let target = null;
while (q.length){
const cur = q.shift();
const x = cur[0], y = cur[1];
if (passengerAt({x:x, y:y})){ target = {x:x, y:y}; break; }
for (const d of DIRS){
const nx = x+d.x, ny = y+d.y;
if (nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
const k = key(nx,ny);
if (blocked.has(k) || prev.has(k)) continue;
prev.set(k, [x,y]);
q.push([nx,ny]);
}
}
if (target){
let cur = target;
let p = prev.get(key(cur.x,cur.y));
while (p && !(p[0]===h.x && p[1]===h.y)){
cur = {x:p[0], y:p[1]};
p = prev.get(key(cur.x,cur.y));
}
return {x: cur.x-h.x, y: cur.y-h.y};
}
// survival fallback: maximize open space, drift toward passengers
let bestD = dir, bestScore = -Infinity;
for (const d of DIRS){
if (isOpp(d,dir)) continue;
const nx = h.x+d.x, ny = h.y+d.y;
if (nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
if (blocked.has(key(nx,ny))) continue;
const space = flood(nx,ny,blocked);
let pd = Infinity;
for (const p of passengers) pd = Math.min(pd, Math.abs(p.x-nx)+Math.abs(p.y-ny));
const sc = space*2 - (pd===Infinity ? 0 : pd);
if (sc > bestScore){ bestScore = sc; bestD = d; }
}
return bestD;
}
/* ---------- game step ---------- */
function step(){
if (over) return;
if (mode === 'play'){
if (queue.length){ const qd = queue.shift(); if (!isOpp(qd,dir)) dir = qd; }
} else {
dir = aiDir();
}
const h = snake[0];
const nh = {x: h.x+dir.x, y: h.y+dir.y};
if (nh.x<0 || nh.y<0 || nh.x>=COLS || nh.y>=ROWS){ crash(); return; }
if (obstacleAt(nh)){ crash(); return; }
const p = passengerAt(nh);
const body = p ? snake : snake.slice(0, snake.length-1);
for (const s of body){ if (s.x===nh.x && s.y===nh.y){ crash(); return; } }
snake.unshift(nh);
if (p){
passengers.splice(passengers.indexOf(p), 1);
if (p.vip){
pendingGrowth += 3; score += 50; picked += 3;
pops.push({x:nh.x, y:nh.y, text:'+50 VIP!', color:'#ffd76a', life:1.3, max:1.3});
if (AC){ tone(523,.09,'sine',.16); tone(659,.09,'sine',.16,.08); tone(784,.12,'sine',.16,.16); tone(1047,.18,'sine',.14,.24); }
} else {
pendingGrowth += 1; score += 10; picked += 1;
pops.push({x:nh.x, y:nh.y, text:'+1 🧍', color:'#aef3a0', life:1.1, max:1.1});
if (AC){ tone(660,.08,'sine',.16); tone(990,.12,'sine',.14,.07); }
}
interval = Math.max(MIN_INT, interval - 3);
if (passengers.length < 4) spawnPassenger();
}
if (pendingGrowth > 0) pendingGrowth--; else snake.pop();
tickCount++;
if (tickCount % 30 === 0 && passengers.length < 4) spawnPassenger();
updateHUD();
}
function crash(){
over = true; shakeT = 0.5;
const h = snake[0];
const cx = h.x*CELL + CELL/2, cy = h.y*CELL + CELL/2;
for (let i=0;i<26;i++){
const a = Math.random()*Math.PI*2, sp = 50 + Math.random()*150;
boom.push({x:cx, y:cy, vx:Math.cos(a)*sp, vy:Math.sin(a)*sp-50, r:2+Math.random()*4.5, life:.6+Math.random()*.6, max:1.2});
}
if (AC){ tone(150,.25,'square',.2); tone(85,.4,'sawtooth',.18,.06); }
if (score > best) best = score;
updateHUD();
if (mode === 'demo'){
overTitle.textContent = '💥 Demo crash!';
overText.textContent = 'The AI engineer got into a pickle… restarting the demo.';
overOverlay.classList.remove('hidden');
clearTimeout(demoRestartT);
demoRestartT = setTimeout(()=>{ resetRun(); overOverlay.classList.add('hidden'); }, 2200);
} else {
overTitle.textContent = '💥 Crash!';
overText.textContent = 'Score ' + score + ' • ' + picked + ' passenger' + (picked===1?'':'s') + ' aboard • Best ' + best;
overOverlay.classList.remove('hidden');
}
}
/* ---------- drawing ---------- */
function drawWheel(x,y,r){
ctx.save();
ctx.translate(x,y);
ctx.fillStyle = '#23232a';
ctx.beginPath(); ctx.arc(0,0,r,0,7); ctx.fill();
ctx.strokeStyle = '#4a4a55'; ctx.lineWidth = 2; ctx.stroke();
ctx.rotate(wheelAngle);
ctx.strokeStyle = '#c9c9d4'; ctx.lineWidth = 1.6;
ctx.beginPath(); ctx.moveTo(-r,0); ctx.lineTo(r,0); ctx.moveTo(0,-r); ctx.lineTo(0,r); ctx.stroke();
ctx.fillStyle = '#8a8a96';
ctx.beginPath(); ctx.arc(0,0,r*0.4,0,7); ctx.fill();
ctx.restore();
}
function drawStar(cx,cy,r,fill,stroke){
ctx.beginPath();
for (let i=0;i<10;i++){
const a = -Math.PI/2 + i*Math.PI/5;
const rad = i%2===0 ? r : r*0.45;
const px = cx+Math.cos(a)*rad, py = cy+Math.sin(a)*rad;
if (i===0) ctx.moveTo(px,py); else ctx.lineTo(px,py);
}
ctx.closePath();
ctx.fillStyle = fill; ctx.fill();
ctx.strokeStyle = stroke; ctx.lineWidth = 1.5; ctx.stroke();
}
function drawPassenger(p, t){
const s = CELL;
const cx = p.x*s + s/2, cy = p.y*s + s/2;
const bob = Math.sin(t*4 + p.seed) * s*0.05;
// platform slab
ctx.fillStyle = p.vip ? '#ffd76a' : '#d8c69b';
rr(p.x*s+2, p.y*s+2, s-4, s-4, 5); ctx.fill();
ctx.strokeStyle = p.vip ? '#a97e12' : '#a68f63'; ctx.lineWidth = 2; ctx.stroke();
// yellow safety line
ctx.fillStyle = '#f2c230';
if (p.side===0) ctx.fillRect(p.x*s+5, p.y*s+5, s-10, 4);
else if (p.side===1) ctx.fillRect(p.x*s+5, p.y*s+s-9, s-10, 4);
else if (p.side===2) ctx.fillRect(p.x*s+5, p.y*s+5, 4, s-10);
else ctx.fillRect(p.x*s+s-9, p.y*s+5, 4, s-10);
// little person
const px = cx, py = cy + bob;
ctx.fillStyle = '#3a4152';
ctx.fillRect(px-s*0.09, py+s*0.08, s*0.07, s*0.12);
ctx.fillRect(px+s*0.02, py+s*0.08, s*0.07, s*0.12);
ctx.fillStyle = p.shirt;
rr(px-s*0.14, py-s*0.10, s*0.28, s*0.22, 4); ctx.fill();
ctx.fillStyle = '#f1c9a2';
ctx.beginPath(); ctx.arc(px, py-s*0.18, s*0.10, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#4a3222';
ctx.beginPath(); ctx.arc(px, py-s*0.20, s*0.10, Math.PI, 0); ctx.fill();
if (p.vip){
drawStar(px, py-s*0.38 + Math.sin(t*6+p.seed)*2, s*0.10, '#fff3b0', '#a97e12');
ctx.fillStyle = 'rgba(255,255,255,' + (0.4 + 0.6*Math.abs(Math.sin(t*5+p.seed))).toFixed(2) + ')';
ctx.beginPath(); ctx.arc(px-s*0.22, py-s*0.30, 2, 0, 7); ctx.arc(px+s*0.24, py-s*0.24, 2, 0, 7); ctx.fill();
}
}
function drawObstacle(o){
const s = CELL, x = o.x*s, y = o.y*s;
if (o.type === 'pillar'){
ctx.fillStyle = '#7b8494';
rr(x+s*0.18, y+s*0.14, s*0.64, s*0.74, 5); ctx.fill();
ctx.strokeStyle = '#3c4350'; ctx.lineWidth = 2; ctx.stroke();
ctx.fillStyle = '#aab2c0';
rr(x+s*0.18, y+s*0.14, s*0.64, s*0.16, 5); ctx.fill();
ctx.fillStyle = '#f2c230';
rr(x+s*0.18, y+s*0.64, s*0.64, s*0.24, 3); ctx.fill();
ctx.strokeStyle = '#23232a'; ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(x+s*0.24, y+s*0.86); ctx.lineTo(x+s*0.40, y+s*0.66);
ctx.moveTo(x+s*0.42, y+s*0.88); ctx.lineTo(x+s*0.58, y+s*0.68);
ctx.stroke();
} else {
ctx.fillStyle = '#a9713f';
rr(x+5, y+6, s-10, s*0.14, 4); ctx.fill();
ctx.strokeStyle = '#7c4f27'; ctx.lineWidth = 2; ctx.stroke();
rr(x+4, y+s*0.36, s-8, s*0.18, 4); ctx.fill(); ctx.stroke();
ctx.fillStyle = '#7c4f27';
ctx.fillRect(x+s*0.14, y+s*0.54, s*0.08, s*0.32);
ctx.fillRect(x+s*0.78, y+s*0.54, s*0.08, s*0.32);
}
}
function drawWagon(i){
const s = CELL;
const seg = snake[i], prev = snake[i-1];
const a = Math.atan2(prev.y-seg.y, prev.x-seg.x);
const cx = seg.x*s + s/2, cy = seg.y*s + s/2;
const col = PALETTE[(i-1) % PALETTE.length];
ctx.save();
ctx.translate(cx, cy); ctx.rotate(a);
ctx.fillStyle = 'rgba(0,0,0,0.28)';
ctx.beginPath(); ctx.ellipse(0, s*0.32, s*0.40, s*0.09, 0, 0, Math.PI*2); ctx.fill();
drawWheel(-s*0.22, s*0.24, s*0.10);
drawWheel(s*0.22, s*0.24, s*0.10);
ctx.fillStyle = col;
rr(-s*0.40, -s*0.24, s*0.80, s*0.46, 6); ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.4)'; ctx.lineWidth = 2; ctx.stroke();
ctx.fillStyle = shade(col, 0.62);
rr(-s*0.42, -s*0.33, s*0.84, s*0.13, 5); ctx.fill();
// window with a little passenger riding inside
ctx.fillStyle = '#cfe8f7';
rr(-s*0.30, -s*0.15, s*0.36, s*0.24, 4); ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.35)'; ctx.lineWidth = 1.5; ctx.stroke();
ctx.fillStyle = '#f1c9a2';
ctx.beginPath(); ctx.arc(-s*0.16, -s*0.02, s*0.075, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#4a3222';
ctx.beginPath(); ctx.arc(-s*0.16, -s*0.035, s*0.075, Math.PI, 0); ctx.fill();
// second window with shine
ctx.fillStyle = '#b9d9ea';
rr(s*0.02, -s*0.15, s*0.28, s*0.24, 4); ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.35)'; ctx.lineWidth = 1.5; ctx.stroke();
ctx.strokeStyle = 'rgba(255,255,255,0.55)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(s*0.08, -s*0.11); ctx.lineTo(s*0.22, -s*0.03); ctx.stroke();
ctx.restore();
}
function drawLoco(){
const s = CELL;
const h = snake[0];
const a = Math.atan2(dir.y, dir.x);
const cx = h.x*s + s/2, cy = h.y*s + s/2;
ctx.save();
ctx.translate(cx, cy); ctx.rotate(a);
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath(); ctx.ellipse(0, s*0.34, s*0.46, s*0.10, 0, 0, Math.PI*2); ctx.fill();
// cowcatcher
ctx.fillStyle = '#8f2f2f';
ctx.beginPath(); ctx.moveTo(s*0.50, s*0.10); ctx.lineTo(s*0.32, s*0.36); ctx.lineTo(s*0.50, s*0.36); ctx.closePath(); ctx.fill();
drawWheel(-s*0.30, s*0.28, s*0.11);
drawWheel(0, s*0.28, s*0.11);
drawWheel(s*0.30, s*0.28, s*0.11);
// cab
ctx.fillStyle = shade('#d64545', 0.8);
rr(-s*0.44, -s*0.30, s*0.40, s*0.50, 5); ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.4)'; ctx.lineWidth = 2; ctx.stroke();
ctx.fillStyle = '#7e2f2f';
rr(-s*0.48, -s*0.40, s*0.46, s*0.12, 5); ctx.fill();
ctx.fillStyle = '#cfe8f7';
rr(-s*0.38, -s*0.23, s*0.26, s*0.20, 3); ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.4)'; ctx.lineWidth = 1.5; ctx.stroke();
// boiler
ctx.fillStyle = '#d64545';
rr(-s*0.06, -s*0.20, s*0.50, s*0.38, 9); ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.4)'; ctx.lineWidth = 2; ctx.stroke();
ctx.fillStyle = '#8f2f2f';
rr(s*0.36, -s*0.20, s*0.10, s*0.38, 4); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.18)';
ctx.fillRect(-s*0.02, s*0.05, s*0.42, s*0.07);
// dome
ctx.fillStyle = '#f2c230';
ctx.beginPath(); ctx.arc(s*0.08, -s*0.21, s*0.065, 0, Math.PI*2); ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.35)'; ctx.lineWidth = 1.5; ctx.stroke();
// chimney
ctx.fillStyle = '#3a3a44';
rr(s*0.24, -s*0.42, s*0.13, s*0.24, 3); ctx.fill();
ctx.fillStyle = '#23232a';
rr(s*0.21, -s*0.47, s*0.19, s*0.09, 3); ctx.fill();
// headlight
ctx.fillStyle = '#ffe9a8';
ctx.beginPath(); ctx.arc(s*0.44, -s*0.08, s*0.085, 0, Math.PI*2); ctx.fill();
ctx.restore();
// headlight glow
const gx = cx + Math.cos(a)*s*0.55, gy = cy + Math.sin(a)*s*0.55;
const g = ctx.createRadialGradient(gx, gy, 2, gx, gy, s*0.75);
g.addColorStop(0, 'rgba(255,238,170,0.5)');
g.addColorStop(1, 'rgba(255,238,170,0)');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(gx, gy, s*0.75, 0, Math.PI*2); ctx.fill();
}
function emitSmoke(){
const h = snake[0];
const a = Math.atan2(dir.y, dir.x);
const cx = h.x*CELL + CELL/2, cy = h.y*CELL + CELL/2;
const lx = CELL*0.30, ly = -CELL*0.44;
const wx = cx + lx*Math.cos(a) - ly*Math.sin(a);
const wy = cy + lx*Math.sin(a) + ly*Math.cos(a);
smoke.push({x:wx, y:wy, vx:-dir.x*10 + (Math.random()-0.5)*8, vy:-20 - Math.random()*16, r:2.5+Math.random()*2, life:0.8+Math.random()*0.5, max:1.3});
if (smoke.length > 90) smoke.shift();
}
function render(t){
ctx.save();
if (shakeT > 0) ctx.translate((Math.random()-0.5)*10*shakeT*2, (Math.random()-0.5)*10*shakeT*2);
// floor
for (let y=0; y<ROWS; y++){
for (let x=0; x<COLS; x++){
ctx.fillStyle = ((x+y)&1) ? '#2e3f35' : '#2a3a30';
rr(x*CELL+1, y*CELL+1, CELL-2, CELL-2, 5); ctx.fill();
}
}
// deadly station edge
ctx.fillStyle = 'rgba(0,0,0,0.35)';
ctx.fillRect(0,0,W,6); ctx.fillRect(0,H-6,W,6); ctx.fillRect(0,0,6,H); ctx.fillRect(W-6,0,6,H);
ctx.strokeStyle = '#f2c230'; ctx.lineWidth = 3; ctx.setLineDash([16,12]);
ctx.strokeRect(9, 9, W-18, H-18);
ctx.setLineDash([]);
// waiting passengers
for (const p of passengers) drawPassenger(p, t);
// obstacles
for (const o of obstacles) drawObstacle(o);
// couplings
ctx.strokeStyle = '#17171f'; ctx.lineWidth = 6; ctx.lineCap = 'round';
ctx.beginPath();
for (let i=1; i<snake.length; i++){
const a = snake[i-1], b = snake[i];
ctx.moveTo(a.x*CELL+CELL/2, a.y*CELL+CELL/2);
ctx.lineTo(b.x*CELL+CELL/2, b.y*CELL+CELL/2);
}
ctx.stroke();
// wagons, tail first
for (let i=snake.length-1; i>=1; i--) drawWagon(i);
// locomotive
drawLoco();
// smoke
for (const p of smoke){
ctx.fillStyle = 'rgba(205,205,215,' + (Math.max(0, p.life/p.max)*0.45).toFixed(3) + ')';
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, 7); ctx.fill();
}
// explosion
for (const p of boom){
ctx.fillStyle = 'rgba(' + (p.r>4 ? '255,130,40' : '255,60,40') + ',' + Math.max(0, p.life/p.max).toFixed(3) + ')';
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, 7); ctx.fill();
}
// floating score popups
for (const p of pops){
const px = p.x*CELL + CELL/2;
const py = p.y*CELL - (1 - p.life/p.max)*CELL*1.3;
ctx.globalAlpha = Math.max(0, Math.min(1, p.life/0.3));
ctx.font = 'bold ' + Math.round(CELL*0.5) + 'px "Trebuchet MS", sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.lineWidth = 4; ctx.strokeStyle = 'rgba(0,0,0,0.7)';
ctx.strokeText(p.text, px, py);
ctx.fillStyle = p.color;
ctx.fillText(p.text, px, py);
ctx.globalAlpha = 1;
}
ctx.restore();
}
/* ---------- main loop ---------- */
function updateFx(dt){
smokeAcc += dt;
if (smokeAcc > 0.085){ smokeAcc = 0; if (snake && snake.length && !over) emitSmoke(); }
for (let i=smoke.length-1; i>=0; i--){
const p = smoke[i];
p.life -= dt; p.x += p.vx*dt; p.y += p.vy*dt; p.vy -= 12*dt; p.r += 7*dt;
if (p.life <= 0) smoke.splice(i,1);
}
for (let i=pops.length-1; i>=0; i--){ pops[i].life -= dt; if (pops[i].life <= 0) pops.splice(i,1); }
for (let i=boom.length-1; i>=0; i--){
const p = boom[i];
p.life -= dt; p.vy += 340*dt; p.x += p.vx*dt; p.y += p.vy*dt;
if (p.life <= 0) boom.splice(i,1);
}
}
function frame(now){
requestAnimationFrame(frame);
let dt = (now - lastT)/1000;
lastT = now;
if (dt > 0.05) dt = 0.05;
if (dt < 0) dt = 0;
wheelAngle += dt*9;
if (shakeT > 0) shakeT -= dt;
if (!over && !paused){
acc += dt*1000;
let guard = 0;
while (acc >= interval && !over && guard < 5){ step(); acc -= interval; guard++; }
if (acc > interval*5) acc = 0;
}
updateFx(dt);
render(now/1000);
}
/* ---------- boot ---------- */
resetRun();
requestAnimationFrame(function boot(t){ lastT = t; requestAnimationFrame(frame); });
</script>
</body>
</html>Reasoning trace (35,301 tokens)
The user wants a snake game where the snake is a train with a locomotive and colorful wagons, picking up passengers waiting on the platform. I need to build a self-contained HTML file with inline CSS and JS.
Key requirements:
- One fenced code block, complete HTML file
- Inline CSS/JS, no external resources
- No localStorage etc.
- Works in 1280×800, adapts to 375px wide, no horizontal scrollbar
- Something visible on first paint (screenshot at ~3s)
- Game must start in self-playing demo mode behind a "Click or press a key to start" message
- Keyboard events only after click — so show "Click or press a key to start" overlay
- Zero console errors in latest Chrome
Design ideas:
- Canvas-based snake game on a grid, styled as a train station. The snake is a train: locomotive (engine) at head with details (chimney, window, cowcatcher), followed by colorful wagons (each segment a wagon with different color, wheels, windows).
- Passengers wait on platforms — maybe drawn as little people (circles with bodies) standing on platform tiles at edges. When the train's locomotive passes over/adjacent to a passenger, they board — wagon gets added (snake grows), passenger count increases, score increases.
- Maybe passengers appear on the platform randomly; the train must "pick them up" by driving the locomotive over the platform tile where the passenger stands. That's clean: passengers stand on platform tiles (border ring), and when the head enters a platform tile occupied by a passenger, the passenger boards and the train grows by one wagon, with a little "+1 passenger" popup and sound.
- Food could be passengers themselves — that fits: instead of apples, passengers are the pickups. Maybe also occasionally a "golden passenger" worth more, or coal to speed up? Keep simple: passengers = growth + score. Maybe special items: a "coal" item that gives speed boost, and a "star passenger" worth 5. But keep scope manageable.
Game mechanics:
- Grid, e.g., 24×16 cells at 1280 wide → cell ~40px... Let's compute: canvas responsive. Use fixed logical grid, say 26 cols × 18 rows, canvas sized to fit container while preserving aspect ratio, scaled via CSS. Use devicePixelRatio scaling for crispness.
- The playfield is the "tracks" area (grass/track), surrounded by a platform ring (1 cell wide) where passengers wait. Walls = station building? If the platform is the border, then colliding with border = death? Classic snake: hitting wall kills. Here maybe hitting the station building (border) ends the game. But passengers are ON the platform (border cells), so the train must enter border cells to pick up passengers... that conflicts with wall collision.
Alternative: platform is a ring INSIDE the field, or platforms along edges but with gaps. Simpler: passengers spawn on random edge-adjacent inner cells? Hmm.
Let me restructure: The field is the tracks (inner area). Around it is the platform (border ring) — the train CAN drive onto platform cells (they're part of the grid) but there are "station obstacles" (benches, pillars, kiosks) placed on platform cells that are deadly. Passengers stand on some platform cells. So the snake can roam the whole grid including platform, but must avoid obstacles and itself. That's more interesting: the whole grid is traversable; obstacles are placed randomly (benches etc.) but not on passenger cells.
Actually simpler and thematic: whole grid is track area; passengers spawn at random cells (standing "on the platform" visualized with a little platform tile under them — draw a platform patch under each passenger cell). Train picks up passenger by driving locomotive onto that cell → passenger boards (train grows), popup "+1", score. Obstacles: a few "signal posts"/"benches" scattered that kill on collision. Maybe spawn new obstacles as score grows. Keep obstacles static per run, spawned at start avoiding spawn area.
Self-playing AI: demo mode where a simple pathfinding AI (BFS) guides the snake toward the nearest passenger while avoiding obstacles and its own body. If no safe path, wander. When user clicks/presses key, switch to manual control (arrow keys/WASD). Show overlay "Click or press a key to start" — in demo mode the AI plays automatically; clicking switches to player control. Also allow pressing key to start.
Actually requirement: "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." So: on load, demo AI plays, overlay says "Click or press a key to start". On click/keypress, overlay hides and player controls. Maybe also show "Demo mode" label. If the player dies, show game over with restart.
AI: BFS from head to nearest passenger target, considering body as obstacles (excluding tail cell which will move, unless growing). Standard approach: treat body cells as blocked except the tail (since it moves away) — approximation. Follow the path. If BFS fails, do a "survival" move: choose move maximizing free space (flood fill) or just any safe move. This is enough for a demo.
Speed: tick every ~140ms in demo, maybe 120ms; increase slightly with score. Use requestAnimationFrame with accumulator, or setInterval. Use rAF loop with time accumulation for smoothness; movement on grid ticks. Also animate wheels rotating and smoke puffs from chimney for juice — draw-based animation independent of grid ticks. Smoke particles emitted from locomotive each tick or continuously.
Visuals:
- Background: night? A cozy station at dusk. Draw ballast/track: grid of subtle tiles, with track lines? Simpler: dark green/brown ground with grid dots, plus a decorative platform border drawn around canvas edges? If passengers can be anywhere, platform patches under them.
- Actually nicer: draw the entire grid as "station floor" tiles (checker of two muted tones), tracks drawn as wooden sleepers under the train? Could draw rail tracks along rows/cols... too complex. Keep: floor tiles + when a passenger is present, draw a platform slab (lighter tile with yellow safety line) on that cell.
- Locomotive: rounded rect body in red/teal, cabin with window, chimney with smoke, cowcatcher triangle at front, two/three wheels that rotate (spokes), headlight glow in movement direction. Direction matters: draw train oriented by direction of each segment (head direction for head; body segments oriented by direction from prev to next).
- Wagons: each wagon a colored rounded rect with a window and two wheels; color cycles through palette. Maybe wagon contains a passenger icon (little head) when it has picked up passengers? Cute: each wagon shows number of passengers? Simpler: wagons get a little passenger head drawn inside if "occupied". Could track that wagons are just colored; when picking up, add wagon — the wagon could display a small face. Let's draw a small passenger silhouette in each wagon (they're "riding"), and the count equals train length. Fun.
- Passengers waiting: little person: head circle, body trapezoid, different shirt colors, standing on platform slab with yellow line. Maybe slight bobbing animation. Occasionally a passenger with a suitcase. Golden passenger (star) worth 3 wagons? Keep: normal passenger = +1 wagon, +10 pts; rare "VIP" golden = +3 wagons, +50 pts, sparkle.
- Popups: floating text "+1 🧍" rising and fading.
- Score HUD: top bar with score, passengers rescued, length, speed. Styled as station sign.
- Game over: overlay panel "The train crashed!" with score and "Press R or click to restart".
- Start overlay: semi-transparent panel: title "CHOO-CHOO! Passenger Express", "🚂 Drive the train and pick up passengers waiting on the platform", "Click or press any key to take control", "Arrow keys / WASD to steer". In demo mode, AI is playing; label "DEMO — AI driving".
Controls: Arrow keys + WASD. Prevent default scrolling for arrows/space. On keydown, if overlay visible → start game (player mode) and apply the key as first direction maybe. Also click starts. Also add touch? Not required; maybe add swipe for mobile — nice-to-have; keep simple but add basic swipe support since 375px adaptation matters. Swipe to steer works without click... but keyboard events need click; touch works anyway. I'll add touch swipe.
Pause when tab hidden? Not necessary.
Canvas sizing: container flex center; canvas logical size: cols=26, rows=18, cell=36 → 936×648 fits in 1280×800 with HUD. Make canvas responsive: compute cell size from min(vw, vh constraints) but keep grid constant; render at devicePixelRatio. Simplest: fixed logical canvas 936×648, CSS width: min(96vw, calc((100vh - hud) * ratio)) etc. Use ResizeObserver or just CSS: canvas { width: min(94vw, 936px); height: auto; } and internal resolution scaled by dpr. Input mapping not needed (keyboard only). Good.
At 375px wide: canvas width 94vw ≈ 352px, height auto ≈ 243px — small but fine; HUD wraps. Maybe increase rows/cols? Keep grid constant; it's fine. Could make cell size adaptive by using more cols on small screens — unnecessary complexity. Keep 26×18. Actually for small screens, maybe reduce to fit: fine.
Let me define grid: COLS=26, ROWS=18. Head starts at center, moving right, length 4 (loco + 3 wagons).
Passengers: maintain up to e.g. 4 waiting at once; spawn on random free cell not on snake, not obstacle, not too close to head (min manhattan distance 5). Each passenger has color, maybe VIP chance 12%.
Obstacles: "benches/pillars" — spawn K = 6 + floor(score/100) obstacles at start, random cells not near head (dist ≥ 4), not on snake. Render as gray pillar/bench. Collision with obstacle or self or out-of-bounds = crash.
Wait — if passengers can spawn anywhere on the floor, the "platform" theme is the slab under them. Good enough, and I'll draw a station-ish border frame around the canvas (CSS/HTML decorative frame with lamps?) Keep the play area framed with a wooden/station border via CSS.
Growth: on pickup, grow by 1 (don't pop tail) plus VIP grows 3. Score += 10 (VIP 50). Also maybe combo if pickups quick — skip.
Speed: base interval 150ms, decreases to min 90ms as score grows: interval = max(90, 150 - score/40).
AI details:
- Target: nearest passenger by BFS distance (or manhattan). BFS over grid avoiding snake body (except tail) and obstacles. If path found, take first step.
- Else: choose among safe neighbor cells (not obstacle, not body, in bounds) the one with max reachable free cells (flood fill) to maximize survival; tie-break by distance to nearest passenger.
- Also avoid reversing (can't anyway since opposite of current dir is blocked by body unless length 1... head can't reverse into neck).
- Edge case: if no safe move, keep direction (will crash) — in demo that's fine, then show game over overlay with "demo crashed, click to play again"? On crash in demo mode, auto-restart after 2s? That keeps demo alive for screenshot. Let's: on crash, show crash overlay; if demo mode, auto restart after 2.5s; if player mode, wait for click/R.
Actually the overlay "Click or press a key to start" is shown during demo. After click, player mode. The demo should be visibly playing behind the overlay — so overlay should be semi-transparent / positioned at bottom or as a small banner, not covering the whole game. Requirement says "behind a 'Click or press a key to start' message" — so overlay can be translucent with the game visible. I'll make a centered translucent card with backdrop blur, game visible around it. Screenshot at 3s shows game in action + message.
HUD: top bar: 🚂 title, Score, Passengers, Length, Best? No localStorage allowed — track best in memory only. Fine.
Sound: optional with WebAudio (no external files). Nice juice: pickup chime, crash thud. Audio requires user gesture; in demo mode no gesture, so guard: only init audio after first interaction; or create on first key/click. Simple: create AudioContext lazily on first user gesture; demo silent. That's fine and zero errors.
Drawing details per cell size s:
- Floor: checkerboard of #2f3b2f? Let's pick a dusk station palette: background outside canvas: deep indigo #1b2340 gradient; canvas floor: alternating #3a4a3f / #35453b? Hmm, station floor maybe warm dark: #4a3f35 / #443a31 with subtle noise dots. Platform slabs: #c9b48a with yellow line #e8c33a. Let me choose:
- Page bg: radial/linear gradient dark blue-purple (#141a33 → #0d1124), some stars? Maybe subtle CSS stars via box-shadows — could add few absolutely positioned dots. Keep light.
- Canvas floor: two tones of dark slate green: #2e3d33 and #2a382f, rounded tile look with 1px gap showing darker bg.
- Rails: maybe draw faint rail lines? Skip.
- Obstacle "pillar/bench": draw a bench: brown rounded rect with legs, or a pillar with stripes (hazard). Let's do "buffer stop / pillar": gray-blue pillar with diagonal hazard stripes at base. Collision = crash. Alternatively make obstacles non-lethal "slow"? Keep lethal, classic.
- Passenger: platform slab (light tan) occupying cell with yellow edge line on the side facing center? Simpler: slab inset 2px, color #d8c49a, border #b89f6f, plus a yellow line 3px along one edge (random per passenger). Person: legs (two small rects), body (rounded rect shirt color), head (circle skin #f2c9a0), maybe hair arc, arms? Keep simple but cute. Bobbing: offset y by sin(time*3 + seed)*1.5px. VIP: golden slab + star above head + sparkles.
- Train:
- Compute each segment's direction: for head, its dir; for segment i, dir from segment[i-1] to segment[i]? Actually segment i's orientation = direction from segment i to segment i+1 (toward tail) or from i-1 to i. For drawing a wagon, orientation along the axis from prev to next. For straight segments, axis = dir between neighbors. For turns, draw a connector; simpler: draw each wagon as rounded rect centered in cell oriented along (next - prev) direction; if turn, use incoming direction. Also draw coupling (small dark rect) between segments.
- Head (locomotive): draw facing its dir. Body: main boiler (rounded rect) in crimson #d64545 with darker outline; cab at rear: taller rect with window (light blue #bfe8ff) and roof; chimney near front top with smoke; front cowcatcher triangle; headlight: glowing circle at front (radial gradient yellow) + light beam maybe. Wheels: 3 circles along bottom, dark with lighter hub, rotating spokes: angle += speed. Since cell is small (36px), keep details chunky.
- Orientation: easiest to draw head in local coords facing +x (right), then rotate canvas by angle of dir. For each wagon similarly rotate by its axis angle.
- Wagon colors palette: ['#e74c3c','#f39c12','#f1c40f','#2ecc71','#1abc9c','#3498db','#9b59b6','#e67e22','#fd79a8','#00cec9'] cycle by index.
- Wagon: rounded rect body, roof slightly darker arc, window (rounded rect light), two wheels, and a little passenger head peeking (circle) — since wagons carry picked-up passengers. Draw head inside window. Cute.
- Couplings: small dark circles/rects between consecutive segments — draw line from cell center to neighbor center with dark stroke before drawing vehicles.
- Smoke: particles array; each tick (or every frame with probability) spawn at chimney world pos: gray circles rising, expanding, fading. Compute chimney pos from head cell + dir offset. Emit continuously in rAF for smoothness (rate limited).
- Popup texts: array {x,y,text,color,t}; rise and fade over 1s.
- Headlight glow: radial gradient at front of loco.
Animation loop: rAF; accumulate dt; while acc >= interval: step(). Render every frame (for smooth smoke/wheels/popups). dt clamp.
Step():
- Determine next dir: player queue or AI.
- newHead = head + dir.
- Check collisions: out of bounds, obstacle, self (body cells; but if not growing, tail cell will be vacated — allow moving into tail cell). Standard: compute willGrow = passenger at newHead (or VIP). Body to check = willGrow ? all segments : all except tail.
- If collision → crash: state='over', spawn explosion particles? maybe screen shake + crash overlay. In demo, schedule auto restart.
- Else move: unshift newHead; if willGrow: handle pickup (remove passenger, grow count, score, popup, sound, maybe spawn new passenger), don't pop tail. Else pop tail.
- Spawn passengers to maintain count (up to 4): random free cell, min dist from head 6, not obstacle.
- Obstacles: fixed per run.
Player input: queue of directions; on keydown map; prevent reversal relative to last queued/current dir. Buffer up to 2-3 inputs per tick to allow quick turns.
Start flow:
- state = 'demo' initially; AI controls; overlay visible: "🚂 Demo — the AI is driving. Click or press any key to take the wheel!"
- On pointerdown or keydown (when state demo): state='play'; hide overlay; init audio; if the key was a direction key, queue it.
- In 'play', on crash: overlay "Crash! Score X — Click or press R to retry"; clicking/R restarts (reset, state='play').
- In 'demo', on crash: small banner "Demo crashed — restarting…" then auto reset after 2s, stay demo. Actually simpler: auto restart immediately after 1.8s with overlay still shown.
- Also 'R' key restarts anytime.
- Space/P pause? optional; add P pause in play mode. Keep minimal but fine.
Also show controls hint at bottom: "←↑↓→ / WASD to steer • P pause • R restart".
Edge: keydown when overlay visible and key is e.g. Enter — start too. "press any key to start" — yes any key starts. But careful: arrows also preventDefault to avoid scroll.
Touch: touchstart records point; touchend computes swipe direction → queue dir; also first touch starts game (if demo).
Rendering crispness: set canvas.width = W*dpr etc., ctx.setTransform(dpr,0,0,dpr,0,0). CSS width set via style to fit container: I'll compute in JS on resize: available = container client size; scale = min(availW/W, availH/H); canvas.style.width = W*scale+'px'. Container height flexible. Simpler: canvas CSS max-width:100%, height auto with fixed aspect via width/height attrs ratio; but dpr changes attr ratio? If I set canvas.width=W*dpr and height=H*dpr, CSS width: min(936px, 94vw) height auto keeps ratio since attr ratio same. That works without JS resize. But on short screens (800px height) 936×648 + HUD fits: HUD ~64 + canvas 648 + padding = ~740 < 800. OK. Use CSS: canvas{ width:min(936px, 94vw); height:auto; image-rendering:auto; border-radius; box-shadow; }. At 375px: width 352, height 243 — small; HUD wraps to two rows; total ~ 64+243+ hints fits 800? yes plenty. Maybe on small screens allow canvas bigger relative: width:min(936px, 96vw). fine.
Hmm, at 375×~667 phones, 243px tall canvas is smallish but acceptable; could increase cols? No—keep.
Alternatively make grid adaptive: cell = clamp based on width... skip, fine.
Let me also add a subtle "station" frame: canvas wrapped in a div with thick border styled like platform edge (yellow-black hazard stripe top?). Nice: .frame { padding:10px; background:#2b2b33; border-radius:16px; } with a hazard stripe strip via repeating-linear-gradient on a top bar. Cute and cheap.
HUD layout: flex row, wrap, gap; items as "sign" chips: dark bg, amber text, monospace-ish font (system fonts only — use font-family: 'Trebuchet MS', Verdana, sans-serif or ui-monospace). No external fonts allowed. Use system stack.
Title: "🚂 Choo-Choo Express". Subtitle small.
Now the AI BFS:
function aiDir(){
const head = snake[0];
const blocked = new Set(obstacles.map(o=>o.x+','+o.y));
snake.forEach((s,i)=>{ if(i < snake.length-1) blocked.add(s.x+','+s.y); }); // tail can move
// Actually if about to grow, tail stays; but we don't know yet; ignoring tail is standard and fine.
// BFS to nearest passenger
let best target: passengers sorted by manhattan; BFS from head; stop when reach any passenger cell; reconstruct first step.
BFS: queue of cells, prev map. visited set includes blocked + head start.
For neighbors: in bounds, not blocked.
If cell has passenger → path found.
If no passenger reachable: fallback survival: for each safe neighbor dir (not blocked, in bounds, not reversing), compute flood fill count from that neighbor avoiding blocked+body; pick max count; tie → closer to nearest passenger.
Return dir.
}
BFS each tick on 26×18=468 cells — trivial.
Player direction queue: dirs array; each tick, shift one if valid (not opposite of current dir). Validate when consuming: newDir not opposite of snake[0] dir (the actual current movement). Also ignore same dir.
Crash handling: set state 'over'; crashAnim: shake canvas (translate random for 300ms), spawn red particles at head, play sound (if audio), show overlay panel with score. In demo: after 2000ms reset() keeping demo state and overlay start message. Implement with timestamp check in loop or setTimeout (setTimeout fine).
Restart reset(): snake = [{x:10,y:9},{x:9,y:9},{x:8,y:9},{x:7,y:9}] dir right; length via array; wagons = snake.slice(1). passengers = spawn 4; obstacles = spawn 8; score=0; interval reset; popups=[]; smoke=[]; state as given.
Wait: snake array includes head; wagons derived. Growth: push? Snake movement: unshift new head; pop tail unless growing. Initial length 4 → 1 loco + 3 wagons.
Passenger spawn function: attempts loop 200: random x,y in bounds; not on snake; not obstacle; not existing passenger; manhattan from head >= 6 (demo AI needs room; in play maybe >=4). Place with random color from palette, vip = Math.random()<0.12.
Obstacles spawn: count = 7; random cell not within dist 5 of head start, not on snake; store {x,y,type:'pillar'|'bench'} random; draw accordingly. Also ensure not blocking? fine.
Collision with self: build set of body cells except tail when not growing.
Pickup check: passenger at newHead cell → pickup.
VIP grow: grow 3 (i.e., don't pop for 3 ticks — implement pendingGrowth counter). Normal: pendingGrowth += 1; VIP += 3. In step: after moving, if pendingGrowth>0 then pendingGrowth-- else pop tail. Cleaner.
Score: normal +10, vip +50; also length bonus? Keep. HUD passengers count = total picked.
Speed interval: base 150; interval = Math.max(88, 150 - Math.floor(totalPicked/3)*8)? Use score: interval = Math.max(85, 155 - score*0.15)? score 100 → 140; 500 → 80. Let's: interval = Math.max(85, 150 - picked*6). picked 10 → 90. ok.
Also add slight speed-up each pickup: interval = max(85, interval-4) dynamic. fine.
Sound (WebAudio, lazy):
- pickup: two quick sine notes (E6→A6) with gain envelope.
- vip: arpeggio.
- crash: noise burst / low square thud.
- click start: short blip.
Guard try/catch.
Smoke particles: each frame, if head exists, emit with prob dt*8: particle at chimney pos: chimney offset in head local coords (front-top): local (s*0.28, -s*0.18) rotated by dir. Particle {x,y,vx,vy,r,life}. vy negative (up), slight vx opposite dir. Draw as gray circle alpha fade. Cap 80.
Wheel rotation: wheelAngle += dt * speed factor (e.g., 6 rad/s * (intervalFactor)). Just wheelAngle += dt*10.
Headlight: at head front center: local (s*0.5, 0): radial gradient circle r=s*0.28 yellow-white alpha 0.9→0. Also draw small beam? skip beam.
Draw order: floor → platform slabs+passengers → obstacles → couplings → wagons (tail to head) → loco → smoke (behind loco? smoke should be above/behind — draw after, rising up so fine either way; draw smoke after train so visible) → popups → headlight glow maybe before smoke. Also vignette? skip.
Turn rendering: for wagon i (index>=1), prev=snake[i-1], next=snake[i+1] (or tail end: next = snake[i-1]? for last segment, next = last + (last - prev)? just use prev for orientation). axis = (next? next-prev : prev - snake[i-1]?) Simplify: dirIn = snake[i-1] - snake[i] (points from wagon to previous), dirOut = snake[i+1] ? snake[i+1]-snake[i] : dirIn... For orientation angle use dirIn (direction from wagon toward head) — consistent along train. angle = atan2(dirIn.y, dirIn.x). For a straight wagon that's along the train. For turns, using dirIn makes wagon align with incoming segment — acceptable visually with couplings drawn as lines between centers.
Couplings: for i from 1..len-1: line from center(snake[i-1]) to center(snake[i]), stroke dark #222 width s*0.12, drawn before vehicles.
Head angle from dir.
Loco drawing (local, facing +x, cell s, origin center):
- shadow ellipse under.
- cowcatcher: triangle at front bottom: points (s*0.5, s*0.12),(s*0.38, s*0.34),(s*0.5, s*0.34)? Let's design loco occupying most of cell: body from x=-s*0.42 to x=+s*0.42.
- Boiler: rounded rect x -s*0.05..s*0.45, y -s*0.22..s*0.18, color crimson, darker bottom stripe.
- Cab: rect x -s*0.42..-s*0.02, y -s*0.34..s*0.18, color darker red; roof rect x -s*0.46..0.0, y -s*0.42..-s*0.34 color #7c2d2d; window: rounded rect x -s*0.34..-s*0.12, y -s*0.28..-s*0.06 fill #bfe8ff with frame.
- Chimney: rect x s*0.28..s*0.40, y -s*0.42..-s*0.22 fill dark; top rim wider rect y -s*0.46..-s*0.40.
- Dome: small circle at x s*0.12, y -s*0.24 gold.
- Headlight: circle at (s*0.47, -s*0.05) r s*0.09 fill #ffe9a8 + glow.
- Wheels: three at x -s*0.28, 0.0, s*0.30, y s*0.26, r s*0.11: fill #2b2b2b stroke #555; hub circle r s*0.045 #999; spokes: two lines rotated by wheelAngle.
- Outline: stroke body parts with rgba(0,0,0,.35).
Wagon drawing (facing +x):
- body rounded rect x -s*0.40..s*0.40, y -s*0.26..s*0.14, color from palette by index, stroke darker.
- roof: rounded rect x -s*0.42..s*0.42, y -s*0.34..-s*0.24, darker shade (use same color with black overlay alpha .25 → use ctx fill rgba(0,0,0,0.25) over? simpler: precomputed darker via color-mix? Not reliable everywhere—actually color-mix supported in latest Chrome. Safer: draw roof as same color then overlay black alpha rect. Or compute darker in JS: parse hex, scale 0.75. I'll write shade(hex,f) helper.
- window: rounded rect x -s*0.26..s*0.10, y -s*0.16..s*0.04 fill #cfeeff; passenger head inside: circle at (x -s*0.08, y -s*0.02) r s*0.075 skin + hair.
- second small window? one is fine.
- wheels: two at x -s*0.22, s*0.22, y s*0.22, r s*0.10 with spokes.
- Maybe cargo: some wagons show coal? skip.
Passenger drawing at cell (cx,cy) with bob = sin(t*4+seed)*s*0.04:
- platform slab: rounded rect inset s*0.06, fill #d9c9a3, stroke #a68f63; yellow line: rect along one edge (random side) height s*0.07 fill #f2c230.
- person: legs: two rects w s*0.07 h s*0.12 at y +s*0.16 dark pants; body: rounded rect w s*0.26 h s*0.24 y -s*0.02.. color shirt; arms skip; head circle r s*0.10 at y -s*0.14 skin #f1c9a2; hair: half-circle top darker. VIP: slab gold #ffd76a, star above (draw 5-point star small) + sparkles random twinkle; body purple.
Draw star helper.
Popup: text like "+1 🧍" or "+50 VIP!" — emoji render fine. font bold s*0.5 px.
Obstacles:
- pillar: rounded rect inset s*0.14 fill #6b7280 stroke #374151, with hazard stripes bottom: clip and diagonal yellow/black? Simplify: base rect fill #4b5563; top cap #9ca3af; diagonal stripe: save, clip rect, rotate stripes... keep simple: draw rect + 3 diagonal yellow lines. Or bench: seat brown rect + two legs. Random type. I'll do both types with simple shapes.
Floor: for each cell fill checker color; also draw subtle inner shadow: stroke rgba(0,0,0,0.15). Maybe draw rivet dots at corners? skip. Add faint track lines? Could draw two horizontal rail lines per row? Might look noisy. Skip.
Also draw border walls? Out of bounds is death; indicate edge with hazard stripe around canvas inner edge: draw in render: stroke rect inset 2 with dashed? Draw 4 edge strips: repeating hazard pattern along inner edge (like platform edge). Nice: draw hazard stripe border inside canvas: for each edge, pattern of yellow/dark diagonal — implement by drawing small triangles? Simple: fill rect strips width s*0.18 along each edge with rgba(0,0,0,0.25), then dashed yellow line at inset s*0.18: ctx.setLineDash([s*0.3, s*0.2]) stroke #f2c230 lineWidth 3. Good.
Game over overlay: HTML div absolutely positioned over canvas wrapper: panel with crash message, final score, best (in-memory), "Click or press R to ride again". Start overlay similar with title/instructions and blinking "▶ Click or press any key to start". Demo badge: "DEMO MODE — AI driving" pill top of overlay. Overlay pointer-events: none? We need click to start — attach listener on window/canvas, so overlay can have pointer-events:none and clicks pass to document handler. Simpler: listen on document for pointerdown/keydown. Overlay pointer-events none so game visible. Good.
Pause: 'P' toggles paused (only in play); overlay "Paused". Keep simple: paused flag stops stepping; render continues. Show small text? Add to HUD? Just show overlay text "Paused — press P". Manage overlays: one overlay div reused with dynamic content? I'll have separate divs: #startOverlay, #overOverlay, #pauseHint. Toggle display.
State machine: mode: 'demo' | 'play'; over: bool; paused: bool.
- demo & !over: AI drives; startOverlay visible (with demo note).
- on start(): mode='play'; hide startOverlay; initAudio; if over overlay visible hide it; reset if over.
Actually flow: initial: reset(); mode='demo'; startOverlay show. Loop runs AI.
- pointerdown/keydown: if mode==='demo' → mode='play'; hide startOverlay; audio init; (if over impossible in demo before start). If key is direction and mode==='play' queue it. If mode==='play' && over && (key==='r'||'R'||Enter or click) → reset(); over=false; hide overOverlay. If mode==='play' && !over && key==='p' toggle pause. If demo && key==='r'? ignore.
- In demo, if crash: over=true; show mini overlay? Show overOverlay with "Demo crashed! Restarting…" and setTimeout 1800 → reset(), over=false, mode stays 'demo', hide overlay. But if user clicked during that window → mode='play' and over handling changes... Manage: keep a pendingDemoRestart timeout id; on start() clear it and set mode='play', over=false, reset? If demo crashed and user clicks "to start", we want fresh run in play mode: reset(); over=false; hide overlays. OK: start() always does: mode='play'; clearTimeout(restartT); over=false; paused=false; if wasOver or always reset()? If user clicks mid-demo (not crashed), we don't want to reset the ongoing demo — just take over: keep state, hide overlay. So: start(): mode='play'; hide start; if over { reset(); over=false; hide over; }. Good.
- Keydown in demo also starts (any key). But careful: if key is direction, also queue after start.
R key in play: reset() (keep mode play, over=false, hide over overlay).
Crash in play: over=true; show overOverlay with score; audio crash; shake.
Also update HUD each frame or on change: score, picked, length, best.
Manual direction queue: inputQueue array (max 3). keydown: dir = map(key); if mode play and !over: push if valid vs last queued or current dir. In step (player): if queue nonempty: d=queue.shift(); if d not opposite of currentDir → currentDir=d. currentDir stored.
AI sets currentDir directly.
Prevent reversal check: opposite(a,b) = a.x===-b.x && a.y===-b.y.
Also initial dir = {x:1,y:0}.
Now write code carefully. Sizes: COLS=26, ROWS=18, CELL=36 → W=936, H=648.
Canvas element: <canvas id="game" width="936" height="648"></canvas>; in JS: const dpr = Math.min(window.devicePixelRatio||1, 2); canvas.width=W*dpr; canvas.height=H*dpr; ctx.setTransform(dpr,0,0,dpr,0,0). CSS handles display size.
Fonts: ctx.font with 'bold 18px "Trebuchet MS", sans-serif' etc.
rAF loop: let last=performance.now(); function frame(now){ dt=(now-last)/1000; last=now; dt=min(dt,0.05); if(!over && !paused){ acc+=dt*1000; while(acc>=interval){ step(); acc-=interval; } } update particles/popups always; render(now/1000); requestAnimationFrame(frame);}
Wheels: wheelAngle += dt*(1000/interval)*2.2 maybe; just wheelAngle += dt*8.
Passenger bob uses t.
Smoke emission: smokeTimer += dt; every 0.09s emit 1 (if train alive) at chimney world pos. chimney world: head center + rotate(localChimney, headAngle) where localChimney=(CELL*0.34, -CELL*0.32). angle from dir.
Particles update: p.x+=p.vx*dt etc., life-=dt; draw alpha=life/max.
Popups update: y -= 30*dt; life-=dt; draw alpha.
Explosion particles on crash: 24 red/orange circles from head center with random velocities, gravity slight.
Shake: shakeT=0.35 on crash; in render: if shakeT>0 { ctx.save(); translate(rand*6*shakeT...); } decrement in frame.
Sounds:
let AC=null; function audio(){ if(!AC){ try{AC=new (window.AudioContext||window.webkitAudioContext)();}catch(e){AC=null;} } if(AC&&AC.state==='suspended')AC.resume(); return AC; }
function beep(freq,dur,type='sine',vol=0.15,when=0){ const ac=audio(); if(!ac)return; const o=ac.createOscillator(),g=ac.createGain(); o.type=type;o.frequency.value=freq; g.gain.setValueAtTime(vol,ac.currentTime+when); g.gain.exponentialRampToValueAtTime(0.001,ac.currentTime+when+dur); o.connect(g).connect(ac.destination); o.start(ac.currentTime+when); o.stop(ac.currentTime+when+dur+0.02); }
pickup: beep(660,0.08); beep(880,0.12,'sine',0.15,0.07);
vip: 523,659,784,1046 spaced.
crash: beep(120,0.3,'square',0.2); noise skip.
Key handling:
const KEYMAP={ArrowUp:{x:0,y:-1},w:...,W:..., ...}
window.addEventListener('keydown', e=>{ const k=e.key; if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(k)) e.preventDefault(); if(mode==='demo'){ startGame(); } const d=KEYMAP[k]||KEYMAP[k.toLowerCase()]; if(d && mode==='play' && !over){ queueDir(d);} if((k==='r'||k==='R') && mode==='play'){ resetRun(); } if((k==='p'||k==='P') && mode==='play' && !over){ paused=!paused; pauseEl.style.display=paused?'flex':'none'; } });
Wait: in demo, pressing R would start game then reset — startGame() then R triggers resetRun (mode now play) — that's fine actually (fresh run). But startGame on any key then key R also resets — harmless.
Hmm: if mode==='demo' and over (demo crashed, waiting restart), keydown → startGame() which resets and enters play. Good.
pointerdown: window.addEventListener('pointerdown', ()=>{ if(mode==='demo') startGame(); else if(over) resetRun(); });
But clicking when paused? ignore.
startGame(): mode='play'; clearTimeout(demoRestartT); over=false; if(overWas) resetRun(); startEl.classList.add('hidden'); audio(); beep(440,0.06,'triangle',0.12);
Careful: over flag reset. Let me structure:
function startGame(){ if(mode!=='demo') return; mode='play'; clearTimeout(demoRestartT); if(over){ resetRun(); } over=false; startOverlay.classList.add('hidden'); overOverlay.classList.add('hidden'); initAudio(); sfxStart(); }
resetRun(){ init state; over=false; paused=false; hide pause; } — but in demo crash we call resetRun without changing mode.
crash(): over=true; shakeT=0.4; spawnExplosion(); sfxCrash(); if(mode==='demo'){ overOverlay show "Demo crashed — restarting…"; demoRestartT=setTimeout(()=>{ resetRun(); overOverlay.classList.add('hidden'); },2000);} else { overOverlay show with score; }
But during demo over period, startOverlay still visible? startOverlay visible whole demo including crash — fine, it says click to start; overlay content maybe unchanged. OK.
resetRun(): snake=[{x:11,y:9},{x:10,y:9},{x:9,y:9},{x:8,y:9}]; dir={x:1,y:0}; queue=[]; pendingGrowth=0; score=0; picked=0; interval=BASE; passengers=[]; obstacles=[]; spawnObstacles(); for(i<4) spawnPassenger(); smoke=[]; pops=[]; over=false; updateHUD();
Passenger spawn: function freeCell(minDist){ for tries 300: x=rand int 1..COLS-2? Allow full range 0..COLS-1 (edge cells ok; hazard border drawn at edge but cells still playable — border stripe is inset visual; fine). Actually if border stripe drawn inset s*0.18, train at edge cell overlaps stripe — visually ok (train on platform edge). Keep range 0..COLS-1 but minDist from head ensures not immediate. Also not on obstacle/passenger/snake. return {x,y}. }
spawnPassenger(): c=freeCell(6); if(!c)return; vip=Math.random()<0.12; passengers.push({x:c.x,y:c.y,vip,shirt:pick(colors),seed:Math.random()*10,side:rand int 0..3});
Obstacles: n=8; tries: c=freeCell(5); obstacles.push({x,y,type:Math.random()<0.5?'pillar':'bench'});
step():
let nd; if(mode==='play' && queue.length){ const q=queue.shift(); if(!isOpposite(q,dir)) dir=q; } else if(mode==='demo'){ dir=aiDir(); }
Actually AI also should not reverse — aiDir ensures safe moves (never opposite since neck blocks). But if AI returns same dir fine.
const head=snake[0]; const nh={x:head.x+dir.x,y:head.y+dir.y};
// collisions
if(nh.x<0||nh.y<0||nh.x>=COLS||nh.y>=ROWS) return crash();
if(obstacleAt(nh)) return crash();
const willGrow = passengerAt(nh) ? true:false; // growth determined by pickup
const body = willGrow? snake : snake.slice(0,-1);
if(body.some(s=>s.x===nh.x&&s.y===nh.y)) return crash();
snake.unshift(nh);
const p=passengerAt(nh);
if(p){ pickPassenger(p,nh); }
if(pendingGrowth>0) pendingGrowth--; else snake.pop();
updateHUD(); maybe spawn new passenger if passengers.length<4 && Math.random()<0.5? Better: after pickup, spawn one new to keep 4: if(passengers.length<4) spawnPassenger();
pickPassenger(p,cell): remove from array; if(p.vip){ pendingGrowth+=3; score+=50; pops.push({x:cell.x,y:cell.y,text:'+50 VIP!',color:'#ffd76a',life:1.4,vy:-34}); sfxVip(); } else { pendingGrowth+=1; score+=10; picked++; pops.push({...'+'+1 🧍'...}); sfxPickup(); } interval=Math.max(MIN_INT, interval-3); best=max...
Wait picked counts passengers; VIP counts as 3 passengers? picked+= p.vip?3:1. ok.
aiDir():
const head=snake[0];
const blocked=new Set(); obstacles.forEach(o=>blocked.add(o.x+','+o.y)); snake.forEach((s,i)=>{ if(i<snake.length-1) blocked.add(s.x+','+s.y); });
// BFS
const prev=new Map(); const key=(x,y)=>x+','+y; const q=[[head.x,head.y]]; prev.set(key(head.x,head.y),null); let found=null;
while(q.length){ const [x,y]=q.shift(); if(passengerAt({x,y})){ found=[x,y]; break;} for(const d of DIRS){ const nx=x+d.x, ny=y+d.y; if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue; const k=nx+','+ny; if(blocked.has(k)||prev.has(k)) continue; prev.set(k,[x,y]); q.push([nx,ny]); } }
Wait head cell itself: passengerAt(head) impossible. Also BFS start shouldn't expand into neck — blocked includes all but tail, and head's neck is in blocked. good.
if(found){ // walk back to first step
let cur=found; let p=prev.get(key(cur.x,cur.y)); while(p && !(p[0]===head.x&&p[1]===head.y)){ cur=p; p=prev.get(key(cur[0],cur[1])); } // cur is first step cell
return {x:cur[0]-head.x, y:cur[1]-head.y};
}
Hmm careful: if found is adjacent to head, p=head → loop doesn't run, cur=found → dir = found-head. good.
// fallback: survival
const options=DIRS.filter(d=>{ if(isOpposite(d,dir)) return false; const nx=head.x+d.x, ny=head.y+d.y; if out continue; if(blocked.has(nx+','+ny)) continue; return true;});
if(!options.length) return dir;
let best=null,bestScore=-1;
for(const d of options){ const nx=head.x+d.x,ny=head.y+d.y; const space=flood(nx,ny,blocked); const dist=nearestPassengerDist(nx,ny); const sc=space*2 - dist; if(sc>bestScore){bestScore=sc;best=d;} }
return best;
flood(x,y,blocked): BFS count reachable avoiding blocked, cap at COLS*ROWS. Return count.
nearestPassengerDist: min manhattan over passengers (Infinity if none).
Note: blocked includes body except tail — good approximation.
One more nuance: BFS treats tail as free, which may cause collision if growing... acceptable for demo.
Also AI might pick passenger adjacent causing immediate growth; fine.
Rendering functions:
function render(t){
ctx.clearRect... actually fill bg: ctx.fillStyle='#222b24'? floor covers all.
draw floor: for y,x: fillStyle=(x+y)%2? '#2c3a31':'#28352c'; fillRect(x*C,y*C,C,C); plus subtle inner stroke.
Actually nicer palette: dark teal-green "grass at night": A #2f4034, B #2a3b30. Grid gap: fillRect with 1px margin: fillRect(x*C+1,y*C+1,C-2,C-2) with round? Use plain rects; corner radius small via roundRect if available (Chrome supports ctx.roundRect). Use roundRect(x*C+1.5, y*C+1.5, C-3, C-3, 6). roundRect supported in latest Chrome — yes. Guard: if(!ctx.roundRect) fallback — Chrome latest has it; fine but to be safe define helper rr(x,y,w,h,r){ ctx.beginPath(); if(ctx.roundRect){ctx.roundRect(x,y,w,h,r);} else {ctx.rect(x,y,w,h);} }.
hazard border: after floor: draw inner dashed yellow line: ctx.save(); ctx.strokeStyle='#f2c230'; ctx.lineWidth=3; ctx.setLineDash([14,10]); ctx.strokeRect(6,6,W-12,H-12); ctx.restore(); plus dark outer strip: fill rect border? fill a 6px frame: draw four rects fill 'rgba(0,0,0,0.35)' at edges width 6. ok.
passengers: for each draw.
obstacles.
couplings: stroke lines between consecutive snake centers: lineWidth 5, strokeStyle '#1c1c22'.
wagons: for i=snake.length-1 down to 1: drawWagon(i).
loco: drawLoco().
smoke particles.
explosion particles.
popups.
}
drawWagon(i): s=CELL; const seg=snake[i]; const prev=snake[i-1]; let dx=prev.x-seg.x, dy=prev.y-seg.y; (points toward head) angle=Math.atan2(dy,dx); cx=seg.x*s+s/2, cy=seg.y*s+s/2; ctx.save(); translate(cx,cy); rotate(angle); color=PALETTE[i % PALETTE.length] (or (i-1)); draw local facing +x:
shadow: ellipse(0, s*0.30, s*0.38, s*0.10) rgba(0,0,0,.3)
wheels at (-s*0.22, s*0.22) and (s*0.22, s*0.22): drawWheel(x,y,r=s*0.10)
body: rr(-s*0.40,-s*0.24,s*0.80,s*0.44,6) fill color; stroke rgba(0,0,0,.35) lw2.
roof: rr(-s*0.42,-s*0.32,s*0.84,s*0.12,5) fill shade(color,0.7)
window: rr(-s*0.28,-s*0.14,s*0.34,s*0.22,4) fill '#cfe8f7' stroke rgba(0,0,0,.3)
passenger head in window: circle(-s*0.16, -s*0.02, s*0.07) fill '#f1c9a2'; hair: arc top fill '#5a3a22' — draw semicircle: ctx.beginPath(); arc(-s*0.16,-s*0.045, s*0.07, Math.PI, 0); fill.
second window: rr(s*0.02,-s*0.14,s*0.26,s*0.22,4) fill '#cfe8f7' — maybe with tiny head too if i%2. Keep one window + maybe a package (brown box) in second: rr fill '#a9713f'. Cute: alternate passengers/parcels. Let's draw second window empty glass with diagonal shine line.
restore.
drawWheel(x,y,r): circle fill '#23232a' stroke '#4a4a55' lw2; hub circle r*0.45 fill '#8a8a96'; spokes: save translate rotate(wheelAngle): line(-r,0,r,0) and line(0,-r,0,r) stroke '#c9c9d4' lw1.5; restore.
drawLoco(): seg=snake[0]; angle=atan2(dir.y,dir.x); cx,cy; save translate rotate.
shadow ellipse.
// order back-to-front local +x forward:
// cab (rear = -x)
cab body: rr(-s*0.44,-s*0.30, s*0.40, s*0.50, 5) fill '#b03a3a'? loco main color crimson '#d64545'; cab slightly darker shade('#d64545',0.8).
roof: rr(-s*0.48,-s*0.40, s*0.46, s*0.12, 5) fill '#7e2f2f'.
window: rr(-s*0.38,-s*0.22, s*0.26, s*0.20, 3) fill '#cfe8f7' stroke dark.
// boiler
rr(-s*0.06,-s*0.20, s*0.50, s*0.38, 8) fill '#d64545' stroke rgba(0,0,0,.35).
front cap: circle(s*0.44, -s*0.01, s*0.10) fill '#e86a6a'? Actually boiler front rounded by rr. Add smokebox front: rr(s*0.36,-s*0.20,s*0.10,s*0.38,4) fill '#8f2f2f'.
// chimney at front top
rr(s*0.24,-s*0.42, s*0.13, s*0.24, 3) fill '#3a3a44'; rim rr(s*0.21,-s*0.46,s*0.19,s*0.08,3) fill '#23232a'.
// dome
circle(s*0.08,-s*0.22, s*0.07) fill '#f2c230' stroke.
// cowcatcher
path: moveTo(s*0.50, s*0.16) lineTo(s*0.34, s*0.36) lineTo(s*0.50, s*0.36) close fill '#8f2f2f'.
// headlight at front
circle(s*0.47,-s*0.08, s*0.08) fill '#ffe9a8'; glow drawn separately in world space maybe; here radial gradient: create radial gradient at that point r s*0.30 from rgba(255,230,140,.8) to transparent, fill circle. Do inside rotated ctx fine.
// wheels: at (-s*0.30, s*0.28),(s*0.02,s*0.28),(s*0.32,s*0.28) r=s*0.11 — three wheels.
// stripe: rr along boiler bottom: fill rgba(255,255,255,0.15) rect.
restore.
Chimney world pos for smoke: local (s*0.30, -s*0.40) rotated: wx = cx + lx*cos - ly*sin; wy = cy + lx*sin + ly*cos.
drawPassenger(p,t): cx=p.x*s+s/2, cy=p.y*s+s/2; bob=Math.sin(t*4+p.seed)*s*0.05;
slab: rr(p.x*s+2, p.y*s+2, s-4, s-4, 5) fill p.vip? '#ffd76a' : '#d9c9a3'; stroke p.vip? '#b8860b':'#a68f63' lw2.
yellow line on random side: side p.side: draw rect 3px thick along that edge inside slab fill '#f2c230'.
person at (cx, cy+bob):
legs: fill '#3b4252' two rects: (-s*0.09, s*0.10, s*0.07, s*0.12) and (s*0.02, s*0.10, s*0.07, s*0.12).
body: rr(-s*0.14, -s*0.08, s*0.28, s*0.22, 4) fill p.shirt.
arms: small rects sides? skip.
head: circle(0, -s*0.16, s*0.10) fill '#f1c9a2'.
hair: arc(0,-s*0.18, s*0.10, PI, 0) fill p.hairColor or '#4a3222'.
if vip: star above: drawStar(cx, cy - s*0.34 + sin(t*6)*2, s*0.09, '#fff3b0') plus sparkles: few small circles twinkling.
drawObstacle(o): pillar: rr(x*s+s*0.16, y*s+s*0.10, s*0.68, s*0.80, 6) fill '#6b7280' stroke '#374151' lw2; top: rr(...s*0.10 height) fill '#9ca3af'; hazard stripes at bottom: 3 diagonal yellow lines clipped... simpler: small rect band at bottom fill '#f2c230' with two dark diagonal lines. bench: seat rr(x*s+4, y*s+s*0.34, s-8, s*0.20, 4) fill '#a9713f' stroke '#7c4f27'; legs: two rects fill '#7c4f27' at bottom; backrest: rr(x*s+4, y*s+6, s-8, s*0.12,4) fill '#a9713f'. ok.
Popups: font `bold ${s*0.42}px ...`; textAlign center; stroke dark then fill color; y = p.y*s - (1-p.life/1.4)*s*1.2; alpha=clamp(p.life/0.4).
Explosion: circles fill orange/red alpha.
Smoke: fill rgba(200,200,210,alpha).
HUD update: scoreEl.textContent=score; pickedEl; lenEl=snake.length; bestEl=best.
HTML structure:
<body>
<div class="wrap">
<header class="hud">
<div class="brand">🚂 <span>Choo-Choo Express</span></div>
<div class="stats">
<div class="chip">⭐ Score <b id="score">0</b></div>
<div class="chip">🧍 Passengers <b id="picked">0</b></div>
<div class="chip">🛤️ Length <b id="len">4</b></div>
<div class="chip">🏆 Best <b id="best">0</b></div>
</div>
</header>
<div class="stage">
<canvas id="game"></canvas>
<div id="startOverlay" class="overlay">
<div class="panel">
<div class="demo-pill">DEMO — AI IS DRIVING</div>
<h1>🚂 Choo-Choo Express</h1>
<p>Steer the little train around the station and drive the <b>locomotive</b> over the platforms where <b>passengers</b> are waiting to board. Each passenger adds a colorful wagon!</p>
<p class="cta blink">▶ Click or press any key to take the wheel ◀</p>
<p class="keys">← ↑ ↓ → or WASD to steer • P pause • R restart</p>
</div>
</div>
<div id="overOverlay" class="overlay hidden">
<div class="panel">
<h1 id="overTitle">💥 Crash!</h1>
<p id="overText">Score 0</p>
<p class="cta blink">Click or press R to ride again</p>
</div>
</div>
<div id="pauseOverlay" class="overlay hidden"><div class="panel"><h1>⏸ Paused</h1><p class="cta">Press P to continue</p></div></div>
</div>
<footer class="hint">Pick up passengers on the platform slabs • avoid pillars, benches and your own wagons • VIP passengers (gold) add 3 wagons!</footer>
</div>
</body>
CSS: body margin 0; min-height 100vh; display flex; center; background: radial-gradient at top #1c2547, #0c1020; color #e8e6df; font-family system. .wrap width min(980px, 100%); padding 12px. .hud flex wrap gap. .brand font-size 22 bold, letter-spacing. .stats flex gap 8 wrap. .chip background rgba(255,255,255,0.06); border 1px solid rgba(255,255,255,0.12); padding 6px 12px; border-radius 999px; font-size 14; b color #ffd76a. .stage position relative; display flex; justify-content center. canvas display block; width min(936px, 100%); height auto; border-radius 14px; box-shadow big; background #223. Also frame: wrap canvas in .frame with padding 10px background linear-gradient(#3a3f4a,#262a33); border-radius 18px; plus hazard top strip: .frame::before? Use a div .hazard above canvas inside frame: height 10px; background repeating-linear-gradient(45deg,#f2c230 0 12px,#1c1c22 12px 24px); border-radius 8px 8px 0 0. Then canvas below with margin-top. Let me structure: .frame{ padding:0 10px 10px; } .hazard{height:12px; margin:0 -10px 10px; background: repeating-linear-gradient(-45deg, #f2c230 0 14px, #23232a 14px 28px); border-radius: 16px 16px 0 0;} canvas inside. Good.
.overlay position absolute inset 0; display flex; align/justify center; pointer-events none; background rgba(8,10,20,0.45); backdrop-filter blur(3px); border-radius 14px (match canvas area — overlay covers stage which includes frame; set overlay inset 0 within .stage; stage wraps frame; fine). .panel background rgba(16,20,36,0.92); border 1px solid rgba(255,214,106,0.35); border-radius 16px; padding 22px 26px; max-width 520px; text-align center; box-shadow. h1 margin. .cta color #ffd76a font-weight bold. .blink animation opacity pulse. .demo-pill background #ffd76a color #222 font-size 11 bold padding 3px 10px radius 999 display inline-block margin-bottom 8px letter-spacing 1px. .hidden{display:none !important}. .hint margin-top 10px font-size 13 opacity .75 text-align center.
Mobile: @media (max-width:480px){ .brand font-size 18; .chip font-size 12 padding 4px 9px; .panel padding 16px; h1 font-size 20; }
Touch swipe: let touchStart=null; canvas/window touchstart: record; touchend: dx,dy; if |dx|>|dy| horizontal... queue dir if play; also if demo → startGame. Add listeners on window with passive true? preventDefault not needed.
Also visibility: prevent arrow scroll — done via preventDefault on keydown for arrows & space.
Now ensure "something meaningful visible on first paint": game running demo behind translucent overlay — yes at 3s screenshot shows train moving, passengers, HUD, overlay message. Good.
Edge: interval variable used in loop; when paused skip stepping. dt clamp.
updateHUD also called on reset.
Potential bug: queueDir validation: const last = queue.length? queue[queue.length-1] : dir; if(!isOpposite(d,last) && !(d.x===last.x&&d.y===last.y)) queue.push(d); cap queue length 3 (shift if >3).
In step for play mode: if queue.length: d=queue.shift(); if(!isOpposite(d,dir)) dir=d. Note dir is current movement dir; also should prevent 180 vs dir even if queued valid earlier — the check at consume time handles.
AI: also ensure aiDir never returns opposite of dir — options filtered; found path first step can't be opposite because neck blocked (snake[1] in blocked since i<length-1). Unless length... length>=4 always, snake[1] blocked. good.
crash(): also stop smoke? fine. Set over=true; best update; HUD. shakeT=0.45; explosion at head world pos. sfxCrash only if audio exists (in demo no audio → beep guards AC null → audio() creates context without gesture — Chrome may create suspended context, no error; resume fails silently. Creating AudioContext before gesture logs a warning not error. To be safe, only call audio() from user-gesture handlers; in sfx functions check if AC exists: function sfx...(){ if(!AC) return; ...}. And initAudio only on gesture. So demo silent, no warnings. Good: beep uses AC directly.
Let me write sfxPickup(){ if(!AC)return; tone(660,0.09,'sine',0.18); tone(990,0.14,'sine',0.16,0.08); } etc. tone(freq,dur,type,vol,delay).
Crash: tone(160,0.25,'square',0.22); tone(90,0.35,'sawtooth',0.18,0.05).
Start: tone(523,0.08,'triangle',0.15); tone(784,0.1,'triangle',0.12,0.08).
Now write spawnObstacles avoiding passengers? passengers spawned after obstacles; freeCell checks obstacles and snake but not passengers (spawnPassenger's freeCell should also avoid existing passengers). Implement cellFree(x,y,checkPassengers).
Let me now also handle "demo restart" overlay text: when demo crashes, overOverlay shows "💥 Demo crash!" and text "Restarting…" — but overOverlay cta says click/R — in demo that would conflict? If user clicks during demo-over, startGame() runs: mode play, over→resetRun. fine. Set overTitle/text accordingly in crash().
Also when play crashes: overText = `Score ${score} • ${picked} passengers aboard`.
Also update best on crash: best=Math.max(best,score).
Now the render of the head glow: after drawing loco, in world coords compute front point: fx=cx+Math.cos(angle)*s*0.5, fy=cy+Math.sin(angle)*s*0.5; radial gradient r=s*0.55: rgba(255,236,160,0.55)→0; fill circle. Draw before smoke.
Also draw a soft light pool? enough.
One more: couplings drawn as lines between centers — but with rotation of wagons, lines under bodies fine.
Also draw small direction indicator? no.
Check canvas roundRect availability — latest Chrome yes. I'll still add fallback helper.
Now sizes: HUD + frame + canvas: canvas CSS width min(936px,100%). At 1280 wide, wrap max 980 → canvas 936. Height 648 + frame padding ~22 + hazard 12 + HUD ~50 + hint ~30 + paddings ≈ 790 ≤ 800. Tight but ok; reduce CELL to 34 → 884×612, total ~750. Let's use CELL=34, COLS=26, ROWS=18 → W=884,H=612. Canvas CSS width min(884px,100%). Better fit. Font sizes scale with s.
Actually let's keep responsive: .wrap width min(920px,100%). canvas width:100% of frame inner. Set canvas style width:100%; height:auto. Frame width fit-content? .stage flex center; .frame{max-width:100%}. canvas width:100% → scales down on small screens. Since attr ratio fixed (W/H), height auto keeps ratio. Good: canvas{width:100%;height:auto;display:block}. frame width: min(884px, 100%). Hmm frame inside stage; set .frame{width:min(904px,100%)} (884+padding 20). canvas 884. At 375px viewport: wrap padding 12*2 → available 351; frame width 351, canvas ~331 wide, height ~242. OK.
Set canvas style width 100% via CSS.
dpr scaling: canvas.width=W*dpr; height=H*dpr; CSS width 100% → browser scales; crisp on retina.
Now write the code fully and carefully check for errors.
Constants:
const COLS=26, ROWS=18, CELL=34, W=COLS*CELL, H=ROWS*CELL;
const BASE_INT=150, MIN_INT=85;
State vars: let snake, dir, queue, pendingGrowth, score, picked, best=0, interval, passengers, obstacles, smoke, pops, boom, mode='demo', over=false, paused=false, acc=0, wheelAngle=0, shakeT=0, demoRestartT=null, lastT=0;
DIRS=[{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
PALETTE=['#e74c3c','#f39c12','#f1c40f','#2ecc71','#16a085','#3498db','#9b59b6','#e67e22','#fd79a8','#00cec9'];
SHIRTS=['#e74c3c','#3498db','#2ecc71','#9b59b6','#f39c12','#fd79a8','#1abc9c','#e67e22'];
Helpers: rnd(n)=Math.floor(Math.random()*n); pick(arr); shade(hex,f): parse #rrggbb → scale.
function shade(hex,f){ const n=parseInt(hex.slice(1),16); const r=Math.min(255,((n>>16)&255)*f)|0, g=Math.min(255,((n>>8)&255)*f)|0, b=Math.min(255,(n&255)*f)|0; return `rgb(${r},${g},${b})`; }
key(x,y)=x+','+y;
isOpp(x,y)= x.x===-y.x && x.y===-y.y.
passengerAt(c)=passengers.find(p=>p.x===c.x&&p.y===c.y);
obstacleAt(c)=obstacles.some(o=>o.x===c.x&&o.y===c.y);
cellFree(x,y,avoidPass): in bounds; !snake.some; !obstacleAt; !(avoidPass&&passengerAt).
freeCell(minDist, avoidPass=true): for(let i=0;i<400;i++){ const x=rnd(COLS),y=rnd(ROWS); if(!cellFree(x,y,avoidPass))continue; const h=snake[0]; if(Math.abs(x-h.x)+Math.abs(y-h.y)<minDist)continue; return {x,y}; } return null;
spawnObstacles(){ obstacles=[]; const n=8; for(let i=0;i<n;i++){ const c=freeCell(6,false); if(c) obstacles.push({x:c.x,y:c.y,type:Math.random()<0.5?'pillar':'bench'}); } }
Wait freeCell checks obstacles already (cellFree checks obstacleAt) — fine as obstacles grow.
spawnPassenger(){ if(passengers.length>=4)return; const c=freeCell(6,true); if(!c)return; passengers.push({x:c.x,y:c.y,vip:Math.random()<0.12,shirt:pick(SHIRTS),seed:Math.random()*10,side:rnd(4)}); }
resetRun(){ snake=[{x:11,y:9},{x:10,y:9},{x:9,y:9},{x:8,y:9}]; dir={x:1,y:0}; queue=[]; pendingGrowth=0; score=0; picked=0; interval=BASE_INT; acc=0; passengers=[]; obstacles=[]; smoke=[]; pops=[]; boom=[]; over=false; paused=false; shakeT=0; spawnObstacles(); for(let i=0;i<4;i++)spawnPassenger(); updateHUD(); pauseOverlay.classList.add('hidden'); }
Hmm initial head at x=11 moving right; ensure cells 8..11 free — obstacles avoid dist 6 from head (11,9) → obstacles not near. good.
step(){ ... as planned. Also after move: if(passengers.length<4 && Math.random()<0.6) spawnPassenger(); } Actually spawn replacement immediately after pickup: if picked happened. Let's: if(pickedThis) spawnPassenger(). And also occasionally if a passenger... they never leave otherwise. Just replace on pickup; also initial 4. But if AI avoids some, they stay — fine, cap 4. Also maybe add new passenger every ~6s? Keep simple: on pickup spawn one (maintains 4 unless spawn fails). Also add: every 20 ticks chance spawn if <4. Add tick counter. Simple: tickCount++; if(tickCount%25===0 && passengers.length<4) spawnPassenger();
aiDir as planned. flood cap 500.
flood(sx,sy,blocked){ const seen=new Set([sx+','+sy]); const q=[[sx,sy]]; let c=0; while(q.length&&c<600){ const [x,y]=q.shift(); c++; for(const d of DIRS){ const nx=x+d.x,ny=y+d.y; if(nx<0||ny<0||nx>=COLS||ny>=ROWS)continue; const k=nx+','+ny; if(seen.has(k)||blocked.has(k))continue; seen.add(k); q.push([nx,ny]); } } return c; }
BFS in aiDir: also blocked set built fresh each call.
crash(){ over=true; shakeT=0.5; const h=snake[0]; const cx=h.x*CELL+CELL/2, cy=h.y*CELL+CELL/2; for(let i=0;i<26;i++){ const a=Math.random()*Math.PI*2, sp=40+Math.random()*140; boom.push({x:cx,y:cy,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp-40,r:2+Math.random()*4,life:0.7+Math.random()*0.5,max:1.2}); } if(AC){tone(150,0.25,'square',0.22); tone(85,0.4,'sawtooth',0.2,0.06);} if(score>best)best=score; updateHUD(); overTitle.textContent = mode==='demo' ? '💥 Demo crash!' : '💥 Crash!'; overText.textContent = mode==='demo' ? 'The AI got into trouble… restarting' : `Score ${score} • ${picked} passenger${picked===1?'':'s'} aboard • Best ${best}`; overOverlay.classList.remove('hidden'); if(mode==='demo'){ demoRestartT=setTimeout(()=>{ resetRun(); overOverlay.classList.add('hidden'); },2200); } }
Wait resetRun sets over=false and hides pause; but overOverlay hide after resetRun — order fine.
Note: in demo crash, startOverlay still visible (it's separate) — both overlays visible; overOverlay covers. ok.
startGame(){ if(mode!=='demo')return; mode='play'; clearTimeout(demoRestartT); if(over){ resetRun(); } overOverlay.classList.add('hidden'); startOverlay.classList.add('hidden'); initAudio(); if(AC){tone(523,0.08,'triangle',0.15); tone(784,0.12,'triangle',0.13,0.08);} }
initAudio(){ if(!AC){ try{ AC=new (window.AudioContext||window.webkitAudioContext)(); }catch(e){ AC=null; } } if(AC&&AC.state==='suspended'){ AC.resume().catch(()=>{}); } }
tone(f,d,type,v,delay){ if(!AC)return; try{ const t0=AC.currentTime+(delay||0); const o=AC.createOscillator(),g=AC.createGain(); o.type=type; o.frequency.setValueAtTime(f,t0); g.gain.setValueAtTime(v,t0); g.gain.exponentialRampToValueAtTime(0.001,t0+d); o.connect(g); g.connect(AC.destination); o.start(t0); o.stop(t0+d+0.05); }catch(e){} }
keydown handler:
window.addEventListener('keydown',(e)=>{ const k=e.key; if(k===' '||k.startsWith('Arrow')) e.preventDefault(); if(mode==='demo'){ startGame(); } const d=KEYMAP[k]||KEYMAP[k.length===1?k.toLowerCase():k]; if(d&&mode==='play'&&!over) queueDir(d); if((k==='r'||k==='R')){ if(mode==='play'){ resetRun(); overOverlay.classList.add('hidden'); } } if((k==='p'||k==='P')&&mode==='play'&&!over){ paused=!paused; pauseOverlay.classList.toggle('hidden',!paused); } });
KEYMAP: {'ArrowUp':{x:0,y:-1},'ArrowDown':{x:0,y:1},'ArrowLeft':{x:-1,y:0},'ArrowRight':{x:1,y:0},'w':..,'s':..,'a':..,'d':..} plus uppercase via toLowerCase for single chars. 'W'.toLowerCase()='w'. So: const d=KEYMAP[k] || (k.length===1?KEYMAP[k.toLowerCase()]:null);
Note: pressing R in demo: startGame() then resetRun — resets demo-run into play mode fresh. acceptable.
But careful: if mode==='demo' and user presses arrow, startGame() runs, then queueDir(d) applies — good, immediate control.
queueDir(d){ const last=queue.length?queue[queue.length-1]:dir; if(isOpp(d,last))return; if(d.x===last.x&&d.y===last.y)return; if(queue.length>=3)queue.shift(); queue.push(d); }
pointerdown: window.addEventListener('pointerdown',()=>{ if(mode==='demo'){ startGame(); } else if(over){ resetRun(); overOverlay.classList.add('hidden'); } });
Note after startGame from demo when over was true, resetRun already ran; pointerdown 'over' branch only in play mode. In demo over case, pointerdown → startGame → handles reset. good.
Touch: let tsx=0,tsy=0; window.addEventListener('touchstart',e=>{ tsx=e.touches[0].clientX; tsy=e.touches[0].clientY; },{passive:true}); window.addEventListener('touchend',e=>{ const dx=e.changedTouches[0].clientX-tsx, dy=e.changedTouches[0].clientY-tsy; if(mode==='demo'){ startGame(); return;} if(over) return; if(Math.abs(dx)<20&&Math.abs(dy)<20)return; const d=Math.abs(dx)>Math.abs(dy)?{x:Math.sign(dx),y:0}:{x:0,y:Math.sign(dy)}; if(mode==='play'&&!over)queueDir(d); },{passive:true});
Frame loop:
function frame(now){ requestAnimationFrame(frame); const dt=Math.min(0.05,(now-lastT)/1000||0.016); lastT=now; wheelAngle+=dt*9; if(shakeT>0)shakeT-=dt; if(!over&&!paused){ acc+=dt*1000; let guard=0; while(acc>=interval&&guard++<5){ step(); acc-=interval; } } updateFx(dt); render(now/1000); }
requestAnimationFrame(t=>{lastT=t;requestAnimationFrame(frame);});
updateFx(dt){ smoke timer: smokeAcc+=dt; if(smokeAcc>0.09){ smokeAcc=0; if(snake&&snake.length){ emitSmoke(); } } update smoke array: p.life-=dt; p.x+=p.vx*dt; p.y+=p.vy*dt; p.vy-=10*dt; p.r+=6*dt; filter life>0. pops: p.life-=dt; p.y-=30*dt; filter. boom: p.life-=dt; p.vy+=300*dt; p.x+=vx*dt... filter. }
emitSmoke(){ const h=snake[0]; const a=Math.atan2(dir.y,dir.x); const cx=h.x*CELL+CELL/2, cy=h.y*CELL+CELL/2; const lx=CELL*0.30, ly=-CELL*0.42; const wx=cx+lx*Math.cos(a)-ly*Math.sin(a), wy=cy+lx*Math.sin(a)+ly*Math.cos(a); smoke.push({x:wx,y:wy,vx:-dir.x*8+ (Math.random()-0.5)*6, vy:-18-Math.random()*14, r:3+Math.random()*2, life:0.9+Math.random()*0.5, max:1.4}); if(smoke.length>90)smoke.shift(); }
render(t): as planned. Also when over, maybe draw red flash? shake handles.
Also draw passengers only; obstacles.
Order in render:
ctx.save(); if(shakeT>0){ ctx.translate((Math.random()-0.5)*8*shakeT*2, (Math.random()-0.5)*8*shakeT*2); }
// floor
for y,x: ctx.fillStyle=((x+y)&1)?'#2e3f35':'#2a3a30'; rr(x*CELL+1,y*CELL+1,CELL-2,CELL-2,5); ctx.fill();
Hmm roundRect then fill: rr helper does beginPath+roundRect; then fill. ok.
// edge frame
ctx.fillStyle='rgba(0,0,0,0.35)'; fillRect(0,0,W,6); fillRect(0,H-6,W,6); fillRect(0,0,6,H); fillRect(W-6,0,6,H);
ctx.strokeStyle='#f2c230'; ctx.lineWidth=3; ctx.setLineDash([16,12]); ctx.strokeRect(9,9,W-18,H-18); ctx.setLineDash([]);
// passengers
passengers.forEach(p=>drawPassenger(p,t));
// obstacles
obstacles.forEach(drawObstacle);
// couplings
ctx.strokeStyle='#191921'; ctx.lineWidth=6; ctx.lineCap='round'; ctx.beginPath(); for(let i=1;i<snake.length;i++){ const a=snake[i-1],b=snake[i]; ctx.moveTo(a.x*CELL+CELL/2,a.y*CELL+CELL/2); ctx.lineTo(b.x*CELL+CELL/2,b.y*CELL+CELL/2);} ctx.stroke();
// wagons
for(let i=snake.length-1;i>=1;i--) drawWagon(i);
// loco
drawLoco();
// headlight glow world
... gradient
// smoke
smoke.forEach(...)
// boom
// pops
ctx.restore();
Note: pops y stored in cell coords? I set pops.push({x:cell.x, y:cell.y ...}) then in render: px=pop.x*CELL+CELL/2, py=pop.y*CELL - (1 - pop.life/pop.max)*CELL*1.1. Let me store max too. alpha=Math.min(1,pop.life/0.35).
drawWagon(i): as planned with angle from snake[i-1]-snake[i].
Check turn case: wagon at corner: prev-head direction; angle aligns to segment toward head — fine.
drawLoco(): uses dir for angle.
Now HUD ids: score, picked, len, best.
updateHUD(){ scoreEl.textContent=score; pickedEl.textContent=picked; lenEl.textContent=snake.length; bestEl.textContent=best; }
Init: const canvas..., ctx...; resetRun(); startOverlay visible by default in HTML. requestAnimationFrame start.
Also handle window resize? Not needed (CSS scales).
Double-check no localStorage etc. No external requests. Emoji fine.
Potential issue: overlay covers canvas with backdrop-filter — clicks pass because pointer-events:none on .overlay. Set .overlay{pointer-events:none}. Panels too (inherit none). Good; window pointerdown still fires.
Also ensure canvas has touch-action none? For swipe maybe; add style touch-action:none on canvas? We listen on window; scrolling on touch might happen; add e.preventDefault in touchmove? Keep simple: .stage{touch-action:none}.
Now, "Click or press a key to start" message must be clear — the CTA line says exactly that. Good.
Let me also make the demo AI decent so screenshot looks alive: BFS to nearest passenger; fine.
One concern: AI blocked set excludes tail — if path goes through tail cell and snake grows there... minor.
Also AI could oscillate: BFS shortest path each tick, fine.
Also ensure step() doesn't run when over — guarded in frame.
Edge: interval changes; while loop guard 5 prevents spiral.
Write drawPassenger carefully:
function drawPassenger(p,t){ const s=CELL; const cx=p.x*s+s/2, cy=p.y*s+s/2; const bob=Math.sin(t*4+p.seed)*s*0.05;
// slab
ctx.fillStyle=p.vip?'#ffd76a':'#d8c69b'; rr(p.x*s+2,p.y*s+2,s-4,s-4,5); ctx.fill(); ctx.strokeStyle=p.vip?'#a97e12':'#a68f63'; ctx.lineWidth=2; ctx.stroke();
// yellow edge
ctx.fillStyle='#f2c230'; const m=s*0.10; if(p.side===0) ctx.fillRect(p.x*s+5,p.y*s+5,s-10,4); else if(p.side===1) ctx.fillRect(p.x*s+5,p.y*s+s-9,s-10,4); else if(p.side===2) ctx.fillRect(p.x*s+5,p.y*s+5,4,s-10); else ctx.fillRect(p.x*s+s-9,p.y*s+5,4,s-10);
// person
const px=cx, py=cy+bob;
ctx.fillStyle='#3a4152'; ctx.fillRect(px-s*0.09,py+s*0.08,s*0.07,s*0.12); ctx.fillRect(px+s*0.02,py+s*0.08,s*0.07,s*0.12);
ctx.fillStyle=p.shirt; rr(px-s*0.14,py-s*0.10,s*0.28,s*0.22,4); ctx.fill();
ctx.fillStyle='#f1c9a2'; ctx.beginPath(); ctx.arc(px,py-s*0.18,s*0.10,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#4a3222'; ctx.beginPath(); ctx.arc(px,py-s*0.20,s*0.10,Math.PI,0); ctx.fill();
if(p.vip){ drawStar(px,py-s*0.38+Math.sin(t*6+p.seed)*2,s*0.10,'#fff3b0','#a97e12');
// sparkles
ctx.fillStyle='rgba(255,255,255,'+(0.4+0.6*Math.abs(Math.sin(t*5+p.seed)))+')';
ctx.beginPath(); ctx.arc(px-s*0.22,py-s*0.30,2,0,7); ctx.arc(px+s*0.24,py-s*0.24,2,0,7); ctx.fill();
}
}
arc end angle 7 > 2π fine.
drawStar(cx,cy,r,fill,stroke): 5-point: beginPath; for i 0..9: angle=-PI/2+i*PI/5; rad=i%2? r*0.45:r; lineTo(cx+cos*r...). fill, stroke.
drawObstacle(o): s=CELL; x=o.x*s,y=o.y*s;
if pillar: rr(x+s*0.18,y+s*0.12,s*0.64,s*0.78,5) fill '#7b8494'; stroke '#3c4350' lw2; rr(x+s*0.18,y+s*0.12,s*0.64,s*0.16,5) fill '#aab2c0'; hazard band: rr(x+s*0.18,y+s*0.66,s*0.64,s*0.24,3) fill '#f2c230'; then dark diagonal lines: ctx.save(); clip? simple: ctx.strokeStyle='#23232a'; lw3; lines: moveTo(x+s*0.22,y+s*0.88) lineTo(x+s*0.40,y+s*0.68); moveTo(x+s*0.40,y+s*0.90) lineTo(x+s*0.58,y+s*0.70); stroke; restore (without clip lines may overflow band slightly — keep within: coordinates chosen inside).
if bench: back rr(x+5,y+6,s-10,s*0.14,4) fill '#a9713f'; seat rr(x+4,y+s*0.36,s-8,s*0.18,4) fill '#b97f4a'; stroke '#7c4f27'; legs: fillRect(x+s*0.14,y+s*0.54,s*0.08,s*0.34), fillRect(x+s*0.78,...).
drawWagon(i):
const s=CELL; const seg=snake[i]; const prev=snake[i-1]; const a=Math.atan2(prev.y-seg.y,prev.x-seg.x); const cx=seg.x*s+s/2, cy=seg.y*s+s/2; const col=PALETTE[(i-1)%PALETTE.length];
ctx.save(); ctx.translate(cx,cy); ctx.rotate(a);
// shadow
ctx.fillStyle='rgba(0,0,0,0.28)'; ctx.beginPath(); ctx.ellipse(0,s*0.32,s*0.40,s*0.09,0,0,Math.PI*2); ctx.fill();
// wheels
drawWheel(-s*0.22,s*0.24,s*0.10); drawWheel(s*0.22,s*0.24,s*0.10);
// body
ctx.fillStyle=col; rr(-s*0.40,-s*0.24,s*0.80,s*0.46,6); ctx.fill(); ctx.strokeStyle='rgba(0,0,0,0.4)'; ctx.lineWidth=2; ctx.stroke();
// roof
ctx.fillStyle=shade(col,0.65); rr(-s*0.42,-s*0.33,s*0.84,s*0.12,5); ctx.fill();
// window with passenger
ctx.fillStyle='#cfe8f7'; rr(-s*0.30,-s*0.15,s*0.36,s*0.24,4); ctx.fill(); ctx.strokeStyle='rgba(0,0,0,0.35)'; ctx.lineWidth=1.5; ctx.stroke();
ctx.fillStyle='#f1c9a2'; ctx.beginPath(); ctx.arc(-s*0.16,-s*0.02,s*0.075,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#4a3222'; ctx.beginPath(); ctx.arc(-s*0.16,-s*0.035,s*0.075,Math.PI,0); ctx.fill();
// second window
ctx.fillStyle='#b9d9ea'; rr(s*0.02,-s*0.15,s*0.28,s*0.24,4); ctx.fill(); ctx.stroke();
// shine
ctx.strokeStyle='rgba(255,255,255,0.6)'; ctx.lineWidth=1.5; ctx.beginPath(); ctx.moveTo(s*0.08,-s*0.11); ctx.lineTo(s*0.22,-s*0.03); ctx.stroke();
ctx.restore();
drawWheel(x,y,r){ ctx.save(); ctx.translate(x,y); ctx.fillStyle='#23232a'; ctx.beginPath(); ctx.arc(0,0,r,0,7); ctx.fill(); ctx.strokeStyle='#4a4a55'; ctx.lineWidth=2; ctx.stroke(); ctx.rotate(wheelAngle); ctx.strokeStyle='#c9c9d4'; ctx.lineWidth=1.6; ctx.beginPath(); ctx.moveTo(-r,0); ctx.lineTo(r,0); ctx.moveTo(0,-r); ctx.lineTo(0,r); ctx.stroke(); ctx.fillStyle='#8a8a96'; ctx.beginPath(); ctx.arc(0,0,r*0.4,0,7); ctx.fill(); ctx.restore(); }
drawLoco(): const s=CELL; const h=snake[0]; const a=Math.atan2(dir.y,dir.x); const cx=h.x*s+s/2, cy=h.y*s+s/2;
ctx.save(); ctx.translate(cx,cy); ctx.rotate(a);
shadow ellipse(0,s*0.34,s*0.46,s*0.10);
// cowcatcher
ctx.fillStyle='#8f2f2f'; ctx.beginPath(); ctx.moveTo(s*0.50,s*0.10); ctx.lineTo(s*0.32,s*0.36); ctx.lineTo(s*0.50,s*0.36); ctx.closePath(); ctx.fill();
// wheels
drawWheel(-s*0.30,s*0.28,s*0.11); drawWheel(s*0.00,s*0.28,s*0.11); drawWheel(s*0.30,s*0.28,s*0.11);
// cab
ctx.fillStyle=shade('#d64545',0.8); rr(-s*0.44,-s*0.30,s*0.40,s*0.50,5); ctx.fill(); ctx.strokeStyle='rgba(0,0,0,0.4)'; ctx.lineWidth=2; ctx.stroke();
// cab roof
ctx.fillStyle='#7e2f2f'; rr(-s*0.48,-s*0.40,s*0.46,s*0.12,5); ctx.fill();
// cab window
ctx.fillStyle='#cfe8f7'; rr(-s*0.38,-s*0.23,s*0.26,s*0.20,3); ctx.fill(); ctx.strokeStyle='rgba(0,0,0,0.4)'; ctx.lineWidth=1.5; ctx.stroke();
// boiler
ctx.fillStyle='#d64545'; rr(-s*0.06,-s*0.20,s*0.50,s*0.38,9); ctx.fill(); ctx.strokeStyle='rgba(0,0,0,0.4)'; ctx.lineWidth=2; ctx.stroke();
// smokebox front
ctx.fillStyle='#8f2f2f'; rr(s*0.36,-s*0.20,s*0.10,s*0.38,4); ctx.fill();
// boiler stripe
ctx.fillStyle='rgba(255,255,255,0.18)'; ctx.fillRect(-s*0.02,s*0.06,s*0.42,s*0.07);
// dome
ctx.fillStyle='#f2c230'; ctx.beginPath(); ctx.arc(s*0.08,-s*0.21,s*0.065,0,7); ctx.fill(); ctx.strokeStyle='rgba(0,0,0,0.35)'; ctx.lineWidth=1.5; ctx.stroke();
// chimney
ctx.fillStyle='#3a3a44'; rr(s*0.24,-s*0.42,s*0.13,s*0.24,3); ctx.fill();
ctx.fillStyle='#23232a'; rr(s*0.21,-s*0.47,s*0.19,s*0.09,3); ctx.fill();
// headlight
ctx.fillStyle='#ffe9a8'; ctx.beginPath(); ctx.arc(s*0.44,-s*0.08,s*0.085,0,7); ctx.fill();
ctx.restore();
// glow in world
const gx=cx+Math.cos(a)*s*0.52, gy=cy+Math.sin(a)*s*0.52; const g=ctx.createRadialGradient(gx,gy,2,gx,gy,s*0.7); g.addColorStop(0,'rgba(255,238,170,0.55)'); g.addColorStop(1,'rgba(255,238,170,0)'); ctx.fillStyle=g; ctx.beginPath(); ctx.arc(gx,gy,s*0.7,0,7); ctx.fill();
Smoke draw: smoke.forEach(p=>{ ctx.fillStyle=`rgba(205,205,215,${Math.max(0,p.life/p.max)*0.5})`; ctx.beginPath(); ctx.arc(p.x,p.y,p.r,0,7); ctx.fill(); });
Boom: boom.forEach(p=>{ ctx.fillStyle=`rgba(${p.r>4?'255,120,40':'255,60,40'},${Math.max(0,p.life/p.max)})`; arc r p.r; });
Pops: pops.forEach(p=>{ const px=p.x*CELL+CELL/2, py=p.y*CELL - (1-p.life/p.max)*CELL*1.2; ctx.globalAlpha=Math.max(0,Math.min(1,p.life/0.3)); ctx.font=`bold ${CELL*0.5}px "Trebuchet MS", sans-serif`; ctx.textAlign='center'; ctx.lineWidth=4; ctx.strokeStyle='rgba(0,0,0,0.7)'; ctx.strokeText(p.text,px,py); ctx.fillStyle=p.color; ctx.fillText(p.text,px,py); ctx.globalAlpha=1; });
pickPassenger: pops.push({x:nh.x,y:nh.y,text:p.vip?'+50 VIP!':'+1 🧍',color:p.vip?'#ffd76a':'#aef3a0',life:1.2,max:1.2});
Emoji in canvas strokeText fine.
Now HTML/CSS assembly. Also add <meta viewport>. Title.
Check: "no horizontal scrollbar" — wrap max-width 100%, canvas width 100%. body overflow-x hidden as safety.
Font: font-family: "Segoe UI", "Trebuchet MS", Verdana, sans-serif.
Let me also ensure the demo doesn't crash instantly: head at (11,9) dir right; obstacles min dist 6 — none within manhattan 6 of head... freeCell(6,false) checks dist from head ≥6 — head is snake[0] at (11,9). obstacles could be at (17,9) dist 6 — allowed (≥6? condition <minDist continue → dist 6 allowed). Path ahead clear-ish. AI will navigate. fine.
Also initial passengers min dist 6 from head. good.
One more: In demo, if AI crashes and resetRun happens, startOverlay still shown (never hidden) — good.
Also when play mode starts mid-demo, the start overlay hides; if user later presses R, resetRun keeps mode play, hides over overlay. Also maybe allow returning to demo? Not needed.
Pause overlay id pauseOverlay.
Also ensure `interval` used before init in frame — resetRun called before rAF start. Order: define everything, get elements, resetRun(), then rAF.
Also step() references queueDir? no.
Potential pitfall: In step for demo, we set dir=aiDir() — but if queue has entries (shouldn't in demo). fine.
Also in play mode, if queue empty, dir unchanged.
Write aiDir carefully:
function aiDir(){ const h=snake[0]; const blocked=new Set(); for(const o of obstacles) blocked.add(key(o.x,o.y)); for(let i=0;i<snake.length-1;i++) blocked.add(key(snake[i].x,snake[i].y));
// BFS to nearest passenger
const prev=new Map(); const startKey=key(h.x,h.y); prev.set(startKey,null); const q=[[h.x,h.y]]; let target=null;
while(q.length){ const [x,y]=q.shift(); if(passengerAt({x,y})){ target={x,y}; break; } for(const d of DIRS){ const nx=x+d.x, ny=y+d.y; if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue; const k=key(nx,ny); if(blocked.has(k)||prev.has(k)) continue; prev.set(k,[x,y]); q.push([nx,ny]); } }
if(target){ let cur=target; let p=prev.get(key(cur.x,cur.y)); while(p && !(p[0]===h.x&&p[1]===h.y)){ cur={x:p[0],y:p[1]}; p=prev.get(key(cur.x,cur.y)); } return {x:cur.x-h.x, y:cur.y-h.y}; }
// survival fallback
let bestD=dir, bestScore=-Infinity;
for(const d of DIRS){ if(isOpp(d,dir)) continue; const nx=h.x+d.x, ny=h.y+d.y; if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue; if(blocked.has(key(nx,ny))) continue; const space=flood(nx,ny,blocked); let pd=Infinity; for(const p of passengers){ pd=Math.min(pd, Math.abs(p.x-nx)+Math.abs(p.y-ny)); } const sc=space*2 - (pd===Infinity?0:pd); if(sc>bestScore){ bestScore=sc; bestD=d; } }
return bestD; }
Note: passengerAt on BFS-expanded cells — but target might be the head cell? head not passenger. ok. Also BFS might target a passenger that's on blocked? passengers never on snake/obstacle. ok.
Edge: prev.get for target chain — target key exists in prev (unless target==head). fine.
flood as defined.
Now the KEYMAP with both arrows and wasd:
const KEYMAP={ArrowUp:{x:0,y:-1},ArrowDown:{x:0,y:1},ArrowLeft:{x:-1,y:0},ArrowRight:{x:1,y:0},w:{x:0,y:-1},s:{x:0,y:1},a:{x:-1,y:0},d:{x:1,y:0}};
Now assemble final HTML. Also add small CSS for .hidden and blink keyframes.
Also the header brand maybe includes a little animated emoji? fine static.
Let me double check requirement "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." — We display it. Note: our keydown listener is on window; in sandboxed iframe, keyboard events go to iframe only after focus/click — either way message shown. Good.
Also "Something meaningful must be visible on first paint" — demo runs immediately. Good.
Now check HUD best: starts 0.
Write updateHUD with element refs after DOM (script at end of body).
Potential console errors: none expected. ctx.roundRect exists in Chrome 99+. fine.
Let me also guard render when snake undefined — resetRun before first frame.
One more detail: canvas CSS width 100% inside frame; frame width min(904px,100%). .stage display flex justify-center. .wrap{width:min(960px,100%); margin:0 auto; padding:14px 12px 20px; box-sizing:border-box}. .frame{width:min(904px,100%); margin:0 auto; background:linear-gradient(180deg,#3a3f4b,#252933); border-radius:18px; padding:0 10px 10px; box-shadow:...; border:1px solid rgba(255,255,255,0.08)}. .hazard{height:12px; margin:0 -10px 10px; border-radius:17px 17px 0 0; background:repeating-linear-gradient(-45deg,#f2c230 0 14px,#23232a 14px 28px)}. canvas{display:block;width:100%;height:auto;border-radius:0 0 8px 8px; background:#2a3a30;}.
Overlay inside .stage (position relative). .stage{position:relative; width:min(904px,100%); margin:10px auto 0;}. But frame width matches stage. Put overlay inside stage covering frame incl hazard — inset 0 border-radius 18px.
Footer hint.
Media query small: reduce paddings, panel font sizes.
Also .hud{display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:10px;} .stats{display:flex; flex-wrap:wrap; gap:8px;}.
Chips: white-space:nowrap.
Now finalize the JS ordering: define consts, state, helpers, element refs, functions, listeners, resetRun(), rAF.
Also `let AC=null;` audio.
Also make sure `overOverlay`, `startOverlay`, `pauseOverlay` refs.
Write everything now, mentally compile.
One subtle bug: in frame(), when over, we still call render — smoke etc continue, fine. acc not advanced. good.
In step(), after crash() we `return` — crash sets over; the while loop in frame continues? acc>=interval loop: after crash, over=true but loop condition only checks acc — guard 5 limits; but subsequent step() calls would run with over... step doesn't check over. Add at top of step: if(over) return. Also in while loop condition add !over: while(acc>=interval && !over && guard++<5). Good.
Also crash during demo: over=true stops stepping; render shows explosion; after timeout resetRun. good.
Another subtlety: pendingGrowth and pop tail: when pickup, we unshift head and don't pop (pendingGrowth--). Implement:
snake.unshift(nh);
const p=passengerAt(nh);
if(p){ // pickup
passengers.splice(passengers.indexOf(p),1);
if(p.vip){ pendingGrowth+=3; score+=50; picked+=3; pops.push({...}); tone... } else { pendingGrowth+=1; score+=10; picked+=1; pops.push(...); }
interval=Math.max(MIN_INT, interval-3);
if(passengers.length<4) spawnPassenger();
}
if(pendingGrowth>0) pendingGrowth--; else snake.pop();
tickCount++; if(tickCount%30===0 && passengers.length<4) spawnPassenger();
updateHUD();
sfx: if(AC){ if(p.vip){tone(523,.09,'sine',.16);tone(659,.09,'sine',.16,.08);tone(784,.12,'sine',.16,.16);tone(1046,.16,'sine',.14,.24);} else {tone(660,.08,'sine',.16);tone(990,.12,'sine',.14,.07);} }
Collision check before unshift as planned. willGrow computed = !!passengerAt(nh). body check uses slice(0,-1) when !willGrow.
Also obstacle/wall check before.
Also self-collision: body includes head? snake includes head at index0; nh can't equal head (moving). body slice includes indices 0..len-1 or 0..len-2; head at old position won't equal nh. fine.
Now demo AI with length 4 initial — BFS fine.
Let me now also consider: does the demo look good at 3s? Train moving right from center, 4 cars, passengers around, smoke. Yes.
Write the final file. Also include <meta charset>. Title "Choo-Choo Express".
CSS blink: @keyframes blink{0%,100%{opacity:1}50%{opacity:0.35}} .blink{animation:blink 1.2s ease-in-out infinite}.
Let me write out the complete code now, being careful with syntax.
Also note: `ctx.arc(x,y,r,0,7)` — 7 > 2π (~6.283), fine.
roundRect helper:
function rr(x,y,w,h,r){ ctx.beginPath(); if(ctx.roundRect){ ctx.roundRect(x,y,w,h,r); } else { ctx.moveTo(x+r,y); ctx.arcTo(x+w,y,x+w,y+h,r); ctx.arcTo(x+w,y+h,x,y+h,r); ctx.arcTo(x,y+h,x,y,r); ctx.arcTo(x,y,x+w,y,r); ctx.closePath(); } }
Use ctx.fill() after rr when fill needed; for stroke after fill need path preserved — fill() doesn't clear path, stroke() strokes same path. Actually after ctx.fill(), path remains; ctx.stroke() works. But I call rr again which begins new path. Pattern: rr(...); ctx.fill(); ctx.strokeStyle=...; ctx.stroke(); — stroke uses last path (still current). Yes, fill doesn't reset path. Good.
In drawWagon I wrote rr(); ctx.fill(); ctx.strokeStyle=...; ctx.stroke(); fine.
Now sizes: CELL=34 → s*0.5 font 17px popups. ok.
Let me write HTML:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Choo-Choo Express — Train Snake</title>
<style> ... </style>
</head>
<body>
<div class="wrap">
<header class="hud">
<div class="brand">🚂 Choo-Choo Express</div>
<div class="stats">
<div class="chip">⭐ Score <b id="score">0</b></div>
<div class="chip">🧍 Aboard <b id="picked">0</b></div>
<div class="chip">🚃 Length <b id="len">4</b></div>
<div class="chip">🏆 Best <b id="best">0</b></div>
</div>
</header>
<div class="stage">
<div class="frame">
<div class="hazard"></div>
<canvas id="game" width="884" height="612"></canvas>
</div>
<div id="startOverlay" class="overlay">
<div class="panel">
<div class="pill">DEMO MODE — THE AI IS DRIVING</div>
<h1>🚂 Choo-Choo Express</h1>
<p>Steer the little train through the station and run the <b>locomotive</b> over platform slabs where <b>passengers</b> are waiting — each one boards and adds a colorful wagon! Gold VIPs add three.</p>
<p class="cta blink">▶ Click or press any key to start ◀</p>
<p class="keys">← ↑ ↓ → / WASD steer • P pause • R restart</p>
</div>
</div>
<div id="overOverlay" class="overlay hidden">
<div class="panel">
<h1 id="overTitle">💥 Crash!</h1>
<p id="overText"></p>
<p class="cta blink">Click or press R to ride again</p>
</div>
</div>
<div id="pauseOverlay" class="overlay hidden">
<div class="panel"><h1>⏸ Paused</h1><p class="cta">Press P to continue</p></div>
</div>
</div>
<footer class="hint">Avoid pillars, benches and your own wagons — the station edges are deadly too. Every passenger picked up = one more wagon!</footer>
</div>
<script> ... </script>
</body>
</html>
Canvas width attr 884 height 612 will be overwritten by dpr scaling in JS — but CSS width 100% height auto uses attr ratio 884/612 — after setting canvas.width=884*dpr, ratio same. good.
JS:
const canvas=document.getElementById('game');
const ctx=canvas.getContext('2d');
const COLS=26,ROWS=18,CELL=34,W=COLS*CELL,H=ROWS*CELL;
const dpr=Math.min(2,window.devicePixelRatio||1);
canvas.width=W*dpr; canvas.height=H*dpr; ctx.setTransform(dpr,0,0,dpr,0,0);
... rest.
Note: setting canvas.width resets context transform — set after. done.
Element refs: scoreEl etc.
Everything inside an IIFE? Not necessary; plain script fine.
Let me now write the full script in order:
(() => { ... })(); to avoid globals — fine either way. I'll use plain top-level in script (no conflicts).
Full script:
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const scoreEl = document.getElementById('score');
const pickedEl = document.getElementById('picked');
const lenEl = document.getElementById('len');
const bestEl = document.getElementById('best');
const startOverlay = document.getElementById('startOverlay');
const overOverlay = document.getElementById('overOverlay');
const overTitle = document.getElementById('overTitle');
const overText = document.getElementById('overText');
const pauseOverlay = document.getElementById('pauseOverlay');
const COLS=26, ROWS=18, CELL=34, W=COLS*CELL, H=ROWS*CELL;
const BASE_INT=150, MIN_INT=85;
const dpr=Math.min(2, window.devicePixelRatio||1);
canvas.width=W*dpr; canvas.height=H*dpr;
ctx.setTransform(dpr,0,0,dpr,0,0);
const DIRS=[{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
const PALETTE=[...]; const SHIRTS=[...];
const KEYMAP={...};
let snake, dir, queue, pendingGrowth, score, picked, best=0, interval, tickCount, passengers, obstacles, smoke, pops, boom;
let mode='demo', over=false, paused=false;
let acc=0, wheelAngle=0, shakeT=0, smokeAcc=0, lastT=0, demoRestartT=null;
let AC=null;
helpers... (rnd, pick, shade, key, isOpp, rr, drawStar, drawWheel)
function passengerAt(c){...} etc.
function updateHUD(){...}
function cellFree(x,y,avoidPass){...}
function freeCell(minDist,avoidPass){...}
function spawnObstacles(){...}
function spawnPassenger(){...}
function resetRun(){...}
function queueDir(d){...}
function aiDir(){...}
function flood(x,y,blocked){...}
function step(){...}
function crash(){...}
function startGame(){...}
function initAudio(){...}
function tone(...){...}
function emitSmoke(){...}
function updateFx(dt){...}
draw functions...
function render(t){...}
function frame(now){...}
listeners...
resetRun();
requestAnimationFrame(t=>{ lastT=t; requestAnimationFrame(frame); });
Check: resetRun references spawnObstacles/spawnPassenger/updateHUD — defined before call (function declarations hoisted anyway).
step():
function step(){
if(over) return;
if(mode==='play'){
if(queue.length){ const q=queue.shift(); if(!isOpp(q,dir)) dir=q; }
} else {
dir=aiDir();
}
const h=snake[0];
const nh={x:h.x+dir.x, y:h.y+dir.y};
if(nh.x<0||nh.y<0||nh.x>=COLS||nh.y>=ROWS){ crash(); return; }
if(obstacleAt(nh)){ crash(); return; }
const p=passengerAt(nh);
const body = p ? snake : snake.slice(0,-1);
for(const s of body){ if(s.x===nh.x && s.y===nh.y){ crash(); return; } }
snake.unshift(nh);
if(p){
passengers.splice(passengers.indexOf(p),1);
if(p.vip){ pendingGrowth+=3; score+=50; picked+=3; pops.push({x:nh.x,y:nh.y,text:'+50 VIP!',color:'#ffd76a',life:1.3,max:1.3}); if(AC){tone(523,.09,'sine',.16);tone(659,.09,'sine',.16,.08);tone(784,.12,'sine',.16,.16);tone(1047,.18,'sine',.14,.24);} }
else { pendingGrowth+=1; score+=10; picked+=1; pops.push({x:nh.x,y:nh.y,text:'+1 🧍',color:'#aef3a0',life:1.1,max:1.1}); if(AC){tone(660,.08,'sine',.16);tone(990,.12,'sine',.14,.07);} }
interval=Math.max(MIN_INT, interval-3);
if(passengers.length<4) spawnPassenger();
}
if(pendingGrowth>0) pendingGrowth--; else snake.pop();
tickCount++;
if(tickCount%30===0 && passengers.length<4) spawnPassenger();
updateHUD();
}
crash():
function crash(){
over=true; shakeT=0.5;
const h=snake[0]; const cx=h.x*CELL+CELL/2, cy=h.y*CELL+CELL/2;
for(let i=0;i<26;i++){ const a=Math.random()*Math.PI*2, sp=50+Math.random()*150; boom.push({x:cx,y:cy,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp-50,r:2+Math.random()*4.5,life:.6+Math.random()*.6,max:1.2}); }
if(AC){ tone(150,.25,'square',.2); tone(85,.4,'sawtooth',.18,.06); }
if(score>best) best=score;
updateHUD();
if(mode==='demo'){
overTitle.textContent='💥 Demo crash!';
overText.textContent='The AI engineer got into a pickle… restarting the demo.';
overOverlay.classList.remove('hidden');
clearTimeout(demoRestartT);
demoRestartT=setTimeout(()=>{ resetRun(); overOverlay.classList.add('hidden'); },2200);
} else {
overTitle.textContent='💥 Crash!';
overText.textContent=`Score ${score} • ${picked} passenger${picked===1?'':'s'} aboard • Best ${best}`;
overOverlay.classList.remove('hidden');
}
}
resetRun():
function resetRun(){
snake=[{x:11,y:9},{x:10,y:9},{x:9,y:9},{x:8,y:9}];
dir={x:1,y:0}; queue=[]; pendingGrowth=0;
score=0; picked=0; interval=BASE_INT; tickCount=0; acc=0;
passengers=[]; obstacles=[]; smoke=[]; pops=[]; boom=[];
over=false; paused=false; shakeT=0;
pauseOverlay.classList.add('hidden');
spawnObstacles();
for(let i=0;i<4;i++) spawnPassenger();
updateHUD();
}
startGame():
function startGame(){
if(mode!=='demo') return;
mode='play';
clearTimeout(demoRestartT);
if(over) resetRun();
over=false;
startOverlay.classList.add('hidden');
overOverlay.classList.add('hidden');
initAudio();
if(AC){ tone(523,.08,'triangle',.15); tone(784,.12,'triangle',.13,.08); }
}
Wait: if over was true in demo and we resetRun, over=false already set by resetRun. The extra over=false harmless.
keydown:
window.addEventListener('keydown', e=>{
const k=e.key;
if(k===' '||k.indexOf('Arrow')===0) e.preventDefault();
if(mode==='demo') startGame();
let d=KEYMAP[k];
if(!d && k.length===1) d=KEYMAP[k.toLowerCase()];
if(d && mode==='play' && !over) queueDir(d);
if(k==='r'||k==='R'){ if(mode==='play'){ resetRun(); overOverlay.classList.add('hidden'); } }
if(k==='p'||k==='P'){ if(mode==='play' && !over){ paused=!paused; pauseOverlay.classList.toggle('hidden', !paused); } }
});
Hmm: pressing 'r' in demo: startGame() runs first (mode becomes play), then resetRun — resets fresh in play mode. ok.
pointerdown:
window.addEventListener('pointerdown', ()=>{
if(mode==='demo'){ startGame(); }
else if(over){ resetRun(); overOverlay.classList.add('hidden'); }
});
touch:
let tsx=0, tsy=0;
window.addEventListener('touchstart', e=>{ tsx=e.touches[0].clientX; tsy=e.touches[0].clientY; }, {passive:true});
window.addEventListener('touchend', e=>{
const dx=e.changedTouches[0].clientX-tsx, dy=e.changedTouches[0].clientY-tsy;
if(mode==='demo'){ startGame(); return; }
if(over) return;
if(Math.abs(dx)<24 && Math.abs(dy)<24) return;
const d = Math.abs(dx)>Math.abs(dy) ? {x:dx>0?1:-1,y:0} : {x:0,y:dy>0?1:-1};
if(mode==='play' && !over) queueDir(d);
}, {passive:true});
queueDir:
function queueDir(d){
const last = queue.length ? queue[queue.length-1] : dir;
if(isOpp(d,last)) return;
if(d.x===last.x && d.y===last.y) return;
if(queue.length>=3) queue.shift();
queue.push(d);
}
frame:
function frame(now){
requestAnimationFrame(frame);
let dt=(now-lastT)/1000; lastT=now;
if(dt>0.05) dt=0.05; if(dt<0) dt=0;
wheelAngle += dt*9;
if(shakeT>0) shakeT-=dt;
if(!over && !paused){
acc += dt*1000;
let guard=0;
while(acc>=interval && !over && guard<5){ step(); acc-=interval; guard++; }
if(acc>interval*5) acc=0;
}
updateFx(dt);
render(now/1000);
}
updateFx:
function updateFx(dt){
smokeAcc+=dt;
if(smokeAcc>0.085){ smokeAcc=0; if(snake && snake.length && !over) emitSmoke(); }
for(let i=smoke.length-1;i>=0;i--){ const p=smoke[i]; p.life-=dt; p.x+=p.vx*dt; p.y+=p.vy*dt; p.vy-=12*dt; p.r+=7*dt; if(p.life<=0) smoke.splice(i,1); }
for(let i=pops.length-1;i>=0;i--){ const p=pops[i]; p.life-=dt; if(p.life<=0) pops.splice(i,1); }
for(let i=boom.length-1;i>=0;i--){ const p=boom[i]; p.life-=dt; p.vy+=340*dt; p.x+=p.vx*dt; p.y+=p.vy*dt; if(p.life<=0) boom.splice(i,1); }
}
render(t): as planned.
drawPassenger, drawObstacle, drawWagon, drawLoco, drawWheel, drawStar.
emitSmoke uses dir — dir is current. ok.
Now double-check render floor colors and contrast with train. Floor #2e3f35/#2a3a30 dark green — crimson loco pops, wagons colorful. Passengers slab light tan pops. Good.
Also maybe draw subtle center "track" darker? skip.
Check drawWagon when snake length large and i from end to 1 — orientation uses prev (toward head). For the last wagon, prev exists (snake[i-1]) — yes i>=1 so prev=snake[i-1] exists. good.
Coupling lines drawn for all i — includes neck. good.
Now CSS details:
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{
min-height:100vh;
font-family:"Segoe UI","Trebuchet MS",Verdana,system-ui,sans-serif;
background:radial-gradient(1200px 700px at 50% -10%, #26305a 0%, #171d38 45%, #0c101f 100%);
color:#ece9df;
overflow-x:hidden;
display:flex; justify-content:center;
}
.wrap{width:min(960px,100%); padding:14px 12px 22px;}
.hud{display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:10px; margin-bottom:4px;}
.brand{font-size:clamp(18px,3vw,24px); font-weight:800; letter-spacing:.5px; text-shadow:0 2px 8px rgba(0,0,0,.5);}
.stats{display:flex; flex-wrap:wrap; gap:8px;}
.chip{background:rgba(255,255,255,.07); border:1px solid rgba(255,255,255,.14); border-radius:999px; padding:6px 13px; font-size:14px; white-space:nowrap;}
.chip b{color:#ffd76a; margin-left:2px;}
.stage{position:relative; width:min(904px,100%); margin:8px auto 0; touch-action:none;}
.frame{background:linear-gradient(180deg,#3b404d,#262a34); border:1px solid rgba(255,255,255,.09); border-radius:18px; padding:0 10px 10px; box-shadow:0 18px 50px rgba(0,0,0,.55);}
.hazard{height:12px; margin:0 -10px 10px; border-radius:17px 17px 0 0; background:repeating-linear-gradient(-45deg,#f2c230 0 14px,#23232a 14px 28px);}
canvas{display:block; width:100%; height:auto; border-radius:0 0 9px 9px; background:#2a3a30;}
.overlay{position:absolute; inset:0; display:flex; align-items:center; justify-content:center; pointer-events:none; background:rgba(8,11,22,.42); backdrop-filter:blur(3px); border-radius:18px; padding:12px;}
.panel{background:rgba(15,19,35,.93); border:1px solid rgba(255,214,106,.4); border-radius:16px; padding:20px 26px; max-width:540px; text-align:center; box-shadow:0 12px 40px rgba(0,0,0,.6);}
.panel h1{margin:6px 0 10px; font-size:clamp(20px,3.4vw,30px);}
.panel p{margin:8px 0; line-height:1.45; font-size:clamp(13px,2vw,15px);}
.pill{display:inline-block; background:#ffd76a; color:#23232a; font-weight:800; font-size:11px; letter-spacing:1.2px; padding:4px 12px; border-radius:999px;}
.cta{color:#ffd76a; font-weight:800; font-size:clamp(14px,2.2vw,17px) !important;}
.keys{opacity:.75; font-size:12px !important;}
.blink{animation:blink 1.15s ease-in-out infinite;}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.3}}
.hidden{display:none !important;}
.hint{margin:12px auto 0; width:min(904px,100%); text-align:center; font-size:12.5px; opacity:.7; line-height:1.5;}
@media (max-width:520px){ .chip{font-size:12px; padding:4px 10px;} .panel{padding:14px 16px;} }
Check height at 800: wrap padding top 14 + hud (~40) + stage margin 8 + frame: hazard 12+10 + canvas height. Canvas display width = min(904,100%) - 20 padding = 884 → height 612. frame total ≈ 12+10+612+10+2 ≈ 646. stage 646. hint ~ 30. total ≈ 14+40+8+646+12+30+22 ≈ 772 < 800.
At 375 wide: canvas width = 375-24(wrap pad)-20(frame pad)-2 border ≈ 329 → height ≈ 228. fine.
One more: overlay inset covers hazard too — radius 18 matches frame. ok.
Now, is `backdrop-filter` fine? yes.
Potential issue: overlay covers stage; pointer-events none so pointerdown on window still fires (window receives events regardless since overlay doesn't block). Yes.
Also initial startOverlay visible; overOverlay hidden; pauseOverlay hidden.
Now write drawObstacle fully:
function drawObstacle(o){
const s=CELL, x=o.x*s, y=o.y*s;
if(o.type==='pillar'){
ctx.fillStyle='#7b8494'; rr(x+s*0.18,y+s*0.14,s*0.64,s*0.74,5); ctx.fill();
ctx.strokeStyle='#3c4350'; ctx.lineWidth=2; ctx.stroke();
ctx.fillStyle='#aab2c0'; rr(x+s*0.18,y+s*0.14,s*0.64,s*0.16,5); ctx.fill();
ctx.fillStyle='#f2c230'; rr(x+s*0.18,y+s*0.64,s*0.64,s*0.24,3); ctx.fill();
ctx.strokeStyle='#23232a'; ctx.lineWidth=3; ctx.beginPath();
ctx.moveTo(x+s*0.24,y+s*0.86); ctx.lineTo(x+s*0.40,y+s*0.66);
ctx.moveTo(x+s*0.42,y+s*0.88); ctx.lineTo(x+s*0.58,y+s*0.68);
ctx.stroke();
} else {
ctx.fillStyle='#a9713f';
rr(x+5,y+6,s-10,s*0.14,4); ctx.fill();
rr(x+4,y+s*0.36,s-8,s*0.18,4); ctx.fill();
ctx.strokeStyle='#7c4f27'; ctx.lineWidth=2;
rr(x+5,y+6,s-10,s*0.14,4); ctx.stroke();
rr(x+4,y+s*0.36,s-8,s*0.18,4); ctx.stroke();
ctx.fillStyle='#7c4f27';
ctx.fillRect(x+s*0.14,y+s*0.54,s*0.08,s*0.32);
ctx.fillRect(x+s*0.78,y+s*0.54,s*0.08,s*0.32);
}
}
Note stroke after two fills: second rr resets path then stroke strokes only seat; I stroke each after its fill — I did rr,fill,rr,fill,stroke,rr,stroke — messy. Rewrite cleanly:
ctx.fillStyle='#a9713f'; rr(x+5,y+6,s-10,s*0.14,4); ctx.fill(); ctx.strokeStyle='#7c4f27'; ctx.lineWidth=2; ctx.stroke();
rr(x+4,y+s*0.36,s-8,s*0.18,4); ctx.fill(); ctx.stroke();
ok.
drawStar:
function drawStar(cx,cy,r,fill,stroke){
ctx.beginPath();
for(let i=0;i<10;i++){
const a=-Math.PI/2 + i*Math.PI/5;
const rad = i%2===0 ? r : r*0.45;
const px=cx+Math.cos(a)*rad, py=cy+Math.sin(a)*rad;
if(i===0) ctx.moveTo(px,py); else ctx.lineTo(px,py);
}
ctx.closePath();
ctx.fillStyle=fill; ctx.fill();
ctx.strokeStyle=stroke; ctx.lineWidth=1.5; ctx.stroke();
}
render:
function render(t){
ctx.save();
if(shakeT>0) ctx.translate((Math.random()-0.5)*10*shakeT*2,(Math.random()-0.5)*10*shakeT*2);
// floor
for(let y=0;y<ROWS;y++){
for(let x=0;x<COLS;x++){
ctx.fillStyle=((x+y)&1)?'#2e3f35':'#2a3a30';
rr(x*CELL+1,y*CELL+1,CELL-2,CELL-2,5); ctx.fill();
}
}
// deadly edge
ctx.fillStyle='rgba(0,0,0,0.35)';
ctx.fillRect(0,0,W,6); ctx.fillRect(0,H-6,W,6); ctx.fillRect(0,0,6,H); ctx.fillRect(W-6,0,6,H);
ctx.strokeStyle='#f2c230'; ctx.lineWidth=3; ctx.setLineDash([16,12]);
ctx.strokeRect(9,9,W-18,H-18); ctx.setLineDash([]);
// passengers
for(const p of passengers) drawPassenger(p,t);
// obstacles
for(const o of obstacles) drawObstacle(o);
// couplings
ctx.strokeStyle='#17171f'; ctx.lineWidth=6; ctx.lineCap='round';
ctx.beginPath();
for(let i=1;i<snake.length;i++){
const a=snake[i-1], b=snake[i];
ctx.moveTo(a.x*CELL+CELL/2,a.y*CELL+CELL/2);
ctx.lineTo(b.x*CELL+CELL/2,b.y*CELL+CELL/2);
}
ctx.stroke();
// wagons
for(let i=snake.length-1;i>=1;i--) drawWagon(i);
// locomotive
drawLoco();
// smoke
for(const p of smoke){
ctx.fillStyle='rgba(205,205,215,'+(Math.max(0,p.life/p.max)*0.45).toFixed(3)+')';
ctx.beginPath(); ctx.arc(p.x,p.y,p.r,0,7); ctx.fill();
}
// explosion
for(const p of boom){
ctx.fillStyle='rgba('+(p.r>4?'255,130,40':'255,60,40')+','+Math.max(0,p.life/p.max).toFixed(3)+')';
ctx.beginPath(); ctx.arc(p.x,p.y,p.r,0,7); ctx.fill();
}
// popups
for(const p of pops){
const px=p.x*CELL+CELL/2;
const py=p.y*CELL - (1-p.life/p.max)*CELL*1.3;
ctx.globalAlpha=Math.max(0,Math.min(1,p.life/0.3));
ctx.font='bold '+Math.round(CELL*0.5)+'px "Trebuchet MS", sans-serif';
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.lineWidth=4; ctx.strokeStyle='rgba(0,0,0,0.7)';
ctx.strokeText(p.text,px,py);
ctx.fillStyle=p.color; ctx.fillText(p.text,px,py);
ctx.globalAlpha=1;
}
ctx.restore();
}
drawLoco with glow after restore — glow drawn unshaken, fine.
Wait: drawLoco computes glow using cx,cy which are inside save/restore scope — define before. I'll compute cx,cy,a at top of drawLoco, use in both parts.
Also in render, headlight glow should be under smoke? drawn inside drawLoco after restore — before smoke in render order since drawLoco called before smoke loop. good.
drawWagon full:
function drawWagon(i){
const s=CELL;
const seg=snake[i], prev=snake[i-1];
const a=Math.atan2(prev.y-seg.y, prev.x-seg.x);
const cx=seg.x*s+s/2, cy=seg.y*s+s/2;
const col=PALETTE[(i-1)%PALETTE.length];
ctx.save();
ctx.translate(cx,cy); ctx.rotate(a);
ctx.fillStyle='rgba(0,0,0,0.28)';
ctx.beginPath(); ctx.ellipse(0,s*0.32,s*0.40,s*0.09,0,0,Math.PI*2); ctx.fill();
drawWheel(-s*0.22,s*0.24,s*0.10);
drawWheel(s*0.22,s*0.24,s*0.10);
ctx.fillStyle=col; rr(-s*0.40,-s*0.24,s*0.80,s*0.46,6); ctx.fill();
ctx.strokeStyle='rgba(0,0,0,0.4)'; ctx.lineWidth=2; ctx.stroke();
ctx.fillStyle=shade(col,0.62); rr(-s*0.42,-s*0.33,s*0.84,s*0.13,5); ctx.fill();
ctx.fillStyle='#cfe8f7'; rr(-s*0.30,-s*0.15,s*0.36,s*0.24,4); ctx.fill();
ctx.strokeStyle='rgba(0,0,0,0.35)'; ctx.lineWidth=1.5; ctx.stroke();
ctx.fillStyle='#f1c9a2'; ctx.beginPath(); ctx.arc(-s*0.16,-s*0.02,s*0.075,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#4a3222'; ctx.beginPath(); ctx.arc(-s*0.16,-s*0.035,s*0.075,Math.PI,0); ctx.fill();
ctx.fillStyle='#b9d9ea'; rr(s*0.02,-s*0.15,s*0.28,s*0.24,4); ctx.fill();
ctx.strokeStyle='rgba(0,0,0,0.35)'; ctx.lineWidth=1.5; ctx.stroke();
ctx.strokeStyle='rgba(255,255,255,0.55)'; ctx.lineWidth=1.5;
ctx.beginPath(); ctx.moveTo(s*0.08,-s*0.11); ctx.lineTo(s*0.22,-s*0.03); ctx.stroke();
ctx.restore();
}
drawLoco:
function drawLoco(){
const s=CELL;
const h=snake[0];
const a=Math.atan2(dir.y,dir.x);
const cx=h.x*s+s/2, cy=h.y*s+s/2;
ctx.save();
ctx.translate(cx,cy); ctx.rotate(a);
ctx.fillStyle='rgba(0,0,0,0.3)';
ctx.beginPath(); ctx.ellipse(0,s*0.34,s*0.46,s*0.10,0,0,Math.PI*2); ctx.fill();
// cowcatcher
ctx.fillStyle='#8f2f2f';
ctx.beginPath(); ctx.moveTo(s*0.50,s*0.10); ctx.lineTo(s*0.32,s*0.36); ctx.lineTo(s*0.50,s*0.36); ctx.closePath(); ctx.fill();
drawWheel(-s*0.30,s*0.28,s*0.11);
drawWheel(0,s*0.28,s*0.11);
drawWheel(s*0.30,s*0.28,s*0.11);
// cab
ctx.fillStyle=shade('#d64545',0.8); rr(-s*0.44,-s*0.30,s*0.40,s*0.50,5); ctx.fill();
ctx.strokeStyle='rgba(0,0,0,0.4)'; ctx.lineWidth=2; ctx.stroke();
ctx.fillStyle='#7e2f2f'; rr(-s*0.48,-s*0.40,s*0.46,s*0.12,5); ctx.fill();
ctx.fillStyle='#cfe8f7'; rr(-s*0.38,-s*0.23,s*0.26,s*0.20,3); ctx.fill();
ctx.strokeStyle='rgba(0,0,0,0.4)'; ctx.lineWidth=1.5; ctx.stroke();
// boiler
ctx.fillStyle='#d64545'; rr(-s*0.06,-s*0.20,s*0.50,s*0.38,9); ctx.fill();
ctx.strokeStyle='rgba(0,0,0,0.4)'; ctx.lineWidth=2; ctx.stroke();
ctx.fillStyle='#8f2f2f'; rr(s*0.36,-s*0.20,s*0.10,s*0.38,4); ctx.fill();
ctx.fillStyle='rgba(255,255,255,0.18)'; ctx.fillRect(-s*0.02,s*0.05,s*0.42,s*0.07);
// dome
ctx.fillStyle='#f2c230'; ctx.beginPath(); ctx.arc(s*0.08,-s*0.21,s*0.065,0,Math.PI*2); ctx.fill();
ctx.strokeStyle='rgba(0,0,0,0.35)'; ctx.lineWidth=1.5; ctx.stroke();
// chimney
ctx.fillStyle='#3a3a44'; rr(s*0.24,-s*0.42,s*0.13,s*0.24,3); ctx.fill();
ctx.fillStyle='#23232a'; rr(s*0.21,-s*0.47,s*0.19,s*0.09,3); ctx.fill();
// headlight
ctx.fillStyle='#ffe9a8'; ctx.beginPath(); ctx.arc(s*0.44,-s*0.08,s*0.085,0,Math.PI*2); ctx.fill();
ctx.restore();
// headlight glow
const gx=cx+Math.cos(a)*s*0.55, gy=cy+Math.sin(a)*s*0.55;
const g=ctx.createRadialGradient(gx,gy,2,gx,gy,s*0.75);
g.addColorStop(0,'rgba(255,238,170,0.5)');
g.addColorStop(1,'rgba(255,238,170,0)');
ctx.fillStyle=g;
ctx.beginPath(); ctx.arc(gx,gy,s*0.75,0,Math.PI*2); ctx.fill();
}
Chimney local pos for smoke: (s*0.30, -s*0.44). matches rim top.
emitSmoke:
function emitSmoke(){
const h=snake[0];
const a=Math.atan2(dir.y,dir.x);
const cx=h.x*CELL+CELL/2, cy=h.y*CELL+CELL/2;
const lx=CELL*0.30, ly=-CELL*0.44;
const wx=cx+lx*Math.cos(a)-ly*Math.sin(a);
const wy=cy+lx*Math.sin(a)+ly*Math.cos(a);
smoke.push({x:wx,y:wy,vx:-dir.x*10+(Math.random()-0.5)*8,vy:-20-Math.random()*16,r:2.5+Math.random()*2,life:0.8+Math.random()*0.5,max:1.3});
if(smoke.length>90) smoke.shift();
}
drawPassenger as earlier.
Helpers:
function rnd(n){ return Math.floor(Math.random()*n); }
function pick(arr){ return arr[Math.floor(Math.random()*arr.length)]; }
function shade(hex,f){ const n=parseInt(hex.slice(1),16); const r=Math.min(255,Math.round(((n>>16)&255)*f)), g=Math.min(255,Math.round(((n>>8)&255)*f)), b=Math.min(255,Math.round((n&255)*f)); return 'rgb('+r+','+g+','+b+')'; }
const key=(x,y)=>x+','+y;
const isOpp=(a,b)=>a.x===-b.x && a.y===-b.y;
function rr(x,y,w,h,r){ ctx.beginPath(); if(ctx.roundRect){ ctx.roundRect(x,y,w,h,r); } else { ctx.rect(x,y,w,h); } }
cellFree/freeCell:
function cellFree(x,y,avoidPass){
if(x<0||y<0||x>=COLS||y>=ROWS) return false;
for(const s of snake) if(s.x===x&&s.y===y) return false;
if(obstacleAt({x,y})) return false;
if(avoidPass && passengerAt({x,y})) return false;
return true;
}
function freeCell(minDist, avoidPass){
for(let i=0;i<500;i++){
const x=rnd(COLS), y=rnd(ROWS);
if(!cellFree(x,y,avoidPass)) continue;
const h=snake[0];
if(Math.abs(x-h.x)+Math.abs(y-h.y) < minDist) continue;
return {x,y};
}
return null;
}
spawnObstacles:
function spawnObstacles(){
obstacles=[];
for(let i=0;i<8;i++){
const c=freeCell(6,false);
if(c) obstacles.push({x:c.x,y:c.y,type:Math.random()<0.5?'pillar':'bench'});
}
}
Note freeCell(6,false) — avoidPass false so obstacles don't stack? cellFree always checks obstacles, so no stacking. good.
spawnPassenger:
function spawnPassenger(){
if(passengers.length>=4) return;
const c=freeCell(6,true);
if(!c) return;
passengers.push({x:c.x,y:c.y,vip:Math.random()<0.12,shirt:pick(SHIRTS),seed:Math.random()*10,side:rnd(4)});
}
aiDir + flood as above.
passengerAt/obstacleAt:
function passengerAt(c){ for(const p of passengers) if(p.x===c.x&&p.y===c.y) return p; return null; }
function obstacleAt(c){ for(const o of obstacles) if(o.x===c.x&&o.y===c.y) return true; return false; }
updateHUD:
function updateHUD(){ scoreEl.textContent=score; pickedEl.textContent=picked; lenEl.textContent=snake.length; bestEl.textContent=best; }
initAudio/tone as above.
drawWheel as above.
OK also initial call order: script at end of body; elements exist. resetRun(); then rAF.
One more check: in frame, first call lastT set via wrapper. I'll do:
requestAnimationFrame(function boot(t){ lastT=t; requestAnimationFrame(frame); });
Also demo: mode='demo' initially, over=false after resetRun. startOverlay visible in HTML (no hidden class). Good.
Test mentally for errors:
- shade('#d64545',0.8) works.
- PALETTE includes hex strings; shade used on them fine.
- In drawPassenger, rr then fill then stroke: I set strokeStyle after fill — path persists. ok.
- ctx.setLineDash reset after border. ok.
- textBaseline set in popups; fine.
- In keydown, k.indexOf('Arrow')===0 covers Arrow keys; also ' ' space prevented.
- Potential: pressing key in demo triggers startGame, and if key is 'p', pause toggles immediately — minor, acceptable.
- pointerdown also fires for touch? pointerdown fires on touch too — then touchend swipe would also queue... pointerdown starts game (demo→play), then touchend: mode==='play', not over, swipe queues dir. That's fine (first tap starts + swipe steers). But a simple tap without swipe: pointerdown starts game, touchend no dir. good. However pointerdown also fires on mouse click — fine.
But wait: in play mode, tapping (pointerdown) when over restarts — good.
- One issue: after startGame via keydown, the same keydown also processes 'r' if key was r → resetRun — fine.
- queueDir uses dir global — during demo not used.
- In step play mode: if queue has dir equal to opposite of dir? queueDir prevented at enqueue relative to last queued; but dir may have changed since; consume-time check handles.
- AI: dir=aiDir() each tick; ensure aiDir returns a DIRS object — yes.
- flood blocked includes body except tail — same as BFS.
Edge: snake length 4, blocked includes snake[0..2] (head, neck, mid), tail free. AI from head: neighbors exclude neck (blocked) — good, no reversal.
- crash when nh equals tail cell and p exists? If passenger on tail cell — passengers never on snake cells, so p implies not body. fine.
Now, the "demo crash restart" — resetRun sets over=false; overOverlay hidden after. During those 2.2s, startOverlay still visible (demo) plus overOverlay — overOverlay on top (later in DOM). ok.
Also in demo mode after crash, over=true stops stepping; render still draws last state + boom. good.
I think the code is solid. Let me also add a tiny detail: title of page and maybe favicon via emoji? No external; skip favicon (or inline data URI emoji — allowed? It's not a network request; data URI fine but unnecessary). Skip.
Also ensure no `alert` etc. none.
Double-check no horizontal scrollbar: body overflow-x hidden; wrap max 960; fine.
Let me also reconsider grid vs 375px: canvas 329×228 — playable-ish. Acceptable per "adapt gracefully".
Maybe increase CELL to 36 and keep width 936? At 800 height: 14+40+8+ (12+10+648+10) +12+30+22 = 776 — fits too. 936 wide wrap 960 fits 1280. At 375: canvas ~331 wide. Similar. I'll go CELL=36, COLS=26, ROWS=18 → 936×648, wrap 980, frame width min(956,100%). Height check: wrap padding 14 top; hud height: brand 24px line → ~34 with chips maybe on same row at 1280 (brand + 4 chips fit in 956? brand ~260px, chips ~ 4×130=520 + gaps → ~800 total, fits one row ~36px). stage margin 8 + frame: hazard 12 + margin 10 + canvas 648 + padding-bottom 10 + border 2 = 692. hint 12 margin + ~32. bottom padding 22. Total ≈ 14+36+8+692+44+22 = 816 > 800. Slightly over → page scrolls vertically a bit; that's okay (no horizontal scrollbar requirement only). But better to fit: reduce to CELL=34 as planned (612 canvas → total ~772). Keep CELL=34. Good.
Actually also consider viewport 1280×800: fine at 772.
Let me finalize CELL=34, W=884, H=612, canvas attr width=884 height=612, wrap 960, frame/stage width min(904px,100%).
Hmm wrap width min(960) but stage 904 → canvas 884. consistent.
Now write the final answer: exactly one fenced code block with ```html.
Let me write the complete code now carefully, ensuring all braces match.
Also one more feature for juice: draw faint "PLATFORM" text? skip.
Also the hint about VIP.
Also make sure the start overlay text mentions passengers/platform as the task says. Yes.
Write it.
One more consideration on `tone` exponentialRampToValueAtTime with value 0.001 from v — fine.
Also `AC.resume()` returns promise; catch.
Edge: In startGame, initAudio creates context within keydown/pointerdown handler — allowed.
Final check on the AI fallback when passengers empty (shouldn't happen): pd=Infinity → sc=space*2; fine.
Also in aiDir BFS, if no passengers reachable (all blocked), target null → fallback. good.
Potential perf: fine.
Now produce final output. I'll write the code block only.
Let me count nothing else outside. Ensure code block language html.
Writing the script fully:
<script>
"use strict";
const canvas=document.getElementById('game');
... (as planned)
I need to be careful: "use strict" fine.
Also note: `const key=(x,y)=>x+','+y;` used in aiDir with key(nx,ny) and key(cur.x,cur.y) — consistent.
In aiDir BFS prev stores [x,y] arrays.
Let me write aiDir exactly:
function aiDir(){
const h=snake[0];
const blocked=new Set();
for(const o of obstacles) blocked.add(key(o.x,o.y));
for(let i=0;i<snake.length-1;i++) blocked.add(key(snake[i].x,snake[i].y));
const prev=new Map();
prev.set(key(h.x,h.y),null);
const q=[[h.x,h.y]];
let target=null;
while(q.length){
const cur=q.shift();
const x=cur[0], y=cur[1];
if(passengerAt({x:x,y:y})){ target={x:x,y:y}; break; }
for(const d of DIRS){
const nx=x+d.x, ny=y+d.y;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
const k=key(nx,ny);
if(blocked.has(k)||prev.has(k)) continue;
prev.set(k,[x,y]);
q.push([nx,ny]);
}
}
if(target){
let cur=target;
let p=prev.get(key(cur.x,cur.y));
while(p && !(p[0]===h.x&&p[1]===h.y)){
cur={x:p[0],y:p[1]};
p=prev.get(key(cur.x,cur.y));
}
return {x:cur.x-h.x, y:cur.y-h.y};
}
let bestD=dir, bestScore=-Infinity;
for(const d of DIRS){
if(isOpp(d,dir)) continue;
const nx=h.x+d.x, ny=h.y+d.y;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
if(blocked.has(key(nx,ny))) continue;
const space=flood(nx,ny,blocked);
let pd=Infinity;
for(const p of passengers) pd=Math.min(pd,Math.abs(p.x-nx)+Math.abs(p.y-ny));
const sc=space*2-(pd===Infinity?0:pd);
if(sc>bestScore){ bestScore=sc; bestD=d; }
}
return bestD;
}
flood:
function flood(sx,sy,blocked){
const seen=new Set([key(sx,sy)]);
const q=[[sx,sy]];
let c=0;
while(q.length && c<600){
const cur=q.shift(); c++;
for(const d of DIRS){
const nx=cur[0]+d.x, ny=cur[1]+d.y;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
const k=key(nx,ny);
if(seen.has(k)||blocked.has(k)) continue;
seen.add(k); q.push([nx,ny]);
}
}
return c;
}
All good. Compose final HTML now.
Also add `ctx.lineJoin='round'` maybe for strokes; optional.
Also `ctx.textBaseline` default alphabetic; I set middle in popups only; reset not needed elsewhere.
Final answer: one code block.