Train snakeOne-line
DeepSeek V4.1 Flash 1017
by DeepSeekReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 1 min 59 s
- Cost
- $0.015
- Output tokens
- 29,423
- Spent thinking
- 20,588 tokens
- Speed
- 247.5 tok/s
- Console errors
- 0
- First token after
- 423 ms
- First visible text after
- 1 min 34 s
- Prompt tokens
- 387
- Code size
- 22.5 KB
- Finish reason
- stop
- Served by
- Cohere
- Model id
deepseek/deepseek-v4.1-flash-1017- Reasoning sent
{"effort":"max"}- Max output
- 64,000 tokens
- Generated
- Nov 11, 2026
What it was asked
The prompt
“make me a snake game where the snake is a little train with a locomotive and colorful wagons, picking up passengers waiting on the platform”
System prompt, sent with every prompt
You are generating a self-contained web demo. It will be rendered automatically in a sandboxed iframe and compared side by side with the output of other AI models given the exact same task. Output rules: - Reply with exactly ONE fenced code block containing the complete file: ```html (or ```svg when the task asks for an SVG). No text before or after the code block. - Everything must be inline: CSS in <style>, JavaScript in <script>. No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task. - Do not use localStorage, sessionStorage, cookies, IndexedDB, alert(), confirm(), prompt() or window.open(): they are blocked in the sandbox and will throw. - The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar. - Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction. - Everything that makes the demo interesting must be visible within the first seconds: nothing hidden behind a high score, a later level or a long play session. - Games must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, so the game can be judged in action without playing. - The iframe only receives keyboard events after the user clicks it: if the demo uses the keyboard, display a clear "Click or press a key to start" message. - The code must run with zero errors in the console of the latest Chrome.
Code run-1.html · 758 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Snake Train</title>
<style>
*{box-sizing:border-box;}
html,body{height:100%;margin:0;}
body{
background:radial-gradient(900px 520px at 50% -10%, #14343a 0%, #0a1218 55%, #060a0e 100%);
color:#e6f6f1;
font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
overflow:hidden;
-webkit-user-select:none;user-select:none;
}
#app{
height:100%;width:100%;max-width:1280px;margin:0 auto;
display:flex;flex-direction:column;gap:6px;
padding:10px 14px 10px;
}
#hud{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;}
#brand{
display:flex;align-items:center;gap:9px;font-weight:800;letter-spacing:.15em;font-size:14px;
background:linear-gradient(90deg,#b8ffe4,#57e0b0);
-webkit-background-clip:text;background-clip:text;color:transparent;
}
#brand .logo{-webkit-text-fill-color:initial;font-size:19px;filter:drop-shadow(0 0 8px rgba(62,242,168,.55));}
#stats{display:flex;gap:8px;}
.stat{
background:rgba(255,255,255,.045);border:1px solid rgba(120,240,200,.16);
border-radius:12px;padding:5px 12px;min-width:76px;text-align:center;
}
.stat .v{font-size:18px;font-weight:800;line-height:1.05;color:#a9ffdc;font-variant-numeric:tabular-nums;}
.stat .k{font-size:9px;letter-spacing:.16em;text-transform:uppercase;color:#6e9c92;margin-top:2px;}
#stage{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;padding:2px;}
#boardWrap{
position:relative;border-radius:18px;overflow:hidden;
box-shadow:0 24px 60px rgba(0,0,0,.62),0 0 0 1px rgba(90,230,180,.16);
}
canvas{display:block;}
#overlay{position:absolute;inset:0;pointer-events:none;}
#demoHint{
position:absolute;left:50%;bottom:14px;transform:translateX(-50%) translateY(10px);
display:flex;align-items:center;gap:9px;
background:rgba(7,20,24,.86);border:1px solid rgba(90,230,180,.45);
color:#c9ffe9;padding:9px 16px;border-radius:999px;
font-size:13px;font-weight:600;letter-spacing:.02em;white-space:nowrap;
opacity:0;transition:opacity .35s ease,transform .35s ease;
box-shadow:0 0 34px rgba(40,220,160,.28),inset 0 0 22px rgba(40,220,160,.07);
}
#demoHint.show{opacity:1;transform:translateX(-50%) translateY(0);}
#demoHint b{color:#3ef2a8;letter-spacing:.16em;font-size:11px;}
.pulse{width:9px;height:9px;border-radius:50%;background:#3ef2a8;box-shadow:0 0 12px #3ef2a8;animation:pl 1.4s infinite;}
@keyframes pl{0%,100%{opacity:1;transform:scale(1);}50%{opacity:.35;transform:scale(.7);}}
#overCard{
position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) scale(.94);
background:rgba(9,20,25,.94);border:1px solid rgba(255,120,120,.35);
border-radius:20px;padding:22px 28px;text-align:center;
box-shadow:0 20px 60px rgba(0,0,0,.65),0 0 40px rgba(255,90,90,.12);
opacity:0;pointer-events:none;transition:opacity .3s ease,transform .3s ease;
max-width:min(90%,340px);
}
#overCard.show{opacity:1;transform:translate(-50%,-50%) scale(1);}
#overCard h2{margin:0 0 6px;font-size:22px;letter-spacing:.02em;color:#ff8f8f;}
#overCard p{margin:0 0 14px;font-size:13.5px;color:#a9c6c0;line-height:1.45;}
#overCard .btn{
display:inline-block;font-size:12.5px;font-weight:700;letter-spacing:.04em;
color:#08211a;background:linear-gradient(180deg,#7dffcd,#2fd69a);
padding:9px 16px;border-radius:999px;
}
#foot{font-size:11.5px;color:#5d827c;text-align:center;letter-spacing:.05em;flex:0 0 auto;}
@media (max-width:520px){
#app{padding:8px 8px 8px;}
#brand{font-size:12px;letter-spacing:.1em;}
.stat{min-width:62px;padding:4px 9px;}
.stat .v{font-size:15px;}
#demoHint{font-size:11px;padding:8px 12px;bottom:10px;}
#foot{display:none;}
}
@media (max-height:560px){ #foot{display:none;} }
</style>
</head>
<body>
<div id="app">
<div id="hud">
<div id="brand"><span class="logo">🚂</span><span>SNAKE TRAIN</span></div>
<div id="stats">
<div class="stat"><div class="v" id="score">0</div><div class="k">passengers</div></div>
<div class="stat"><div class="v" id="len">4</div><div class="k">wagons</div></div>
</div>
</div>
<div id="stage">
<div id="boardWrap">
<canvas id="c"></canvas>
<div id="overlay">
<div id="demoHint"><span class="pulse"></span><b>DEMO</b><span>Click or press a key to start</span></div>
<div id="overCard">
<h2>Derailed!</h2>
<p id="overText">You picked up 0 passengers.</p>
<div class="btn">Click or press a key to restart</div>
</div>
</div>
</div>
</div>
<div id="foot">ARROW KEYS / WASD to steer · pick up passengers to add wagons</div>
</div>
<script>
(function(){
'use strict';
var COLS = 22, ROWS = 14;
var DIRS = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
var PALETTE = ['#ef476f','#ffd166','#06d6a0','#118ab2','#c77dff','#ff9f1c','#4cc9f0','#f72585'];
var canvas = document.getElementById('c');
var ctx = canvas.getContext('2d');
var stage = document.getElementById('stage');
var scoreEl = document.getElementById('score');
var lenEl = document.getElementById('len');
var demoHint = document.getElementById('demoHint');
var overCard = document.getElementById('overCard');
var overText = document.getElementById('overText');
var W = 0, H = 0, dpr = 1, cell = 32, offX = 0, offY = 0;
/* ---------- state ---------- */
var snake = [], prevSnake = [], dir = {x:1,y:0}, dirQueue = [];
var food = {x:0,y:0};
var score = 0, pendingGrowth = 0;
var state = 'demo'; // demo | play | over
var acc = 0, tickMs = 118;
var particles = [], rings = [], segAngles = [];
var smokeAcc = 0;
/* ---------- helpers ---------- */
function rr(c, x, y, w, h, r){
var rad = Math.min(r, Math.abs(w)/2, Math.abs(h)/2);
c.beginPath();
c.moveTo(x+rad, y);
c.arcTo(x+w, y, x+w, y+h, rad);
c.arcTo(x+w, y+h, x, y+h, rad);
c.arcTo(x, y+h, x, y, rad);
c.arcTo(x, y, x+w, y, rad);
c.closePath();
}
function shade(hex, f){
var n = parseInt(hex.slice(1), 16);
var r = (n>>16)&255, g = (n>>8)&255, b = n&255;
r = Math.min(255, Math.round(r*f));
g = Math.min(255, Math.round(g*f));
b = Math.min(255, Math.round(b*f));
return 'rgb('+r+','+g+','+b+')';
}
/* ---------- sizing ---------- */
function resize(){
var r = stage.getBoundingClientRect();
var availW = Math.max(80, r.width - 6);
var availH = Math.max(80, r.height - 6);
cell = Math.floor(Math.min(availW/COLS, availH/ROWS));
if (!isFinite(cell) || cell < 6) cell = 6;
W = cell*COLS; H = cell*ROWS;
dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W*dpr);
canvas.height = Math.round(H*dpr);
canvas.style.width = W+'px';
canvas.style.height = H+'px';
ctx.setTransform(dpr,0,0,dpr,0,0);
offX = 0; offY = 0;
}
/* ---------- game ---------- */
function randomFood(){
var occ = {}, i;
for (i=0;i<snake.length;i++) occ[snake[i].x+','+snake[i].y] = 1;
var free = [];
for (var y=0;y<ROWS;y++){
for (var x=0;x<COLS;x++){
if (!occ[x+','+y]) free.push({x:x,y:y});
}
}
if (!free.length) return {x:0,y:0};
return free[(Math.random()*free.length)|0];
}
function updateHUD(){
scoreEl.textContent = score;
lenEl.textContent = Math.max(0, snake.length-1);
}
function syncOverlay(){
demoHint.classList.toggle('show', state === 'demo');
overCard.classList.toggle('show', state === 'over');
if (state === 'over'){
overText.textContent = 'You picked up ' + score + ' passenger' + (score===1?'':'s') + '.';
}
}
function reset(mode){
snake = [{x:8,y:7},{x:7,y:7},{x:6,y:7},{x:5,y:7},{x:4,y:7}];
prevSnake = snake.map(function(p){ return {x:p.x,y:p.y}; });
dir = {x:1,y:0};
dirQueue = [];
score = 0;
pendingGrowth = 0;
acc = 0;
particles.length = 0;
rings.length = 0;
segAngles.length = 0;
state = mode;
food = randomFood();
updateHUD();
syncOverlay();
}
function die(){
if (state === 'demo'){ reset('demo'); return; }
state = 'over';
syncOverlay();
}
function step(){
prevSnake = snake.map(function(p){ return {x:p.x,y:p.y}; });
if (state === 'demo'){
var d = aiDir();
if (d) dir = d;
} else if (dirQueue.length){
dir = dirQueue.shift();
}
var head = snake[0];
var nh = {x: head.x + dir.x, y: head.y + dir.y};
if (nh.x < 0 || nh.y < 0 || nh.x >= COLS || nh.y >= ROWS){ die(); return; }
var limit = snake.length - (pendingGrowth > 0 ? 0 : 1);
for (var i=0;i<limit;i++){
if (snake[i].x === nh.x && snake[i].y === nh.y){ die(); return; }
}
snake.unshift(nh);
if (nh.x === food.x && nh.y === food.y){
score++;
pendingGrowth += 2;
rings.push({x: (nh.x+0.5)*cell, y: (nh.y+0.5)*cell, life: 1});
food = randomFood();
updateHUD();
}
if (pendingGrowth > 0) pendingGrowth--; else snake.pop();
}
/* ---------- AI ---------- */
function bfsFirst(start, goal, blocked){
var prev = new Int32Array(COLS*ROWS);
for (var i=0;i<prev.length;i++) prev[i] = -1;
var seen = new Uint8Array(COLS*ROWS);
var sIdx = start.y*COLS + start.x;
var gIdx = goal.y*COLS + goal.x;
if (sIdx === gIdx) return null;
var q = [sIdx], qi = 0;
seen[sIdx] = 1;
var found = false;
while (qi < q.length){
var cur = q[qi++];
if (cur === gIdx){ found = true; break; }
var cx = cur % COLS, cy = (cur / COLS) | 0;
for (var k=0;k<4;k++){
var d = DIRS[k];
var nx = cx + d.x, ny = cy + d.y;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
var ni = ny*COLS + nx;
if (seen[ni] || blocked[ni]) continue;
seen[ni] = 1;
prev[ni] = cur;
q.push(ni);
}
}
if (!found) return null;
var node = gIdx;
while (prev[node] !== -1 && prev[node] !== sIdx) node = prev[node];
if (prev[node] === -1) return null;
return { x: (node % COLS) - start.x, y: ((node / COLS) | 0) - start.y };
}
function floodCount(sx, sy, blocked){
var seen = new Uint8Array(COLS*ROWS);
var start = sy*COLS + sx;
if (blocked[start]) return 0;
var st = [start];
seen[start] = 1;
var count = 0;
while (st.length){
var cur = st.pop();
count++;
var cx = cur % COLS, cy = (cur / COLS) | 0;
for (var k=0;k<4;k++){
var d = DIRS[k];
var nx = cx + d.x, ny = cy + d.y;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
var ni = ny*COLS + nx;
if (seen[ni] || blocked[ni]) continue;
seen[ni] = 1;
st.push(ni);
}
}
return count;
}
function aiDir(){
var head = snake[0];
var blocked = new Uint8Array(COLS*ROWS);
for (var i=0;i<snake.length-1;i++) blocked[snake[i].y*COLS + snake[i].x] = 1;
var first = bfsFirst(head, food, blocked);
var cands = [];
for (var k=0;k<4;k++){
var d = DIRS[k];
if (d.x === -dir.x && d.y === -dir.y) continue;
var nx = head.x + d.x, ny = head.y + d.y;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
if (blocked[ny*COLS + nx]) continue;
cands.push(d);
}
if (!cands.length) return dir;
var blocked2 = blocked.slice();
blocked2[head.y*COLS + head.x] = 1;
var need = snake.length + 2;
var best = cands[0], bestScore = -Infinity;
for (var j=0;j<cands.length;j++){
var cd = cands[j];
var cx2 = head.x + cd.x, cy2 = head.y + cd.y;
var area = floodCount(cx2, cy2, blocked2);
var sc = area;
if (area < need) sc -= 100000;
if (first && cd.x === first.x && cd.y === first.y) sc += 5000;
if (sc > bestScore){ bestScore = sc; best = cd; }
}
return best;
}
/* ---------- particles ---------- */
function updateParticles(dt){
for (var i=particles.length-1;i>=0;i--){
var p = particles[i];
p.x += p.vx*dt;
p.y += p.vy*dt;
p.vy *= 0.985;
p.r += cell*0.55*dt;
p.life -= dt*0.85;
if (p.life <= 0) particles.splice(i,1);
}
for (var j=rings.length-1;j>=0;j--){
rings[j].life -= dt*2.2;
if (rings[j].life <= 0) rings.splice(j,1);
}
}
function spawnSmoke(dt, px, py, ang){
smokeAcc += dt;
while (smokeAcc > 0.065 && particles.length < 80){
smokeAcc -= 0.065;
var lx = 0.02*cell, ly = -0.50*cell;
var wx = px + Math.cos(ang)*lx - Math.sin(ang)*ly;
var wy = py + Math.sin(ang)*lx + Math.cos(ang)*ly;
particles.push({
x: wx + (Math.random()-0.5)*cell*0.12,
y: wy + (Math.random()-0.5)*cell*0.06,
vx: (Math.random()-0.5)*cell*0.55,
vy: -cell*(0.7 + Math.random()*0.8),
r: cell*0.085,
life: 1
});
}
}
/* ---------- drawing ---------- */
function drawBackground(){
var g = ctx.createLinearGradient(0,0,0,H);
g.addColorStop(0,'#0b141a');
g.addColorStop(1,'#060b0f');
ctx.fillStyle = g;
ctx.fillRect(0,0,W,H);
var bw = COLS*cell, bh = ROWS*cell;
var rad = Math.min(cell*0.55, 24);
ctx.save();
rr(ctx, offX, offY, bw, bh, rad);
ctx.clip();
var bg = ctx.createLinearGradient(offX, offY, offX+bw, offY+bh);
bg.addColorStop(0,'#12332d');
bg.addColorStop(0.5,'#0f2a26');
bg.addColorStop(1,'#0a1e1b');
ctx.fillStyle = bg;
ctx.fillRect(offX, offY, bw, bh);
for (var y=0;y<ROWS;y++){
for (var x=0;x<COLS;x++){
if ((x+y) & 1){
ctx.fillStyle = 'rgba(255,255,255,0.022)';
ctx.fillRect(offX + x*cell, offY + y*cell, cell, cell);
}
}
}
ctx.restore();
ctx.save();
rr(ctx, offX+1, offY+1, bw-2, bh-2, rad);
ctx.strokeStyle = 'rgba(90,220,170,0.22)';
ctx.lineWidth = 2;
ctx.stroke();
ctx.restore();
}
function drawFood(now){
var s = cell;
var cx = offX + (food.x+0.5)*s;
var cy = offY + (food.y+0.5)*s;
var pulse = 0.5 + 0.5*Math.sin(now/280);
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, s*0.46*(0.9+0.12*pulse), 0, Math.PI*2);
ctx.fillStyle = 'rgba(255,214,102,'+(0.08+0.13*pulse)+')';
ctx.fill();
// platform
ctx.fillStyle = '#37474f';
rr(ctx, cx-s*0.37, cy+s*0.20, s*0.74, s*0.15, s*0.05);
ctx.fill();
ctx.fillStyle = '#5d7780';
rr(ctx, cx-s*0.37, cy+s*0.20, s*0.74, s*0.055, s*0.03);
ctx.fill();
var bob = Math.sin(now/300)*s*0.03;
var py = cy - s*0.01 + bob;
// body
ctx.fillStyle = '#ffd166';
rr(ctx, cx-s*0.105, py-s*0.05, s*0.21, s*0.27, s*0.075);
ctx.fill();
// head
ctx.fillStyle = '#ffe0b2';
ctx.beginPath();
ctx.arc(cx, py-s*0.17, s*0.115, 0, Math.PI*2);
ctx.fill();
// hair / cap
ctx.fillStyle = '#ef476f';
ctx.beginPath();
ctx.arc(cx, py-s*0.185, s*0.115, Math.PI, 0);
ctx.fill();
// suitcase
ctx.fillStyle = '#8ecae6';
rr(ctx, cx+s*0.13, py+s*0.05, s*0.135, s*0.14, s*0.03);
ctx.fill();
ctx.fillStyle = '#5aa8c9';
rr(ctx, cx+s*0.13, py+s*0.05, s*0.135, s*0.045, s*0.02);
ctx.fill();
ctx.restore();
}
function drawSmokeAndRings(){
var i, p;
for (i=0;i<particles.length;i++){
p = particles[i];
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI*2);
ctx.fillStyle = 'rgba(226,240,245,' + (0.34*p.life) + ')';
ctx.fill();
}
for (i=0;i<rings.length;i++){
var rg = rings[i];
ctx.beginPath();
ctx.arc(rg.x, rg.y, cell*(0.35 + (1-rg.life)*1.0), 0, Math.PI*2);
ctx.strokeStyle = 'rgba(255,224,130,' + (0.75*rg.life) + ')';
ctx.lineWidth = Math.max(1.5, cell*0.09*rg.life);
ctx.stroke();
}
}
function drawWagon(p, ang, color){
var s = cell;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(ang);
// shadow
ctx.fillStyle = 'rgba(0,0,0,0.34)';
ctx.beginPath();
ctx.ellipse(0, s*0.30, s*0.40, s*0.13, 0, 0, Math.PI*2);
ctx.fill();
// wheels
var wr = s*0.115;
var wxs = [-s*0.22, s*0.22];
for (var i=0;i<2;i++){
ctx.fillStyle = '#171d24';
ctx.beginPath();
ctx.arc(wxs[i], s*0.255, wr, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = '#5d6b78';
ctx.beginPath();
ctx.arc(wxs[i], s*0.255, wr*0.4, 0, Math.PI*2);
ctx.fill();
}
var bw = s*0.86, bh = s*0.50;
var g = ctx.createLinearGradient(0, -s*0.30, 0, s*0.20);
g.addColorStop(0, shade(color, 1.28));
g.addColorStop(0.55, color);
g.addColorStop(1, shade(color, 0.68));
ctx.fillStyle = g;
rr(ctx, -bw/2, -s*0.30, bw, bh, s*0.12);
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.36)';
ctx.lineWidth = Math.max(1, s*0.028);
ctx.stroke();
// roof highlight
ctx.fillStyle = 'rgba(255,255,255,0.26)';
rr(ctx, -bw/2 + s*0.05, -s*0.275, bw - s*0.10, s*0.075, s*0.035);
ctx.fill();
// windows
ctx.fillStyle = 'rgba(216,242,255,0.94)';
rr(ctx, -s*0.29, -s*0.10, s*0.20, s*0.20, s*0.045);
ctx.fill();
rr(ctx, s*0.09, -s*0.10, s*0.20, s*0.20, s*0.045);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.55)';
rr(ctx, -s*0.29, -s*0.10, s*0.20, s*0.065, s*0.03);
ctx.fill();
rr(ctx, s*0.09, -s*0.10, s*0.20, s*0.065, s*0.03);
ctx.fill();
ctx.restore();
}
function drawLoco(p, ang){
var s = cell;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(ang);
// ground shadow
ctx.fillStyle = 'rgba(0,0,0,0.38)';
ctx.beginPath();
ctx.ellipse(0, s*0.30, s*0.46, s*0.15, 0, 0, Math.PI*2);
ctx.fill();
// wheels
var wr = s*0.135;
var wxs = [-s*0.26, -s*0.02, s*0.28];
for (var i=0;i<3;i++){
ctx.fillStyle = '#151b21';
ctx.beginPath();
ctx.arc(wxs[i], s*0.25, wr, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = '#5d6b78';
ctx.beginPath();
ctx.arc(wxs[i], s*0.25, wr*0.42, 0, Math.PI*2);
ctx.fill();
}
// cowcatcher
ctx.fillStyle = '#3a444e';
ctx.beginPath();
ctx.moveTo(s*0.28, s*0.02);
ctx.lineTo(s*0.48, s*0.26);
ctx.lineTo(s*0.28, s*0.26);
ctx.closePath();
ctx.fill();
// boiler body
var bg = ctx.createLinearGradient(0, -s*0.24, 0, s*0.24);
bg.addColorStop(0, '#ff7b7b');
bg.addColorStop(0.5, '#e03131');
bg.addColorStop(1, '#a11d1d');
ctx.fillStyle = bg;
rr(ctx, -s*0.44, -s*0.22, s*0.80, s*0.46, s*0.11);
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.35)';
ctx.lineWidth = Math.max(1, s*0.028);
ctx.stroke();
// cab
var cg = ctx.createLinearGradient(0, -s*0.42, 0, s*0.22);
cg.addColorStop(0, '#cf2f2f');
cg.addColorStop(1, '#7d1414');
ctx.fillStyle = cg;
rr(ctx, -s*0.46, -s*0.42, s*0.34, s*0.64, s*0.09);
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.4)';
ctx.stroke();
// cab window
ctx.fillStyle = '#bfe9ff';
rr(ctx, -s*0.40, -s*0.34, s*0.22, s*0.20, s*0.045);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.55)';
rr(ctx, -s*0.40, -s*0.34, s*0.22, s*0.07, s*0.035);
ctx.fill();
// boiler highlight
ctx.fillStyle = 'rgba(255,255,255,0.20)';
rr(ctx, -s*0.06, -s*0.18, s*0.38, s*0.09, s*0.045);
ctx.fill();
// chimney
ctx.fillStyle = '#2b333c';
rr(ctx, -s*0.02, -s*0.46, s*0.16, s*0.26, s*0.04);
ctx.fill();
ctx.fillStyle = '#47525d';
rr(ctx, -s*0.06, -s*0.52, s*0.24, s*0.09, s*0.035);
ctx.fill();
// headlight
ctx.save();
ctx.shadowColor = 'rgba(255,232,150,0.95)';
ctx.shadowBlur = s*0.55;
ctx.fillStyle = '#fff3bf';
ctx.beginPath();
ctx.arc(s*0.30, -s*0.08, s*0.075, 0, Math.PI*2);
ctx.fill();
ctx.restore();
ctx.restore();
}
function drawTrain(dt){
var t = Math.min(acc / tickMs, 1);
var pts = [], i;
for (i=0;i<snake.length;i++){
var p = snake[i];
var pr = prevSnake[i] || p;
pts.push({
x: offX + (pr.x + (p.x - pr.x)*t + 0.5)*cell,
y: offY + (pr.y + (p.y - pr.y)*t + 0.5)*cell
});
}
for (i=0;i<pts.length;i++){
var ang;
if (i === 0){
ang = Math.atan2(dir.y, dir.x);
} else {
var dx = pts[i-1].x - pts[i].x;
var dy = pts[i-1].y - pts[i].y;
if (Math.abs(dx) + Math.abs(dy) < 0.6){
ang = (segAngles[i] !== undefined) ? segAngles[i] : Math.atan2(dir.y, dir.x);
} else {
ang = Math.atan2(dy, dx);
}
}
segAngles[i] = ang;
}
spawnSmoke(dt, pts[0].x, pts[0].y, segAngles[0]);
for (i=pts.length-1;i>=1;i--){
drawWagon(pts[i], segAngles[i], PALETTE[(i-1) % PALETTE.length]);
}
drawLoco(pts[0], segAngles[0]);
}
/* ---------- loop ---------- */
var last = 0;
function frame(now){
if (!last) last = now;
var dt = (now - last)/1000;
last = now;
if (dt > 0.06) dt = 0.06;
if (dt < 0) dt = 0;
if (state !== 'over'){
acc += dt*1000;
var guard = 0;
while (acc >= tickMs && guard < 6){
acc -= tickMs;
step();
guard++;
if (state === 'over'){ acc = 0; break; }
}
if (acc > tickMs) acc = 0;
}
updateParticles(dt);
ctx.clearRect(0,0,W,H);
drawBackground();
drawFood(now);
drawSmokeAndRings();
drawTrain(dt);
requestAnimationFrame(frame);
}
/* ---------- input ---------- */
function setDir(d){
var ref = dirQueue.length ? dirQueue[dirQueue.length-1] : dir;
if (d.x === -ref.x && d.y === -ref.y) return;
if (d.x === ref.x && d.y === ref.y) return;
if (dirQueue.length < 2) dirQueue.push(d);
}
function startPlay(){
reset('play');
}
window.addEventListener('keydown', function(e){
var k = e.key;
if (k === 'ArrowUp' || k === 'ArrowDown' || k === 'ArrowLeft' || k === 'ArrowRight' || k === ' '){
e.preventDefault();
}
if (state !== 'play') startPlay();
var d = null;
if (k === 'ArrowUp' || k === 'w' || k === 'W') d = {x:0,y:-1};
else if (k === 'ArrowDown' || k === 's' || k === 'S') d = {x:0,y:1};
else if (k === 'ArrowLeft' || k === 'a' || k === 'A') d = {x:-1,y:0};
else if (k === 'ArrowRight' || k === 'd' || k === 'D') d = {x:1,y:0};
if (d) setDir(d);
}, {passive:false});
window.addEventListener('pointerdown', function(){
if (state !== 'play') startPlay();
});
window.addEventListener('resize', resize);
if (window.ResizeObserver){
try { new ResizeObserver(function(){ resize(); }).observe(stage); } catch(err){}
}
/* ---------- boot ---------- */
resize();
reset('demo');
requestAnimationFrame(function(t0){
resize();
last = 0;
requestAnimationFrame(frame);
});
})();
</script>
</body>
</html>Reasoning trace (20,588 tokens)
Let me create a nice snake game with a train theme. Single HTML file.
Design:
- Canvas-based grid game.
- Snake head = locomotive (drawn with canvas shapes), body segments = colorful wagons.
- Passengers waiting at platform cells (little people icons) — picking them up grows the train and adds score.
- Self-playing demo mode behind "Click or press a key to start" overlay.
- Responsive: canvas resizes to fit viewport.
Let me plan the rendering: grid of cells, e.g., 24 cols x 16 rows. Cell size computed from canvas size. Use device pixel ratio.
Draw background as grass/tracks? Let's do a dark scene with rails pattern maybe. Keep it clean: background with subtle grid of grass/dirt, plus rails on the tracks? Simpler: draw a subtle checkerboard of greens, and each snake segment drawn as a wagon with wheels and windows.
Train rendering: For each segment, draw a rounded rect with the segment color, wheels (dark circles), and connecting coupler. Head is a locomotive: a bigger rounded rect with a chimney, a cab, a front cowcatcher, and a face? Keep it cute.
Direction: the train moves in grid steps. To make it look like a train, segments follow head with a delay. Classic snake interpolation: keep a smooth movement using step progress. Let's do smooth movement: snake positions are grid cells; render with interpolation between previous and current positions based on tick progress. That looks nice.
Simplify: I'll maintain snake as array of grid cells (head first). Each tick, move. Render with lerp: for each segment i, its rendered position = lerp(prevPos[i], curPos[i], t) where prevPos is the array before the move. Actually storing history of positions is easier: keep a list of grid cells the head has visited (path). Then render segment i at path position interpolated.
Let me do: positions array (head first). At tick, unshift new head, pop tail (unless growing). Keep a copy of previous positions array for interpolation. But lengths differ when growing. Handle: prevPositions = positions.slice() before update. When growing, new array has one more element; for interpolation, the last segment's prev = its current position (no movement) — actually the new tail segment appears; just render it at its current position without interpolation (or scale-in animation). Simpler: for i < prevPositions.length, lerp; for new segments, use current pos.
Let's implement:
```
function step(){
prevPositions = positions.map(p=>({...p}));
const head = positions[0];
const nh = {x: head.x+dir.x, y: head.y+dir.y};
...
}
```
Wrapping? No — walls kill. Or wrap? Let's use walls (border) — actually for a train, wrapping around edges is fine and forgiving. I'll use walls with a nice border fence and game over... Hmm, for demo mode AI needs to avoid walls. Walls are classic. Let's do walls.
Actually for smoother demo viewing, maybe wrap-around is nicer? No, keep walls; AI avoids them.
Passengers: spawn at random empty cells, drawn as little waiting people (colored circle head + body) with a small platform tile. When the train head reaches a passenger cell, score += 1 (or 10), train grows by 2, passenger removed, new one spawns. Also play a "choo" via WebAudio? Audio may be blocked without interaction; skip or guard. I'll add simple WebAudio beep on pickup with try/catch, only after user interaction. Keep it optional and safe.
AI demo: greedy pathfinding with flood-fill safety (avoid trapping). Implement BFS to nearest passenger avoiding body cells and walls; also check that after moving, the free space reachable is >= snake length (safety). Simple approach:
- Compute candidate directions sorted by distance to food (BFS path).
- For each candidate dir, if the resulting cell is safe (not wall, not body except tail which moves), simulate and compute reachable area; pick the one with the largest area if area < length, else pick the one reducing BFS distance.
Standard simple AI:
```
function aiChooseDir(){
const options = dirs.filter(d => !isReverse(d) && isFree(head+d));
// BFS from head to food avoiding body
// pick option on the shortest path
// safety: flood fill
}
```
Let me implement:
1. Build occupancy set of body cells (excluding tail which will move, if not growing).
2. BFS from head over free cells to find path to food. Get first step direction.
3. Simulate: for each candidate dir, compute new head, then flood-fill count of reachable free cells (with body updated). If count >= snake length + 2, it's "safe".
4. Among safe candidates, prefer the BFS first step; else the one with max flood fill.
That's decent. Grid 24x16=384 cells, fine performance-wise per tick (tick ~ 8 per second).
Let's set the tick rate: base 130ms, speeds up slightly? Keep constant-ish, maybe 110ms for snappy demo. With interpolation it looks smooth.
Now visuals:
Canvas size: fit container. Compute cell size = min(width/COLS, height/ROWS). Draw with DPR scaling.
Background: draw a "depot yard": dark asphalt/gravel with subtle dots? Or grass. Let's do a nice night-ish gradient with soft vignette, plus grid lines subtle. Actually a train yard: dark green-brown ground with gravel speckles. Passengers stand on small platform tiles.
Hmm, maybe more charming: light theme with grass green checkerboard. Let's do: background color #123 (deep blue-green) with subtle checker #16332c. Rails: draw dashed lines along the path of the train? That would be complex but cool — draw track rails following the snake body path. That's a nice touch: draw a track (two parallel lines with sleepers) along the train's trail. Could be heavy but fine.
Maybe simpler: draw the trail as a faded path behind the train. Actually let's draw tracks under the train body: for each segment, draw a small track tile? That may look cluttered.
Let me keep: the ground is a subtle grid. The train itself is the star: locomotive + colorful wagons with wheels, windows, couplings.
Locomotive drawing (in cell coordinates, cell size s):
- Body: rounded rect from (0.08s, 0.3s) to (0.95s, 0.85s) in the direction of travel. Rotating the whole thing based on direction is easiest: translate to center, rotate by angle, draw in local coords pointing right.
Local coordinate frame: train faces +x. Draw:
- Cowcatcher: triangle at front (x from 0.85 to 1.0).
- Main boiler: rounded rect from x 0.15..0.9, y 0.35..0.8, color dark red/blue.
- Cab: rect at back x 0.1..0.4, y 0.15..0.8, slightly taller.
- Chimney: small rect at x 0.65..0.78, y 0.1..0.35 with a puff of smoke? Smoke particles could be cute. Let's add smoke puffs as particles from the chimney — animated and pretty. Keep count low.
- Wheels: two circles at bottom.
- Headlight: yellow circle at front.
Wagons: rounded rect body in color, windows (2 small light squares), wheels, coupling bar to previous segment.
Colors array: ['#ff6b6b','#ffd93d','#6bcB77','#4d96ff','#c77dff','#ff9f1c','#00d4d4'] etc.
Rotation: for the head, rotate based on direction. For wagons, they should be oriented along the direction from previous segment to this one — but with lerp that's tricky. Simpler: orient each wagon along the vector from the segment ahead of it to itself (using rendered positions). Actually since segments follow the same path, the direction from prev rendered to current rendered gives the orientation. Use atan2. When they're at the same position (during turns), keep last angle. Fine — a train bending around corners looks great.
But wagons should be connected — with cell-sized spacing and rotations, corners will have gaps/overlaps. It's fine visually, that's how snake games look.
Maybe I'll draw the wagons as squares with a slight gap so it looks like cars.
Let me also add: passengers appear as small figures on a platform tile with a pulsing ring, colored to match... they board the train.
Score HUD: "Passengers: N" top-left, maybe with a train icon. And "Length". Keep in the DOM above canvas.
Overlay: "Click or press a key to start" with a subtle pulse. In demo mode the AI plays, and overlay text says "DEMO — click or press a key to drive the train". On click/keypress, start the real game.
Game over: overlay "Derailed! Score: N — click to restart".
Let's also handle: when the player dies, show overlay and go back to demo? Let's do: game over screen, click to restart the real game.
Keyboard: arrow keys / WASD. Prevent reverse. Prevent page scroll.
Let me now write the code.
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Snake Train</title>
<style>...</style>
</head>
<body>
<div id="app">
<header>... score ...</header>
<div id="stage"><canvas id="c"></canvas><div id="overlay">...</div></div>
<footer>hint</footer>
</div>
<script>...</script>
</body>
</html>
```
Layout: full viewport height, flex column. Stage flex:1 with position relative; canvas absolutely filling.
Sizing: use ResizeObserver on stage, set canvas width/height = rect * dpr, style width/height = rect px. Then compute cell = min(w/COLS, h/ROWS), and offset to center the board.
COLS = 22, ROWS = 14? Aspect ratio 22/14 ≈ 1.57, matches 1280x800-ish stage. Good. Let's use COLS=22, ROWS=14 = 308 cells.
At 1280 wide, stage maybe 1200x600 → cell = min(1200/22=54.5, 600/14=42.8) = 42.8. Board = 941x600. Fine.
Now the game loop with requestAnimationFrame and accumulator.
```js
const COLS=22, ROWS=14;
let cell, offX, offY;
function resize(){...}
```
State:
```js
let snake = []; // {x,y} head first
let prevSnake = [];
let dir = {x:1,y:0};
let nextDir = {x:1,y:0};
let food = {x,y}; // passenger
let grow = 0;
let score = 0;
let state = 'demo'; // 'demo' | 'play' | 'over'
let tickMs = 120;
let acc = 0;
let t = 0; // interpolation 0..1
```
Wait: interpolation needs progress since last tick. I'll compute `t = acc / tickMs` clamped to 1... Actually with accumulator: acc increases by dt; when acc >= tickMs, step and acc -= tickMs. Render t = acc/tickMs. But careful: after stepping, prevSnake = old positions, snake = new. Render lerp(prev, cur, t). Good.
Actually with the "positions array" approach and growing, prevSnake may be shorter. Handle i >= prevSnake.length → use current pos with a scale-in? Let's just use current pos (no lerp) — it'll pop in. Hmm, but then it'd be at the head-adjacent position... no. Let's think: when growing, the tail stays. positions before: [h, a, b, c]; after eating: new head n prepended, tail not popped: [n, h, a, b, c]. prevSnake = [h,a,b,c]. So index 0..3 interpolate: prevSnake[i] → snake[i]. snake[i] for i=1..3 equals prevSnake[i-1]. So segment i moves from prevSnake[i] to prevSnake[i-1] — correct train movement. Index 4 (new tail) has no prev; use snake[4] = prevSnake[3] = c, which is where the tail was — correct! So use `prevSnake[Math.min(i, prevSnake.length-1)]`? No: for i=4, prevSnake[3]=c and snake[4]=c → no movement, perfect. So: prev = prevSnake[i] ?? snake[i]. Good, that works naturally.
Hmm but for i=4 with prevSnake.length=4, prevSnake[4] is undefined → use snake[4]=c → lerp from c to c = static.
Rendering positions: px = offX + (x+0.5)*cell.
Now AI.
```js
function isWall(p){ return p.x<0||p.y<0||p.x>=COLS||p.y>=ROWS; }
function bodySet(){ const s=new Set(); snake.forEach((p,i)=>{ if(i<snake.length-1) s.add(p.x+','+p.y); }); return s; }
```
Excluding tail because it moves... but if growing, the tail stays. For AI safety it's fine to be conservative and include all. Let's include all but the very last, that's standard.
BFS from head to food over free cells:
```js
function bfsDir(start, goal, blocked){
// returns first step direction or null
}
```
Implement BFS with parent tracking.
Then candidate directions = [{1,0},{-1,0},{0,1},{0,-1}] minus reverse of current dir, minus blocked/wall.
For each candidate, simulate: newHead, body' = snake (shift), flood fill from newHead counting free cells (blocked = body cells excluding tail). If reachable >= snake.length (approx), safe.
Pick: prefer safe candidate equal to bfsFirstStep; else safe candidate with max flood area; else any candidate with max flood area.
Also need the AI to not immediately reverse. Also if no food path, move toward the largest space.
Good enough.
Let's write flood fill with a Uint8Array visited.
Passenger placement: random free cell not on snake and not equal to current food.
Also spawn a "station platform" visual under the passenger.
Now, drawing details.
Colors:
- Background: radial gradient dark teal.
- Grid: subtle lines rgba(255,255,255,0.03).
Actually let me make the ground look like a depot yard: base #1b2a2f with subtle lighter squares checker. Plus a border frame (fence) around the board — draw a rounded rect stroke.
Let me draw the board area: rect from offX,offY size COLS*cell, ROWS*cell. Fill with gradient. Then checker cells. Then a subtle inner shadow.
Passenger drawing: platform tile: rounded rect filling ~0.8 of cell in a warm tan color (#8a6f4f) with a lighter top? Then the person: head circle + body triangle. Colors random from palette. Add a bobbing animation and a pulsing glow ring to attract attention.
Let me write drawPassenger(cx, cy, s, color, t):
- ring: circle stroke with alpha pulsing.
- platform: roundRect(cx-0.38s, cy+0.18s, 0.76s, 0.16s) fill #c9a227? Hmm, keep the person on a small platform.
- person: body as rounded rect / triangle from (cy+0.2s) up to (cy-0.05s), head circle at (cy-0.2s) radius 0.13s, plus a little suitcase.
Good.
Train drawing in local coords with rotation:
```js
function drawLoco(ctx, cx, cy, s, angle){
ctx.save();
ctx.translate(cx,cy);
ctx.rotate(angle);
// now draw in local space where +x is forward, cell size s
...
ctx.restore();
}
```
Local space: x from -s/2 to s/2, y from -s/2 to s/2.
Locomotive:
- shadow ellipse under: ctx.fillStyle rgba(0,0,0,0.35); ellipse(0, s*0.34, s*0.42, s*0.12)
- wheels: two dark circles at (-s*0.22, s*0.3) and (s*0.18, s*0.3), r = s*0.13, color #222 with gray rim.
- main body: roundRect(-s*0.42, -s*0.16, s*0.8, s*0.42) fill gradient red #e63946 → #b02a37.
- cab: roundRect(-s*0.46, -s*0.34, s*0.34, s*0.5) fill #2b3a55? Let's do cab same red with darker top, and a window (light blue) rect.
- boiler: front part slightly rounded, add a lighter stripe.
- chimney: roundRect(-s*0.02, -s*0.44, s*0.16, s*0.2) fill #333, plus a flared top.
- cowcatcher: triangle at front: path from (s*0.38, s*0.1) to (s*0.5, s*0.3) to (s*0.38, s*0.3). fill #444.
- headlight: circle at (s*0.36, -s*0.02) r s*0.07 fill #ffe680 with glow.
- Smoke from chimney: particles.
Hmm, the chimney at x=-0.02 which is center-ish; cab at back. Fine.
Let me simplify to make it look decent at ~40px cells. At 40px, s=40: body 32x17 px. Details small but visible.
Wagon drawing:
- shadow ellipse
- wheels 2 circles
- body: roundRect(-s*0.42, -s*0.2, s*0.84, s*0.42) fill color with darker bottom border
- roof: slightly darker rounded rect on top? Or a lighter stripe.
- windows: two light rounded rects at (-s*0.22,-s*0.1) and (s*0.04,-s*0.1) size s*0.14 x s*0.16 fill #cfe9ff.
- coupling: small line to the back.
Colors per wagon index: palette[ (index-1) % palette.length ].
Also add a tiny number of passengers visible in the wagons? Cute: each wagon shows a little face in the window. Maybe overkill.
Smoke particles: array {x,y,life,size} in world coords, spawned at chimney world position each tick or every few frames. Draw as soft circles with alpha fading. Keep max ~40.
Actually spawning at each frame from the head's chimney position (rotated) — compute world position of chimney: head render pos + rotate(local chimney pos) by angle. Let's just spawn at head pos with small random offset. Simple enough.
I'll spawn a puff every ~80ms when moving.
Let's write the code now.
```js
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const COLS=22, ROWS=14;
let cell=30, offX=0, offY=0, W=0, H=0, dpr=1;
function resize(){
const stage = document.getElementById('stage');
const r = stage.getBoundingClientRect();
dpr = Math.min(window.devicePixelRatio||1, 2);
W = r.width; H = r.height;
canvas.width = Math.round(W*dpr);
canvas.height = Math.round(H*dpr);
canvas.style.width = W+'px';
canvas.style.height = H+'px';
ctx.setTransform(dpr,0,0,dpr,0,0);
const pad = 8;
cell = Math.floor(Math.min((W-pad*2)/COLS, (H-pad*2)/ROWS));
cell = Math.max(cell, 8);
offX = (W - cell*COLS)/2;
offY = (H - cell*ROWS)/2;
}
```
Hmm, cell as integer loses some precision but fine.
Game state and functions.
```js
let snake, prevSnake, dir, food, score, state, acc, tickMs, particles, lastDir;
```
reset(mode):
```js
function reset(mode){
snake = [{x:5,y:7},{x:4,y:7},{x:3,y:7}];
prevSnake = snake.map(p=>({...p}));
dir = {x:1,y:0};
score = 0;
state = mode;
acc = 0;
particles = [];
food = randomFood();
updateHUD();
}
```
Wait, in demo the AI drives. In play the player drives. Same snake.
step():
```js
function step(){
prevSnake = snake.map(p=>({...p}));
if(state==='demo'){ dir = aiDir(); }
const head = snake[0];
const nh = {x: head.x+dir.x, y: head.y+dir.y};
// wall collision
if(nh.x<0||nh.y<0||nh.x>=COLS||nh.y>=ROWS){ die(); return; }
// self collision (ignore tail if not growing)
const bodyEnd = snake.length - (grow>0?0:1);
for(let i=0;i<bodyEnd;i++){ if(snake[i].x===nh.x && snake[i].y===nh.y){ die(); return; } }
...
}
```
Hmm careful: grow mechanic. Let's use a simpler growth: when eating, snake grows by 2 (don't pop tail for next 2 steps). Use `pendingGrowth` counter.
```js
snake.unshift(nh);
if(nh.x===food.x && nh.y===food.y){
score++; pendingGrowth += 2; food = randomFood(); spawnParticles; updateHUD();
}
if(pendingGrowth>0){ pendingGrowth--; } else { snake.pop(); }
```
But prevSnake interpolation: when we don't pop, prevSnake is shorter than snake by 1 → handled.
Wait order: unshift then maybe pop. prevSnake = old snake (before unshift). After unshift, snake.length = prevSnake.length+1. If we pop, length equal again. If not, length = prev+1 → new tail at index prev.length which equals prevSnake[prev.length-1]... let's check: prevSnake = [h,a,b,c]. After unshift: [n,h,a,b,c]. No pop → index 4 = c = prevSnake[3]. Yes as computed before. Good.
die(): state='over'; show overlay; also in demo mode, just reset demo (the AI shouldn't die, but if it does, restart silently).
Let's handle: if state==='demo' and die → reset('demo') silently. Actually with the AI it shouldn't happen; but keep safe.
Interpolation factor for render: t = clamp(acc/tickMs, 0, 1). Hmm after step we set acc -= tickMs, so acc < tickMs, t in [0,1). Good. But we want the segment to reach the new cell exactly at t=1, which happens right before the next step. OK.
Careful: at reset, prevSnake = snake, t=0.
Rendering:
```js
function render(now){
ctx.clearRect(0,0,W,H);
drawBoard();
drawFood(now);
drawParticles();
// train
const t = state==='over' ? 1 : Math.min(acc/tickMs, 1);
// compute rendered positions
const pts = snake.map((p,i)=>{
const pr = prevSnake[i] || p;
return {
x: offX + (pr.x + (p.x-pr.x)*t + 0.5)*cell,
y: offY + (pr.y + (p.y-pr.y)*t + 0.5)*cell
};
});
// angles
...
}
```
Wait, if state==='over' the snake stops; t stays. Fine either way.
Hmm, one issue: on death, prevSnake was set then we returned before unshift. So prevSnake = old snake copy, snake = old snake → t lerp between identical → no movement. Fine.
Angle for segment i: direction from pts[i+1] to pts[i] (pointing forward = toward head). For head: use dir vector. Let's compute angle = atan2(pts[i+1].y - pts[i].y, pts[i+1].x - pts[i].x) for i>0... wait we want the wagon facing the direction of travel, which is from its own position toward the segment in front (index i-1). So for segment i, forward = pts[i-1] - pts[i]. For i=0 (loco), forward = dir.
But when pts[i-1] and pts[i] coincide (during interpolation on turns, they might be at the same spot briefly? no, they're always a cell apart roughly). At t just after a turn, adjacent segments can be close but not identical. Actually at a corner, segment i moves from cell A to cell B while segment i-1 moves from B to C. At t=0.5, i is halfway A→B, i-1 halfway B→C. They're not identical. Good. But near t=0 they might both be at B? No: at t=0, i is at A, i-1 is at B. Fine.
However there's a degenerate case when the segment didn't move (growing new tail): pts[i] == pts[i-1]? For the new tail: it's at c and the segment in front (index 3) is at... prevSnake[3]=c → moving to prevSnake[2]=b. At t=0, both at c. Degenerate. Handle by keeping a stored angle per segment index, falling back to previous. I'll compute angle and if the distance is < 0.001, use the angle from the segment ahead or the global dir.
Simpler: keep `segAngles` array updated each frame; if degenerate, reuse last. Fine.
Actually easier: for each segment, compute angle from the movement of that segment itself: forward = pts[i] - prevRenderPos... no.
Let's just use: forward = pts[i-1] - pts[i], if |forward| < 1e-3, use lastAngle[i] ?? angleFromDir(dir). Store.
Let's write drawing:
```js
function drawLoco(cx,cy,s,ang){...}
function drawWagon(cx,cy,s,ang,color,idx){...}
```
roundRect helper: ctx.roundRect exists in modern Chrome. Use it but guard? Chrome supports it. I'll write my own helper to be safe.
```js
function rr(ctx,x,y,w,h,r){ ctx.beginPath(); ctx.moveTo(x+r,y); ctx.arcTo(x+w,y,x+w,y+h,r); ctx.arcTo(x+w,y+h,x,y+h,r); ctx.arcTo(x,y+h,x,y,r); ctx.arcTo(x,y,x+w,y,r); ctx.closePath(); }
```
Now the board drawing.
```js
function drawBoard(){
// outer area background
const g = ctx.createLinearGradient(0,0,0,H);
g.addColorStop(0,'#0d1b2a'); g.addColorStop(1,'#0a1420');
ctx.fillStyle=g; ctx.fillRect(0,0,W,H);
const bw = COLS*cell, bh = ROWS*cell;
// board bg
ctx.save();
rr(ctx, offX, offY, bw, bh, cell*0.4);
ctx.clip();
const bg = ctx.createLinearGradient(offX, offY, offX, offY+bh);
bg.addColorStop(0,'#14342f'); bg.addColorStop(1,'#0e2724');
ctx.fillStyle = bg; ctx.fillRect(offX,offY,bw,bh);
// checker
for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++){
if((x+y)%2===0){ ctx.fillStyle='rgba(255,255,255,0.025)'; ctx.fillRect(offX+x*cell, offY+y*cell, cell, cell); }
}
// grid lines subtle
...
ctx.restore();
// border
rr(ctx, offX+1, offY+1, bw-2, bh-2, cell*0.4);
ctx.strokeStyle='rgba(120,220,180,0.25)'; ctx.lineWidth=2; ctx.stroke();
}
```
Maybe draw a "rail yard" texture with sleepers? Skip.
Passenger: draw at food cell.
Now the HUD: DOM elements above canvas showing "Passengers 0" and "Length". Or draw on canvas. DOM is easier and crisper. Let's do DOM header with title and stats.
Overlay: absolutely positioned over the stage, centered card with text. Pointer-events none? We need clicks to be captured anywhere on the stage → attach click handler to window/document. Overlay can be pointer-events:none and we listen on window for click and keydown.
Message:
- demo: "🚂 SNAKE TRAIN" / "Click or press any key to drive" with pulsing.
- play: hidden.
- over: "Derailed! Score N" + "Click or press a key to restart".
Careful: clicking to start should also count as a key. On start, state='play', reset('play'), hide overlay.
Also handle: pressing arrow keys during demo starts the game.
Also prevent arrow scroll: e.preventDefault() on arrow keys.
Let's handle the overlay show/hide with a class.
Now AI implementation details.
```js
function aiDir(){
const head = snake[0];
const blocked = new Uint8Array(COLS*ROWS);
for(let i=0;i<snake.length-1;i++) blocked[snake[i].y*COLS+snake[i].x]=1;
// BFS to food
const dirs=[{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
const firstStep = bfsFirst(head, food, blocked);
const cands = dirs.filter(d=>!(d.x===-dir.x&&d.y===-dir.y) && free(head.x+d.x, head.y+d.y, blocked));
...
}
```
Wait, need to exclude the reverse of the current dir. Note dir here is the current direction. Since snake.length>=2, reversing causes instant self-collision anyway; the blocked check would catch the neck cell... Actually the neck cell (index 1) is in blocked (since we include indices 0..len-2, which includes index 1). Yes, so reverse is automatically blocked when length>1. Good, but keep the explicit check anyway.
bfsFirst: BFS from head to food over free cells, returns the first direction taken.
```js
function bfsFirst(start, goal, blocked){
const prev = new Int32Array(COLS*ROWS).fill(-1);
const seen = new Uint8Array(COLS*ROWS);
const q=[start.y*COLS+start.x]; seen[q[0]]=1;
const goalIdx = goal.y*COLS+goal.x;
let found=false;
while(q.length){
const cur=q.shift();
if(cur===goalIdx){found=true;break;}
const cx=cur%COLS, cy=(cur/COLS)|0;
for(const d of DIRS){
const nx=cx+d.x, ny=cy+d.y;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
const ni=ny*COLS+nx;
if(seen[ni]||blocked[ni]) continue;
seen[ni]=1; prev[ni]=cur; q.push(ni);
}
}
if(!found) return null;
// backtrack
let cur=goalIdx;
while(prev[cur]!==-1 && prev[cur]!==start.y*COLS+start.x){ cur=prev[cur]; }
// cur is now the first step
const sx=start.x, sy=start.y;
return {x: (cur%COLS)-sx, y: ((cur/COLS)|0)-sy};
}
```
Careful: if goal === start... won't happen since food is never on the snake.
Edge: if prev[goalIdx] === -1 and goal !== start → not found, but we set found only if we pop goal. OK.
Hmm, the backtrack loop: `while(prev[cur] !== startIdx && prev[cur] !== -1) cur = prev[cur];` then cur is a neighbor of start. Let me rewrite properly:
```js
let cur = goalIdx;
while(prev[cur] !== -1 && prev[cur] !== startIdx) cur = prev[cur];
if(prev[cur] === -1) return null; // shouldn't happen
return {x:(cur%COLS)-start.x, y:((cur/COLS)|0)-start.y};
```
If goal is adjacent to start, prev[goalIdx] === startIdx → loop doesn't run → cur = goalIdx → correct dir.
Good.
floodFill(startIdx, blocked): count reachable.
```js
function floodCount(sx, sy, blocked){
const seen=new Uint8Array(COLS*ROWS);
let cnt=0; const st=[sy*COLS+sx]; seen[sy*COLS+sx]=1;
while(st.length){ const cur=st.pop(); cnt++;
const cx=cur%COLS, cy=(cur/COLS)|0;
for(const d of DIRS){ ... if(!seen && !blocked) push }
}
return cnt;
}
```
For the candidate evaluation, build a blocked array simulating the move: copy base blocked, add the current head position (since the head moves away, but the body shifts... conservative: the head's old cell becomes body). Also remove the tail if not growing. Let's do:
```js
const blocked2 = blocked.slice(); // Uint8Array copy
blocked2[head.y*COLS+head.x] = 1; // head becomes body
if(pendingGrowth===0){ const tail=snake[snake.length-1]; blocked2[tail.y*COLS+tail.x]=0; }
```
Hmm, but base blocked already excludes the tail (we only added indices 0..len-2). So no need to remove. Right — I already excluded the tail. So blocked2 = blocked + head cell.
Then floodCount(nh.x, nh.y, blocked2) and require >= snake.length + 2 (or just >= snake.length). Let's use `>= snake.length` as safe threshold. Since the snake needs room to maneuver.
Choose:
```js
let best=null, bestScore=-Infinity;
for(const d of cands){
const nh={x:head.x+d.x,y:head.y+d.y};
const area = floodCount(nh.x,nh.y,blocked2);
const isPath = firstStep && d.x===firstStep.x && d.y===firstStep.y;
let sc = area;
if(area < snake.length+2) sc = area - 10000; // unsafe
if(isPath) sc += 5000;
if(sc > bestScore){bestScore=sc; best=d;}
}
```
Hmm, the +5000 for path should not override safety. If unsafe, sc is area-10000 which is very negative; a safe alternative with area ~50 gives 50 > -9900. Good. If both unsafe, prefer larger area. Good. But the path bonus might push a path candidate with area 20 above a non-path with area 200. Both unsafe-ish... Let's make path bonus smaller relative: use `sc = area` then if unsafe `sc = area - 100000`. And path bonus +10000 only if safe? Let's do:
```js
let sc = area;
if(area < need) sc -= 100000;
if(isPath) sc += 5000;
```
If path candidate is unsafe: area-100000+5000. A safe non-path with area 100: 100 > -95000. Good. Two safe: path wins. Good.
If no candidates at all → return dir (keep going) — will die. Fine.
The AI's need = snake.length + 2 maybe too strict early on. With 308 cells and length 3, fine.
Actually as the snake grows long, `need = snake.length + 2` could make everything unsafe → falls back to max area, which is reasonable.
Good enough for a demo.
Tick speed: 120ms. With interpolation it looks smooth.
Hmm, also the AI should be reasonably fast so the demo shows pickups quickly. 110ms.
Let me now think about the smoke particles: spawn at the head's world position offset backward. Each frame with probability. Let's spawn one every 2 frames.
```js
particles.push({x: px, y: py, vx: (Math.random()-0.5)*10, vy: -20-Math.random()*15, life: 1, r: cell*0.12});
```
Update with dt: p.x += p.vx*dt; p.y += p.vy*dt; p.life -= dt*1.2; r grows.
Draw: white circles with alpha = life*0.35.
Spawn position: head render pos + offset backward. Just use head pos + small random.
Let's cap particles at 60.
Now writing the actual code. Also, add sound? Skip for safety, or add tiny WebAudio blips guarded by try/catch and only after user gesture. I'll add a simple `beep` function with lazy AudioContext creation, called on pickup — in demo mode no user gesture → context suspended → no sound, no error. Actually creating an AudioContext without a gesture is allowed but it starts suspended; resume() returns a promise that may reject → caught. It's fine but adds risk. I'll include it guarded — it adds charm. Hmm, "zero errors in console". A rejected promise handled with .catch is fine. Let's include minimal sound.
Actually, let's skip audio to keep it safe and clean. Focus on visuals.
Let me write the HUD in DOM:
```html
<div class="hud">
<div class="brand"><span class="dot"></span> SNAKE TRAIN</div>
<div class="stats">
<div class="stat"><span id="score">0</span><small>passengers</small></div>
<div class="stat"><span id="len">3</span><small>wagons</small></div>
</div>
</div>
```
Style: dark, monospace-ish system font.
Layout with flex column: hud (auto), stage (flex:1, min-height 0), footer hint.
For 375px wide: hud stacks, stage still works. Cell size small but ok.
Let me make the stage have max-width 1200px centered and margin auto.
Let me write the CSS.
```css
*{box-sizing:border-box}
html,body{height:100%;margin:0}
body{
background: radial-gradient(1200px 600px at 50% -10%, #16323a, #070d12 60%);
color:#e8f4f0;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
overflow:hidden;
display:flex; align-items:center; justify-content:center;
height:100dvh;
}
#app{ width:100%; height:100%; display:flex; flex-direction:column; padding:10px 12px 8px; gap:8px; max-width:1240px; margin:0 auto;}
```
Hmm, body is flex centered, app is 100% — fine.
hud: display flex, justify-between, align-items center.
stage: position relative, flex:1, min-height:0, border-radius:18px, overflow:hidden, box-shadow.
canvas: display block, position absolute, inset 0.
overlay: position absolute inset 0, display flex, center, background rgba(4,10,14,0.55), backdrop-filter blur(3px). Text card.
For the demo overlay, maybe don't blur the whole thing too much so the game is visible. Use a lighter dim: rgba(4,10,14,0.35) with the card in the center. The demo continues playing behind it, visible. Good.
Actually if the overlay covers with a card in the middle, it hides the game. Put the card near the bottom? Or make it a slim pill at the bottom center. Let's do: overlay with a pill at the bottom for demo ("DEMO • Click or press any key to drive"), and the game fully visible. That's better for the screenshot — the game action is visible.
Hmm, but the instruction says display a clear "Click or press a key to start" message. A pill at the bottom center is clear. Let's make it prominent: centered horizontally, near the bottom, with a pulsing glow. Good.
For game over: centered card, since the game is over anyway.
Let's implement overlay as a container with two children: #demoHint (pill at bottom) and #overCard (centered card). Toggle via state.
Now writing code.
```js
const DIRS=[{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
```
Full code:
```js
(function(){
'use strict';
const COLS=22, ROWS=14;
const canvas=document.getElementById('c');
const ctx=canvas.getContext('2d');
const stage=document.getElementById('stage');
const scoreEl=document.getElementById('score');
const lenEl=document.getElementById('len');
const demoHint=document.getElementById('demoHint');
const overCard=document.getElementById('overCard');
const overText=document.getElementById('overText');
let W=0,H=0,dpr=1,cell=32,offX=0,offY=0;
function resize(){
const r=stage.getBoundingClientRect();
W=Math.max(1,r.width); H=Math.max(1,r.height);
dpr=Math.min(window.devicePixelRatio||1,2);
canvas.width=Math.round(W*dpr); canvas.height=Math.round(H*dpr);
canvas.style.width=W+'px'; canvas.style.height=H+'px';
ctx.setTransform(dpr,0,0,dpr,0,0);
const pad=10;
cell=Math.min((W-pad*2)/COLS,(H-pad*2)/ROWS);
cell=Math.max(cell,6);
offX=(W-cell*COLS)/2;
offY=(H-cell*ROWS)/2;
}
window.addEventListener('resize',resize);
```
Careful: stage.getBoundingClientRect at load might be fine. Use ResizeObserver too.
Game vars:
```js
let snake=[], prevSnake=[], dir={x:1,y:0}, food={x:0,y:0}, score=0, pendingGrowth=0;
let state='demo', acc=0, tickMs=115, particles=[], segAngles=[];
let lastTime=0, smokeTimer=0;
```
reset:
```js
function reset(mode){
snake=[{x:6,y:7},{x:5,y:7},{x:4,y:7}];
prevSnake=snake.map(p=>({x:p.x,y:p.y}));
dir={x:1,y:0};
score=0; pendingGrowth=0; acc=0; particles.length=0; segAngles.length=0;
state=mode;
food=randomFood();
updateHUD();
syncOverlay();
}
```
randomFood: pick random free cell not on snake, not equal to current food.
```js
function randomFood(){
const occ=new Set(snake.map(p=>p.x+','+p.y));
const free=[];
for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++){ if(!occ.has(x+','+y)) free.push({x,y}); }
if(!free.length) return {x:0,y:0};
return free[Math.floor(Math.random()*free.length)];
}
```
Should also avoid spawning directly in front of the head? Not necessary.
step:
```js
function step(){
prevSnake=snake.map(p=>({x:p.x,y:p.y}));
if(state==='demo'){ const d=aiDir(); if(d) dir=d; }
const head=snake[0];
const nh={x:head.x+dir.x,y:head.y+dir.y};
if(nh.x<0||nh.y<0||nh.x>=COLS||nh.y>=ROWS){ die(); return; }
const limit=snake.length-(pendingGrowth>0?0:1);
for(let i=0;i<limit;i++){
if(snake[i].x===nh.x&&snake[i].y===nh.y){ die(); return; }
}
snake.unshift(nh);
if(nh.x===food.x&&nh.y===food.y){
score++; pendingGrowth+=2;
burst(nh.x,nh.y);
food=randomFood();
updateHUD();
}
if(pendingGrowth>0) pendingGrowth--; else snake.pop();
}
```
Wait — if we eat, pendingGrowth += 2, then immediately `if(pendingGrowth>0) pendingGrowth--` → 1. So next step we also don't pop. Net growth = 2. Good.
Hmm but careful: the collision check with `limit`: if pendingGrowth>0, the tail stays, so check all segments. Otherwise, the tail moves away, so ignore the last. Good.
die():
```js
function die(){
if(state==='demo'){ reset('demo'); return; }
state='over';
syncOverlay();
}
```
Also the player's snake should maybe flash. Skip.
updateHUD: scoreEl.textContent = score; lenEl.textContent = snake.length - 1 (wagons) or snake.length.
syncOverlay:
```js
function syncOverlay(){
demoHint.classList.toggle('show', state==='demo');
overCard.classList.toggle('show', state==='over');
if(state==='over') overText.textContent = 'You picked up '+score+' passenger'+(score===1?'':'s')+'!';
}
```
Input:
```js
function startPlay(){
if(state==='play') return;
reset('play');
state='play'; syncOverlay();
}
```
Wait reset sets state; fine.
```js
window.addEventListener('keydown', e=>{
const k=e.key;
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(k)) e.preventDefault();
if(state!=='play'){ startPlay(); /* also set dir if arrow */ }
let d=null;
if(k==='ArrowUp'||k==='w'||k==='W') d={x:0,y:-1};
else if(k==='ArrowDown'||k==='s'||k==='S') d={x:0,y:1};
else if(k==='ArrowLeft'||k==='a'||k==='A') d={x:-1,y:0};
else if(k==='ArrowRight'||k==='d'||k==='D') d={x:1,y:0};
if(d) setDir(d);
});
```
setDir must avoid reversing: check against current dir AND the queued dir (we only have one dir applied per tick). Since we set dir immediately and the tick uses it, reversing check against current dir is enough. But if two keys pressed within one tick, the second could reverse relative to the first... e.g., moving right, press up then left within one tick: dir=up then left is allowed (not reverse of up) → left. Then the snake moves left while its body is to the left → collision. Hmm. Classic issue. To fix, keep a queue of directions applied at ticks.
Let's use a small queue: `dirQueue = []`, max 2. On key: compare against last queued (or current dir), if not reverse and not same, push. At step: if queue.length, dir = queue.shift().
Good.
For demo, dir is set by AI directly.
Now render.
```js
function render(now){
ctx.clearRect(0,0,W,H);
drawBackground();
drawFood(now);
drawSmoke();
drawTrain(now);
}
```
Order: smoke behind the train? Smoke rises from the chimney, so in front is fine. Let's draw smoke after the train.
drawTrain:
```js
const t = state==='over' ? 1 : Math.min(acc/tickMs,1);
const pts = [];
for(let i=0;i<snake.length;i++){
const p=snake[i];
const pr=prevSnake[i]||p;
pts.push({
x: offX+(pr.x+(p.x-pr.x)*t+0.5)*cell,
y: offY+(pr.y+(p.y-pr.y)*t+0.5)*cell
});
}
```
Hmm when state==='over', t=1 means the snake jumps to final positions. But on death, snake didn't move, so prevSnake == snake, so it doesn't matter. Actually on death we set prevSnake = copy of snake and returned, so all equal. Fine, use normal t.
Angles:
```js
for(let i=0;i<pts.length;i++){
let ang;
if(i===0){
ang = Math.atan2(dir.y, dir.x);
} else {
const dx=pts[i-1].x-pts[i].x, dy=pts[i-1].y-pts[i].y;
if(Math.abs(dx)+Math.abs(dy) < 0.5) ang = segAngles[i]!==undefined?segAngles[i]:Math.atan2(dir.y,dir.x);
else ang = Math.atan2(dy,dx);
}
segAngles[i]=ang;
}
```
Wait for i=0, using dir gives instant rotation on turn. Could smooth but fine.
Hmm, but the head's rendered position lerps from prev to current; if dir changed at the tick, the head rotates immediately at t=0 while still moving diagonally. Minor.
Better: head angle = atan2(currentHeadPos - prevHeadPos) if distance>0 else dir. Let's use the lerp direction: pts[0] - ptsPrev[0]. Actually simpler: use dir. It's fine.
Actually let's do: for i=0, angle from pts[0] to... no. Use dir.
Draw order: draw wagons from tail to head so the head is on top.
```js
for(let i=pts.length-1;i>=1;i--){ drawWagon(pts[i], segAngles[i], wagonColor(i)); }
drawLoco(pts[0], segAngles[0]);
```
Wagon color: palette[(i-1)%palette.length].
drawWagon(p, ang, color):
```js
function drawWagon(p, ang, color){
const s=cell;
ctx.save();
ctx.translate(p.x,p.y);
ctx.rotate(ang);
// shadow
ctx.fillStyle='rgba(0,0,0,0.35)';
ctx.beginPath(); ctx.ellipse(0, s*0.30, s*0.40, s*0.13, 0,0,Math.PI*2); ctx.fill();
// wheels
ctx.fillStyle='#20262e';
const wr=s*0.115;
[-s*0.22, s*0.22].forEach(wx=>{ ctx.beginPath(); ctx.arc(wx, s*0.26, wr,0,Math.PI*2); ctx.fill(); });
ctx.fillStyle='#59636f';
[-s*0.22, s*0.22].forEach(wx=>{ ctx.beginPath(); ctx.arc(wx, s*0.26, wr*0.42,0,Math.PI*2); ctx.fill(); });
// body
const bw=s*0.86, bh=s*0.50;
const g=ctx.createLinearGradient(0,-bh/2,0,bh/2);
g.addColorStop(0, shade(color, 1.25));
g.addColorStop(1, shade(color, 0.72));
ctx.fillStyle=g;
rr(ctx,-bw/2,-s*0.30,bw,bh,s*0.12); ctx.fill();
// roof line
ctx.fillStyle='rgba(255,255,255,0.25)';
rr(ctx,-bw/2+s*0.04,-s*0.28,bw-s*0.08,s*0.07,s*0.04); ctx.fill();
// windows
ctx.fillStyle='rgba(220,245,255,0.92)';
rr(ctx,-s*0.28,-s*0.10,s*0.20,s*0.20,s*0.04); ctx.fill();
rr(ctx, s*0.08,-s*0.10,s*0.20,s*0.20,s*0.04); ctx.fill();
// outline
ctx.strokeStyle='rgba(0,0,0,0.35)'; ctx.lineWidth=Math.max(1,s*0.03);
rr(ctx,-bw/2,-s*0.30,bw,bh,s*0.12); ctx.stroke();
ctx.restore();
}
```
Hmm the body vertical range: from -0.30s to +0.20s. Wheels at y=0.26s, partially below the body. Good.
Windows at y -0.10 to 0.10 — inside the body (body spans -0.30..0.20). Good.
shade(color, factor): parse hex, multiply.
```js
function shade(hex, f){
const n=parseInt(hex.slice(1),16);
let r=(n>>16)&255, g=(n>>8)&255, b=n&255;
r=Math.min(255,Math.round(r*f)); g=Math.min(255,Math.round(g*f)); b=Math.min(255,Math.round(b*f));
return 'rgb('+r+','+g+','+b+')';
}
```
Cache the results to avoid recomputing per frame — it's fine, small.
drawLoco(p, ang):
```js
function drawLoco(p, ang){
const s=cell;
ctx.save();
ctx.translate(p.x,p.y);
ctx.rotate(ang);
// shadow
ctx.fillStyle='rgba(0,0,0,0.4)';
ctx.beginPath(); ctx.ellipse(0,s*0.31,s*0.44,s*0.14,0,0,Math.PI*2); ctx.fill();
// wheels
const wr=s*0.14;
ctx.fillStyle='#1b2027';
[[-s*0.26],[s*0.10],[s*0.34]].forEach(...)
```
Let's do 3 wheels: -0.26, 0.02, 0.30 at y 0.26.
Hmm, keep 2 big + 1 small? Simpler: 3 wheels.
- body main: roundRect(-s*0.44, -s*0.22, s*0.78, s*0.46, s*0.10) red gradient.
- cab at back: roundRect(-s*0.46,-s*0.40,s*0.36,s*0.62, s*0.08) darker red.
- cab window: roundRect(-s*0.40,-s*0.32,s*0.22,s*0.20, s*0.04) light blue.
- boiler front: circle-ish at x s*0.30? Add a lighter red roundRect(-s*0.06,-s*0.22, s*0.42, s*0.46) with a highlight.
- chimney: roundRect(-s*0.06,-s*0.50,s*0.16,s*0.22,s*0.04) dark gray, plus a flared top roundRect(-s*0.09,-s*0.54,s*0.22,s*0.08).
- cowcatcher: triangle from (s*0.34, s*0.06) to (s*0.50, s*0.28) to (s*0.34, s*0.28).
- headlight: circle at (s*0.36,-s*0.10) r s*0.07, fill '#ffe9a3' with a glow (shadowBlur).
- outline.
Let me be careful with coordinate sanity: everything within [-0.5s, 0.5s].
Body: x from -0.44 to 0.34, y from -0.22 to 0.24. OK.
Cab: x -0.46..-0.10, y -0.40..0.22. That overlaps the body top-left. Fine, drawn on top.
Chimney at x -0.06..0.10, y -0.50..-0.28. It sits on the boiler top (body top is -0.22). So chimney base at -0.28 is above the body top -0.22 → floating. Let's set chimney y from -0.46 to -0.18 so it overlaps the body. Good.
Wheels at y 0.26, radius 0.14 → bottom at 0.40. Body bottom 0.24. Good.
The shadow ellipse at y 0.31 with ry 0.14 → spans 0.17..0.45. Fine, drawn first.
Now smoke: spawn at the chimney world position. Chimney local (-0.06..0.10, -0.5) → center local (0.02s, -0.48s). World: rotate by ang.
```js
const cx = p.x + Math.cos(ang)*(0.02*cell) - Math.sin(ang)*(-0.48*cell);
const cy = p.y + Math.sin(ang)*(0.02*cell) + Math.cos(ang)*(-0.48*cell);
```
Hmm, local point (lx,ly) → world = (px + lx*cos - ly*sin, py + lx*sin + ly*cos). Yes.
I'll spawn smoke in the update loop rather than render. Let's do it in render with a timer — simpler: accumulate smokeTimer += dt; if > 0.09 spawn.
Let me restructure the animation loop:
```js
let last=performance.now();
function frame(now){
const dt = Math.min(0.05, (now-last)/1000);
last=now;
if(state!=='over'){
acc += dt*1000;
while(acc>=tickMs){ acc-=tickMs; step(); if(state==='over'){acc=0;break;} }
}
updateParticles(dt);
render(now);
requestAnimationFrame(frame);
}
```
Careful with the while loop: if state changes to demo-reset inside step, fine.
Also cap the while iterations.
Particles update:
```js
function updateParticles(dt){
for(let i=particles.length-1;i>=0;i--){
const p=particles[i];
p.x+=p.vx*dt; p.y+=p.vy*dt; p.vy-=...
```
Let's have smoke rise: vy negative, slow down. p.life -= dt*0.9; p.r += dt*cell*0.35; if life<=0 remove.
Spawn in render: need the head world position and angle.
```js
let smokeAcc=0;
function spawnSmoke(dt, px, py, ang){
smokeAcc+=dt;
while(smokeAcc>0.07 && particles.length<70){
smokeAcc-=0.07;
const lx=0.02*cell, ly=-0.48*cell;
const wx = px + Math.cos(ang)*lx - Math.sin(ang)*ly;
const wy = py + Math.sin(ang)*lx + Math.cos(ang)*ly;
particles.push({x:wx+(Math.random()-0.5)*cell*0.1, y:wy, vx:(Math.random()-0.5)*cell*0.3, vy:-cell*(0.5+Math.random()*0.4), life:1, r:cell*0.10});
}
}
```
Hmm vy in units per second: -cell*0.5 ≈ -20 px/s. Slow. Let's use -cell*0.8 to -cell*1.4.
Draw smoke: fillStyle rgba(230,240,245, alpha), arc.
Now drawFood with pulse:
```js
function drawFood(now){
const s=cell;
const cx=offX+(food.x+0.5)*s, cy=offY+(food.y+0.5)*s;
const pulse=0.5+0.5*Math.sin(now/280);
// glow ring
ctx.save();
ctx.beginPath(); ctx.arc(cx,cy,s*0.42*(0.9+0.1*pulse),0,Math.PI*2);
ctx.fillStyle='rgba(255,214,102,'+(0.10+0.12*pulse)+')'; ctx.fill();
// platform
ctx.fillStyle='#3a4a52';
rr(ctx, cx-s*0.36, cy+s*0.20, s*0.72, s*0.14, s*0.05); ctx.fill();
ctx.fillStyle='#5b7078';
rr(ctx, cx-s*0.36, cy+s*0.20, s*0.72, s*0.05, s*0.02); ctx.fill();
// person
const bob=Math.sin(now/300)*s*0.03;
const py=cy-s*0.02+bob;
// body
ctx.fillStyle='#ffd166';
rr(ctx, cx-s*0.10, py-s*0.04, s*0.20, s*0.26, s*0.07); ctx.fill();
// head
ctx.fillStyle='#ffe0b2';
ctx.beginPath(); ctx.arc(cx, py-s*0.16, s*0.11, 0, Math.PI*2); ctx.fill();
// hat/hair
ctx.fillStyle='#ef476f';
ctx.beginPath(); ctx.arc(cx, py-s*0.19, s*0.11, Math.PI, 0); ctx.fill();
// suitcase
ctx.fillStyle='#8ecae6';
rr(ctx, cx+s*0.12, py+s*0.06, s*0.13, s*0.13, s*0.03); ctx.fill();
ctx.restore();
}
```
Careful with the head/hair arc: arc from PI to 0 draws the top half. Fine.
Now the board background. Let's add a subtle "rail yard" look: dark green-gray with faint grid, plus rounded corners.
Also, drawing the tracks behind the train would be cool but skip.
Let me write drawBackground:
```js
function drawBackground(){
const g=ctx.createLinearGradient(0,0,0,H);
g.addColorStop(0,'#0b141a'); g.addColorStop(1,'#060b0f');
ctx.fillStyle=g; ctx.fillRect(0,0,W,H);
const bw=COLS*cell, bh=ROWS*cell;
ctx.save();
rr(ctx,offX,offY,bw,bh,Math.min(cell*0.5,22)); ctx.clip();
const bg=ctx.createLinearGradient(offX,offY,offX+bw,offY+bh);
bg.addColorStop(0,'#12312c'); bg.addColorStop(0.5,'#0f2a26'); bg.addColorStop(1,'#0b201d');
ctx.fillStyle=bg; ctx.fillRect(offX,offY,bw,bh);
for(let y=0;y<ROWS;y++){
for(let x=0;x<COLS;x++){
if((x+y)&1){ ctx.fillStyle='rgba(255,255,255,0.022)'; ctx.fillRect(offX+x*cell,offY+y*cell,cell,cell); }
}
}
ctx.restore();
// frame
ctx.save();
rr(ctx,offX+1,offY+1,bw-2,bh-2,Math.min(cell*0.5,22));
ctx.strokeStyle='rgba(90,220,170,0.22)'; ctx.lineWidth=2; ctx.stroke();
ctx.restore();
}
```
Good.
Performance: the checker loop is 308 fills per frame — fine.
Now, resize handling with ResizeObserver:
```js
if(window.ResizeObserver){ new ResizeObserver(resize).observe(stage); }
```
But careful: resize changes canvas size which might trigger the observer again... it observes the stage, not the canvas, and the canvas is absolutely positioned so it doesn't affect the stage size. Should be fine.
Also initial call to resize() before reset.
Order: define everything, then resize(), reset('demo'), requestAnimationFrame.
But resize needs `cell` for drawing only. Fine.
Let's also make sure the first paint has content: demo starts immediately, the overlay pill shows. Screenshot at 3s shows the train moving and picking up passengers.
Let me also add a subtle "passenger count" popup animation when scoring? Skip.
One more consideration: the demo AI should be able to pick up passengers frequently so the train grows visibly within 3 seconds. At 115ms/tick, in 3s that's ~26 ticks. The snake starts at length 3, moves at 1 cell/tick. The food is random — average distance maybe 8 cells. So within 3s it might pick up 1-3 passengers. Good. Let's start the demo with the food placed near-ish? Just random is fine. Maybe make the initial snake a bit longer (5 segments) so it looks like a train right away. Yes! Start with 5 segments so it looks like a train: loco + 4 wagons.
snake = [{x:8,y:7},{x:7,y:7},{x:6,y:7},{x:5,y:7},{x:4,y:7}]. With COLS=22, that's fine.
Colors palette:
```js
const PALETTE=['#ef476f','#ffd166','#06d6a0','#118ab2','#c77dff','#ff9f1c','#4cc9f0','#f72585'];
```
Wagon color for index i: PALETTE[(i-1)%PALETTE.length].
Now the AI. Let me write it carefully.
```js
function aiDir(){
const head=snake[0];
const blocked=new Uint8Array(COLS*ROWS);
for(let i=0;i<snake.length-1;i++) blocked[snake[i].y*COLS+snake[i].x]=1;
const first=bfsFirst(head, food, blocked);
const cands=[];
for(const d of DIRS){
if(d.x===-dir.x && d.y===-dir.y) continue;
const nx=head.x+d.x, ny=head.y+d.y;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
if(blocked[ny*COLS+nx]) continue;
cands.push(d);
}
if(!cands.length) return dir;
const blocked2=blocked.slice();
blocked2[head.y*COLS+head.x]=1;
const need=snake.length+2;
let best=cands[0], bestScore=-Infinity;
for(const d of cands){
const nx=head.x+d.x, ny=head.y+d.y;
const area=floodCount(nx,ny,blocked2);
let sc=area;
if(area<need) sc-=100000;
if(first && d.x===first.x && d.y===first.y) sc+=5000;
if(sc>bestScore){ bestScore=sc; best=d; }
}
return best;
}
```
Note: `blocked` excludes the tail, so the head's own cell isn't blocked but the head moves anyway. blocked2 adds the head cell (it becomes body). Fine.
floodCount and bfsFirst as described.
One issue: bfsFirst uses `blocked` which excludes the tail — good, allows pathing through the tail cell.
Now the dir queue for the player:
```js
let dirQueue=[];
function setDir(d){
const ref = dirQueue.length? dirQueue[dirQueue.length-1] : dir;
if(d.x===-ref.x && d.y===-ref.y) return;
if(d.x===ref.x && d.y===ref.y) return;
if(dirQueue.length<2) dirQueue.push(d);
}
```
In step: `if(state==='play' && dirQueue.length) dir = dirQueue.shift();`
Hmm but in demo, dirQueue should be cleared. Fine.
Careful: in step for demo we call aiDir() which uses the current dir. Good.
Now overlay sync and the start handler.
```js
function startPlay(){
dirQueue=[];
reset('play');
}
```
reset sets state='play' and calls syncOverlay which hides the demo hint and the over card. Good.
Click handler: `window.addEventListener('pointerdown', ()=>{ if(state!=='play') startPlay(); })`.
But also clicking should not start when the game is over? It should restart — yes, startPlay.
Keyboard: any key starts.
Also make sure that clicking on the canvas doesn't cause issues. Fine.
Now the overlay HTML:
```html
<div id="overlay">
<div id="demoHint" class="hint show">
<span class="pulse"></span> DEMO · Click or press any key to drive the train
</div>
<div id="overCard" class="card">
<h2>Derailed!</h2>
<p id="overText"></p>
<button>Click or press a key to restart</button>
</div>
</div>
```
Overlay: position absolute inset 0, pointer-events none, display flex column, align-items center, justify-content flex-end (hint at bottom) and the card centered... Two different alignments. Use: #overlay { position:absolute; inset:0; pointer-events:none; display:grid; place-items:center; } and #demoHint { position:absolute; bottom:14px; left:50%; transform:translateX(-50%); }.
Card: display none unless .show.
Let's write CSS:
```css
#overlay{position:absolute;inset:0;pointer-events:none;}
#demoHint{
position:absolute; left:50%; bottom:16px; transform:translateX(-50%) translateY(8px);
background:rgba(8,20,24,0.82); border:1px solid rgba(90,230,180,0.45);
color:#c9ffe9; padding:10px 18px; border-radius:999px; font-size:14px; font-weight:600;
letter-spacing:.02em; white-space:nowrap; opacity:0; transition:opacity .3s, transform .3s;
box-shadow:0 0 30px rgba(40,220,160,0.25), inset 0 0 20px rgba(40,220,160,0.08);
display:flex; align-items:center; gap:9px;
}
#demoHint.show{opacity:1;transform:translateX(-50%) translateY(0);}
```
The dot pulse:
```css
#demoHint .pulse{width:9px;height:9px;border-radius:50%;background:#3ef2a8;box-shadow:0 0 12px #3ef2a8;animation:pulse 1.4s infinite;}
@keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.35;transform:scale(.75)}}
```
On narrow screens, the hint text is long. Use font-size 12px and shorter text on mobile via media query? Just make the text shorter: "Click or press a key to start". And add a "DEMO" label separate. Let's do: `<span class="pulse"></span><b>DEMO</b> · Click or press a key to start`. At 375px width, ~40 chars at 12px ≈ 240px + padding. OK, fits.
Card CSS: dark card with border, centered.
Now, will the game area be too tall/short? With flex column: hud ~48px, footer ~24px, gaps, padding. On 800px height, the stage gets ~690px. Width up to 1240. cell = min(1220/22=55, 670/14=47.8) → 47.8. Board 1052x670. Good.
On 375x667: stage ~ 375-24=351 wide, height ~ 667-100=560. cell = min(331/22=15, 540/14=38.5) = 15. Board 330x210, centered vertically in a 560-tall stage — lots of empty space. Hmm, that's ugly. Better to size the stage to the board aspect ratio.
Option: make the stage's aspect ratio fixed via CSS `aspect-ratio: 22/14` and max-height 100%. Then the canvas fills the stage. But then on a tall narrow screen, the stage is short and the rest is empty background — but at least the board fills the stage.
Let's do: stage { flex:1; min-height:0; display:flex; align-items:center; justify-content:center; } and canvas sized to the computed board size (not filling the stage). Then the board is centered with the page background around it. That's clean.
So: compute cell from the stage rect, set canvas CSS width/height = COLS*cell x ROWS*cell, positioned centered. Then offX/offY = 0. Actually simpler: canvas exactly the board size, drawn from 0,0.
Let's do that. canvas absolute? Use flex centering with the canvas as a flex item.
```css
#stage{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;padding:4px;}
canvas{display:block;border-radius:16px;box-shadow:0 18px 50px rgba(0,0,0,.55), 0 0 0 1px rgba(90,230,180,.18);}
```
And in resize: compute cell = floor(min((W-8)/COLS,(H-8)/ROWS)); boardW = cell*COLS; boardH = cell*ROWS; canvas.style.width = boardW+'px' etc. offX=offY=0.
Then the overlay should be positioned over the canvas. If the overlay is absolute inset 0 on the stage, the hint at bottom:16px might be outside the board on a tall screen. Hmm. Better: wrap canvas + overlay in a `#boardWrap` that is inline-block sized to the canvas.
Structure:
```html
<div id="stage">
<div id="boardWrap">
<canvas id="c"></canvas>
<div id="overlay">...</div>
</div>
</div>
```
#boardWrap { position:relative; border-radius:16px; overflow:hidden; } with the size set from JS? Since the canvas is inside and defines the size, the wrap auto-sizes. Yes — the canvas is a block element with explicit style width/height, so the wrap shrinks to it (if it's inline-block/flex item). Use display:flex on stage with center, and the wrap as a flex item → it sizes to the canvas. Good.
Overlay absolute inset 0 within the wrap.
Now the rounded corners: apply overflow hidden + border-radius on the wrap, and the canvas fills it.
Let's finalize resize:
```js
function resize(){
const r=stage.getBoundingClientRect();
const availW=Math.max(100,r.width-6), availH=Math.max(100,r.height-6);
cell=Math.floor(Math.min(availW/COLS, availH/ROWS));
cell=Math.max(cell,6);
const bw=cell*COLS, bh=cell*ROWS;
W=bw; H=bh;
dpr=Math.min(window.devicePixelRatio||1,2);
canvas.width=Math.round(bw*dpr); canvas.height=Math.round(bh*dpr);
canvas.style.width=bw+'px'; canvas.style.height=bh+'px';
ctx.setTransform(dpr,0,0,dpr,0,0);
offX=0; offY=0;
}
```
Note: setting canvas.width resets the transform, so setTransform after. Good.
Everything draws relative to offX=0, offY=0. Simplify by keeping the vars.
Potential issue: getBoundingClientRect of the stage before layout settles → use ResizeObserver and an initial rAF call.
Let's just call resize() in a rAF at start, plus on window resize and via ResizeObserver.
Now the HUD design. Let's make it look nice:
```html
<div id="hud">
<div id="brand"><span class="logo">🚂</span><span>SNAKE TRAIN</span></div>
<div id="stats">
<div class="stat"><div class="v" id="score">0</div><div class="k">passengers</div></div>
<div class="stat"><div class="v" id="len">5</div><div class="k">cars</div></div>
</div>
</div>
```
Emoji 🚂 is fine (system font).
CSS: brand with letter-spacing, gradient text maybe.
Footer: small hint "Arrow keys / WASD to steer · eat passengers to grow".
Maybe skip the footer to save vertical space; put the hint inside the HUD? Keep a slim footer with the controls hint, hidden on very short screens.
I'll include a footer with `font-size:12px; color:#6d8b86;` and `@media (max-height:600px){display:none}`.
Now let's write everything out.
One more thing: the "cars" counter should be snake.length-1 (wagons). Let's show that.
Let me also add a small flash effect on the board when picking up (a quick expanding ring at the food position). Let's add to particles: type 'ring'. Simpler: keep smoke only, plus a score pop in the DOM. Skip; the smoke is enough. Actually a pickup ring is nice and cheap. I'll add `rings` array with x,y,life. Draw expanding circle.
Let me add rings in burst():
```js
function burst(gx,gy){
rings.push({x:offX+(gx+0.5)*cell, y:offY+(gy+0.5)*cell, life:1});
}
```
But cell may change on resize; the ring position is in pixels. Acceptable.
Update rings: life -= dt*2.2. Draw: r = cell*(0.4 + (1-life)*0.9), alpha = life*0.7, stroke gold.
OK.
Now writing the final code. Let me be careful about the order of definitions and hoisting (function declarations hoist, so fine).
Let me write it.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Snake Train</title>
<style>
:root{
--green:#3ef2a8;
}
*{box-sizing:border-box;}
html,body{height:100%;margin:0;}
body{
background:
radial-gradient(900px 500px at 50% -8%, #14343a 0%, #0a1218 55%, #060a0e 100%);
color:#e6f6f1;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
overflow:hidden;
-webkit-user-select:none; user-select:none;
}
#app{
height:100%; width:100%; max-width:1280px; margin:0 auto;
display:flex; flex-direction:column; gap:6px;
padding:10px 14px 10px;
}
#hud{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;}
#brand{display:flex;align-items:center;gap:9px;font-weight:800;letter-spacing:.14em;font-size:15px;
background:linear-gradient(90deg,#b8ffe4,#57e0b0);-webkit-background-clip:text;background-clip:text;color:transparent;}
#brand .logo{-webkit-text-fill-color:initial;font-size:20px;filter:drop-shadow(0 0 8px rgba(62,242,168,.5));}
#stats{display:flex;gap:10px;}
.stat{
background:rgba(255,255,255,.045); border:1px solid rgba(120,240,200,.16);
border-radius:12px; padding:5px 12px; min-width:78px; text-align:center;
backdrop-filter: blur(6px);
}
.stat .v{font-size:18px;font-weight:800;line-height:1.05;color:#a9ffdc;font-variant-numeric:tabular-nums;}
.stat .k{font-size:9.5px;letter-spacing:.16em;text-transform:uppercase;color:#6e9c92;margin-top:2px;}
#stage{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;padding:2px;}
#boardWrap{position:relative;border-radius:18px;overflow:hidden;
box-shadow:0 24px 60px rgba(0,0,0,.6), 0 0 0 1px rgba(90,230,180,.16);}
canvas{display:block;}
#overlay{position:absolute;inset:0;pointer-events:none;}
...
</style>
```
For the stat box: on narrow screens, keep them small.
Now let me assemble the JS fully and double check.
Also `overText` should say something like "You picked up 7 passengers." plus the restart prompt in the button.
I'll now write out the full code.
Potential subtlety: `prevSnake[i] || p` — if prevSnake[i] exists it's an object (truthy). Fine.
Another: in `step()`, after die() we return but `prevSnake` was already updated to the copy — that's fine since the snake didn't move.
But careful: when die() → reset('demo') in demo mode, prevSnake and snake are reset. Fine.
Let me handle the render when snake is empty — never empty.
The `segAngles` array must be reset on reset (length 0). And it's indexed by segment; when the snake grows, index i has a stored angle from before? It's overwritten each frame in the loop for all i, so fine.
Wait, in the angle loop, for i>0 the fallback uses segAngles[i] which may be from a previous frame at a different index — but since we always overwrite in order and the degenerate case is rare, it's fine.
Let me now also double check the interpolation `t` when state==='over': acc stops accumulating, so t stays frozen at whatever. That's fine (train stops mid-move). Actually on death, prevSnake==snake so no visual difference.
Hmm, but on death, prevSnake was set to the copy at the start of step, and snake unchanged → identical. And t = acc/tickMs which is whatever. Fine.
Let me write drawTrain to also handle the case where the head has just been re-rendered.
OK, writing final now.
One more: the `food` should not spawn on the head's next cell... not needed.
Also: when the player dies, the demo doesn't restart automatically. The overlay card shows. Fine.
Let me write the code.
Also need `updateHUD()` called on reset and eat.
Final check on the AI flood fill threshold: `need = snake.length + 2`. With snake length 5 and 308 cells, the areas will be huge → all safe → picks the path. Good.
Now writing out everything.
For drawLoco, let me carefully write:
```js
function drawLoco(p, ang){
const s = cell;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(ang);
// ground shadow
ctx.fillStyle = 'rgba(0,0,0,0.38)';
ctx.beginPath(); ctx.ellipse(0, s*0.30, s*0.46, s*0.15, 0, 0, Math.PI*2); ctx.fill();
// wheels
const wr = s*0.135;
const wxs = [-s*0.26, -s*0.02, s*0.28];
for (const wx of wxs){
ctx.fillStyle = '#161c22';
ctx.beginPath(); ctx.arc(wx, s*0.25, wr, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#5d6b78';
ctx.beginPath(); ctx.arc(wx, s*0.25, wr*0.42, 0, Math.PI*2); ctx.fill();
}
// cowcatcher
ctx.fillStyle = '#3a444e';
ctx.beginPath();
ctx.moveTo(s*0.30, s*0.02);
ctx.lineTo(s*0.48, s*0.26);
ctx.lineTo(s*0.30, s*0.26);
ctx.closePath(); ctx.fill();
// main boiler body
const bg = ctx.createLinearGradient(0, -s*0.24, 0, s*0.24);
bg.addColorStop(0, '#ff6b6b');
bg.addColorStop(0.5, '#e03131');
bg.addColorStop(1, '#a11d1d');
ctx.fillStyle = bg;
rr(ctx, -s*0.44, -s*0.22, s*0.80, s*0.46, s*0.11); ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.35)'; ctx.lineWidth = Math.max(1, s*0.028); ctx.stroke();
// cab
const cg = ctx.createLinearGradient(0, -s*0.42, 0, s*0.22);
cg.addColorStop(0, '#c92a2a');
cg.addColorStop(1, '#7d1414');
ctx.fillStyle = cg;
rr(ctx, -s*0.46, -s*0.42, s*0.34, s*0.64, s*0.09); ctx.fill();
ctx.strokeStyle='rgba(0,0,0,0.4)'; ctx.stroke();
// cab window
ctx.fillStyle = '#bfe9ff';
rr(ctx, -s*0.40, -s*0.34, s*0.22, s*0.20, s*0.045); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.55)';
rr(ctx, -s*0.40, -s*0.34, s*0.22, s*0.07, s*0.035); ctx.fill();
// boiler highlight
ctx.fillStyle = 'rgba(255,255,255,0.20)';
rr(ctx, -s*0.06, -s*0.18, s*0.38, s*0.09, s*0.045); ctx.fill();
// chimney
ctx.fillStyle = '#2b333c';
rr(ctx, -s*0.02, -s*0.46, s*0.16, s*0.26, s*0.04); ctx.fill();
ctx.fillStyle = '#454f5a';
rr(ctx, -s*0.06, -s*0.52, s*0.24, s*0.09, s*0.035); ctx.fill();
// headlight
const lx = s*0.30, ly = -s*0.08;
ctx.save();
ctx.shadowColor = 'rgba(255,232,150,0.95)';
ctx.shadowBlur = s*0.5;
ctx.fillStyle = '#fff3bf';
ctx.beginPath(); ctx.arc(lx, ly, s*0.075, 0, Math.PI*2); ctx.fill();
ctx.restore();
ctx.restore();
}
```
Hmm, the cab is drawn over the body's left part; the body starts at -0.44 and the cab spans -0.46..-0.12. Good.
Chimney at y -0.46..-0.20 overlaps the body top (-0.22). Good.
Headlight at (0.30, -0.08) — on the front of the boiler. Good.
Note: the cab's y range -0.42..0.22 and the body's -0.22..0.24 — cab is taller. Good.
Wagon: the front of the wagon is +x.
Let me also add a coupling bar between wagons? Skip — they're adjacent anyway.
Now let me write the full file.
Also for `rr` with negative radius guard — radius must be <= half of w/h. All values are small relative to sizes. OK.
Let me write out the final answer.
One last check on the `while(acc>=tickMs)` loop with `state==='over'` break — if state becomes 'over', we break and set acc=0.
Also in demo, die() → reset('demo') → state stays 'demo', acc=0. The while loop would continue with acc=0 → exits. Fine.
Writing now.
Also the footer hint text: "Arrow keys / WASD · pick up passengers to add cars".
Let me make sure the page doesn't scroll: body overflow hidden, height 100%.
Use 100dvh? body height:100% works if html height 100%. Fine.
Final code writing.