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
- 2 min 6 s
- Cost
- $0.0078
- Output tokens
- 26,450
- Spent thinking
- 18,994 tokens
- Speed
- 210.3 tok/s
- Console errors
- 0
- First token after
- 539 ms
- First visible text after
- 1 min 40 s
- Prompt tokens
- 387
- Code size
- 19.6 KB
- Finish reason
- stop
- Served by
- DeepInfra
- Model id
deepseek/deepseek-v4.1-flash-1017- Reasoning sent
{"effort":"low"}- Max output
- 64,000 tokens
- Generated
- Nov 10, 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 · 664 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Passenger Express</title>
<style>
*{box-sizing:border-box}
html,body{height:100%;margin:0}
body{
background:radial-gradient(ellipse at 50% -10%, #1e3346 0%, #0a1017 60%);
color:#e9f0f6;
font-family:ui-rounded,"Segoe UI",system-ui,-apple-system,sans-serif;
overflow:hidden;
}
.wrap{
height:100%;display:flex;flex-direction:column;gap:6px;
padding:10px 12px 6px;max-width:1500px;margin:0 auto;
}
header{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;flex:0 0 auto}
h1{
margin:0;font-size:clamp(15px,2.1vw,22px);font-weight:900;letter-spacing:.3px;
background:linear-gradient(90deg,#ffd166,#ff9f45,#ff6b6b);
-webkit-background-clip:text;background-clip:text;color:transparent;white-space:nowrap;
}
.stats{display:flex;gap:6px;flex-wrap:wrap}
.stat{
background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);
border-radius:999px;padding:3px 10px;font-size:clamp(9px,1.25vw,12px);
color:#9fb3c4;letter-spacing:.4px;text-transform:uppercase;font-weight:700;white-space:nowrap;
}
.stat b{color:#fff;font-size:1.15em;margin-left:3px}
.stage{
flex:1 1 auto;min-height:0;position:relative;
display:flex;align-items:center;justify-content:center;overflow:hidden;
}
canvas{
display:block;border-radius:16px;
box-shadow:0 0 0 2px rgba(255,255,255,.07),0 20px 60px rgba(0,0,0,.6);
}
.overlay{
position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
background:rgba(6,12,18,.5);cursor:pointer;transition:opacity .25s;border-radius:16px;
}
.overlay.hidden{opacity:0;pointer-events:none}
.card{
text-align:center;padding:16px 24px;border-radius:16px;
background:rgba(14,24,34,.9);border:1px solid rgba(255,255,255,.13);
box-shadow:0 16px 44px rgba(0,0,0,.55);max-width:90%;
}
.big{font-size:clamp(14px,1.9vw,20px);font-weight:800;animation:pulse 1.6s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
.sub{margin-top:7px;font-size:clamp(9px,1.3vw,12.5px);color:#8fa4b6;line-height:1.5}
.hint{flex:0 0 auto;text-align:center;font-size:clamp(9px,1.25vw,12px);color:#5f7488;letter-spacing:.3px}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>🚂 Passenger Express</h1>
<div class="stats">
<div class="stat">Passengers <b id="score">0</b></div>
<div class="stat">Best <b id="best">0</b></div>
<div class="stat">Cars <b id="len">3</b></div>
</div>
</header>
<div class="stage" id="stage">
<canvas id="game"></canvas>
<div class="overlay" id="overlay">
<div class="card">
<div class="big" id="ovTitle">Click or press a key to start</div>
<div class="sub" id="ovSub">Arrow keys / WASD to drive · pick up everyone waiting on the platforms</div>
</div>
</div>
</div>
<div class="hint">Every passenger that hops aboard adds a brand-new wagon to the train.</div>
</div>
<script>
(function(){
"use strict";
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const stage = document.getElementById('stage');
const overlay = document.getElementById('overlay');
const ovTitle = document.getElementById('ovTitle');
const ovSub = document.getElementById('ovSub');
const scoreEl = document.getElementById('score');
const bestEl = document.getElementById('best');
const lenEl = document.getElementById('len');
// ---------- constants ----------
const WAGON_COLORS = ['#f2b134','#4ecdc4','#ff6b6b','#a06cd5','#5cb85c','#f78fb3','#6ec6ff','#ffa45c'];
const PASSENGER_COLORS = ['#e94f37','#3f88c5','#44bba4','#f6ae2d','#9b5de5','#ef476f','#06d6a0'];
const DIRS = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
let COLS = 24, ROWS = 16, CELL = 22, W = 0, H = 0;
let narrow = window.innerWidth < 680;
let snake, dir, nextDir, passengers, particles;
let score = 0, best = 0, state = 'demo', stepMs = 120;
let acc = 0, last = 0, smokeAt = 0;
let autoRestart = false, restartAt = 0;
// ---------- helpers ----------
function rrect(x,y,w,h,r){
r = Math.min(r, w/2, h/2);
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();
}
function shade(hex, amt){
const n = parseInt(hex.slice(1),16);
const r = (n>>16)&255, g = (n>>8)&255, b = n&255;
const f = v => Math.max(0, Math.min(255, Math.round(amt<0 ? v*(1+amt) : v + (255-v)*amt)));
return 'rgb('+f(r)+','+f(g)+','+f(b)+')';
}
function rnd(a,b){ return a + Math.random()*(b-a); }
// ---------- layout ----------
function chooseGrid(){
COLS = narrow ? 14 : 24;
ROWS = narrow ? 18 : 16;
}
function resize(){
const r = stage.getBoundingClientRect();
const availW = Math.max(120, r.width - 10);
const availH = Math.max(120, r.height - 10);
CELL = Math.max(8, Math.floor(Math.min(availW/COLS, availH/ROWS) * 2) / 2);
W = CELL * COLS;
H = CELL * ROWS;
const dpr = Math.min(2, window.devicePixelRatio || 1);
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);
}
// ---------- game state ----------
function reset(){
const cx = Math.floor(COLS/2), cy = Math.floor(ROWS/2);
snake = [{x:cx,y:cy},{x:cx-1,y:cy},{x:cx-2,y:cy}];
dir = {x:1,y:0};
nextDir = {x:1,y:0};
passengers = [];
particles = [];
score = 0;
acc = 0;
for (let i=0;i<3;i++) spawnPassenger();
updateHUD();
}
function spawnPassenger(){
const free = [];
for (let y=0;y<ROWS;y++){
for (let x=0;x<COLS;x++){
if (snake.some(s => s.x===x && s.y===y)) continue;
if (passengers.some(p => p.x===x && p.y===y)) continue;
free.push({x,y});
}
}
if (!free.length) return;
const c = free[Math.floor(Math.random()*free.length)];
passengers.push({
x:c.x, y:c.y,
phase: Math.random()*10,
colors: [PASSENGER_COLORS[Math.floor(Math.random()*PASSENGER_COLORS.length)],
PASSENGER_COLORS[Math.floor(Math.random()*PASSENGER_COLORS.length)]]
});
}
function updateHUD(){
if (score > best) best = score;
scoreEl.textContent = score;
bestEl.textContent = best;
lenEl.textContent = snake.length;
}
function step(){
if (state !== 'demo' && state !== 'play') return;
if (state === 'demo') aiChoose();
if (!(nextDir.x === -dir.x && nextDir.y === -dir.y)) dir = nextDir;
const head = snake[0];
const nx = head.x + dir.x, ny = head.y + dir.y;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS){ die(); return; }
const eating = passengers.some(p => p.x===nx && p.y===ny);
const limit = eating ? snake.length : snake.length - 1;
for (let i=0;i<limit;i++){
if (snake[i].x===nx && snake[i].y===ny){ die(); return; }
}
snake.unshift({x:nx,y:ny});
if (eating){
passengers = passengers.filter(p => !(p.x===nx && p.y===ny));
score++;
spawnPassenger();
burst(nx, ny);
stepMs = Math.max(72, 128 - score*2.2);
updateHUD();
} else {
snake.pop();
}
}
function die(){
const wasDemo = (state === 'demo');
state = 'over';
autoRestart = wasDemo;
restartAt = performance.now() + 900;
const h = snake[0];
for (let i=0;i<22;i++){
particles.push({
x:(h.x+0.5)*CELL, y:(h.y+0.5)*CELL,
vx: rnd(-0.14,0.14), vy: rnd(-0.18,0.04),
life: rnd(400,900), maxLife: 900,
size: CELL*rnd(0.05,0.13),
color: i%3===0 ? '#ffd166' : (i%3===1 ? '#ff8c5a' : 'rgba(190,200,210,0.8)'),
grav: 0.00018
});
}
if (!wasDemo){
ovTitle.textContent = '💥 Derailed!';
ovSub.textContent = 'Click or press a key to run the line again';
overlay.classList.remove('hidden');
}
}
function burst(gx, gy){
const cx = (gx+0.5)*CELL, cy = (gy+0.5)*CELL;
for (let i=0;i<16;i++){
const a = Math.random()*Math.PI*2, sp = rnd(0.05,0.24);
particles.push({
x:cx, y:cy,
vx: Math.cos(a)*sp, vy: Math.sin(a)*sp - 0.05,
life: rnd(350,750), maxLife: 750,
size: CELL*rnd(0.04,0.1),
color: i%2 ? '#ffd166' : '#fff6d8',
grav: 0.00035
});
}
}
function emitSmoke(now){
if (state !== 'demo' && state !== 'play') return;
if (now - smokeAt < 130) return;
smokeAt = now;
const ang = Math.atan2(dir.y, dir.x);
const hx = (snake[0].x+0.5)*CELL, hy = (snake[0].y+0.5)*CELL;
const ox = 0.17*CELL, oy = -0.46*CELL;
const px = hx + Math.cos(ang)*ox - Math.sin(ang)*oy;
const py = hy + Math.sin(ang)*ox + Math.cos(ang)*oy;
particles.push({
x:px, y:py,
vx: rnd(-0.02,0.02), vy: -0.035 - Math.random()*0.02,
life: 700, maxLife: 700,
size: CELL*rnd(0.07,0.12),
color: 'rgba(214,224,236,0.4)',
grav: 0
});
if (particles.length > 320) particles.splice(0, particles.length-320);
}
// ---------- AI ----------
function floodSize(sx, sy, blocked){
const seen = new Uint8Array(COLS*ROWS);
const start = sx + sy*COLS;
const q = [start];
seen[start] = 1;
let qi = 0, count = 0;
while (qi < q.length){
const cur = q[qi++];
count++;
const cx = cur % COLS, cy = (cur - cx) / COLS;
for (let k=0;k<4;k++){
const nx = cx + DIRS[k].x, ny = cy + DIRS[k].y;
if (nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
const ni = nx + ny*COLS;
if (seen[ni] || blocked.has(ni)) continue;
seen[ni] = 1;
q.push(ni);
}
}
return count;
}
function bfsDist(sx, sy, blocked){
const dist = new Int16Array(COLS*ROWS).fill(-1);
const start = sx + sy*COLS;
dist[start] = 0;
const targets = new Set();
for (let i=0;i<passengers.length;i++) targets.add(passengers[i].x + passengers[i].y*COLS);
const q = [start];
let qi = 0;
while (qi < q.length){
const cur = q[qi++];
const d = dist[cur];
if (targets.has(cur)) return d;
const cx = cur % COLS, cy = (cur - cx) / COLS;
for (let k=0;k<4;k++){
const nx = cx + DIRS[k].x, ny = cy + DIRS[k].y;
if (nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
const ni = nx + ny*COLS;
if (dist[ni] !== -1 || blocked.has(ni)) continue;
dist[ni] = d + 1;
q.push(ni);
}
}
return Infinity;
}
function aiChoose(){
const head = snake[0];
const blocked = new Set();
for (let i=0;i<snake.length-1;i++) blocked.add(snake[i].x + snake[i].y*COLS);
const options = [];
for (let k=0;k<4;k++){
const d = DIRS[k];
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;
const ni = nx + ny*COLS;
if (blocked.has(ni)) continue;
options.push({
d: d,
space: floodSize(nx, ny, blocked),
dist: bfsDist(nx, ny, blocked)
});
}
if (!options.length) return;
const safe = options.filter(o => o.space > snake.length + 1);
const pool = safe.length ? safe : options;
let bestOpt = pool[0], bestVal = Infinity;
for (let i=0;i<pool.length;i++){
const o = pool[i];
const v = (o.dist === Infinity ? 2000 : o.dist*12) - Math.min(o.space, 110);
if (v < bestVal){ bestVal = v; bestOpt = o; }
}
nextDir = bestOpt.d;
}
// ---------- drawing ----------
function drawWheel(x, y, r, spin){
ctx.fillStyle = '#241f1d';
ctx.beginPath(); ctx.arc(x, y, r, 0, 6.2832); ctx.fill();
ctx.fillStyle = '#7d726a';
ctx.beginPath(); ctx.arc(x, y, r*0.55, 0, 6.2832); ctx.fill();
ctx.strokeStyle = '#241f1d';
ctx.lineWidth = r*0.22;
ctx.beginPath();
ctx.moveTo(x - Math.cos(spin)*r*0.5, y - Math.sin(spin)*r*0.5);
ctx.lineTo(x + Math.cos(spin)*r*0.5, y + Math.sin(spin)*r*0.5);
ctx.stroke();
}
function drawLocomotive(){
const spin = T() * 0.012;
// wheels
drawWheel(-0.24, 0.30, 0.13, spin);
drawWheel( 0.06, 0.30, 0.13, spin);
drawWheel( 0.32, 0.31, 0.10, spin);
// cowcatcher
ctx.fillStyle = '#3a3330';
ctx.beginPath();
ctx.moveTo(0.40, 0.06);
ctx.lineTo(0.52, 0.30);
ctx.lineTo(0.30, 0.30);
ctx.closePath();
ctx.fill();
// chassis
ctx.fillStyle = '#22201f';
rrect(-0.46, -0.06, 0.92, 0.30, 0.08); ctx.fill();
// cab
ctx.fillStyle = '#a8332a';
rrect(-0.46, -0.38, 0.36, 0.44, 0.07); ctx.fill();
// cab roof
ctx.fillStyle = '#2b2320';
rrect(-0.50, -0.44, 0.44, 0.10, 0.04); ctx.fill();
// cab window
ctx.fillStyle = '#ffe9a3';
rrect(-0.40, -0.30, 0.22, 0.16, 0.03); ctx.fill();
// boiler
const g = ctx.createLinearGradient(0, -0.26, 0, 0.08);
g.addColorStop(0, '#ef5c45');
g.addColorStop(1, '#a8291c');
ctx.fillStyle = g;
rrect(-0.10, -0.26, 0.52, 0.32, 0.15); ctx.fill();
// boiler front band
ctx.fillStyle = '#2b2320';
rrect(0.30, -0.24, 0.10, 0.28, 0.03); ctx.fill();
// chimney
ctx.fillStyle = '#3b322e';
rrect(0.10, -0.44, 0.14, 0.21, 0.03); ctx.fill();
rrect(0.07, -0.48, 0.20, 0.07, 0.03); ctx.fill();
// headlight glow
ctx.fillStyle = 'rgba(255,231,150,0.18)';
ctx.beginPath(); ctx.arc(0.40, -0.10, 0.20, 0, 6.2832); ctx.fill();
ctx.fillStyle = '#ffe9a3';
ctx.beginPath(); ctx.arc(0.40, -0.10, 0.075, 0, 6.2832); ctx.fill();
}
function drawWagon(color, idx){
const spin = T() * 0.012 + idx * 0.7;
drawWheel(-0.20, 0.30, 0.105, spin);
drawWheel( 0.20, 0.30, 0.105, spin);
// coupling
ctx.fillStyle = '#3b322e';
rrect(-0.53, 0.03, 0.11, 0.09, 0.02); ctx.fill();
// chassis
ctx.fillStyle = '#22201f';
rrect(-0.44, -0.02, 0.88, 0.28, 0.06); ctx.fill();
// body
ctx.fillStyle = color;
rrect(-0.42, -0.26, 0.84, 0.32, 0.06); ctx.fill();
// roof
ctx.fillStyle = shade(color, -0.45);
rrect(-0.46, -0.36, 0.92, 0.12, 0.05); ctx.fill();
// windows
ctx.fillStyle = 'rgba(255,242,196,0.95)';
rrect(-0.27, -0.18, 0.18, 0.17, 0.03); ctx.fill();
rrect( 0.09, -0.18, 0.18, 0.17, 0.03); ctx.fill();
// stripe
ctx.fillStyle = 'rgba(0,0,0,0.20)';
rrect(-0.42, 0.03, 0.84, 0.05, 0.02); ctx.fill();
}
function drawPerson(x, y, sc, color){
ctx.save();
ctx.translate(x, y);
ctx.scale(sc, sc);
ctx.fillStyle = color;
rrect(-0.10, -0.10, 0.20, 0.28, 0.07); ctx.fill();
ctx.fillStyle = '#f2c9a0';
ctx.beginPath(); ctx.arc(0, -0.19, 0.085, 0, 6.2832); ctx.fill();
ctx.fillStyle = '#3a2f2a';
ctx.beginPath(); ctx.arc(0, -0.215, 0.088, Math.PI, 0); ctx.fill();
ctx.restore();
}
function drawPassenger(p, now){
const s = CELL;
ctx.save();
ctx.translate((p.x+0.5)*s, (p.y+0.5)*s);
ctx.scale(s, s);
// platform slab
ctx.fillStyle = '#cfc8b4';
rrect(-0.40, -0.40, 0.80, 0.80, 0.12); ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.28)';
ctx.lineWidth = 0.035;
ctx.stroke();
// platform texture lines
ctx.strokeStyle = 'rgba(120,112,96,0.35)';
ctx.lineWidth = 0.022;
ctx.beginPath();
ctx.moveTo(-0.34, -0.22); ctx.lineTo(0.34, -0.22);
ctx.moveTo(-0.34, -0.06); ctx.lineTo(0.34, -0.06);
ctx.stroke();
// yellow safety strip
ctx.fillStyle = '#f0c02f';
rrect(-0.40, 0.26, 0.80, 0.10, 0.03); ctx.fill();
// waiting people
const bob = Math.sin(now*0.005 + p.phase) * 0.035;
drawPerson(-0.15, -0.02 + bob, 0.92, p.colors[0]);
drawPerson( 0.15, 0.00 - bob, 0.80, p.colors[1]);
ctx.restore();
}
function drawTrain(){
for (let i=snake.length-1; i>=0; i--){
const seg = snake[i];
let ang;
if (i === 0){
ang = Math.atan2(dir.y, dir.x);
} else {
const prev = snake[i-1];
ang = Math.atan2(prev.y - seg.y, prev.x - seg.x);
}
ctx.save();
ctx.translate((seg.x+0.5)*CELL, (seg.y+0.5)*CELL);
ctx.rotate(ang);
ctx.scale(CELL, CELL);
if (i === 0) drawLocomotive();
else drawWagon(WAGON_COLORS[(i-1) % WAGON_COLORS.length], i);
ctx.restore();
}
}
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 += p.grav*dt;
p.life -= dt;
if (p.life <= 0) particles.splice(i,1);
}
}
function drawParticles(){
for (let i=0;i<particles.length;i++){
const p = particles[i];
const a = Math.max(0, p.life / p.maxLife);
ctx.globalAlpha = a;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * (0.35 + 0.65*a), 0, 6.2832);
ctx.fill();
}
ctx.globalAlpha = 1;
}
function draw(now){
ctx.clearRect(0,0,W,H);
// ground
const bg = ctx.createLinearGradient(0,0,0,H);
bg.addColorStop(0, '#1a2836');
bg.addColorStop(1, '#0c141c');
ctx.fillStyle = bg;
ctx.fillRect(0,0,W,H);
// grid
ctx.strokeStyle = 'rgba(255,255,255,0.035)';
ctx.lineWidth = 1;
ctx.beginPath();
for (let x=1;x<COLS;x++){ ctx.moveTo(x*CELL, 0); ctx.lineTo(x*CELL, H); }
for (let y=1;y<ROWS;y++){ ctx.moveTo(0, y*CELL); ctx.lineTo(W, y*CELL); }
ctx.stroke();
// subtle track sleepers
ctx.fillStyle = 'rgba(255,255,255,0.018)';
for (let y=0;y<ROWS;y++){
for (let x=0;x<COLS;x++){
if ((x+y) % 2 === 0) ctx.fillRect(x*CELL+CELL*0.36, y*CELL+CELL*0.36, CELL*0.28, CELL*0.28);
}
}
for (let i=0;i<passengers.length;i++) drawPassenger(passengers[i], now);
drawTrain();
drawParticles();
}
// ---------- loop ----------
function T(){ return performance.now(); }
function loop(now){
const dt = Math.min(120, (last ? now - last : 16));
last = now;
if (state === 'demo' || state === 'play'){
acc += dt;
let guard = 0;
while (acc >= stepMs && guard++ < 5){
acc -= stepMs;
step();
if (state === 'over') break;
}
} else if (state === 'over' && autoRestart && now >= restartAt){
reset();
state = 'demo';
stepMs = 120;
acc = 0;
}
updateParticles(dt);
emitSmoke(now);
draw(now);
requestAnimationFrame(loop);
}
// ---------- input ----------
function beginPlay(){
if (state === 'over') reset();
state = 'play';
stepMs = Math.max(72, 128 - score*2.2);
acc = 0;
overlay.classList.add('hidden');
updateHUD();
}
const KEYS = {
'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}
};
window.addEventListener('keydown', function(e){
const k = (e.key && e.key.length === 1) ? e.key.toLowerCase() : e.key;
const d = KEYS[k];
if (d) e.preventDefault();
if (state !== 'play'){
beginPlay();
if (d && !(d.x === -dir.x && d.y === -dir.y)) nextDir = d;
return;
}
if (d && !(d.x === -dir.x && d.y === -dir.y)) nextDir = d;
}, {passive:false});
overlay.addEventListener('click', beginPlay);
stage.addEventListener('pointerdown', function(e){
if (state !== 'play') beginPlay();
});
window.addEventListener('resize', function(){
const n = window.innerWidth < 680;
if (n !== narrow){
narrow = n;
chooseGrid();
resize();
reset();
state = 'demo';
stepMs = 120;
acc = 0;
} else {
resize();
}
});
// ---------- boot ----------
chooseGrid();
resize();
reset();
state = 'demo';
stepMs = 120;
overlay.classList.remove('hidden');
requestAnimationFrame(loop);
})();
</script>
</body>
</html>Reasoning trace (18,994 tokens)
Let me build a single-file HTML snake game where the snake is a train: locomotive head + colorful wagons, picking up passengers at platforms.
Design:
- Canvas-based grid game.
- Snake body segments: head is a locomotive (drawn with canvas shapes), wagons are colorful rectangles with wheels.
- Food: passengers waiting on the "platform" — small figures standing at the edge/on tiles. When the train reaches them, they board (wagon added), score increases.
- Self-playing demo mode with AI, with "Click or press a key to start" overlay.
- Responsive: canvas scales.
Let me think about grid: 24x16 cells maybe. Cell size computed. Canvas sized to fit viewport.
Let's implement:
State:
- COLS = 24, ROWS = 16
- cell size = min(available width/COLS, available height/ROWS)
- snake: array of {x,y}, head first.
- direction, nextDirection.
- passengers: array of {x,y, color, bob} — passengers spawn on random free cells; drawn as little people standing on a "platform" tile (a light gray tile with yellow edge line).
- Also maybe "station" tiles appear.
Rendering: draw ground (grass/track). Actually a train needs tracks. Draw rails on all cells? Might be busy. Let's draw subtle track ties on the ground grid, and the train rides on top. That looks nice.
Let me draw:
- Background: dark green/teal.
- Grid: subtle lines.
- Track: for each cell, draw two rails? Too much. Simple: draw the whole background as a "rail yard" with faint sleepers.
Actually simpler and prettier: draw the play field with a subtle checker pattern of grass tones, and draw track segments along the snake path? That's complex. Let's just draw faint diagonal stripes? Hmm.
Alternative: Draw track for every cell in the grid: horizontal sleeper lines. Let's do: each cell gets a small sleeper (dark brown rect) at center, with two thin gray rails horizontally. That would look like a rail grid. Could be OK — actually looks like a train yard. Let's do subtle low-opacity.
Hmm, but train moves in all directions, so grid rails both directions look like a mesh. Let's instead just use a plain ground with dark tiles and let the train be the star. Add faint dashed lines connecting grid.
I'll go with: dark blue-gray ground, subtle grid dots, and the train drawn with high detail: locomotive with boiler, cab, chimney, cowcatcher, headlight glow, wheels; wagons with roofs, windows, wheels, colored bodies, and coupling lines.
Rotation: the train is drawn along direction. For each segment, I know direction to next segment for the head (its heading) and for body segments the direction to the previous segment (toward head). Simplify: draw each segment as a rounded rect oriented along its direction. Draw the locomotive for index 0 with heading = direction; wagons oriented along the direction from that segment to the previous segment (i.e., toward the head).
Actually easier: compute for each segment i, a direction vector: for i=0 heading = current direction; for i>0, dir = normalize(seg[i-1] - seg[i]).
But when the snake wraps or turns, segments are adjacent so direction is axis-aligned. Good.
Draw everything rotated: ctx.save(); translate(center); rotate(angle); draw; restore.
Angles: right=0, down=PI/2, left=PI, up=-PI/2.
Let's make the drawing of a locomotive facing right in local coords with cell size s: body from -0.5s to 0.5s.
Locomotive:
- Wheels: 3 dark circles along bottom at y=+0.28s, radius 0.13s.
- Body: rounded rect from -0.45s to 0.42s, y -0.05s to 0.25s, color red/darkred.
- Boiler: cylinder from -0.45 to 0.05, y -0.2 to 0.05.
- Cab: rect at back (left) x -0.45..-0.15, y -0.35..0.05, color.
- Chimney: rect at x 0.12..0.28, y -0.42..-0.2.
- Cowcatcher: triangle at front.
- Headlight: small yellow circle at front with glow.
Hmm this is getting detailed. Let's keep it moderately simple but charming.
Let me define drawing in local space where the cell is 1x1 centered at 0,0, and I scale by s.
Locomotive (facing +x):
```
// wheels
wheels at (-0.25, 0.32), (0.05,0.32), (0.3,0.32) r=0.11
// chassis
roundRect(-0.5,-0.05, 1.0, 0.4, 0.08) fill #b23a2e (dark red)
// boiler
roundRect(-0.35,-0.28, 0.75, 0.3, 0.12) fill #e04b3a, plus highlight
// cab
roundRect(-0.5,-0.42, 0.35, 0.5, 0.08) fill #8f2c22
// cab window
rect(-0.44,-0.34,0.23,0.16) fill #ffe9a8
// chimney
rect(0.16,-0.5,0.16,0.2) fill #3a2b28 -> hmm overlapping
// dome
```
Let me just do coordinates carefully. Cell 1x1: x from -0.5 to 0.5, y from -0.5 to 0.5.
Front is +x (right). So locomotive front at x=0.5, back at x=-0.5.
- Cab (rear): x from -0.48 to -0.12, y from -0.30 to 0.28. Fill dark red #a83a2c.
- Cab window: x -0.42..-0.18, y -0.22..-0.04, yellow.
- Boiler: x -0.12..0.34, y -0.15..0.22, rounded. Fill red #e05545.
- Boiler front plate: x 0.34..0.44, y -0.18..0.25, fill #8f2c22.
- Chimney: x 0.16..0.32, y -0.36..-0.14, fill #4a3a35, with a wider top.
- Cowcatcher: triangle from (0.44,0.1) to (0.5,0.32) to (0.3,0.32). Actually keep it: path (0.42,0.05) -> (0.52,0.33) -> (0.3,0.33) fill dark gray.
- Wheels: circles at (-0.28, 0.3) r 0.15, (0.05,0.3) r 0.15, (0.32,0.32) r 0.1. Dark #2b2320 with lighter hub.
- Headlight: circle at (0.42,-0.02) r 0.07 yellow, glow.
Hmm, coordinate y down positive. So "above" is negative y. That's fine.
Wagon (facing +x), color c:
- Body: roundRect(-0.42,-0.22, 0.84, 0.42, 0.08) fill lighten(c).
- Roof: roundRect(-0.46,-0.34,0.92,0.14,0.06) fill darken(c).
- Windows: two small rects at y -0.12..0.02, x -0.28..-0.12 and 0.12..0.28? Wait windows inside body. Body y from -0.22 to 0.2. Windows y -0.14..-0.0.
- Wheels: circles at (-0.22,0.28) r 0.12 and (0.22,0.28) r 0.12.
- Coupling: small rect at x -0.5..-0.42, y 0.05..0.12 gray.
Passengers: little people standing on a platform tile. Draw platform tile: rounded rect light gray/tan with a yellow safety line. Then two little figures with round head and body, bobbing.
Actually "waiting on the platform" — draw a small platform (a tile with a light concrete color and a yellow strip) and 1-3 tiny people. That's the food.
When eaten: score += 1 (or passengers count), snake grows, new passengers spawn.
Also maybe show a "+1" particle.
Game loop: use time-based movement with speed, e.g., move every 130ms.
Self-play AI: simple BFS to nearest passenger avoiding body (excluding tail if not growing). Use BFS on grid; if no path, pick any safe move. Should be decent.
To avoid wrapping complexity: walls kill. The AI must avoid walls. Let's use walls (classic). Actually with walls, BFS is straightforward.
Hmm, but AI could get long snake and get stuck. Fine, it will die eventually; then respawn automatically in demo mode. Let's handle game over: in demo mode, auto restart after a moment.
Let me make the AI reasonably good: BFS to food; also, after computing path, check that the move doesn't immediately trap. Keep simple; also add a fallback to follow tail if no path to food.
Let's implement:
```js
function aiChoose(){
const head = snake[0];
// BFS from head
const goals = passengers.map(p=>p.x+','+p.y);
...
}
```
Simpler BFS: dist grid from head over free cells (free = not body except tail, in bounds). Then choose neighbor with min dist to nearest passenger, and among those prefer the one that keeps most free space? Let's do BFS from head; find nearest passenger reachable; then step. To avoid self-trapping, after choosing next cell, simulate and check flood fill reachable area >= snake length. That's a nice safety check.
Let's implement:
- occupancy set of snake cells (all except tail will move away; treat tail as free since it moves, unless growing).
- For each candidate direction (not reverse, not into wall, not into body-except-tail), simulate: newSnake = [newHead, ...snake.slice(0,-1)] (if not growing) — actually growth happens when eating.
- Compute BFS distance from newHead to nearest passenger. If reachable, score = -dist... then also compute free space via flood fill; require space >= snake length*? Let's just pick the candidate with a path to food and max free space, tie-break by shortest distance.
That's a bit heavy but grid is 24x16=384 cells, fine.
Let's do:
```
candidates = safe moves
best = null
for c of candidates:
simulate new head
space = floodFillSize(newHead, blockedSet)
if space < snake.length*0.6 -> skip (danger)
dist = bfsDist(newHead, food)
value = (dist===Infinity? 1000 : dist) - space*0.05
pick min value
```
Hmm. Let's keep it simpler: score = dist if finite else 999; tie-break by larger space.
Fine.
Now rendering & layout.
HTML structure:
- full-screen flex center, dark background with gradient.
- A container with header (title, score) and canvas.
- Overlay div with "CLICK OR PRESS A KEY TO START".
Canvas sizing: compute available size, set cell = floor(min((w)/COLS, h/ROWS)), canvas width = cell*COLS, height = cell*ROWS. Use devicePixelRatio for crispness.
Let's set: COLS=22, ROWS=15? aspect 22/15 ≈ 1.47. Fairly wide. Let's use COLS=24, ROWS=16 → 1.5.
Layout: header ~80px, canvas area max height = viewportHeight - 140. On 1280x800: available height ~ 800-140 = 660, width ~ 1280-60 = 1220. cell = min(1220/24=50.8, 660/16=41.25) = 41. Canvas 984x660. Good.
At 375 wide: header smaller. available width ~ 345, height maybe 800-140=660. cell = min(345/24=14.4, 660/16=41) = 14. Canvas 345x230. Small but ok. Maybe reduce COLS on small screens? Keep it simple — it's fine, but tiny. Could set COLS/ROWS dynamically based on width: if window.innerWidth < 600, use COLS=16, ROWS=16. Hmm, that changes game logic. Let's just accept smaller cells; but drawing details at 14px will be mush. Acceptable.
Actually, better: on narrow screens, keep aspect but the canvas gets small in height. Let's make grid 20x20 for a more square aspect? Then on 1280x800: cell=min(1220/20=61, 660/20=33)=33 → 660x660. That's fine but leaves side space. Eh.
Let's use COLS=24, ROWS=16 and center. Fine.
Alternatively dynamically choose grid: mobile 14x20? Too complex. Keep 24x16.
Hmm, on mobile portrait 375x800 the canvas would be 345x230 — quite small with lots of empty space. Let's make the game area flexible: compute cell = floor(min(availW/COLS, availH/ROWS)) and it's fine.
Better idea: make the aspect adapt by choosing ROWS based on available aspect. Nah.
Let me just do it. Add responsive: on small screens use COLS=16, ROWS=20 (portrait) and cell larger. Actually the game logic doesn't care about grid dims as long as they're set at init. But changing on resize mid-game is messy. I'll set grid at load based on window size, and only recompute cell size on resize.
Let's do: if (window.innerWidth < 640) { COLS=14; ROWS=18 } else { COLS=24; ROWS=16 }.
375 wide: availW ~ 345, availH ~ 800-150=650. cell = min(345/14=24.6, 650/18=36) = 24. Canvas 336x432. Better.
Good.
Now the drawing of ground: fill with a dark color, draw a subtle grid, and maybe draw track rails under the snake? Let's draw faint train-track pattern: for each cell draw a small dark sleeper. Might look nice with low alpha.
I'll draw: background gradient from #14202b to #0e1720. Then grid lines at alpha 0.06. Then done.
Passenger platform rendering:
- Draw a rounded rect covering ~0.9 of the cell, fill #c9c2b0 (concrete), with a yellow safety stripe along one edge, plus 1-2 little people. But people on a small cell... Let's draw a single person per food tile plus maybe a second smaller. Actually let's make the food a "station" with 2 people.
Person: head circle r=0.09 at (x, -0.05), body rounded rect. Colors random.
Let's do: platform tile (light) with people drawn on top, bobbing with sin(time).
Actually to make it more readable, draw the platform as a rounded rect of size 0.86 with a darker border, and 2 passengers.
OK.
Also add a subtle glow/pulse around food to attract.
Let's write the code.
```js
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
let COLS, ROWS, CELL;
function setupGrid(){
if (window.innerWidth < 640) { COLS=14; ROWS=18; } else { COLS=24; ROWS=16; }
}
```
Careful: grid change only at init/resize — if resize changes grid, reset game. I'll only change grid on load; on resize just recompute CELL. Hmm but going from desktop to mobile width... it's a demo, just recompute cell with fixed grid. Actually let's compute grid on load and on resize if the breakpoint changes, restart game. Simple enough: track `isNarrow`.
Let's just do: on resize, if narrow-status changed, re-init the whole game. Otherwise just resize canvas.
Game state:
```js
let snake, dir, nextDir, passengers, score, best, state ('demo'|'play'|'over'), moveTimer, stepMs, particles, trains...
```
Snake init: length 3, horizontal in middle, dir right.
Passenger spawn: random free cell not on snake.
Let's write movement:
```js
function step(){
if (state === 'over') return;
// apply nextDir
dir = nextDir;
const head = snake[0];
const nx = head.x + dir.x, ny = head.y + dir.y;
if (out of bounds || hits body (excluding tail if not growing)) -> gameOver
...
}
```
Hits body: check snake.slice(0, snake.length-1) unless growing (i.e., next cell has food). Actually standard: the tail moves away unless we eat. So collision with snake[snake.length-1] is OK if not eating.
Then unshift new head. If new head on a passenger: score++, remove passenger, spawn new, don't pop tail, add particles. Else pop tail.
Also speed increases slightly with score.
Rendering loop with requestAnimationFrame, accumulate time.
Let's write draw():
```js
function draw(t){
ctx.clearRect...
drawGround();
drawPassengers(t);
drawTrain(t);
drawParticles();
}
```
Order: draw train above passengers? Passengers are on platform tiles the train drives over. Train on top is fine.
Train drawing: iterate snake from tail to head so head on top.
For each segment i:
- pos center = (x+0.5)*CELL, (y+0.5)*CELL
- angle: for i=0 use dir; else direction from segment i to i-1.
Wait: the wagon should point toward the head (the segment in front). dirVec = seg[i-1] - seg[i]. Yes.
Since segments are adjacent, that vector is unit axis-aligned.
Compute angle = atan2(dy, dx).
Then ctx.save(); translate(cx,cy); rotate(angle); scale(CELL/ ... ) — I'll draw in unit space with scale(CELL, CELL) then coordinates in -0.5..0.5. But line widths then scale too — fine, use proportionally small widths.
Careful with rotate + non-uniform... uniform so fine.
Let's write drawLocomotive(ctx) drawing in unit space, and drawWagon(ctx, colorIndex).
Also coupling between wagons: draw a small bar connecting at the rear. Since each wagon is drawn separately, a coupling at x=-0.5 of each wagon pointing backwards... the gap between cells is zero (cells adjacent), so segments touch. Add connector rect from -0.5 to -0.42. Fine.
Colorful wagons: palette of colors, wagon i uses palette[i % palette.length].
Add subtle animation: wheels rotate — draw spokes with angle based on time. Nice touch.
Let's write:
```js
function wheel(x,y,r,spin){
ctx.fillStyle='#2a2320'; ctx.beginPath(); ctx.arc(x,y,r,0,7); ctx.fill();
ctx.fillStyle='#6b625c'; ctx.beginPath(); ctx.arc(x,y,r*0.55,0,7); ctx.fill();
ctx.strokeStyle='#2a2320'; ctx.lineWidth=r*0.18;
ctx.beginPath(); ctx.moveTo(x-Math.cos(spin)*r*0.5, y-Math.sin(spin)*r*0.5); ctx.lineTo(x+Math.cos(spin)*r*0.5,y+Math.sin(spin)*r*0.5); ctx.stroke();
}
```
Fine.
Now the AI.
```js
function aiStep(){
const head = snake[0];
const bodySet = new Set(); // key x,y
for (let i=0;i<snake.length-1;i++) bodySet.add(key(snake[i])); // exclude tail (it moves)
const dirs = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
let best=null, bestScore=Infinity;
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 (bodySet.has(key(nx,ny))) continue;
// distance to nearest passenger via BFS from (nx,ny)
const dist = bfsDist(nx,ny,bodySet);
const space = floodSize(nx,ny,bodySet);
let s;
if (dist===Infinity) s = 10000 - space; // prefer more space if no food reachable
else s = dist*10 - Math.min(space, 60)*1;
// penalize tight spaces
if (space < snake.length + 3) s += 5000;
if (s < bestScore){ bestScore=s; best=d; }
}
if (best) nextDir = best;
}
```
bfsDist: BFS from start over free cells (not in bodySet, in bounds), find min dist to any passenger.
floodSize: count reachable free cells.
These are called 4 times per step; grid 384 cells → fine.
Hmm, careful: `space < snake.length+3` — the tail cell is free but occupied physically. Flood counts it. Fine approximately.
Edge case: no best → keep going (will die).
Let's also make the AI not too good — it's fine either way.
Game over in demo: after 700ms, restart demo.
Now overlay: show when state==='demo' && !started. Actually rules say: games must start in self-playing demo mode behind a "Click or press a key to start" message. So the demo AI plays behind a translucent overlay with the message. On click/keypress, hide overlay and give control to the player.
I'll keep overlay semi-transparent so the game is visible.
Layout HTML:
```html
<div class="wrap">
<header>
<h1>🚂 Passenger Express</h1>
<div class="stats">Score / Best / Passengers</div>
</header>
<div class="stage">
<canvas id="game"></canvas>
<div id="overlay" class="overlay">
<div class="card">
<div class="big">Click or press a key to start</div>
<div class="sub">Arrow keys / WASD to drive the train</div>
</div>
</div>
</div>
<div class="hint">Pick up passengers waiting at the platform platforms</div>
</div>
```
Overlay absolutely positioned over canvas — need stage to size to canvas. Use position:relative on .stage with inline-block and canvas display:block.
Responsive: canvas resized via JS.
Let's write the resize function:
```js
function resize(){
const stage = document.querySelector('.stage');
const maxW = stage.parentElement.clientWidth; // wrap
...
}
```
Simpler: compute from window:
```
const availW = Math.min(window.innerWidth - 40, 1280);
const availH = window.innerHeight - (window.innerWidth<640? 150 : 170);
const cell = Math.max(6, Math.floor(Math.min(availW/COLS, availH/ROWS)));
CELL = cell;
const w = cell*COLS, h = cell*ROWS;
const dpr = Math.min(2, window.devicePixelRatio||1);
canvas.width = w*dpr; canvas.height = h*dpr;
canvas.style.width = w+'px'; canvas.style.height = h+'px';
ctx.setTransform(dpr,0,0,dpr,0,0);
```
Then draw in CSS pixel space.
Careful: ctx.setTransform resets each resize; also in draw I use ctx.save/restore but the base transform stays.
Header height: h1 ~ 26px + padding. Let's measure: wrap padding 16, header height ~ 56, hint ~ 24, gaps. So availH = window.innerHeight - 130 maybe. Let's use a dynamic measurement: use stage's parent clientHeight? Since stage is sized by canvas, that's circular.
Let's just compute:
```
const headEl = document.querySelector('header');
const hintEl = document.querySelector('.hint');
const usedH = headEl.offsetHeight + hintEl.offsetHeight + 60;
```
Hmm offsetHeight of header might be affected. Let's do it: after layout, compute available = window.innerHeight - header.offsetHeight - hint.offsetHeight - 56 (paddings/gaps). That's reasonable.
Let me structure the wrap with flex column and let the stage flex:1. Then measure stage's clientWidth/clientHeight... but stage contains canvas which is absolute? If canvas is absolutely positioned inside stage, stage can be flex:1 and we can measure it. But then the canvas needs centering: use flex center on stage with position relative... Let's make .stage { flex:1; display:flex; align-items:center; justify-content:center; position:relative; min-height:0; } and the canvas inside as normal element; overlay absolute inset 0 with pointer-events none? Overlay sizing to stage, fine, centered content.
But if stage has flex:1 and canvas is inside, stage's clientHeight is determined by flex, good. And its width by container. Then measure and set cell.
But careful: setting canvas size could change stage size? No, because stage is flex:1 with min-height:0 and overflow hidden, and canvas won't force growth if we cap by stage's dimensions measured before. To be safe, set canvas style width/height, and stage overflow hidden.
Let's do that.
```js
function resize(){
const r = stage.getBoundingClientRect();
const availW = r.width - 8;
const availH = r.height - 8;
const cell = Math.max(6, Math.floor(Math.min(availW/COLS, availH/ROWS)));
...
}
```
Good.
Now, careful: at first load, the fonts etc. Fine.
Let's write drawGround with grid.
Also draw a border/frame around canvas area via CSS box-shadow.
Now particles: on eat, spawn ~10 small particles (stars/sparkles) with velocity, life.
Also "+1" floating text.
Let me now write the whole thing.
```js
const COLOR_WAGONS = ['#f2b134','#4ecdc4','#ff6b6b','#a06cd5','#5cb85c','#f78fb3','#6ec6ff'];
```
Locomotive color: fixed dark red/black.
Let me write drawLocomotive(c) where c = {x,y} sizes in unit.
```js
function drawLocomotive(){
// wheels
const spin = T * 0.02;
wheel(-0.26, 0.30, 0.135, spin);
wheel(0.04, 0.30, 0.135, spin);
wheel(0.32, 0.31, 0.10, spin);
// cowcatcher
ctx.fillStyle = '#3a3330';
ctx.beginPath();
ctx.moveTo(0.34, 0.10); ctx.lineTo(0.52, 0.34); ctx.lineTo(0.22, 0.34); ctx.closePath(); ctx.fill();
// body chassis
ctx.fillStyle = '#1f1a18';
rrect(-0.46, -0.02, 0.94, 0.34, 0.09); ctx.fill();
// boiler
const g = ctx.createLinearGradient(0,-0.2,0,0.2);
g.addColorStop(0,'#e8503c'); g.addColorStop(1,'#a8291c');
ctx.fillStyle = g;
rrect(-0.14, -0.20, 0.50, 0.38, 0.15); ctx.fill();
// boiler front band
ctx.fillStyle = '#2b2320';
rrect(0.28, -0.18, 0.10, 0.34, 0.04); ctx.fill();
// headlight
ctx.fillStyle = '#ffe9a3';
ctx.beginPath(); ctx.arc(0.39, -0.02, 0.075, 0, 7); ctx.fill();
ctx.fillStyle='rgba(255,233,163,0.25)';
ctx.beginPath(); ctx.arc(0.39,-0.02,0.16,0,7); ctx.fill();
// chimney
ctx.fillStyle = '#3b322e';
rrect(0.10, -0.38, 0.15, 0.20, 0.03); ctx.fill();
rrect(0.07, -0.42, 0.21, 0.08, 0.03); ctx.fill();
// cab
ctx.fillStyle = '#8f2c22';
rrect(-0.46, -0.40, 0.34, 0.56, 0.07); ctx.fill();
// cab roof
ctx.fillStyle = '#2b2320';
rrect(-0.50, -0.46, 0.42, 0.09, 0.04); ctx.fill();
// cab window
ctx.fillStyle = '#ffe9a3';
rrect(-0.42, -0.30, 0.24, 0.18, 0.04); ctx.fill();
}
```
Hmm the cowcatcher at y 0.34 exceeds 0.5? no fine.
Wheels at y=0.30 with r=0.135 → bottom at 0.435. OK within cell.
Wagon:
```js
function drawWagon(color, idx){
const spin = T*0.02 + idx;
wheel(-0.20, 0.30, 0.12, spin);
wheel(0.20, 0.30, 0.12, spin);
// coupling
ctx.fillStyle = '#3a3330';
rrect(-0.52, 0.02, 0.10, 0.09, 0.02); ctx.fill();
// body
ctx.fillStyle = color;
rrect(-0.44, -0.20, 0.88, 0.42, 0.07); ctx.fill();
// roof
ctx.fillStyle = shade(color,-0.4);
rrect(-0.48, -0.32, 0.96, 0.14, 0.05); ctx.fill();
// windows
ctx.fillStyle = 'rgba(255,240,190,0.92)';
rrect(-0.28, -0.12, 0.18, 0.18, 0.03); ctx.fill();
rrect(0.10, -0.12, 0.18, 0.18, 0.03); ctx.fill();
// stripe
ctx.fillStyle='rgba(0,0,0,0.18)';
rrect(-0.44, 0.10, 0.88, 0.06, 0.02); ctx.fill();
}
```
Need shade function for hex colors.
```js
function shade(hex, amt){ // amt -1..1
const n = parseInt(hex.slice(1),16);
let r=(n>>16)&255, g=(n>>8)&255, b=n&255;
const f = v => Math.max(0, Math.min(255, Math.round(amt<0 ? v*(1+amt) : v + (255-v)*amt)));
return `rgb(${f(r)},${f(g)},${f(b)})`;
}
```
rrect helper: builds path.
```js
function rrect(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();
}
```
Note in unit space r values like 0.07 — fine.
Passenger drawing: platform + people.
```js
function drawPassenger(p, t){
const cx = (p.x+0.5)*CELL, cy=(p.y+0.5)*CELL;
const s = CELL;
const bob = Math.sin(t*0.005 + p.phase)*s*0.04;
ctx.save();
ctx.translate(cx, cy);
// platform
ctx.fillStyle = '#cfc9b8';
rrectScaled(-0.44,-0.44,0.88,0.88,0.14); // rounded
...
}
```
Hmm the ctx is in pixel space here, so use direct numbers multiplied by s. Let me write with s multiplier manually or use ctx.scale(s,s) then use unit coords. Simpler: ctx.translate(cx,cy); ctx.scale(s,s); then draw in unit space. But then shadowBlur etc scale. Fine.
Let's do that: scale by s and draw unit coordinates.
Platform: rrect(-0.42,-0.42,0.84,0.84,0.12) fill '#d8d2c0', stroke '#b3ab97' lineWidth 0.05 (units → 0.05*s px). Hmm lineWidth in unit space of 0.05 = 2px at s=40. Good.
Yellow safety line: rrect(-0.42, 0.28, 0.84, 0.10, 0.03) fill '#f2c744'.
People: 2 figures. Person at (dx, dy):
- head circle r 0.08 at (0, -0.18)
- body: rrect(-0.09, -0.10, 0.18, 0.26, 0.06)
With bob applied.
People at x=-0.14 and x=0.12, y baseline 0.18.
Colors random from palette.
Also add a pulsing ring? Maybe subtle. Let's add a soft glow behind: ctx.fillStyle='rgba(255,220,120,0.10)' circle radius 0.5 pulsing. Meh — the platform is enough.
Let's make sure passengers are visible: platform light on dark bg — good contrast.
Particles: array of {x,y,vx,vy,life,color}. Draw in pixel space.
Now the main loop:
```js
let last = performance.now(), acc = 0;
function loop(now){
const dt = Math.min(100, now-last); last=now;
T = now;
if (state !== 'paused'){
acc += dt;
if (acc >= stepMs){ acc = 0; step(); }
}
draw(now);
requestAnimationFrame(loop);
}
```
Wait, variable step timing: use while(acc>=stepMs){acc-=stepMs; step();} to avoid drift. Good.
step() logic:
```js
function step(){
if (state==='over'){ overTimer -= stepMs; if(overTimer<=0) reset(); return; }
if (state==='demo') aiChoose();
dir = nextDir;
const head = snake[0];
const nx = head.x+dir.x, ny = head.y+dir.y;
if (nx<0||nx>=COLS||ny<0||ny>=ROWS){ die(); return; }
const eating = passengers.some(p=>p.x===nx&&p.y===ny);
const limit = eating ? snake.length : snake.length-1;
for (let i=0;i<limit;i++){ if (snake[i].x===nx&&snake[i].y===ny){ die(); return; } }
snake.unshift({x:nx,y:ny});
if (eating){
passengers = passengers.filter(p=>!(p.x===nx&&p.y===ny));
score++;
spawnPassenger();
burst(nx,ny);
stepMs = Math.max(70, 130 - score*2);
} else {
snake.pop();
}
}
```
Hmm careful: `limit = eating ? snake.length : snake.length-1` — checking against snake[0..limit-1]. If eating we check all including tail (since tail won't move). Correct.
Wait: but the new head position could equal the tail when not eating — allowed. limit = length-1 excludes index length-1 (the tail). Correct.
die():
```js
function die(){
state='over'; overTimer = 900;
// explosion particles
for(...) burst at head with gray smoke
}
```
In demo mode auto-reset. In play mode show "Game Over — click to restart"? Let's just auto restart too but show a flash. Simpler: on death, if state was 'play', set state='over', show overlay "Game over — press key to restart"? Hmm overlay is the start screen. Let's reuse: show overlay with message "Game Over — Click or press a key to restart". On click, reset and start playing.
But we want the demo to keep going. In demo mode, auto restart after 900ms.
Let me implement:
```js
function die(){
if (state==='over') return;
const wasDemo = state==='demo';
state='over';
overTimer = wasDemo ? 800 : 999999;
smoke();
if (!wasDemo) showOverlay('Game Over','Click or press a key to play again');
}
```
And in step when state==='over': if overTimer<=99999 countdown, and when reaches 0, reset to demo.
Let's define: `respawnAt` timestamp. In loop: if state==='over' && wasDemo && now>respawnAt → reset(true).
Simpler:
```js
let restartAt = 0;
function die(){ ... state='over'; restartAt = performance.now() + 900; if(state was play) showOverlay(...) }
```
But in play mode we want to wait for user input. So only set restartAt if demo.
I'll track `state` values: 'demo', 'play', 'over'. If 'over' and autoRestart true → restart to demo.
Let's write:
```js
function die(){
const demo = (state === 'demo');
state = 'over';
autoRestart = demo;
restartAt = performance.now() + 1000;
smokeBurst();
if (!demo){
overlay.classList.remove('hidden');
overlayTitle.textContent = 'Game Over';
overlaySub.textContent = 'Click or press a key to play again';
}
}
```
And in loop: if (state==='over' && autoRestart && performance.now()>restartAt) { reset(); startDemo(); }
reset() sets up snake, passengers, score; sets state.
startDemo(): state='demo'; stepMs=120.
startPlay(): state='play'; stepMs=130; overlay hidden.
On overlay click / key: if state==='demo' or 'over' (not autoRestart) → reset + startPlay.
Actually if state is 'over' with autoRestart (demo died), user click should start play. Let's handle: any click/key while overlay visible → reset & startPlay.
Overlay is visible during demo (with start message) and during play-over. Handle both.
Key handling: arrow keys, WASD. Also prevent scrolling.
But in play mode, keyboard events only after click — the overlay click handles it.
Also handle keydown when overlay visible: start play and also apply the direction? Just start.
Now, careful: while demo, overlay covers canvas — but pointer-events on overlay for click. Good.
Let me also add: while playing, the overlay hidden.
Now `reset()`:
```js
function reset(){
snake = [];
const cy = Math.floor(ROWS/2);
const cx = Math.floor(COLS/2);
for (let i=0;i<3;i++) snake.push({x: cx-i, y: cy});
dir = {x:1,y:0}; nextDir={x:1,y:0};
passengers = [];
score = 0;
particles = [];
stepMs = 130;
for (let i=0;i<2;i++) spawnPassenger();
spawnPassenger();
}
```
Wait — reset sets stepMs; startPlay sets it again. Fine.
spawnPassenger: pick random free cell (not on snake, not on another passenger). Also avoid spawning right next to head? Nah.
```js
function spawnPassenger(){
const free = [];
for (let y=0;y<ROWS;y++) for (let x=0;x<COLS;x++){
if (snake.some(s=>s.x===x&&s.y===y)) continue;
if (passengers.some(p=>p.x===x&&p.y===y)) continue;
free.push({x,y});
}
if (!free.length) return;
const c = free[Math.floor(Math.random()*free.length)];
passengers.push({x:c.x, y:c.y, phase: Math.random()*10, people: [randColor(), randColor()]});
}
```
Keep max 2-3 passengers.
Now drawTrain:
```js
function drawTrain(t){
for (let i=snake.length-1;i>=0;i--){
const seg = snake[i];
let ang;
if (i===0) ang = Math.atan2(dir.y, dir.x);
else {
const prev = snake[i-1];
ang = Math.atan2(prev.y-seg.y, prev.x-seg.x);
}
ctx.save();
ctx.translate((seg.x+0.5)*CELL, (seg.y+0.5)*CELL);
ctx.rotate(ang);
ctx.scale(CELL, CELL);
if (i===0) drawLocomotive(); else drawWagon(COLOR_WAGONS[(i-1)%COLOR_WAGONS.length], i);
ctx.restore();
}
}
```
Hmm: rotating means the train cars will be rotated 90° when moving vertically, which looks a bit odd but is a common style (like a top-down train). Actually for a top-down train game, it's fine and looks good.
Wait: rotating a side-view locomotive to face down means it appears rotated — top-down view of a train viewed from side rotated... It's stylistically fine (many games do this). Yes.
Alternatively always draw side view and flip horizontally. That's uglier for vertical movement. I'll go with rotation.
One issue: with rotation, the wheels at bottom rotate too. Fine.
Now the shadow: add a subtle shadow under each car. Skip.
Let's add glow at headlight: use ctx.shadowBlur? In scaled space that'd be weird. Skip, use a radial gradient circle instead.
Now let's write draw() fully with clearing.
Ground:
```js
const g = ctx.createLinearGradient(0,0,0,h);
g.addColorStop(0,'#16212b'); g.addColorStop(1,'#0d151c');
ctx.fillStyle=g; ctx.fillRect(0,0,w,h);
// grid
ctx.strokeStyle='rgba(255,255,255,0.035)'; ctx.lineWidth=1;
for x... vertical lines at CELL multiples
```
Plus small dots at cell corners maybe.
Also add a subtle "track" hint: draw dashed line down the middle of each row? Nah.
Let's add vignette? skip.
Let's write it all now.
Score display: update DOM in draw or on change. Use a function updateHUD().
HUD: Score (passengers boarded), Best (max in session), Length.
Let's write the HTML/CSS.
```css
* { box-sizing: border-box; }
html,body { height:100%; margin:0; }
body {
background: radial-gradient(circle at 50% 0%, #1d2b3a, #0a1017 70%);
color: #e8eef4;
font-family: ui-rounded, "Segoe UI", system-ui, sans-serif;
display:flex; align-items:center; justify-content:center;
overflow:hidden;
}
.wrap { width:100%; height:100%; display:flex; flex-direction:column; padding:10px 14px 8px; gap:8px; max-width: 1400px; margin:0 auto;}
header { display:flex; align-items:center; justify-content:space-between; gap:12px; flex:0 0 auto;}
h1 { font-size: clamp(15px, 2.2vw, 22px); margin:0; letter-spacing:.5px; font-weight:800;
background: linear-gradient(90deg,#ffd166,#ff8c42,#ff5d5d); -webkit-background-clip:text; background-clip:text; color:transparent;}
.stats { display:flex; gap:8px; }
.stat { background: rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.09); border-radius:10px; padding:4px 10px; font-size: clamp(10px,1.4vw,13px); ...}
```
Keep it clean.
.stage { flex:1 1 auto; min-height:0; position:relative; display:flex; align-items:center; justify-content:center; }
canvas { display:block; border-radius:14px; box-shadow: 0 0 0 2px rgba(255,255,255,.07), 0 18px 50px rgba(0,0,0,.55); }
.overlay { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; border-radius:14px; background: rgba(8,14,20,.55); backdrop-filter: blur(2px); cursor:pointer; transition: opacity .2s; }
.overlay.hidden { opacity:0; pointer-events:none; }
.card { text-align:center; padding:18px 26px; border-radius:16px; background: rgba(16,26,36,.85); border:1px solid rgba(255,255,255,.12); box-shadow:0 12px 40px rgba(0,0,0,.5); }
.card .big { font-size: clamp(14px, 2vw, 20px); font-weight:800; }
.card .sub { font-size: clamp(10px,1.4vw,13px); opacity:.7; margin-top:6px;}
```
Add a blinking animation on the big text.
Hint line at bottom with small text.
Now the JS. Careful with overlay hiding: use classList.
Let's finalize code.
Also add `passengers` max — spawn 2 at start? Let's have 2-3 waiting. I'll spawn 3 at start and after eating respawn to keep 3? Or keep count constant at 3. Hmm, with 3 the AI has choices. Let's keep constant count = 3.
Actually the classic: one food. With a train picking up passengers, having 2-3 platforms is nicer. Let's keep 3.
When eating, remove that one and spawn one → always 3.
Score = passengers picked up.
Now the AI with 3 targets — BFS finds nearest. Good.
Let me write bfsDist over the grid with a Uint16Array or plain array.
```js
const DIRS = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
function bfsDist(sx, sy, blocked){
const dist = new Int16Array(COLS*ROWS).fill(-1);
const q = [sx+sy*COLS];
dist[sx+sy*COLS]=0;
let qi=0;
let bestD = Infinity;
const targets = new Set(passengers.map(p=>p.x+p.y*COLS));
while(qi<q.length){
const cur=q[qi++];
const d = dist[cur];
if (targets.has(cur)) { return d; } // BFS ensures first found is nearest
const cx = cur%COLS, cy=(cur-cx)/COLS;
for (const dd of DIRS){
const nx=cx+dd.x, ny=cy+dd.y;
if (nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
const ni=nx+ny*COLS;
if (dist[ni]!==-1) continue;
if (blocked.has(ni)) continue;
dist[ni]=d+1; q.push(ni);
}
}
return Infinity;
}
```
Wait: if the start cell is a target... it's the new head after moving, could be a passenger cell — then dist 0. Good, but we return only when popping. The start is pushed, so d=0 and check targets → returns 0. Good.
Int16Array fill(-1) works.
floodSize similar but counts all reachable.
```js
function floodSize(sx,sy,blocked){
const seen = new Uint8Array(COLS*ROWS);
const q=[sx+sy*COLS]; seen[sx+sy*COLS]=1;
let qi=0,count=0;
while(qi<q.length){
const cur=q[qi++]; count++;
const cx=cur%COLS, cy=(cur-cx)/COLS;
for(const dd of DIRS){
const nx=cx+dd.x, ny=cy+dd.y;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
const ni=nx+ny*COLS;
if(seen[ni]||blocked.has(ni)) continue;
seen[ni]=1; q.push(ni);
}
}
return count;
}
```
Note: if the new head cell itself is blocked (it's not, since we filtered), fine.
blocked = Set of snake body indices excluding the tail (since it moves). For the simulation we should also exclude the tail because after moving, the tail moves away. Good enough.
aiChoose:
```js
function aiChoose(){
const head = snake[0];
const blocked = new Set();
for (let i=0;i<snake.length-1;i++) blocked.add(snake[i].x+snake[i].y*COLS);
// don't enter the current tail? it will move, so it's free
let best=null, bestScore=Infinity;
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;
const ni = nx+ny*COLS;
if (blocked.has(ni)) continue;
const space = floodSize(nx,ny,blocked);
if (space < Math.min(snake.length+2, COLS*ROWS*0.5)) continue; // would trap
const dist = bfsDist(nx,ny,blocked);
let s = (dist===Infinity ? 1000 : dist*10) - Math.min(space,80);
if (s<bestScore){bestScore=s;best=d;}
}
if (best) nextDir = best;
}
```
Hmm, `space < snake.length+2` — when the snake is long, this could exclude everything. Fallback: if best is null after the first pass, do a second pass without the space check. Let's implement with a fallback loop.
Also careful with the BFS blocked set: the tail cell should be free, and we excluded it. But the new head might be the tail cell — no, we excluded tail from blocked so it's allowed. Good.
Wait, one problem: excluding the tail from blocked means BFS may route through the tail cell which is fine.
Let's add fallback:
```js
function aiChoose(){
const candidates = ...;
let best = pick(true) || pick(false);
}
```
I'll write it as a function with a strict flag.
Edge case: when the snake is a baby (length 3), OK.
Now let's think about the initial demo. state='demo' at load, and the overlay is shown. So the AI plays behind the overlay. Good.
Performance: aiChoose each step (~8 steps/sec) with 4 BFS/flood on 384 cells → trivial.
Now writing the draw loop's T variable for animations.
Let me write out the final code.
One more detail: `ctx.scale(CELL, CELL)` then drawing with lineWidth in unit units. E.g., ctx.lineWidth = 0.04. That's fine.
But shadows: I'll avoid ctx.shadowBlur in scaled space.
Let me write drawLocomotive carefully with the unit-square (x:-0.5..0.5, y:-0.5..0.5), front = +x.
Wheels first (behind body):
- (-0.24, 0.30) r 0.14
- (0.06, 0.30) r 0.14
- (0.34, 0.30) r 0.11
Cowcatcher: triangle from (0.36,0.05) to (0.50,0.30) to (0.24,0.30). Hmm, that overlaps the third wheel. Let's skip the cowcatcher or make it smaller at front: path (0.42,0.06)->(0.50,0.26)->(0.34,0.26). Third wheel at 0.34 would overlap. Move third wheel to 0.20 and it's fine. Actually let me just have two big wheels for the loco: at -0.22 and 0.06, r 0.15, and a small one at 0.30 r 0.10.
Cowcatcher at x 0.38..0.50, y 0.05..0.28. OK, doesn't overlap the small wheel at 0.30 (r 0.1 → up to 0.40). Slight overlap, acceptable.
Body:
- chassis dark: rrect(-0.46,-0.04, 0.92, 0.36, 0.10) → y from -0.04 to 0.32. Covers wheels top. Hmm, wheels at y 0.30 with r 0.15 → top at 0.15, so chassis from -0.04 to 0.32 would cover most of the wheels. Bad. Let's make chassis y from -0.06 to 0.22 (height 0.28), so wheels at cy 0.30 r 0.15 (top 0.15) are partially covered — that's realistic, wheels peek below.
Actually let's make wheels smaller: r 0.12 at y 0.30 → bottom 0.42, top 0.18. Chassis bottom at 0.22 hides the top third. Good.
Body layout:
- chassis: rrect(-0.46, -0.06, 0.92, 0.30, 0.08), fill '#22201f' (dark).
- boiler: rrect(-0.10, -0.26, 0.52, 0.32, 0.15) fill gradient red. So boiler sits above chassis, y -0.26..0.06.
- boiler band (front): rrect(0.30, -0.24, 0.10, 0.28, 0.03) fill '#2b2320'.
- cab: rrect(-0.46, -0.38, 0.36, 0.44, 0.07) fill '#a8332a'. y -0.38..0.06.
- cab roof: rrect(-0.50,-0.44, 0.44, 0.10, 0.04) fill '#2b2320'.
- cab window: rrect(-0.40,-0.30, 0.22, 0.16, 0.03) fill '#ffe9a3'.
- chimney: rrect(0.10,-0.44, 0.14, 0.20, 0.03) fill '#3b322e'; top cap rrect(0.07,-0.48,0.20,0.07,0.03).
Hmm chimney y from -0.44 to -0.24, boiler top at -0.26 → ok.
- headlight: circle (0.40, -0.10) r 0.07, yellow; plus glow circle r 0.15 with rgba.
Wait boiler front band at x 0.30..0.40. Headlight at 0.40 - overlapping edge. Fine.
Boiler bottom at 0.06 = chassis top -0.06? chassis top is -0.06, so boiler overlaps into chassis. Fine.
Total vertical: -0.48 (chimney cap) to 0.42 (wheels). Good, roughly centered.
Wagon:
- wheels: (-0.20, 0.30) r 0.11, (0.20, 0.30) r 0.11.
- chassis: rrect(-0.44,-0.02,0.88,0.28,0.06) fill '#22201f'.
- body: rrect(-0.42,-0.26,0.84,0.32,0.06) fill color.
- roof: rrect(-0.46,-0.36,0.92,0.12,0.05) fill shade(color,-0.45).
- windows: two rrects y -0.18..-0.02, x -0.26..-0.10 and 0.10..0.26, fill 'rgba(255,240,190,.95)'.
- stripe: rrect(-0.42, 0.02, 0.84, 0.05, 0.02) fill rgba(0,0,0,.2).
- coupling: rrect(-0.52, 0.04, 0.10, 0.08, 0.02) fill '#3b322e'.
Good.
Now: wagons might overlap adjacent cells when rotated? They're within the unit cell, so no.
Let's write the code now.
Also, add "smoke puffs" from the chimney? That would be cute but requires tracking world positions. Could add: particles spawned at the head position every few frames. Let's add small smoke particles from the chimney of the loco. Simpler: skip; add only on eat.
Actually a little smoke would add life. Let's add: every step, spawn 1-2 smoke particles at the head cell offset opposite to direction (toward the back-top). Compute world position: headCenter + rotate((-0.14, -0.44)) by angle. Meh, let's just spawn at the head center with slight random offset and slow upward drift. Colors light gray, fading.
Let's do it in the render loop with a timer: every 120ms if state!=='over', spawn smoke at chimney world pos. Compute:
```js
const ang = Math.atan2(dir.y,dir.x);
const hx = (snake[0].x+0.5)*CELL, hy=(snake[0].y+0.5)*CELL;
const ox = 0.17, oy = -0.46;
const px = hx + (Math.cos(ang)*ox - Math.sin(ang)*oy)*CELL;
const py = hy + (Math.sin(ang)*ox + Math.cos(ang)*oy)*CELL;
```
Good.
Particles: {x,y,vx,vy,life,maxLife,size,color,type}.
Update in draw loop with dt.
OK let's write it.
Also on eating: spawn confetti particles at the platform.
Let me write the full code now.
```js
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const stage = document.querySelector('.stage');
const overlay = document.getElementById('overlay');
const overlayTitle = document.getElementById('ovTitle');
const overlaySub = document.getElementById('ovSub');
const scoreEl = document.getElementById('score');
const bestEl = document.getElementById('best');
const lenEl = document.getElementById('len');
let COLS = 24, ROWS = 16, CELL = 20, W = 0, H = 0;
let snake, dir, nextDir, passengers, particles, score, best = 0, state, stepMs, acc = 0, last = 0, T = 0;
let autoRestart = false, restartAt = 0, smokeAt = 0;
```
Grid setup based on width:
```js
function chooseGrid(){
const narrow = window.innerWidth < 680;
COLS = narrow ? 14 : 24;
ROWS = narrow ? 18 : 16;
}
```
Init: chooseGrid(); resize(); reset(); state='demo';
resize():
```js
function resize(){
const r = stage.getBoundingClientRect();
const availW = Math.max(120, r.width - 8);
const availH = Math.max(120, r.height - 8);
const cell = Math.min(availW/COLS, availH/ROWS);
CELL = Math.max(8, cell);
W = Math.round(CELL*COLS); H = Math.round(CELL*ROWS);
// use fractional CELL for precision? use float cell then W = cell*COLS
const dpr = Math.min(2, window.devicePixelRatio || 1);
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);
}
```
Use float CELL (no floor) — fine, gives perfect fit.
But rounding W/H: use W = CELL*COLS exactly (float) and set style width. Fine.
Hmm, canvas.style.width with float → browser handles it. Let's round to 0.5. Just use Math.round and recompute CELL = W/COLS? Simpler: keep float, set style width = W.toFixed(2)+'px'.
Eh, just use Math.round(W) and CELL stays float; the drawing uses CELL for positions so it'd be slightly off at the right edge. Negligible.
Actually let's set CELL = Math.floor(min * 100)/100 and W = CELL*COLS. Fine.
I'll just use:
```js
CELL = Math.max(8, Math.floor(Math.min(availW/COLS, availH/ROWS) * 2) / 2);
```
Half-pixel granularity. Good.
Now writing draw.
```js
function draw(now){
ctx.clearRect(0,0,W,H);
// ground
const bg = ctx.createLinearGradient(0,0,0,H);
bg.addColorStop(0,'#1a2836'); bg.addColorStop(1,'#0c141c');
ctx.fillStyle = bg; ctx.fillRect(0,0,W,H);
// grid
ctx.strokeStyle='rgba(255,255,255,0.035)'; ctx.lineWidth=1;
ctx.beginPath();
for(let x=1;x<COLS;x++){ ctx.moveTo(x*CELL,0); ctx.lineTo(x*CELL,H); }
for(let y=1;y<ROWS;y++){ ctx.moveTo(0,y*CELL); ctx.lineTo(W,y*CELL); }
ctx.stroke();
...
}
```
Then passengers, then particles under train? Particles above. Let's do: passengers → train → particles.
Ok writing drawPassenger with scale.
```js
function drawPassenger(p, now){
const s = CELL;
ctx.save();
ctx.translate((p.x+0.5)*s, (p.y+0.5)*s);
ctx.scale(s,s);
// platform
ctx.fillStyle = '#cfc8b4';
rrect(-0.40,-0.40,0.80,0.80,0.12); ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.25)'; ctx.lineWidth = 0.035; ctx.stroke();
// yellow safety line at bottom
ctx.fillStyle = '#f0c02f';
rrect(-0.40, 0.26, 0.80, 0.10, 0.03); ctx.fill();
// people
const bob = Math.sin(now*0.005 + p.phase) * 0.03;
drawPerson(-0.15, 0.10 + bob, 0.9, p.people[0]);
drawPerson(0.14, 0.12 - bob, 0.8, p.people[1]);
ctx.restore();
}
function drawPerson(x,y,scale,color){
ctx.save();
ctx.translate(x,y); ctx.scale(scale,scale);
// body
ctx.fillStyle = color;
rrect(-0.10,-0.10,0.20,0.28,0.07); ctx.fill();
// head
ctx.fillStyle = '#f2c9a0';
ctx.beginPath(); ctx.arc(0,-0.19,0.085,0,7); ctx.fill();
// hat/hair
ctx.fillStyle = '#3a2f2a';
ctx.beginPath(); ctx.arc(0,-0.22,0.085,Math.PI,0); ctx.fill();
ctx.restore();
}
```
Hmm the person's feet at y + 0.18 relative to the offset. With y base 0.10, body from 0.0 to 0.28. Platform bottom at 0.40. Yellow line at 0.26..0.36. Person overlaps the line a bit. Let's move people up: base y = -0.05. Body from -0.15 to 0.13. Head at -0.24. Platform top -0.40. OK, good.
Let's set drawPerson(-0.15, -0.02+bob, ...) and (0.14, 0.0-bob,...).
Fine.
Person body height 0.28 * 0.9 = 0.25. Head r 0.085. Total ~0.42 tall. Fits in the 0.8 platform. Good.
Now let's assemble everything.
Particle drawing: pixel space.
```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 += p.grav*dt;
p.life -= dt;
if (p.life<=0) particles.splice(i,1);
}
}
```
dt in ms → velocities in px/ms. e.g., vx = 0.08 px/ms = 80px/s. Fine.
Draw:
```js
for (const p of particles){
const a = Math.max(0, p.life/p.maxLife);
ctx.globalAlpha = a;
ctx.fillStyle = p.color;
ctx.beginPath(); ctx.arc(p.x,p.y,p.size*(0.4+0.6*a),0,7); ctx.fill();
}
ctx.globalAlpha = 1;
```
Good.
Now the main loop and input.
Input:
```js
const KEYS = {
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},
W:..., etc
};
```
Just lowercase the key.
```js
window.addEventListener('keydown', e=>{
const k = e.key.length===1 ? e.key.toLowerCase() : e.key;
const d = KEYS[k];
if (d) e.preventDefault();
if (state!=='play'){ startPlay(); return; } // any key starts
if (d && !(d.x===-dir.x && d.y===-dir.y)) nextDir = d;
});
```
Careful: if state === 'play' and the key is not a direction, ignore.
But on startPlay we should also apply the direction if it's a valid one? Nah.
Hmm: `if (state !== 'play') startPlay()` — but if state is 'over' with the overlay showing "Game Over", startPlay() should reset first. Let's handle inside startPlay: if state==='over' → reset().
Actually reset() then startPlay().
```js
function beginPlay(){
if (state === 'over') reset();
state = 'play';
stepMs = 130;
acc = 0;
overlay.classList.add('hidden');
updateHUD();
}
```
But when the demo dies and autoRestart brings it back as demo... fine.
Wait, but if the demo is running and the user clicks, we don't reset — the player continues from the demo's current state. That's nice actually. Keep going. Yes.
Hmm but the demo may be in a dead state. state==='over' && autoRestart → if user clicks, reset & play. Good.
Overlay click: `overlay.addEventListener('click', beginPlay)` — but overlay has pointer-events none when hidden. When visible and state==='play'? Can't be.
Also click on canvas → beginPlay too. Add to stage.
Let's also handle: canvas click when playing does nothing.
Now, `die()` when playing shows the overlay with Game Over. But then clicking → beginPlay → state==='over' → reset() → play.
Now let's handle the auto restart in the loop:
```js
if (state === 'over' && autoRestart && now >= restartAt){ reset(); state='demo'; stepMs=120; }
```
Careful: reset() doesn't change state. Let me make reset() not set state; callers do.
Let me define:
```js
function reset(){
const cy = Math.floor(ROWS/2), cx = Math.floor(COLS/2);
snake = [{x:cx,y:cy},{x:cx-1,y:cy},{x:cx-2,y:cy}];
dir = {x:1,y:0}; nextDir={x:1,y:0};
passengers = []; particles = [];
score = 0;
for (let i=0;i<3;i++) spawnPassenger();
updateHUD();
}
```
Hmm, if cx-2 <0? COLS is at least 14, fine.
startDemo(): state='demo'; stepMs=120; acc=0.
Let's write the init:
```js
chooseGrid(); resize(); reset(); state='demo'; stepMs=120;
overlay shown with "Click or press a key to start".
requestAnimationFrame(loop);
```
Now updateHUD:
```js
function updateHUD(){
scoreEl.textContent = score;
bestEl.textContent = best;
lenEl.textContent = snake.length;
if (score > best) { best = score; bestEl.textContent = best; }
}
```
Careful ordering. Do best update before setting.
Now the loop:
```js
function loop(now){
const dt = Math.min(120, now - last || 16);
last = now; T = now;
if (state === 'demo' || state === 'play'){
acc += dt;
let guard = 0;
while (acc >= stepMs && guard++ < 5){ acc -= stepMs; step(); if (state==='over') break; }
} else if (state === 'over' && autoRestart && now >= restartAt){
reset(); state='demo'; stepMs=120; acc=0;
}
updateParticles(dt);
emitSmoke(now);
draw(now);
requestAnimationFrame(loop);
}
```
emitSmoke every 140ms when state!=='over'.
OK. Also, in `step()` when state==='over' we shouldn't run.
Also in the while loop, `step()` might set state='over' — break.
Let me finalize `step()`:
```js
function step(){
if (state !== 'demo' && state !== 'play') return;
if (state === 'demo') aiChoose();
if (nextDir.x !== -dir.x || nextDir.y !== -dir.y) dir = nextDir;
...
}
```
Wait, nextDir is already validated on input. But dir could be... fine, just dir = nextDir.
Hmm, one subtlety: after turning, dir=nextDir; if the user presses two keys within one step, the second could be a 180 relative to nextDir but not to dir. Validate against dir:
```js
if (!(nextDir.x === -dir.x && nextDir.y === -dir.y)) dir = nextDir;
```
Good.
Collision & movement as described.
Now writing the final code. Let me be careful with `rrect` being used with the current transform.
Also note in `drawLocomotive` I use `ctx.createLinearGradient` every frame — that's fine but a bit wasteful; acceptable.
Let me write everything.
One more thought on the "overlay" covering the canvas: it's `position:absolute; inset:0` of the stage which is larger than the canvas (stage is flex:1). So the overlay is bigger than the canvas. That's fine visually — a dark scrim over the whole stage. But the border-radius applies to the stage area. Acceptable. Actually might look better to keep the scrim only over the canvas. Eh, it's fine — full stage darkening looks intentional.
Hmm, but on wide screens the stage is much wider than the canvas (since canvas is height-limited). The scrim covers the whole width. It's fine, looks like a modal.
Let's finalize.
For the header I want a small "🚂" emoji or a canvas-drawn train. Use text emoji in the h1. That's fine (no external font).
Writing final HTML now.
Let me double check `floodSize` with `blocked` containing the head? blocked contains snake[0..len-2] which includes the current head. The new head (nx,ny) is not in blocked (we checked). Good.
But wait: when we exclude the tail from blocked and count flood from the new head, the flood includes the tail cell which will be vacated — correct.
However the body cells excluding the tail are blocked — correct after moving (except the old head is now body... it's in blocked already since we included indices 0..len-2 which includes head at 0). Right, after the move the old head becomes body. Good.
Edge: what about the cell that will be the new head being the old tail? Allowed since not blocked. Then after moving, the tail moves away and the new head is there. Correct.
Now let me write the code out.
Regarding `Int16Array(COLS*ROWS).fill(-1)` — need `new Int16Array(n).fill(-1)`. Yes.
Let's write.
Also for the "best" display, updating on each score change.
Final check on the `KEYS` map keys for arrow keys: e.key = 'ArrowUp' etc. Good.
I'll now produce the file.
One more thing: `document.querySelector('.stage')` etc. — put the script at the end of body.
Also prevent page scroll on arrow keys: e.preventDefault() when d exists.
Let me write it.
Also add touch: tap to start (click works on mobile).
Writing the code:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Passenger Express</title>
<style>...</style>
</head>
<body>
<div class="wrap">
<header>
<h1>🚂 Passenger Express</h1>
<div class="stats">
<div class="stat">Passengers <b id="score">0</b></div>
<div class="stat">Best <b id="best">0</b></div>
<div class="stat">Cars <b id="len">3</b></div>
</div>
</header>
<div class="stage">
<canvas id="game"></canvas>
<div class="overlay" id="overlay">
<div class="card">
<div class="big" id="ovTitle">Click or press a key to start</div>
<div class="sub" id="ovSub">Arrow keys or WASD to drive · pick up everyone waiting at the platforms</div>
</div>
</div>
</div>
<div class="hint">The train grows a new wagon for every passenger that hops aboard.</div>
</div>
<script>...</script>
</body>
</html>
```
CSS details for the header on mobile: flex-wrap.
Let me write the CSS:
```css
*{box-sizing:border-box}
html,body{height:100%;margin:0}
body{
background:radial-gradient(ellipse at 50% -10%, #1e3346 0%, #0a1017 60%);
color:#e9f0f6;
font-family:ui-rounded,"Segoe UI",system-ui,-apple-system,sans-serif;
overflow:hidden;
}
.wrap{
height:100%;display:flex;flex-direction:column;gap:6px;
padding:10px 12px 6px;max-width:1500px;margin:0 auto;
}
header{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}
h1{margin:0;font-size:clamp(15px,2.1vw,22px);font-weight:900;letter-spacing:.3px;
background:linear-gradient(90deg,#ffd166,#ff9f45,#ff6b6b);
-webkit-background-clip:text;background-clip:text;color:transparent;white-space:nowrap}
.stats{display:flex;gap:6px}
.stat{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);
border-radius:999px;padding:3px 10px;font-size:clamp(9px,1.25vw,12px);
color:#9fb3c4;letter-spacing:.4px;text-transform:uppercase;font-weight:700;white-space:nowrap}
.stat b{color:#fff;font-size:1.15em;margin-left:3px}
.stage{flex:1 1 auto;min-height:0;position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden}
canvas{display:block;border-radius:16px;box-shadow:0 0 0 2px rgba(255,255,255,.07),0 20px 60px rgba(0,0,0,.6)}
.overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
background:rgba(6,12,18,.5);cursor:pointer;transition:opacity .25s;border-radius:16px}
.overlay.hidden{opacity:0;pointer-events:none}
.card{text-align:center;padding:16px 24px;border-radius:16px;
background:rgba(14,24,34,.9);border:1px solid rgba(255,255,255,.13);
box-shadow:0 16px 44px rgba(0,0,0,.55);max-width:90%}
.big{font-size:clamp(14px,1.9vw,20px);font-weight:800;animation:pulse 1.6s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.55}}
.sub{margin-top:7px;font-size:clamp(9px,1.3vw,12.5px);color:#8fa4b6;line-height:1.5}
.hint{flex:0 0 auto;text-align:center;font-size:clamp(9px,1.25vw,12px);color:#5f7488;letter-spacing:.3px}
```
Good.
Now the JS. Writing it out fully.
Also handle window resize → resize(); but if the breakpoint changed, re-choose grid and reset.
```js
let narrow = window.innerWidth < 680;
window.addEventListener('resize', ()=>{
const n = window.innerWidth < 680;
if (n !== narrow){ narrow = n; chooseGrid(); reset(); }
resize();
});
```
chooseGrid uses narrow.
OK write.
Also I should double-check the canvas doesn't overflow the stage on tiny heights. The stage overflow:hidden handles it.
Final code writing time.
For the AI's `pick` fallback, I'll write:
```js
function aiChoose(){
const head = snake[0];
const blocked = new Set();
for (let i=0;i<snake.length-1;i++) blocked.add(snake[i].x + snake[i].y*COLS);
const options = [];
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;
const ni = nx + ny*COLS;
if (blocked.has(ni)) continue;
const space = floodSize(nx,ny,blocked);
const dist = bfsDist(nx,ny,blocked);
options.push({d, space, dist});
}
if (!options.length) return;
const safe = options.filter(o => o.space > snake.length + 1);
const pool = safe.length ? safe : options;
let best = pool[0], bestVal = Infinity;
for (const o of pool){
let v = (o.dist === Infinity ? 2000 : o.dist * 12) - Math.min(o.space, 100);
if (v < bestVal){ bestVal = v; best = o; }
}
nextDir = best.d;
}
```
Good.
Now writing everything for real.
For emitSmoke, only when snake exists and state is demo/play.
```js
function emitSmoke(now){
if (state !== 'demo' && state !== 'play') return;
if (now - smokeAt < 130) return;
smokeAt = now;
const ang = Math.atan2(dir.y, dir.x);
const hx = (snake[0].x+0.5)*CELL, hy = (snake[0].y+0.5)*CELL;
const ox = 0.16*CELL, oy = -0.46*CELL;
const px = hx + Math.cos(ang)*ox - Math.sin(ang)*oy;
const py = hy + Math.sin(ang)*ox + Math.cos(ang)*oy;
particles.push({x:px,y:py,vx:(Math.random()-0.5)*0.02,vy:-0.03-Math.random()*0.02,
life:700,maxLife:700,size:CELL*0.09,color:'rgba(220,225,235,0.45)',grav:-0.00002});
}
```
Hmm grav negative = floats up more. Fine, keep grav 0 for smoke.
Let's cap particles length to avoid growth: if (particles.length > 300) shift. Fine.
burst(x,y) for eating: 14 particles with random velocity, colors gold/white.
OK writing now for real.
drawTrain also needs a shadow ellipse under each car? skip.
Let me write the final file.