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
- 3 min 53 s
- Cost
- $0.042
- Output tokens
- 41,434
- Spent thinking
- 30,407 tokens
- Speed
- 178.2 tok/s
- Console errors
- 0
- First token after
- 786 ms
- First visible text after
- 3 min 17 s
- Prompt tokens
- 387
- Code size
- 28.9 KB
- Finish reason
- stop
- Served by
- Modal
- Model id
deepseek/deepseek-v4.1-flash-1017- Reasoning sent
{"effort":"high"}- 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 · 906 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Snake Train — Passenger Express</title>
<style>
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{height:100%}
body{
margin:0;
font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
background:radial-gradient(120% 90% at 50% -10%, #2b3a5c 0%, #1a2338 42%, #0c1120 100%);
color:#e9eefb;
overflow:hidden;
user-select:none;
}
#app{height:100%;display:flex;flex-direction:column}
/* ---------- HUD ---------- */
#hud{
display:flex;align-items:center;justify-content:space-between;
gap:16px;padding:16px 26px 6px;flex:0 0 auto;
}
.brand{display:flex;align-items:center;gap:12px;min-width:0}
.logo{
font-size:30px;line-height:1;
filter:drop-shadow(0 4px 8px rgba(0,0,0,.55));
animation:chug 2.4s ease-in-out infinite;
}
@keyframes chug{0%,100%{transform:translateY(0) rotate(-2deg)}50%{transform:translateY(-3px) rotate(2deg)}}
.titles h1{
margin:0;font-size:20px;font-weight:900;letter-spacing:.02em;
background:linear-gradient(92deg,#ffd166,#ff8f5e 70%,#ff6f91);
-webkit-background-clip:text;background-clip:text;color:transparent;
white-space:nowrap;
}
.titles .sub{
font-size:10.5px;letter-spacing:.16em;text-transform:uppercase;color:#7f8fb3;
margin-top:2px;white-space:nowrap;
}
.stats{display:flex;gap:10px}
.stat{
background:rgba(255,255,255,.055);
border:1px solid rgba(255,255,255,.10);
border-radius:14px;padding:6px 16px;min-width:84px;text-align:center;
box-shadow:inset 0 1px 0 rgba(255,255,255,.08);
}
.stat .k{display:block;font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;color:#8194b8;font-weight:700}
.stat .v{display:block;font-size:20px;font-weight:900;color:#fff;line-height:1.15;font-variant-numeric:tabular-nums}
/* ---------- Stage ---------- */
#stage{
position:relative;flex:1 1 auto;min-height:0;
display:flex;align-items:center;justify-content:center;
padding:8px 12px 112px;
}
#boardWrap{
padding:8px;border-radius:22px;
background:linear-gradient(160deg,#42506e 0%,#232c42 45%,#151b2a 100%);
box-shadow:0 26px 60px -14px rgba(0,0,0,.85),
inset 0 1px 0 rgba(255,255,255,.16),
inset 0 -1px 0 rgba(0,0,0,.4);
}
canvas{display:block;border-radius:14px;background:#efe8d8;}
/* ---------- Overlay ---------- */
#overlay{
position:absolute;left:0;right:0;bottom:14px;
display:flex;justify-content:center;padding:0 14px;
pointer-events:none;
opacity:0;visibility:hidden;
transition:opacity .28s ease,visibility .28s;
}
#overlay.show{opacity:1;visibility:visible}
.card{
max-width:580px;text-align:center;
background:rgba(16,22,36,.92);
border:1px solid rgba(255,255,255,.14);
border-radius:18px;
padding:13px 26px 15px;
box-shadow:0 18px 44px rgba(0,0,0,.6),inset 0 1px 0 rgba(255,255,255,.10);
backdrop-filter:blur(10px);
-webkit-backdrop-filter:blur(10px);
animation:cardIn .32s cubic-bezier(.2,1.3,.5,1);
}
@keyframes cardIn{from{transform:translateY(10px) scale(.97);opacity:0}to{transform:none;opacity:1}}
.badge{
display:inline-block;font-size:10px;font-weight:900;letter-spacing:.18em;
text-transform:uppercase;color:#ffc857;margin-bottom:5px;
}
.card h2{margin:0 0 5px;font-size:19px;font-weight:900;color:#fff;letter-spacing:.01em}
.card p{margin:0;font-size:13px;line-height:1.5;color:#9db0d2}
.card b{color:#e6eeff}
.card b.gold{color:#ffc857}
/* ---------- Small screens ---------- */
@media (max-width:700px){
#hud{padding:12px 14px 2px;flex-direction:column;align-items:stretch;gap:9px}
.titles h1{font-size:17px}
.titles .sub{font-size:9px}
.logo{font-size:25px}
.stats{width:100%}
.stat{flex:1;min-width:0;padding:5px 6px}
.stat .v{font-size:16px}
.stat .k{font-size:8.5px;letter-spacing:.08em}
#stage{padding:6px 10px 138px}
#boardWrap{padding:6px;border-radius:18px}
.card{padding:11px 16px 13px;border-radius:15px}
.card h2{font-size:15.5px}
.card p{font-size:12px}
#overlay{bottom:10px}
}
</style>
</head>
<body>
<div id="app">
<header id="hud">
<div class="brand">
<div class="logo">🚂</div>
<div class="titles">
<h1>Snake Train</h1>
<div class="sub">All aboard the passenger express</div>
</div>
</div>
<div class="stats">
<div class="stat"><span class="k">Passengers</span><span class="v" id="score">0</span></div>
<div class="stat"><span class="k">Wagons</span><span class="v" id="wagons">0</span></div>
<div class="stat"><span class="k">Best</span><span class="v" id="best">0</span></div>
</div>
</header>
<main id="stage">
<div id="boardWrap"><canvas id="c"></canvas></div>
<div id="overlay">
<div class="card" id="card">
<div class="badge" id="badge">Demo</div>
<h2 id="cardTitle">Click or press a key to start</h2>
<p id="cardText">Use <b>↑ ↓ ← →</b> or <b>WASD</b>. Every passenger you collect hops aboard as a new wagon.</p>
</div>
</div>
</main>
</div>
<script>
(function () {
'use strict';
/* ======================= Setup ======================= */
const COLS = 24, ROWS = 16, FOOD_COUNT = 3;
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const stage = document.getElementById('stage');
const overlay = document.getElementById('overlay');
const badgeEl = document.getElementById('badge');
const titleEl = document.getElementById('cardTitle');
const textEl = document.getElementById('cardText');
const scoreEl = document.getElementById('score');
const wagonEl = document.getElementById('wagons');
const bestEl = document.getElementById('best');
const DIRS = {
up: { x: 0, y: -1 },
right: { x: 1, y: 0 },
down: { x: 0, y: 1 },
left: { x: -1, y: 0 }
};
const DLIST = [DIRS.up, DIRS.right, DIRS.down, DIRS.left];
/* ======================= Trains data ======================= */
const WAGON_COLORS = [
{ main: '#f0b429', light: '#ffd97a', dark: '#b8801a' },
{ main: '#3fbfa0', light: '#85e6cb', dark: '#248a72' },
{ main: '#5b8def', light: '#9dbcff', dark: '#3a5fc0' },
{ main: '#e2607e', light: '#ff9cb2', dark: '#a83c56' },
{ main: '#a97ce0', light: '#cfa9f5', dark: '#774fb0' },
{ main: '#f2913b', light: '#ffbc78', dark: '#b96a1e' },
{ main: '#77d94f', light: '#b0f08e', dark: '#4ea332' }
];
const SKINS = ['#f6c9a0', '#e0a878', '#bd7f57', '#8d5c3c', '#f2d5b8'];
const COATS = ['#e8543f', '#3f7ae8', '#28a06e', '#e8a23f', '#9b5ce8', '#e84f8f', '#2fb6c9'];
const PANTS = ['#3b4252', '#2c3550', '#4a4038', '#2d4a3e'];
const CASES = ['#c0392b', '#2d6cdf', '#6b4a2f', '#1f8a70'];
const HATS = ['#2b2f3a', '#5a3b2a', '#b03a48', '#2f5f8a'];
function pick(a){ return a[(Math.random() * a.length) | 0]; }
/* ======================= State ======================= */
let cell = 24, dpr = 1;
let snake = [], prevSnake = [];
let dir = DIRS.right, nextDir = DIRS.right;
let foods = [];
let score = 0, best = 0;
let state = 'demo'; // 'demo' | 'demoDead' | 'play' | 'over'
let acc = 0, last = 0;
let demoDeadAt = 0;
let groundCache = null;
let puffs = [], sparks = [], floaters = [];
let lastScore = -1, lastWagons = -1, lastBest = -1;
/* ======================= Helpers ======================= */
function rr(c, x, y, w, h, r) {
r = Math.min(r, Math.abs(w) / 2, Math.abs(h) / 2);
c.beginPath();
c.moveTo(x + r, y);
c.arcTo(x + w, y, x + w, y + h, r);
c.arcTo(x + w, y + h, x, y + h, r);
c.arcTo(x, y + h, x, y, r);
c.arcTo(x, y, x + w, y, r);
c.closePath();
}
/* ======================= Resize / ground ======================= */
function resize() {
const cs = getComputedStyle(stage);
const pl = parseFloat(cs.paddingLeft) || 0;
const pr = parseFloat(cs.paddingRight) || 0;
const pt = parseFloat(cs.paddingTop) || 0;
const pb = parseFloat(cs.paddingBottom)|| 0;
const availW = stage.clientWidth - pl - pr - 22;
const availH = stage.clientHeight - pt - pb - 22;
if (availW < 30 || availH < 30) return;
let nc = Math.floor(Math.min(availW / COLS, availH / ROWS));
nc = Math.max(10, nc);
if (nc === cell && groundCache) return;
cell = nc;
const w = cell * COLS, h = cell * ROWS;
dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
buildGround(w, h);
}
function buildGround(w, h) {
const g = document.createElement('canvas');
g.width = Math.round(w * dpr);
g.height = Math.round(h * dpr);
const gx = g.getContext('2d');
gx.setTransform(dpr, 0, 0, dpr, 0, 0);
gx.fillStyle = '#efe8d8';
gx.fillRect(0, 0, w, h);
// subtle checker
gx.fillStyle = 'rgba(150,130,95,0.055)';
for (let y = 0; y < ROWS; y++)
for (let x = 0; x < COLS; x++)
if ((x + y) & 1) gx.fillRect(x * cell, y * cell, cell, cell);
// tile grid
gx.strokeStyle = 'rgba(120,100,70,0.13)';
gx.lineWidth = 1;
for (let x = 0; x <= COLS; x++) {
gx.beginPath(); gx.moveTo(x * cell + 0.5, 0); gx.lineTo(x * cell + 0.5, h); gx.stroke();
}
for (let y = 0; y <= ROWS; y++) {
gx.beginPath(); gx.moveTo(0, y * cell + 0.5); gx.lineTo(w, y * cell + 0.5); gx.stroke();
}
// deterministic speckles
let seed = 987654321;
const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
for (let i = 0; i < 300; i++) {
const x = rnd() * w, y = rnd() * h, r = rnd() * cell * 0.06 + 0.4;
gx.beginPath();
gx.arc(x, y, r, 0, Math.PI * 2);
gx.fillStyle = 'rgba(120,100,70,' + (0.025 + rnd() * 0.05).toFixed(3) + ')';
gx.fill();
}
// vignette
const vg = gx.createRadialGradient(
w / 2, h / 2, Math.min(w, h) * 0.22,
w / 2, h / 2, Math.max(w, h) * 0.74
);
vg.addColorStop(0, 'rgba(0,0,0,0)');
vg.addColorStop(1, 'rgba(70,55,35,0.17)');
gx.fillStyle = vg;
gx.fillRect(0, 0, w, h);
groundCache = g;
}
/* ======================= Game flow ======================= */
function resetGame() {
const cy = Math.floor(ROWS / 2);
snake = [{ x: 5, y: cy }, { x: 4, y: cy }, { x: 3, y: cy }];
prevSnake = snake.slice();
dir = DIRS.right;
nextDir = DIRS.right;
score = 0;
acc = 0;
foods = [];
puffs = []; sparks = []; floaters = [];
for (let i = 0; i < FOOD_COUNT; i++) spawnFood();
}
function spawnFood() {
const head = snake[0];
const taken = new Set();
for (const s of snake) taken.add(s.y * COLS + s.x);
for (const f of foods) taken.add(f.y * COLS + f.x);
const free = [];
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
if (taken.has(y * COLS + x)) continue;
if (Math.abs(x - head.x) + Math.abs(y - head.y) < 4) continue;
free.push({ x, y });
}
}
if (!free.length) return;
const p = free[(Math.random() * free.length) | 0];
foods.push({
x: p.x, y: p.y,
phase: Math.random() * Math.PI * 2,
coat: pick(COATS),
pants: pick(PANTS),
skin: pick(SKINS),
hat: pick(HATS),
bag: pick(CASES)
});
}
function currentStepMs() {
if (state === 'demo' || state === 'demoDead') return 135;
return Math.max(82, 148 - score * 4);
}
function step() {
prevSnake = snake.slice();
dir = nextDir;
const head = snake[0];
const nx = head.x + dir.x;
const ny = head.y + dir.y;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) { die(); return; }
const willGrow = foods.some(f => f.x === nx && f.y === ny);
const limit = willGrow ? snake.length : snake.length - 1;
for (let i = 0; i < limit; i++) {
if (snake[i].x === nx && snake[i].y === ny) { die(); return; }
}
// smoke puff behind the chimney
if (puffs.length < 70) {
const cxw = (nx + 0.5) * cell, cyw = (ny + 0.5) * cell;
const off = cell * 0.26;
const life = 560 + Math.random() * 320;
puffs.push({
x: cxw + dir.x * off,
y: cyw + dir.y * off,
vx: (Math.random() - 0.5) * 20 - dir.x * 12,
vy: (Math.random() - 0.5) * 20 - dir.y * 12 - 16,
r: cell * 0.11,
grow: cell * 0.72,
life: life,
max: life
});
}
snake.unshift({ x: nx, y: ny });
if (willGrow) {
const idx = foods.findIndex(f => f.x === nx && f.y === ny);
const f = foods[idx];
foods.splice(idx, 1);
score++;
if (score > best) best = score;
spawnBurst(nx, ny, f.coat);
spawnFood();
} else {
snake.pop();
}
}
function die() {
acc = 0;
if (state === 'play') {
state = 'over';
if (score > best) best = score;
showOverlay('over');
} else {
state = 'demoDead';
demoDeadAt = performance.now();
}
}
/* ======================= Particles ======================= */
function spawnBurst(gx, gy, color) {
const cx = (gx + 0.5) * cell, cy = (gy + 0.5) * cell;
for (let i = 0; i < 14; i++) {
const a = Math.random() * Math.PI * 2;
const s = cell * (1.4 + Math.random() * 2.6);
sparks.push({
x: cx, y: cy,
vx: Math.cos(a) * s, vy: Math.sin(a) * s,
life: 500 + Math.random() * 220, max: 700,
r: cell * (0.055 + Math.random() * 0.06),
color: Math.random() < 0.35 ? '#ffd166' : color
});
}
floaters.push({ x: cx, y: cy - cell * 0.35, life: 900, max: 900, text: '+1' });
}
function updateParticles(dt) {
const k = dt / 1000;
for (let i = puffs.length - 1; i >= 0; i--) {
const p = puffs[i];
p.life -= dt;
if (p.life <= 0) { puffs.splice(i, 1); continue; }
p.x += p.vx * k; p.y += p.vy * k;
p.vx *= 0.965; p.vy *= 0.965;
p.r += p.grow * k;
}
for (let i = sparks.length - 1; i >= 0; i--) {
const s = sparks[i];
s.life -= dt;
if (s.life <= 0) { sparks.splice(i, 1); continue; }
s.x += s.vx * k; s.y += s.vy * k;
s.vx *= 0.93; s.vy *= 0.93;
}
for (let i = floaters.length - 1; i >= 0; i--) {
const f = floaters[i];
f.life -= dt;
if (f.life <= 0) { floaters.splice(i, 1); continue; }
f.y -= 46 * k;
}
}
function drawPuffs() {
for (const p of puffs) {
const a = Math.max(0, p.life / p.max);
const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, Math.max(1, p.r));
g.addColorStop(0, 'rgba(255,255,255,' + (a * 0.72).toFixed(3) + ')');
g.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(p.x, p.y, Math.max(1, p.r), 0, Math.PI * 2);
ctx.fill();
}
}
function drawSparks() {
for (const s of sparks) {
ctx.globalAlpha = Math.max(0, s.life / s.max);
ctx.fillStyle = s.color;
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawFloaters() {
if (!floaters.length) return;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '900 ' + Math.round(cell * 0.58) + 'px ui-sans-serif,system-ui,sans-serif';
for (const f of floaters) {
const a = Math.max(0, f.life / f.max);
ctx.globalAlpha = a;
ctx.lineWidth = Math.max(2, cell * 0.07);
ctx.strokeStyle = 'rgba(255,255,255,0.92)';
ctx.strokeText(f.text, f.x, f.y);
ctx.fillStyle = '#e89a1f';
ctx.fillText(f.text, f.x, f.y);
}
ctx.globalAlpha = 1;
}
/* ======================= Drawing: passengers ======================= */
function drawPassenger(f, now) {
const cx = (f.x + 0.5) * cell;
const cy = (f.y + 0.5) * cell;
const bob = Math.sin(now / 290 + f.phase) * cell * 0.035;
// platform tile
ctx.save();
ctx.shadowColor = 'rgba(50,35,15,0.20)';
ctx.shadowBlur = cell * 0.22;
ctx.shadowOffsetY = cell * 0.07;
ctx.fillStyle = 'rgba(255,249,232,0.97)';
rr(ctx, cx - cell * 0.42, cy - cell * 0.42, cell * 0.84, cell * 0.84, cell * 0.26);
ctx.fill();
ctx.restore();
ctx.strokeStyle = 'rgba(240,180,41,0.95)';
ctx.lineWidth = Math.max(1, cell * 0.05);
rr(ctx, cx - cell * 0.42, cy - cell * 0.42, cell * 0.84, cell * 0.84, cell * 0.26);
ctx.stroke();
const by = cy + bob;
// ground shadow
ctx.fillStyle = 'rgba(70,50,30,0.16)';
ctx.beginPath();
ctx.ellipse(cx, cy + cell * 0.30, cell * 0.20, cell * 0.065, 0, 0, Math.PI * 2);
ctx.fill();
// legs
ctx.fillStyle = f.pants;
rr(ctx, cx - cell * 0.11, by + cell * 0.10, cell * 0.08, cell * 0.19, cell * 0.04); ctx.fill();
rr(ctx, cx + cell * 0.03, by + cell * 0.10, cell * 0.08, cell * 0.19, cell * 0.04); ctx.fill();
// torso
ctx.fillStyle = f.coat;
rr(ctx, cx - cell * 0.16, by - cell * 0.075, cell * 0.32, cell * 0.27, cell * 0.11); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.22)';
rr(ctx, cx - cell * 0.10, by - cell * 0.04, cell * 0.08, cell * 0.18, cell * 0.04); ctx.fill();
// head
ctx.fillStyle = f.skin;
ctx.beginPath();
ctx.arc(cx, by - cell * 0.175, cell * 0.115, 0, Math.PI * 2);
ctx.fill();
// hat / hair
ctx.fillStyle = f.hat;
ctx.beginPath();
ctx.arc(cx, by - cell * 0.195, cell * 0.118, Math.PI * 1.02, Math.PI * 1.98);
ctx.fill();
// suitcase
ctx.fillStyle = f.bag;
rr(ctx, cx + cell * 0.20, by + cell * 0.10, cell * 0.16, cell * 0.14, cell * 0.04);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.fillRect(cx + cell * 0.265, by + cell * 0.115, cell * 0.03, cell * 0.11);
}
/* ======================= Drawing: train ======================= */
function drawLoco() {
const L = cell * 0.96, W = cell * 0.84;
const x0 = -L / 2, y0 = -W / 2;
// shadow
ctx.fillStyle = 'rgba(45,32,18,0.18)';
rr(ctx, x0 + cell * 0.02, y0 + cell * 0.11, L, W, W * 0.30);
ctx.fill();
// cowcatcher
ctx.fillStyle = '#39404f';
ctx.beginPath();
ctx.moveTo(L * 0.44, -W * 0.34);
ctx.lineTo(L * 0.44 + L * 0.17, 0);
ctx.lineTo(L * 0.44, W * 0.34);
ctx.closePath();
ctx.fill();
// chassis
ctx.fillStyle = '#2b303c';
rr(ctx, x0, y0, L, W, W * 0.28);
ctx.fill();
// boiler
const g = ctx.createLinearGradient(0, -W * 0.34, 0, W * 0.34);
g.addColorStop(0, '#ff8a5c');
g.addColorStop(0.45, '#e2462a');
g.addColorStop(1, '#a72d18');
ctx.fillStyle = g;
rr(ctx, x0 + L * 0.30, y0 + W * 0.18, L * 0.68, W * 0.64, W * 0.24);
ctx.fill();
// cab
ctx.fillStyle = '#d8462a';
rr(ctx, x0 + L * 0.04, y0 + W * 0.05, L * 0.31, W * 0.90, W * 0.22);
ctx.fill();
ctx.fillStyle = '#2b303c';
rr(ctx, x0 + L * 0.08, y0 + W * 0.13, L * 0.23, W * 0.74, W * 0.18);
ctx.fill();
// cab windows
ctx.fillStyle = '#a8e4ff';
rr(ctx, x0 + L * 0.135, y0 + W * 0.24, L * 0.085, W * 0.17, W * 0.05); ctx.fill();
rr(ctx, x0 + L * 0.135, y0 + W * 0.59, L * 0.085, W * 0.17, W * 0.05); ctx.fill();
// brass dome
ctx.beginPath();
ctx.arc(x0 + L * 0.42, 0, W * 0.115, 0, Math.PI * 2);
ctx.fillStyle = '#f3c14b';
ctx.fill();
ctx.beginPath();
ctx.arc(x0 + L * 0.42, 0, W * 0.055, 0, Math.PI * 2);
ctx.fillStyle = '#fff0b8';
ctx.fill();
// chimney
ctx.beginPath();
ctx.arc(x0 + L * 0.63, 0, W * 0.215, 0, Math.PI * 2);
ctx.fillStyle = '#2b303c';
ctx.fill();
ctx.beginPath();
ctx.arc(x0 + L * 0.63, 0, W * 0.105, 0, Math.PI * 2);
ctx.fillStyle = '#69718a';
ctx.fill();
// headlamp
const lg = ctx.createRadialGradient(x0 + L * 0.88, 0, 0, x0 + L * 0.88, 0, W * 0.26);
lg.addColorStop(0, 'rgba(255,244,190,1)');
lg.addColorStop(0.45, 'rgba(255,225,130,0.55)');
lg.addColorStop(1, 'rgba(255,215,110,0)');
ctx.fillStyle = lg;
ctx.beginPath();
ctx.arc(x0 + L * 0.88, 0, W * 0.26, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fff2b8';
ctx.beginPath();
ctx.arc(x0 + L * 0.88, 0, W * 0.085, 0, Math.PI * 2);
ctx.fill();
}
function drawWagon(idx) {
const c = WAGON_COLORS[idx % WAGON_COLORS.length];
const L = cell * 0.86, W = cell * 0.74;
const x0 = -L / 2, y0 = -W / 2;
// shadow
ctx.fillStyle = 'rgba(45,32,18,0.16)';
rr(ctx, x0 + cell * 0.02, y0 + cell * 0.10, L, W, W * 0.30);
ctx.fill();
// body
const g = ctx.createLinearGradient(0, -W / 2, 0, W / 2);
g.addColorStop(0, c.light);
g.addColorStop(0.5, c.main);
g.addColorStop(1, c.dark);
ctx.fillStyle = g;
rr(ctx, x0, y0, L, W, W * 0.30);
ctx.fill();
// roof panel
ctx.fillStyle = 'rgba(255,255,255,0.30)';
rr(ctx, x0 + cell * 0.07, y0 + cell * 0.07, L - cell * 0.14, W - cell * 0.14, W * 0.24);
ctx.fill();
// skylight strip
ctx.fillStyle = 'rgba(178,232,255,0.88)';
rr(ctx, -L * 0.21, -W * 0.13, L * 0.42, W * 0.26, W * 0.13);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.55)';
rr(ctx, -L * 0.21, -W * 0.13, L * 0.42, W * 0.09, W * 0.05);
ctx.fill();
// coupling
ctx.fillStyle = '#39404f';
rr(ctx, x0 - cell * 0.07, -W * 0.11, cell * 0.11, W * 0.22, W * 0.07);
ctx.fill();
}
function drawTrain(t) {
const n = snake.length;
for (let i = n - 1; i >= 0; i--) {
const cur = snake[i];
const prev = prevSnake[i] || cur;
const cx = (prev.x + (cur.x - prev.x) * t + 0.5) * cell;
const cy = (prev.y + (cur.y - prev.y) * t + 0.5) * cell;
let dx = cur.x - prev.x, dy = cur.y - prev.y;
if (dx === 0 && dy === 0) {
const nb = snake[i - 1] || snake[i + 1];
if (nb) { dx = cur.x - nb.x; dy = cur.y - nb.y; }
else { dx = dir.x; dy = dir.y; }
if (dx === 0 && dy === 0) { dx = dir.x; dy = dir.y; }
}
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(Math.atan2(dy, dx));
if (i === 0) drawLoco();
else drawWagon(i - 1);
ctx.restore();
}
}
/* ======================= Main draw ======================= */
function draw(t, now) {
const W = cell * COLS, H = cell * ROWS;
ctx.clearRect(0, 0, W, H);
if (groundCache) ctx.drawImage(groundCache, 0, 0, W, H);
else { ctx.fillStyle = '#efe8d8'; ctx.fillRect(0, 0, W, H); }
for (const f of foods) drawPassenger(f, now);
drawTrain(t);
drawPuffs();
drawSparks();
drawFloaters();
}
/* ======================= AI (demo) ======================= */
function floodArea(sx, sy, occ) {
const N = COLS * ROWS;
const start = sy * COLS + sx;
if (occ[start]) return 0;
const seen = new Uint8Array(N);
const q = new Int32Array(N);
let qh = 0, qt = 0, count = 0;
seen[start] = 1; q[qt++] = start;
while (qh < qt) {
const id = q[qh++];
count++;
const x = id % COLS, y = (id - x) / COLS;
for (let d = 0; d < 4; d++) {
const nx = x + DLIST[d].x, ny = y + DLIST[d].y;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
const nid = ny * COLS + nx;
if (seen[nid] || occ[nid]) continue;
seen[nid] = 1; q[qt++] = nid;
}
}
return count;
}
function safeMove(mv) {
const hx = snake[0].x + mv.x, hy = snake[0].y + mv.y;
if (hx < 0 || hy < 0 || hx >= COLS || hy >= ROWS) return false;
const occ = new Uint8Array(COLS * ROWS);
for (let i = 0; i < snake.length - 1; i++) occ[snake[i].y * COLS + snake[i].x] = 1;
if (occ[hy * COLS + hx]) return false;
return floodArea(hx, hy, occ) >= snake.length + 3;
}
function aiDir() {
const head = snake[0];
const N = COLS * ROWS;
const occ = new Uint8Array(N);
for (let i = 0; i < snake.length; i++) occ[snake[i].y * COLS + snake[i].x] = 1;
const dist = new Int32Array(N).fill(-1);
const first = new Int32Array(N).fill(-1);
const queue = [];
for (let d = 0; d < 4; d++) {
const mv = DLIST[d];
if (mv.x === -dir.x && mv.y === -dir.y) continue;
const nx = head.x + mv.x, ny = head.y + mv.y;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
const id = ny * COLS + nx;
if (occ[id] || dist[id] !== -1) continue;
dist[id] = 1; first[id] = d; queue.push(id);
}
let qi = 0;
while (qi < queue.length) {
const id = queue[qi++];
const x = id % COLS, y = (id - x) / COLS;
for (let d = 0; d < 4; d++) {
const nx = x + DLIST[d].x, ny = y + DLIST[d].y;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
const nid = ny * COLS + nx;
if (dist[nid] !== -1 || occ[nid]) continue;
dist[nid] = dist[id] + 1;
first[nid] = first[id];
queue.push(nid);
}
}
let bestId = -1, bestD = Infinity;
for (const f of foods) {
const id = f.y * COLS + f.x;
if (dist[id] >= 0 && dist[id] < bestD) { bestD = dist[id]; bestId = id; }
}
if (bestId >= 0) {
const mv = DLIST[first[bestId]];
if (mv && safeMove(mv)) return mv;
}
// fallback: pick the legal move that leaves the most room
let bestMv = null, bestArea = -1;
for (let d = 0; d < 4; d++) {
const mv = DLIST[d];
if (mv.x === -dir.x && mv.y === -dir.y) continue;
const nx = head.x + mv.x, ny = head.y + mv.y;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
if (occ[ny * COLS + nx]) continue;
const area = floodArea(nx, ny, occ);
if (area > bestArea) { bestArea = area; bestMv = mv; }
}
return bestMv || dir;
}
/* ======================= HUD / overlay ======================= */
function updateHud() {
if (score !== lastScore) { scoreEl.textContent = score; lastScore = score; }
const w = Math.max(0, snake.length - 1);
if (w !== lastWagons) { wagonEl.textContent = w; lastWagons = w; }
if (best !== lastBest) { bestEl.textContent = best; lastBest = best; }
}
function showOverlay(kind) {
if (kind === 'demo') {
badgeEl.textContent = '🚂 Demo — the little train drives itself';
titleEl.textContent = 'Click or press a key to start';
textEl.innerHTML = 'Use <b>↑ ↓ ← →</b> or <b>WASD</b>. Every passenger you collect hops aboard as a new wagon.';
} else {
badgeEl.textContent = '💥 Derailed';
titleEl.textContent = 'Click or press a key to try again';
textEl.innerHTML = 'You picked up <b class="gold">' + score + '</b> passenger' +
(score === 1 ? '' : 's') + ' and pulled <b>' + Math.max(1, score + 1) +
'</b> cars. Best: <b>' + best + '</b>.';
}
overlay.classList.add('show');
}
function hideOverlay() { overlay.classList.remove('show'); }
function startPlaying() {
state = 'play';
resetGame();
hideOverlay();
lastScore = -1; lastWagons = -1;
}
/* ======================= Input ======================= */
function setDir(nd) {
if (state !== 'play') return;
if (nd.x === -dir.x && nd.y === -dir.y) return;
if (nd.x === dir.x && nd.y === dir.y) return;
nextDir = nd;
}
window.addEventListener('keydown', function (e) {
const k = e.key;
if (k === ' ' || k === 'Spacebar' || k.indexOf('Arrow') === 0) e.preventDefault();
if (state === 'play') {
if (k === 'ArrowUp' || k === 'w' || k === 'W') setDir(DIRS.up);
else if (k === 'ArrowDown' || k === 's' || k === 'S') setDir(DIRS.down);
else if (k === 'ArrowLeft' || k === 'a' || k === 'A') setDir(DIRS.left);
else if (k === 'ArrowRight' || k === 'd' || k === 'D') setDir(DIRS.right);
} else if (state === 'demo' || state === 'over') {
startPlaying();
}
});
window.addEventListener('pointerdown', function () {
if (state === 'demo' || state === 'over') startPlaying();
});
/* ======================= Loop ======================= */
function loop(now) {
requestAnimationFrame(loop);
if (!last) last = now;
let dt = now - last;
last = now;
if (dt > 250) dt = 250;
if (state === 'demo' || state === 'play') {
const stepMs = currentStepMs();
acc += dt;
let guard = 0;
while (acc >= stepMs && guard++ < 4) {
acc -= stepMs;
if (state === 'demo') nextDir = aiDir();
step();
if (state !== 'demo' && state !== 'play') { acc = 0; break; }
}
} else if (state === 'demoDead') {
if (now - demoDeadAt > 850) {
state = 'demo';
resetGame();
for (let i = 0; i < FOOD_COUNT; i++) if (foods.length < FOOD_COUNT) spawnFood();
}
}
updateParticles(dt);
const t = (state === 'demo' || state === 'play')
? Math.min(1, acc / currentStepMs())
: 1;
draw(t, now);
updateHud();
}
/* ======================= Boot ======================= */
resize();
resetGame();
showOverlay('demo');
requestAnimationFrame(loop);
if (window.ResizeObserver) {
const ro = new ResizeObserver(function () { resize(); });
ro.observe(stage);
}
window.addEventListener('resize', resize);
window.addEventListener('orientationchange', function () { setTimeout(resize, 120); });
})();
</script>
</body>
</html>Reasoning trace (30,407 tokens)
Let me build a nice Snake game where the snake is a train: locomotive head, colorful wagons following, and passengers waiting on the platform (station tiles) to be picked up.
Design:
- Canvas-based grid game.
- Head = locomotive (drawn with shapes: body, cab, chimney, wheels, cowcatcher).
- Body segments = wagons with different colors, connected by couplings.
- Food = passengers standing on the platform (little people figures). When eaten, a new wagon is added to the train and score increases.
- Self-playing demo mode behind "Click or press a key to start" overlay.
- Grid maybe 24x18 cells with cell size ~40px. Canvas responsive.
Let me plan the visuals:
- Background: grassy green with subtle texture, or a station-like scene. Let's do a top-down view? Hmm, snake games are usually top-down. But a train with a locomotive seen from top... Wagons from top-down look like rectangles. Passengers from top-down look like circles (heads). That could work but less charming.
Alternative: side view grid? Snake moving in 4 directions in side view is weird.
Let's do top-down but draw the train slightly 3/4 style? Simplest robust approach: top-down view with the train drawn as rectangles with details. The locomotive from top has a rounded nose, cab, chimney circle. Wagons from top: rectangles with roofs and colored tops, plus little windows.
Actually a charming approach: draw the train as a top-down but with a bit of "3D" perspective: each car is a rounded rect with a lighter roof inset. Passengers waiting: small heads (circles) with shoulders, standing on a platform tile (light gray tile with yellow edge line). That reads well top-down.
Direction changes: track curves? Keep it simple — cars rotate to align with travel direction. Snake segments: each wagon orientation via direction from previous segment. That looks good.
Let me implement:
Grid: cols=26, rows=18? Let's compute canvas sizing. Viewport 1280x800. Let's have canvas fill available space with aspect ratio matching grid.
Simpler: fixed logical canvas size, scale with CSS. Use devicePixelRatio.
Let me pick cell = 32 logical px, grid 30x18 -> 960x576. That fits nicely in 1280x800 with HUD.
Actually let's make it responsive: compute canvas size from container.
Let me do: the canvas is sized to fit container while maintaining aspect ratio cols/rows. Draw in logical units of cell size = canvasWidth / cols.
Game logic:
- Snake array of {x,y} grid positions, head first.
- Direction, nextDirection.
- Move step every X ms; demo AI slower.
- Food: passengers array? Let's have multiple passengers waiting simultaneously (3 at a time) — more interesting visually. Eating one adds a wagon and score +1, and spawns a new passenger.
Hmm, but the classic snake with one food is fine. Multiple foods makes the board busier and prettier. Let's keep 3 passengers.
Wait, but "picking up passengers waiting on the platform" — passengers spawn at platform tiles. Let's draw platform decoration: a station building, some tiles. Actually keep the board as a station yard: green grass, and a "platform" strip along the edges where passengers spawn? But then passengers are always at edges, making the game easy/weird.
Simpler: the whole yard is a platform-ish floor (concrete with tile lines), passengers appear at random cells. Add decorations: benches, planters, lamp posts around the edges (non-colliding? or just decorative outside the grid). Let's put decorative elements inside the grid cells but ensure passengers/spawn space.
Let me keep it clean: background = concrete platform floor with subtle tile grid, plus some decorative items drawn on top (station sign, benches) at fixed positions that don't block movement — but that could be confusing with passengers. Hmm. Risk of visual clutter.
Let's do: light gray platform tiles with the classic snake grid subtle. Passengers are clearly small figures with a shadow, standing, with a little bobbing animation. Good.
Walls: The train wraps around edges? Or dies at walls? For a demo AI, wrapping is safer and looks smoother. But classic snake dies at walls. Let's use walls = track boundary drawn as a fence/rail. Hmm, self-collision is still possible.
Actually for the AI demo to look good and not die constantly, let's make the edges wrap (like going around a loop) — hmm, a train wrapping is weird.
Alternative: AI uses a safe-ish greedy algorithm with flood-fill safety check. I'll implement a decent AI: greedy toward nearest passenger, avoiding immediate collision, with a simple flood-fill check preferring moves that keep space. That usually survives well with 3 foods on a 30x18 grid.
Let me just do a simple BFS path-finding AI to the nearest food, plus fallback to any safe move with most free space. That's robust.
Walls: die → respawn. In demo mode, on death, reset quickly. Actually let's make the train bounce... no. Death + restart is fine, but the screenshot at 3s shouldn't catch a dead state. With BFS AI on a 30x18 grid with 3 foods and moderate speed, it should survive.
Actually to be extra safe, let me make walls wrap (tunnel!). A train going into a tunnel and coming out the other side — cute. I can draw tunnel entrances at edges. Hmm, that adds complexity. Let's just do walls with collision, and the AI avoids them. If it dies, reset after a short pause and continue — demo continues.
Hmm, but the user asked for a game to play. Let me just implement it well.
Speed: step every 110ms normally; demo 120ms. That's ~8 steps/sec.
Let me now think about drawing the train cars.
Coordinate system: I'll use cell size S. For each snake segment i with position (x,y) in grid:
- center = (x*S + S/2, y*S + S/2)
- direction toward previous segment (for head: current direction).
Draw the car as a rounded rect of size S*0.86 length-wise, S*0.72 cross-wise, rotated to align with direction. Actually add a slight gap so cars look separate.
For head (locomotive): length S*0.94, width S*0.8. Draw:
- body rounded rect (dark green/black with red-ish?) Let's make locomotive: deep blue/teal body with red roof, gold trim, chimney (dark circle near front), headlamp (yellow glow circle at the very front), cowcatcher (triangle at the front).
- Actually top-down: chimney = dark circle with lighter inner circle at ~25% from front along center. Cab = a lighter rect near the back.
- Headlamp: small yellow circle at the front edge with a glow.
Wagons: colors array: red, orange, gold, green, teal, blue, purple, pink. Each wagon: rounded rect body, roof lighter shade inset, two small windows (light squares) along the length, wheels? Top-down no wheels. Add coupling: a dark line between wagons.
Let me orient: I'll set up canvas transform: translate to center, rotate by angle, then draw car centered, with "forward" = -Y axis? Let's define car local coordinates with length along X, forward = +X. Then rotate accordingly.
Angle from segment direction (from segment i to segment i-1? no). For segment i, direction = position[i-1] - position[i]... Actually the direction the car is facing = direction of travel = pos[i-1] - pos[i] (toward the head). For the head, it's the current direction.
Compute dx, dy. If dx>0 angle=0, dx<0 angle=PI, dy<0 angle=-PI/2, dy>0 angle=PI/2. With forward = +X.
But careful with wrapping — no wrapping, so fine. Except when the snake turns, adjacent segments are one cell apart either horizontally or vertically always (since it's a grid snake). Good.
Smooth animation: interpolate positions between steps? That's nicer but adds complexity with turning. Let's do it: keep a `t` progress (0..1) between steps and render each segment at lerp(prevPos, pos). Hmm, for a snake the segment positions are already discrete; interpolating each segment between its old and new position works and looks smooth. Since each segment moves into the previous segment's cell, and turns happen at the head, the visual would show the body sliding. With cars rotating instantly at corners it can look odd — a car would rotate 90° instantly while its position lerps.
Hmm. Compromise: use lerp for position but compute angle from movement delta (pos - prevPos) so rotation matches motion. For the head turning, the head angle would be from its own movement. For body segments, they follow. Actually if body segment moves from (5,5) to (6,5) horizontally, its angle is horizontal. Meanwhile the next segment behind it moves from (4,5) to (5,5). Both horizontal. It works nicely! Cars stay axis-aligned and just slide. Corners will look a bit "teleporty" but honestly it's fine and smooth.
Hmm, but with lerp, at a corner the segments all shift and the arrangement looks like a snake. Fine. Let's do lerp with easing? Linear is fine for constant speed. Actually let's do a slight smoothing... no, linear is correct for constant speed.
Let's do interpolation. t goes 0→1 over step duration.
Actually, let me reconsider: with lerp, at t=0.5 the head is halfway between cells. If the head is turning, the head at 0.5 is halfway diagonal... no: head prev pos (5,5) → (5,4) going up. So it slides up. Angle = up. Fine. The wagon behind goes (4,5)→(5,5) right. So there's a visual kink — that's exactly like snake bodies. Good.
Ok.
Passengers: draw at cell center, small figure: shadow ellipse, body (rounded rect, colored coat), head (circle, skin tone), maybe a little hat/hair, and a small suitcase beside. Bobbing animation with sin.
Add a "platform tile" under passenger? Maybe a subtle yellow-edged tile to mark it as a pickup point. Nice touch: draw a rounded light tile with a yellow rim under each passenger. That makes them pop.
Score/HUD: top bar with score, wagons count, best (in memory, not localStorage — just a variable). Also "SPEED" maybe.
Overlay: "Click or press a key to start" — with a nice card. On start, hide overlay and reset game (player mode).
Modes: 'demo' | 'playing' | 'over'. In demo, AI plays. In playing, player controls with arrows/WASD. On death, show game over card with score and "Click or press key to restart" → back to demo? No: restart playing.
Let's simplify: game states: demo, playing, dead. Overlay shown in demo and dead.
Actually for the screenshot, we want the demo running with the overlay message visible but not blocking the view too much. Use a semi-transparent overlay with a message card at the bottom or center that doesn't obscure the whole board. Let's do a centered card with backdrop blur over the whole board but light (rgba(0,0,0,0.25)) so the game is visible.
Hmm, but a screenshot comparison — the demo should look great. Let's make the overlay a card at the bottom center: "Click or press a key to start" with a subtitle. Not covering the game much. Good.
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>
</div>
<script>...</script>
</body>
</html>
```
Layout: flex column, centered, with a wooden/station-ish backdrop.
Colors: dark slate background (#0f172a-ish) with warm accents; or a light warm background. Let's do a deep navy/charcoal background with a wood-ish panel, canvas has a green/cream platform.
Let me go with: page background = radial gradient dark teal/navy. Board = cream/light-gray platform with tile grid. Train = colorful. Passengers = colorful. Looks nice.
Hmm, maybe better: board = soft green grass with a path? "platform" — let's do light stone tiles (#e8e2d5 / #ded7c7) with a subtle grid and a slight vignette. Train colors pop.
Actually, I like a warm sand platform. Let's do:
- cell fill: #eae4d6, grid lines #ded6c4.
- Add a few decorative darker tiles pattern? Keep it.
Border of the board: a dark rail/fence — draw thick rounded border in a wood/dark color.
Let's write drawing code.
Canvas setup:
```js
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const COLS = 28, ROWS = 18;
```
Aspect 28/18 = 1.556.
Sizing: use a container with width based on available space. I'll compute in JS: available width = stage.clientWidth, height = stage.clientHeight, cell = min(w/COLS, h/ROWS). Then canvas logical size = COLS*cell x ROWS*cell, with DPR scaling.
Simpler: set canvas.style.width/height in px and canvas.width = cssW*dpr. Then use ctx.setTransform(dpr,0,0,dpr,0,0) and draw in CSS px with cell = cssW/COLS.
Let's implement resize():
```js
function resize(){
const stage = document.getElementById('stage');
const rect = stage.getBoundingClientRect();
const pad = 0;
const maxW = rect.width, maxH = rect.height;
cell = Math.floor(Math.min(maxW/COLS, maxH/ROWS));
const w = cell*COLS, h = cell*ROWS;
canvas.style.width = w+'px'; canvas.style.height = h+'px';
const dpr = Math.min(window.devicePixelRatio||1, 2);
canvas.width = Math.round(w*dpr); canvas.height = Math.round(h*dpr);
ctx.setTransform(dpr,0,0,dpr,0,0);
}
```
Use cell as integer for crisp rendering. Then draw with cell.
Actually with cell integer and canvas CSS size integer, good.
Let's use ResizeObserver on stage, or window resize.
Stage: flex:1, min-height:0, display:flex, align-items:center, justify-content:center.
Hmm, since canvas size depends on stage size and canvas is inside stage, that could cause feedback loops. Use position:absolute for canvas? Let's make stage `position:relative` and canvas absolutely positioned centered, so canvas doesn't affect stage size. Then stage gets size from flex layout. Good.
Game state:
```js
const DIRS = {up:{x:0,y:-1}, down:{x:0,y:1}, left:{x:-1,y:0}, right:{x:1,y:0}};
let snake, dir, nextDir, foods, score, best, state, stepMs, acc, tPrev, prevSnake, growPending, deadTimer.
```
Snake as array of {x,y}, index 0 = head.
Step function:
```js
function step(){
dir = nextDir;
const head = snake[0];
const nx = head.x + dir.x, ny = head.y + dir.y;
// wall collision
if (nx<0||ny<0||nx>=COLS||ny>=ROWS) { die(); return; }
// self collision: check all except tail (tail will move) unless growing
const limit = growPending>0 ? snake.length : snake.length-1;
for (let i=0;i<limit;i++) if (snake[i].x===nx&&snake[i].y===ny) { die(); return; }
...
}
```
Standard. Need prevSnake for interpolation: store copy of snake before step.
Let me restructure: keep `snake` as the current logical positions, and `prevSnake` as positions before last step. For interpolation, for each segment i, current pos snake[i], prev pos prevSnake[i] (if exists, else snake[i]).
When growing, the new tail segment has no prev — use snake[i] (stays put). Good.
Render loop:
```js
function frame(now){
const dt = Math.min(now - lastTime, 100); lastTime = now;
if (state==='playing' || state==='demo') {
acc += dt;
while (acc >= stepMs) { acc -= stepMs; step(); if (state==='dead') break; }
}
const t = state==='dead' ? 1 : Math.min(1, acc/stepMs);
draw(t, now);
requestAnimationFrame(frame);
}
```
Hmm, on die, we want to stop. Let's have `state` values: 'demo', 'playing', 'dead'. When dead, we set a timer to reset.
Actually let's simplify: on death, set state='dead', show overlay "Game over" and wait for input (in playing) or auto-restart after 1.2s (in demo). Hmm, but if playing and dead, the demo should maybe resume? Let's do: on death → state='dead', overlay shows game over with score. Click/key → restart in playing mode.
For demo death → immediately reset demo (with maybe a tiny flash). Fine.
Let me make demo robust enough that deaths are rare.
AI:
```js
function aiChoose(){
const head = snake[0];
// BFS to nearest food
// build blocked set from snake (except tail), then BFS from head over free cells
}
```
With snake length growing, grid 28x18=504 cells. BFS is cheap.
Plan:
1. Compute blocked grid: cells occupied by snake body (indices 0..len-2 maybe; the tail moves so it's free, but if we eat we grow — ignore that subtlety).
2. BFS from head to each food, find shortest path. Take the first move of the shortest path to the nearest reachable food.
3. Safety: simulate the move; check that from the new head position there's enough free space (flood fill >= snake.length + some margin). If not safe, pick the move that maximizes reachable free space.
4. If no food reachable, pick the safe move with max space.
Good enough.
Let me write helper `isBlocked(x,y, bodyCells)`.
Actually a subtlety: when the snake is long, BFS to food may go through tight spaces. For a demo it's fine.
Let me write:
```js
function aiNextDir(){
const head = snake[0];
// occupancy
const occ = new Uint8Array(COLS*ROWS);
for (let i=0;i<snake.length-1;i++) occ[snake[i].y*COLS+snake[i].x] = 1;
// note head included in body; that's fine since we start BFS from head's neighbors
// BFS from head
const dist = new Int32Array(COLS*ROWS).fill(-1);
const firstMove = new Int32Array(COLS*ROWS).fill(-1);
const q = [];
const hx = head.x, hy = head.y;
const dirs = [DIRS.up, DIRS.down, DIRS.left, DIRS.right];
for (let d=0; d<4; d++){
const nx=hx+dirs[d].x, ny=hy+dirs[d].y;
if (nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
if (occ[ny*COLS+nx]) continue;
if (dirs[d].x===-dir.x && dirs[d].y===-dir.y) continue; // no reverse
...
}
```
Wait, we also must not reverse into the neck. dir is current direction. Reverse = -dir. Skip.
Hmm but actually the neck occupies that cell anyway so occ handles it... unless length 1.
Continue BFS, and track `firstMove` for each reached cell.
Then find nearest food cell (any food whose cell has dist>=0 and not occupied) → choose that firstMove.
Then safety check on candidate move: simulate new head at candidate; flood fill count free cells from there; require count >= snake.length (or some threshold like min(snake.length+2, ...)). If safe, return it.
If not safe or no path, fallback: evaluate all 4 legal moves, score by flood fill area, choose max. If all zero, choose any legal.
Let me implement floodFill(startX,startY,occ) returning count.
For the fallback, tentatively mark the new head and tail considerations... keep simple.
Alright.
Now drawing details.
drawTrack/border: draw a rounded rect background over whole canvas in a dark color (#3a3128?) as the "frame", then inner platform.
Actually since canvas is exactly COLS*cell, let's draw:
- fill whole canvas with platform color.
- draw subtle tile grid lines.
- draw a border inset (dark rail) — but that eats cells. Instead, draw the border as an overlay of 3px on the edges (on top of the border cells). Or draw the outer edge shadow inside the canvas. Since the canvas is at the edge, drawing a 4px inset border on top of the first row would look like a wall. Let's just add a CSS box-shadow/border-radius on the canvas element itself with a border. Then the play area is exactly the canvas. Nice.
So canvas CSS: border-radius: 14px; box-shadow: big; border: 4px solid #2a2f3a maybe. But border adds to size... with box-sizing: border-box the CSS width includes it, and then the coordinate mapping is off. Let's instead put a wrapper div with padding = border. Or use outline. Simplest: wrapper div with background and padding 6px, border-radius, canvas inside. But then the canvas centering... Let's do:
```html
<div id="stage">
<div id="boardWrap"><canvas id="c"></canvas></div>
<div id="overlay">...</div>
</div>
```
boardWrap: padding:6px; border-radius: 18px; background: linear-gradient(#2b3340,#1b2130); box-shadow.
And resize computes cell from stage size minus padding.
Simpler: I'll compute in resize using the stage rect and subtract a fixed frame padding (16px). Then set canvas size. The wrap will size to content.
Let's just do: measure stage rect (which is flex:1 with padding). Inside, an absolutely centered wrapper. Let's do:
```css
#stage{position:relative; flex:1; min-height:0; display:grid; place-items:center; padding: 10px;}
#boardWrap{position:relative; padding:8px; border-radius:20px; background:...; box-shadow:...;}
canvas{display:block; border-radius:12px;}
```
Because #stage is a grid with place-items center, and #boardWrap is a normal flow child, the canvas size affects the wrap size, which could affect stage... but stage has flex:1 with definite size from the flex container, so no feedback. Should be OK. To be safe, set #boardWrap max-width/max-height 100%.
Hmm, grid place-items center with a child whose size is determined by JS... The stage size comes from the flex layout (flex:1, min-height:0) → definite height. Width 100%. So no loop. Good.
In resize:
```js
const pad = 20; // stage padding + wrap padding
const availW = stage.clientWidth - 24;
const availH = stage.clientHeight - 24;
```
Let's just compute availW = stage.clientWidth - 24, availH = stage.clientHeight - 24 (accounting for stage padding 10*2 = 20 plus a bit).
Actually stage has padding 10px so clientWidth includes padding. Use stage.clientWidth - 20 (padding) - 16 (wrap padding) = -36. Let's use -40.
Then cell = Math.max(8, Math.floor(Math.min(availW/COLS, availH/ROWS))).
Alright.
Now the drawing functions.
```js
function draw(t, now){
const W = COLS*cell, H = ROWS*cell;
ctx.clearRect(0,0,W,H);
drawGround();
drawPassengers(now);
drawTrain(t, now);
}
```
drawGround:
- fill '#e9e2d2'
- tile lines: for x from 0..COLS, vertical line at x*cell with '#dbd2bd', 1px. Similarly horizontal.
- Add subtle noise? Skip, use a radial vignette.
- Add a "station" decoration: a rectangle area near the top-left with a sign? Might clutter. Let's add subtle platform markings: a yellow safety line along the edges? Hmm.
Let me add some subtle scenery: scattered small dots/texture via a precomputed pattern drawn once to an offscreen canvas. Could be nice. Let's pre-render the ground to an offscreen canvas on resize (tiles + noise dots + vignette) then drawImage each frame. Performance good.
I'll add random small speckles with a seeded RNG for consistent look.
Also maybe add a few "bench" and "lamp" decorations at fixed cells drawn on the ground layer. Let's add: 4 lamp posts at fixed positions (corners-ish), 2 benches. They're purely visual and drawn in the ground layer (so the train drives over them — slightly odd). Maybe skip to avoid confusion.
I'll add subtle footstep-like speckles only.
drawPassengers: for each food {x,y}:
- center cx, cy
- draw tile: rounded rect of size cell*0.86, fill '#fff8e6' with a soft shadow and a gold dashed border? Let's do: fill 'rgba(255,235,180,0.85)' rounded, stroke '#f0b429' 2px.
- Draw person:
- bob = Math.sin(now/300 + phase)*cell*0.03
- shadow ellipse
- legs: two small rounded rects dark
- body: rounded rect in coat color, width cell*0.34, height cell*0.3
- head: circle radius cell*0.11, skin color
- hair/hat: arc
- suitcase: small rect next to them in another color.
Sizes relative to cell (say cell=32): person total height ~ cell*0.7.
Let's define relative:
- shadow: ellipse at (cx, cy + cell*0.30), rx cell*0.22, ry cell*0.07, fill rgba(0,0,0,0.15)
- legs: rect from y cy+0.16 to cy+0.30 → two 3px wide
- body: rounded rect centered at (cx, cy+0.03), w=cell*0.30, h=cell*0.30, r=cell*0.10
- head: circle at (cx, cy-cell*0.18) r=cell*0.11
- hat: small arc or rect.
- suitcase: rounded rect at (cx + cell*0.26, cy+cell*0.16), w cell*0.18, h cell*0.14.
Hmm that might be too detailed for small cells. At cell=32, a person is ~22px tall. Doable.
Let's ensure the bob applies to the whole person but the shadow stays.
drawTrain:
For i from snake.length-1 down to 0 (draw tail first so head on top), draw car at interpolated pos.
Interpolation:
```js
function segPos(i, t){
const c = snake[i];
const p = prevSnake[i] || c;
return { x: (p.x + (c.x - p.x)*t + 0.5)*cell, y: (p.y + (c.y-p.y)*t + 0.5)*cell };
}
```
Careful: when the snake grows, the new tail index has prevSnake[i] undefined → use c.
Also when the head moves into a new cell, prevSnake[0] is old head position, snake[0] is new. Good. And snake[i] = prevSnake[i-1] after a step (body follows). So interpolation is consistent.
Angle:
```js
const a = Math.atan2(c.y - p.y, c.x - p.x) // direction of movement
```
If p equals c (no movement, e.g., new tail), fall back to the segment's neighbor direction. Let's compute fallback: use direction from segment i+1 to i (i.e., toward the tail) reversed... Simply: if zero, use angle of (snake[i-1] - snake[i]) i.e., toward the head. Actually for the tail car the movement is what matters. Let's just compute:
```js
let dx = c.x - p.x, dy = c.y - p.y;
if (dx===0 && dy===0) {
const nb = snake[i-1] || snake[i+1];
if (nb) { dx = snake[i].x - nb.x; dy = snake[i].y - nb.y; } // pointing away from neighbor
}
```
Hmm for the head with i=0, nb = snake[1], pointing away from the neck = forward direction.
For a new tail segment i = len-1, nb = snake[len-2], pointing away from that = backward along the body = correct direction.
Then angle = atan2(dy, dx).
Draw car with ctx.save(); translate(pos); rotate(angle); ... draw with forward = +X.
Car drawing (local coords, forward +X, length L along X, width Wd along Y):
Locomotive (i===0):
- L = cell*0.98, Wd = cell*0.80
- Shadow: rounded rect offset (0, cell*0.08) fill rgba(0,0,0,0.18) — actually a blur shadow via ctx.shadowBlur is easier but slow. Use a manual offset rect.
- Main body: rounded rect from (-L/2, -Wd/2) size L x Wd, radius cell*0.16, fill gradient from #d94f3d? Let's do a classic steam loco: dark green body #2f6b4f with red accents? Or a bright red loco #e2493d with black roof.
Let's design:
- body base: #1f3a5f (deep blue) with lighter top.
- Actually let's do a charming red loco: #e04b3a body, #ffd166 trim, dark #2b2b35 details.
Top-down loco:
1. Main chassis: rounded rect L×Wd, fill '#d93b32' (red), with a slightly darker stroke.
2. Roof panel: rounded rect inset from -L/2+0.16L to +... hmm.
Let me think of what a top-down steam locomotive looks like:
- Front: cowcatcher (a trapezoid/triangle) at the nose, dark gray.
- Boiler: long cylinder, dark (near-black/dark green) with a red or brass band.
- Chimney: circle near the front on the boiler, dark with a lighter ring.
- Cab: a box at the rear, wider, with windows (light blue) on top.
- Dome: small circle between chimney and cab.
That's a nice set. Let's do:
- Body (boiler + cab) as a rounded rect: from x=-L/2 to x=L/2, Wd wide. Fill dark green #2d5c4a? Let's use a rich color: #1f4d3f... Hmm, contrasting with the cream platform: a dark loco with red accents looks classic.
Actually let's use a warm red loco: boiler #c8352b (crimson), roof/trim #2c2c34 dark, brass #f2c14e, windows #bde7ff.
Details in local coords (forward = +X, so the nose is at +L/2):
- Cowcatcher: path from (L/2, -Wd*0.42) to (L/2 + L*0.10, 0) to (L/2, Wd*0.42) → triangle, fill '#3a3f4a'. Hmm, that extends beyond the cell. Let's keep within: nose at L/2 = cell*0.49, extends to cell*0.58. Slight overflow into the next cell is fine visually but might overlap the cell boundary. It's fine.
Hmm, wait — the train is a snake; the head's nose sticks out. It's fine.
- Boiler: rounded rect from x=-L*0.12 to L*0.42, width Wd*0.72, fill '#cf3a2e'. Wait, let's simplify: the whole body is one rounded rect in #cf3a2e, then add:
- a darker "roof" strip?
Let me do this concretely:
```
L = cell*0.94, W = cell*0.78
// shadow
roundRect(-L/2+2, -W/2+3, L, W, r) fill rgba(0,0,0,0.18)
// main body
roundRect(-L/2, -W/2, L, W, r=cell*0.18) fill '#d6402f'
// boiler darker band along center
roundRect(-L*0.35, -W*0.28, L*0.7, W*0.56, r=cell*0.12) fill '#b32d24'?
```
Hmm, getting complicated. Simpler & readable top-down loco:
1. Base plate (dark gray #333a45) rounded rect L×W.
2. Cab: rounded rect at rear (x from -L/2+2 to -L/2 + L*0.34), full height W*0.94, fill red #d2402f, with two windows (light #cdefff) at x center of cab, offset ±W*0.22.
3. Boiler: rounded rect from -L*0.16 to L*0.40 along x, height W*0.62, fill #2f9e6f? no...
OK let me just pick: locomotive = red cab + gold/dark boiler + chimney.
Actually, simpler and very readable: make the locomotive look like a chunky cartoon train from above:
- Body: rounded rect, gradient red (#ef5a44 → #c33425).
- A darker "roof" rounded rect inset by cell*0.06 on the rear half (the cab roof) — dark charcoal #2b2f3a with a light #d9e6f2 window strip.
- Chimney: circle at 0.22*L forward, radius W*0.22, fill #2b2f3a with inner #6b7280 and a highlight.
- Headlamp: circle at the nose, radius W*0.16, fill #ffd166 with a glow.
- Cowcatcher: triangle at the front, dark.
That's 5 elements — good enough and reads as a train.
Wagons:
- Body: rounded rect L=cell*0.86, W=cell*0.74, radius cell*0.16, fill the wagon color (gradient light→dark for depth).
- Roof: inset rounded rect (inset cell*0.09) in a lighter tint of the color, with two or three windows in #dbeeff? Actually top-down wagon roofs are solid. Add a lighter roof panel + a thin dark outline + two small round vents.
- Add a thin dark line at the rear (coupling).
Hmm — to make it read as a train, the coupling between cars: draw a short dark bar from the rear of car i to the front of car i+1. Since cars are ~0.86*cell long in a cell of size cell, the gap between centers is cell, so gap between car edges is cell*0.14. A small bar of width cell*0.14 at the rear center. That'd be drawn per car at the rear. But when cars are perpendicular at a corner the coupling looks weird. It's fine — skip couplings, or draw them only when the two are aligned. Let's draw a small dark nub at the rear of each car, and at the front of the locomotive. Eh, let's just add a small dark rectangle at the rear center of each car (the coupler) — small, won't look bad.
Windows on wagons: since it's top-down, two small light-blue rounded rects along the roof. Let's add 2 small circles/rects in a light color so it reads as a passenger car. Good.
Color palette for wagons:
```js
const WAGON_COLORS = [
{main:'#f2b134', light:'#ffd77a', dark:'#c8891c'}, // yellow
{main:'#4cc0a0', light:'#88e6cb', dark:'#2e8f75'}, // teal
{main:'#5b8def', light:'#93b8ff', dark:'#3a62b8'}, // blue
{main:'#e0607a', light:'#ff9db0', dark:'#b03c55'}, // pink
{main:'#a97ce0', light:'#c9a6f5', dark:'#7d54b3'}, // purple
{main:'#f28f3b', light:'#ffb877', dark:'#c26a20'}, // orange
{main:'#7ed957', light:'#a9ef8c', dark:'#54a833'}, // green
];
```
Wagon index i-1 % length.
Now the head must be smoothly drawn. Also add a subtle "smoke puff" from the chimney — animated particles. That'd be a lovely touch. Let's add a simple particle system: every ~120ms (when moving), spawn a puff at the chimney position in world coords, with velocity opposite the direction, growing and fading. Keep it light.
Chimney world position: head center + rotate(forward*0.22L, 0). Let's compute and store when spawning.
Actually smoke puffs add life. Let's do it: array of {x,y,r,life,angle}. Each frame, update r += dt*0.02 etc. Draw as white circles with alpha.
Cap at 60 particles.
Also add a "chuff" animation? Skip.
Passenger pickup effect: floating "+1" text and sparkles. Let's add a simple pop particle burst when eating. Good for juice.
Let's also add a subtle screen shake? No, keep it calm.
Sound: no.
Now the HUD:
- Title "SNAKE TRAIN" with a little emoji.
- Score: passengers picked up.
- Best score.
- Length/wagons.
- Speed maybe.
Design: top bar with rounded pill cards.
Overlay: bottom-center card. Text: "🚂 Snake Train — Click or press any key to start". Actually per rules: "Click or press a key to start". Let's put a centered overlay with a semi-transparent scrim, and a card. But it would hide the demo. Compromise: scrim with very low opacity (0.15) + card at the bottom center. It's a screenshot comparison — the game must be visible. I'll do a card at the bottom center, plus a soft dark gradient at the bottom of the canvas for readability.
Hmm, but the "start" message should be clear. Bottom card with bold text works.
Let me put the overlay as an absolutely positioned div covering the full stage with pointer-events:none (so clicks go to... well, we handle clicks on document anyway), and the card has pointer-events:auto. Actually simpler: overlay has pointer-events:none, and we listen for clicks on window.
Card design:
```html
<div id="overlay" class="show">
<div class="card">
<div class="kicker">🐤 DEMO</div>
<h2>Click or press a key to start</h2>
<p>Arrow keys / WASD to drive the train. Pick up every passenger to add a wagon.</p>
</div>
</div>
```
Position: absolute bottom: 18px, left/right center. Max width 520px.
When playing, hide overlay. When dead, show game-over card: "Derailed! Score: N — Click or press a key to restart".
Now, keyboard handling: keydown on window. Arrow keys preventDefault.
Start logic:
```js
function startGame(){
state='playing';
resetGame();
hideOverlay();
}
```
But wait: on death in playing mode, we show game over; pressing a key restarts (playing). In demo mode, pressing a key starts the game.
Let me define:
- `mode` = 'demo' | 'play'
- `alive` = true/false
Hmm. Let's keep it simple:
```js
let state = 'demo'; // 'demo' | 'play' | 'over'
```
- demo: AI drives; overlay shows start card.
- play: player drives; no overlay.
- over: overlay shows game over card; the train stays on screen frozen.
On death:
- if state==='demo' → resetGame() after 700ms (setTimeout) and stay demo. To avoid setTimeout stacking, use a `respawnTimer`.
- if state==='play' → state='over', show card.
On key/click:
- if state==='demo' → state='play', resetGame(), hide overlay.
- if state==='over' → state='play', resetGame(), hide overlay.
- if state==='play' → nothing (or ignore).
Good.
Now the reset:
```js
function resetGame(){
snake = [{x:6,y:9},{x:5,y:9},{x:4,y:9}];
prevSnake = snake.map(s=>({...s}));
dir = DIRS.right; nextDir = DIRS.right;
score = 0; acc = 0;
foods = [];
for (let k=0;k<FOOD_COUNT;k++) spawnFood();
particles = []; puffs = [];
}
```
Start position: left middle, moving right. Length 3 → locomotive + 2 wagons. Nice, so there are wagons visible at the start.
spawnFood: random free cell not on snake, not on another food, and not directly in front of the head (to avoid instant pickup). Also prefer cells at least 3 away from the head.
FOOD_COUNT = 3.
step():
```js
function step(){
prevSnake = snake.map(s=>({...s}));
dir = nextDir;
const nx = snake[0].x + dir.x, ny = snake[0].y + dir.y;
if (nx<0||ny<0||nx>=COLS||ny>=ROWS){ die(); return; }
const willGrow = foods.some(f=>f.x===nx&&f.y===ny);
const limit = willGrow ? 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 (willGrow){
const idx = foods.findIndex(f=>f.x===nx&&f.y===ny);
const f = foods[idx];
foods.splice(idx,1);
score++;
spawnBurst(nx,ny,f.color);
spawnFood();
} else {
snake.pop();
}
}
```
Wait — prevSnake must have the same length as snake after growth. When we grow, snake gets +1 at the head; prevSnake has one fewer. For interpolation with index i of the new snake: new snake[i] for i>=1 equals old snake[i-1]. And prevSnake[i] = old snake[i] which is wrong for i>=1...
Hmm. Let me reconsider. After growth, snake = [newHead, old0, old1, ..., oldN]. prevSnake (old) = [old0, old1, ..., oldN]. For the interpolation, segment i of the new snake should interpolate from... well, in reality the cars all move forward one cell, and the new car appears at the tail? No — the tail doesn't move when growing. Actually in snake, when you eat, the tail stays and everything shifts forward.
Segment i's new position = old position of segment i-1. So for interpolation, prev position of segment i should be old position of segment i... but the segment identity shifted. Ugh.
The right way: think of the train as cars in a line. The head car moves forward. Car #1 (first wagon) moves into where the head was. So car #1's new cell = old head cell, and its previous cell = old car#1 cell = old snake[1]. So for the new snake array index i, prevPos should be old snake[i] if it exists, else (for the newly added tail car when growing) its own position (it doesn't move... wait, when growing, the new tail car appears).
Let's carefully: old snake array S_old = [h, b1, b2, ..., bN] (positions). After a normal step (no growth), new array = [h', h, b1, ..., bN-1] — the tail bN is removed. Each index i in the new array corresponds to the car that was at index i-1 in the old array... no wait. Index i in the new array is a *car identity*, not a position.
Car identities: car0 = locomotive, car1..carN = wagons. In the old array, car0 is at position h. After the step, car0 is at h'. Car1 was at b1 and is now at h. Car2 moves to b1. Etc. So the position of car i in the new array equals the position of car i-1 in the old array. And the previous position of car i is its old position = old array[i].
New array index i holds car i's new position = old[i-1]. Old array index i holds car i's old position. So prevPos for new-index i = old[i].
So `prevSnake[i]` where prevSnake is the old array — exactly right! For i=0: prev = old[0] = h, new = h'. ✓. For i=1: prev = old[1] = b1, new = h ✓ (car1 moved from b1 to h).
When growing: new array = [h', h, b1, ..., bN] (tail kept). New index N+1 = bN (the new wagon, appearing at the old tail position). prevSnake[N+1] doesn't exist → use its own position bN → it stays put and appears. That's a bit abrupt but acceptable. Actually it looks like a new wagon materializes at the tail. That's fine and even thematic (coupling a new wagon).
Hmm, but the rendered car at the tail never moves on the step it's created, then moves normally afterward. Fine.
Great, so `prevSnake = snake.slice()` (shallow copies of the objects, since we don't mutate them — we push new objects). Actually `snake.unshift({x,y})` creates a new object, and `snake.pop()` removes. The existing objects are not mutated. So a shallow copy of the array is enough: `prevSnake = snake.slice()`.
Careful: snake.slice() gives references to the same point objects, but since we never mutate points, that's fine. Good.
Now interpolation for index i where prevSnake[i] is undefined → use snake[i].
Now the render.
Let's write the draw functions.
```js
function rr(ctx, 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();
}
```
Ground pre-render:
```js
let groundCanvas = null;
function buildGround(){
const W = COLS*cell, H = ROWS*cell;
groundCanvas = document.createElement('canvas');
groundCanvas.width = W*dpr; ...
}
```
Hmm, need dpr. Let's just make the ground canvas at device pixels.
Actually, simpler: draw ground each frame — it's just a fill + some lines. 28+18 lines = 46 line draws, fine at 60fps. But with a fill + stroke each... it's cheap enough. Let's just do it live but keep it simple.
Ground drawing:
```js
ctx.fillStyle = '#efe8d8';
ctx.fillRect(0,0,W,H);
// checker tiles
for (let y=0;y<ROWS;y++) for(let x=0;x<COLS;x++){
if ((x+y)%2===0){ ctx.fillStyle='rgba(0,0,0,0.022)'; ctx.fillRect(x*cell,y*cell,cell,cell); }
}
// grid lines
ctx.strokeStyle='rgba(120,100,70,0.13)'; ctx.lineWidth=1;
for (let x=0;x<=COLS;x++){ ctx.beginPath(); ctx.moveTo(x*cell+.5,0); ctx.lineTo(x*cell+.5,H); ctx.stroke(); }
...
```
The double loop is 504 iterations with fillRect — acceptable but let's precompute. Fine, it's cheap.
Actually let's pre-render into an offscreen canvas once per resize for cleanliness. I'll do that.
Vignette: radial gradient overlay from transparent center to rgba(0,0,0,0.08) edges. Draw each frame with the ground.
OK let me write the whole thing now.
```js
const COLS = 28, ROWS = 18;
```
28*18 = 504 cells. Cell size on 1280 wide: stage width ~1280-40 = 1240 → cell = min(1240/28, (800-hud-...)/18). HUD ~ 70px, stage height ~ 800-70-40 = 690 → 690/18 = 38.3; 1240/28=44.3. So cell ≈ 38. Board = 1064 x 684. Good.
On 375px wide: stage width 375-40=335 → 335/28 = 11.96 → cell=11. Board 308x198. Hmm, quite small vertically. That's fine; the HUD stacks.
Actually for mobile, maybe reduce COLS. Let's keep it responsive by adjusting grid? That complicates. 28x18 with cell 11 is small but playable-ish. Let's use COLS=24, ROWS=16 → on desktop cell = min(1240/24=51.6, 690/16=43.1) = 43. Board 1032x688. On mobile: 335/24=13.9 → cell 13, board 312x208. Slightly better.
Let's go with 24x16 = 384 cells. Good.
Hmm, on desktop the board would be 1032x688 which is nice and big.
Let me set COLS=24, ROWS=16.
Now, the food count 3 on a 384-cell board with a growing snake. Fine.
Speed: stepMs based on state. Demo: 130ms. Play: starts at 150ms, decreasing with score? Let's keep constant 120ms for play, 130 for demo. Actually make it slightly progressive: stepMs = Math.max(75, 140 - score*3). Nice for gameplay.
Hmm, for the demo, keep it at 130 constant so the AI has time.
Let me finalize:
```js
function currentStepMs(){
if (state==='demo') return 135;
return Math.max(80, 145 - score*4);
}
```
Now let's write the AI.
```js
function aiDir(){
const head = snake[0];
const N = COLS*ROWS;
const occ = new Uint8Array(N);
for (let i=0;i<snake.length;i++) occ[snake[i].y*COLS+snake[i].x]=1;
// head cell occupied — that's fine, we BFS from neighbors.
const dist = new Int32Array(N).fill(-1);
const first = new Int32Array(N).fill(-1); // direction index of first move
const queue = [];
const dlist = [DIRS.up, DIRS.right, DIRS.down, DIRS.left];
for (let d=0; d<4; d++){
const dx = dlist[d].x, dy = dlist[d].y;
if (dx === -dir.x && dy === -dir.y) continue; // no reverse
const nx = head.x+dx, ny = head.y+dy;
if (nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
if (occ[ny*COLS+nx]) continue;
const id = ny*COLS+nx;
if (dist[id] !== -1) continue;
dist[id] = 1; first[id] = d; queue.push(id);
}
let qi=0;
while (qi<queue.length){
const id = queue[qi++];
const x = id%COLS, y = (id/COLS)|0;
for (let d=0; d<4; d++){
const nx = x+dlist[d].x, ny=y+dlist[d].y;
if (nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
const nid = ny*COLS+nx;
if (dist[nid]!==-1) continue;
if (occ[nid]) continue;
dist[nid]=dist[id]+1; first[nid]=first[id]; queue.push(nid);
}
}
// find nearest food
let bestId=-1, bestD=1e9;
for (const f of foods){
const id = f.y*COLS+f.x;
if (dist[id]>=0 && dist[id]<bestD){ bestD=dist[id]; bestId=id; }
}
// candidate
if (bestId>=0){
const d = first[bestId];
if (safeMove(dlist[d])) return dlist[d];
}
// fallback: maximize flood fill area
let bestMv=null, bestArea=-1;
for (let d=0; d<4; d++){
const mv = dlist[d];
if (mv.x===-dir.x && mv.y===-dir.y) continue;
const nx=head.x+mv.x, ny=head.y+mv.y;
if (nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
if (occ[ny*COLS+nx]) continue;
const area = floodArea(nx,ny,occ);
if (area>bestArea){ bestArea=area; bestMv=mv; }
}
return bestMv || dir;
}
```
Wait, the head cell is occupied in `occ`, so BFS from the head's neighbors is fine. But there's an issue: when the snake is long, the tail cell will be vacated. Ignoring that is conservative — fine.
safeMove(mv): simulate the head moved to (hx+mv.x, hy+mv.y), then flood-fill from there over free cells (occupied = body except the tail? ) and require area >= snake.length + 2.
```js
function safeMove(mv){
const nx = snake[0].x+mv.x, ny = snake[0].y+mv.y;
if (nx<0||ny<0||nx>=COLS||ny>=ROWS) return false;
const occ = new Uint8Array(COLS*ROWS);
for (let i=0;i<snake.length-1;i++) occ[snake[i].y*COLS+snake[i].x]=1;
if (occ[ny*COLS+nx]) return false;
const area = floodArea(nx,ny,occ);
return area >= snake.length + 3;
}
```
Hmm, `floodArea` starting at (nx,ny) — but occ doesn't include the head's new position, so it's free. Good. But it includes the old head cell as occupied (snake[0] is in the loop i<length-1). That's correct-ish since the body follows.
floodArea:
```js
function floodArea(sx, sy, occ){
const seen = new Uint8Array(COLS*ROWS);
const q=[sy*COLS+sx]; seen[sy*COLS+sx]=1;
let count=0, qi=0;
while(qi<q.length){
const id=q[qi++]; count++;
const x=id%COLS, y=(id/COLS)|0;
for (let d=0;d<4;d++){
const nx=x+dlist...
}
}
}
```
Need a global dirs array.
OK.
Edge case: if safeMove fails for all, bestMv from fallback. If bestMv null (surrounded), return dir (will die). Acceptable.
Let's also add: the AI shouldn't be too good at chasing multiple foods... whatever.
Also, the AI in demo mode when there's no food path will wander. Fine.
Now particle systems.
Puffs (smoke):
```js
puffs = []; // {x,y,r,life,maxLife}
```
Spawn when the head steps (in step()): compute the chimney world position based on current head cell and direction.
```js
const L = cell*0.94, W = cell*0.78;
const chimneyLocalX = L*0.22;
```
After rotating by the head's angle, chimney world = headCenter + (cos*chimneyLocalX, sin*chimneyLocalX).
Then puff velocity: mostly upward on screen (-y) plus random. Actually smoke should drift up in screen space regardless of the train direction — yes, that's natural (top-down though... in top-down, smoke would go out from the chimney and drift). Let's have it drift opposite the direction of travel slightly and expand.
Simple: puff at chimney pos, vx = -dir.x*8 + rand, vy = -dir.y*8 + rand, r grows from cell*0.12 to cell*0.4, alpha fades.
Draw as light gray circles.
Puff lifetime ~700ms.
Also, spawn on every step (135ms) — that's ~7/s, each living 700ms → ~5 puffs alive. Fine.
Pickup burst particles: small colored circles flying outward, fading.
Floating text: "+1" rising. Let's add a `floaters` array.
Maybe skip floaters to reduce complexity. Actually a "+1" is nice juice. I'll add it simply.
Let me now handle the game loop and timing carefully.
```js
let last = 0;
let acc = 0;
function loop(now){
if (!last) last = now;
let dt = now - last; last = now;
if (dt > 200) dt = 200;
const stepMs = currentStepMs();
if (state==='demo' || state==='play'){
acc += dt;
let guard = 0;
while (acc >= stepMs && guard++ < 5){
acc -= stepMs;
if (state==='demo') { const d = aiDir(); if (d) nextDir = d; }
step();
if (state!=='demo' && state!=='play') break;
}
}
updateParticles(dt);
draw(state==='over'?1:Math.min(1, acc/stepMs), now);
requestAnimationFrame(loop);
}
```
Problem: after step() causes death in demo mode, state changes... let's handle death: in demo, `die()` sets a respawnTimer; but state stays 'demo'. Let's set a flag `dead=true` and pause stepping until reset.
Let me restructure with a `frozen` flag or handle via state:
- On death in demo: state='demo-dead'? Meh.
Let's do this: `die()`:
```js
function die(){
if (state==='play'){ state='over'; showOverOverlay(); }
else { // demo
demoDeadAt = performance.now();
state='demoDead';
}
acc = 0;
}
```
And in the loop, if state==='demoDead' and now - demoDeadAt > 900 → state='demo', resetGame().
And for the 'over' state, freeze (don't step).
The while loop guard: after step, check state and break if it's 'over' or 'demoDead'.
Let's make it clean:
```js
if (state==='demo'||state==='play'){
acc += dt;
while (acc >= stepMs){
acc -= stepMs;
if (dir ...)
step();
if (state!=='demo' && state!=='play') { acc=0; break; }
}
}
```
And handle demoDead timer separately.
Also, in demo mode, nextDir is set by AI before step; in play mode it's set by input.
Note: in step() I do `dir = nextDir`. And input sets nextDir only if it's not a reversal relative to `dir`. Standard.
Player input:
```js
function setDir(nd){
if (state!=='play') return;
if (nd.x === -dir.x && nd.y === -dir.y) return;
if (nd.x === dir.x && nd.y === dir.y) return;
nextDir = nd;
}
```
Hmm, if the player presses two keys quickly between steps, the second check against `dir` might allow a reversal in the queue (e.g., dir=right, press up then left → left is not a reversal of right? -dir.x = -1, left.x=-1 → it IS a reversal → blocked. OK good.) But press up then... fine.
Actually there's the classic bug: dir=right, press up (nextDir=up), then press left before the step → left is not reverse of right (left is the reverse), wait: -dir.x = -1 = left.x → blocked. Good.
dir=right, press up then down: down is not reverse of right, so nextDir=down. Then step: dir becomes down. But the snake was going right and now goes down — legal. But it went up... no it didn't, it never stepped. Fine.
Alright.
Now write drawTrain.
```js
function drawTrain(t, now){
const n = snake.length;
// draw from tail to head
for (let i=n-1; i>=0; i--){
const cur = snake[i];
const prev = prevSnake[i] || cur;
let px = prev.x, py = prev.y;
if (i>=prevSnake.length) { px = cur.x; py = cur.y; }
const cx = (px + (cur.x-px)*t + 0.5)*cell;
const cy = (py + (cur.y-py)*t + 0.5)*cell;
let dx = cur.x - px, dy = cur.y - py;
if (dx===0 && dy===0){
const nb = snake[i-1] || snake[i+1];
if (nb){ dx = snake[i].x - nb.x; dy = snake[i].y - nb.y; }
else { dx = dir.x; dy = dir.y; }
}
const ang = Math.atan2(dy, dx);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(ang);
if (i===0) drawLoco(); else drawWagon(i-1);
ctx.restore();
}
}
```
Wait, `prevSnake[i] || cur` — if prevSnake[i] is undefined use cur. And px = prev.x. Since prevSnake[i] is undefined only for the new tail, px=cur.x. OK so the two lines are redundant; just use `const prev = prevSnake[i] || cur;`.
Hmm, but there's a subtlety: `t` for the head vs. the body. All use the same t. Fine.
Now drawLoco in local coords (forward = +x):
```js
function drawLoco(){
const L = cell*0.96, W = cell*0.82;
const x0 = -L/2, y0 = -W/2;
// shadow
ctx.fillStyle='rgba(30,25,20,0.16)';
rr(ctx, x0-cell*0.02, y0+cell*0.06, L, W, cell*0.2); ctx.fill();
// cowcatcher
ctx.fillStyle='#3b4250';
ctx.beginPath();
ctx.moveTo(L*0.5, -W*0.40);
ctx.lineTo(L*0.5 + L*0.13, 0);
ctx.lineTo(L*0.5, W*0.40);
ctx.closePath();
ctx.fill();
// main body
const grad = ctx.createLinearGradient(0,-W/2,0,W/2);
grad.addColorStop(0,'#f0603f');
grad.addColorStop(0.5,'#e04a2e');
grad.addColorStop(1,'#b8331d');
ctx.fillStyle=grad;
rr(ctx, x0, y0, L, W, cell*0.22); ctx.fill();
// boiler (darker strip)
ctx.fillStyle='rgba(0,0,0,0.18)';
rr(ctx, x0+L*0.34, y0+W*0.18, L*0.60, W*0.64, cell*0.16); ctx.fill();
// cab at rear
ctx.fillStyle='#2c303c';
rr(ctx, x0+L*0.04, y0+W*0.06, L*0.34, W*0.88, cell*0.14); ctx.fill();
// cab windows
ctx.fillStyle='#a9e2ff';
rr(ctx, x0+L*0.10, y0+W*0.18, L*0.10, W*0.22, cell*0.05); ctx.fill();
rr(ctx, x0+L*0.10, y0+W*0.60, L*0.10, W*0.22, cell*0.05); ctx.fill();
// chimney
ctx.beginPath(); ctx.arc(L*0.18, 0, W*0.24, 0, 7); ctx.fillStyle='#2c303c'; ctx.fill();
ctx.beginPath(); ctx.arc(L*0.18, 0, W*0.13, 0, 7); ctx.fillStyle='#5d6472'; ctx.fill();
// headlamp
ctx.beginPath(); ctx.arc(L*0.46, 0, W*0.13, 0, 7); ctx.fillStyle='#ffe08a'; ctx.fill();
// gold trim line
...
}
```
Hmm, the chimney at L*0.18 forward — with the cab at the rear. Wait, the "forward" is +x, and the nose is at +L/2. So the chimney should be toward the front, at maybe x = +L*0.18. And the boiler dark strip from L*0.02 to L*0.42? Let me redo:
Local x from -L/2 (rear) to +L/2 (front).
- Cab (rear): from -L/2+2 to -L/2 + L*0.36.
- Boiler: from -L/2 + L*0.36 to L*0.42, a narrower rect (W*0.62) centered.
- Chimney: circle at x = -L/2 + L*0.62, radius W*0.20.
- Headlamp: at x = L*0.44, small.
Hmm wait, that's a bit inconsistent with what I wrote. Let me define in absolute local coords:
```
rear = -L/2, front = L/2
cab: x from rear+0.03L to rear+0.36L, height W*0.92
boiler: x from rear+0.34L to front-0.02L, height W*0.66, fill '#c9382a' (slightly darker red) or dark gray
chimney: center at rear+0.62L, r = W*0.20
dome: center at rear+0.46L, r = W*0.12, fill gold
```
Hmm, the chimney and dome both. Let's keep the chimney only, plus a headlamp at the front.
Actually a top-down steam loco: the boiler is a long shape with the chimney near the front (a big circle) and the cab at the rear. The cowcatcher at the front. Good.
Let me finalize the loco:
1. Shadow.
2. Cowcatcher triangle at the front (dark slate).
3. Main body rounded rect (red gradient), full L×W.
4. Cab block at rear: dark charcoal rounded rect, with 2 windows.
5. Boiler: a slightly lighter/darker red rounded rect from mid to front, narrower.
Hmm, that's a lot of red. Let's make: body = dark charcoal base (the frame), boiler = red, cab = red with charcoal roof.
Design B:
- Base plate: rounded rect L×W, fill '#2e3440' (dark).
- Boiler: rounded rect from rear+0.30L to front-0.03L, height W*0.62, centered, fill red gradient (#ef5f42 → #c7331d), rounded ends.
- Cab: rounded rect at the rear, from rear+0.02L to rear+0.32L, height W*0.96, fill '#e0472b', with a darker roof inset and 2 light windows.
- Chimney: circle r=W*0.19 at front-0.18L, fill '#3a4152' + inner '#6b7486'.
- Dome: circle r=W*0.10 at front-0.42L, fill '#f5c451'.
- Headlamp: circle r=W*0.11 at front-0.03L... on the boiler front. Fill '#ffe9a8' with a glow.
- Cowcatcher: triangle at front.
That looks like a train.
Wagons:
- Base: rounded rect L=0.88*cell × W=0.76*cell, fill color.main gradient.
- Roof panel: inset rounded rect, fill color.light, with a slightly darker edge.
Hmm, top-down wagon = we see the roof. So: outer body (color.main, the chassis edges) + inner roof (color.light). Two windows (small light-blue rounded rects) at ±x. Plus a coupling nub at the rear (dark).
Actually to make wagons look like they have a roof, use: outer rounded rect in color.dark-ish, then inner rounded rect in color.main with a highlight gradient. Then 2 small windows in light blue. Then a small dark stripe at both ends.
Let me just do:
```
// shadow
// body
grad = linear gradient across width from color.light to color.main to color.dark
rr(x0,y0,L,W, cell*0.18) fill grad
// roof inset
rr(x0+cell*0.08, y0+cell*0.08, L-cell*0.16, W-cell*0.16, cell*0.13) fill rgba(255,255,255,0.35)
// windows: 2 small light rects
```
Hmm. Let's do:
- Body: color.main gradient (vertical: light→main→dark).
- Roof: inner rounded rect with color.light at 0.55 alpha → creates a lighter center.
- Two windows: light-blue rounded rects at x = ±L*0.16, size cell*0.14 × cell*0.16, centered vertically... but for a top-down wagon, windows would be on the sides. Eh, it's a cartoon. Let's put 2 small dark-blue rounded squares on the roof - like skylights. Fine.
Actually, better: put the windows as small light rectangles along the sides of the roof (offset in y), which reads as windows. Let's do 2 windows at x=±L*0.15, y=±W*0.28? That's 4 small squares. At cell=38, L=33, W=29 → each window ~5x5 px. Too small.
Simpler: one horizontal strip of light blue across the middle of the roof at x from -L*0.25 to L*0.25, height W*0.2, rounded. That reads as a roof window/skylight. Good.
I'll do: a light strip + a thin darker line down the center.
OK whatever, it'll look decent. Let's also add a couple of small dark "wheels" visible at the sides? Not from top-down. Skip.
Let's write it and move on.
One more: the coupling between cars — draw at the rear of each car a small dark rounded rect of width cell*0.15, height cell*0.16, at x = x0 (rear edge). It'll fill the gap between cars partially. Good.
Now the passenger drawing.
```js
function drawPassenger(f, now){
const cx = (f.x+0.5)*cell, cy=(f.y+0.5)*cell;
const phase = f.phase;
const bob = Math.sin(now/280 + phase)*cell*0.035;
// platform tile
ctx.save();
ctx.shadowColor='rgba(0,0,0,0.16)'; ctx.shadowBlur=cell*0.2; ctx.shadowOffsetY=cell*0.06;
ctx.fillStyle='rgba(255,247,224,0.95)';
rr(ctx, cx-cell*0.42, cy-cell*0.42, cell*0.84, cell*0.84, cell*0.24); ctx.fill();
ctx.restore();
// dashed gold border
ctx.strokeStyle='rgba(240,180,41,0.9)'; ctx.lineWidth=Math.max(1,cell*0.045);
rr(ctx, cx-cell*0.42, cy-cell*0.42, cell*0.84, cell*0.84, cell*0.24); ctx.stroke();
const baseY = cy + bob;
// shadow
ctx.fillStyle='rgba(60,45,30,0.18)';
ctx.beginPath(); ctx.ellipse(cx, cy+cell*0.30, cell*0.20, cell*0.07, 0,0,7); ctx.fill();
// legs
ctx.fillStyle = f.pants;
rr(ctx, cx-cell*0.11, baseY+cell*0.10, cell*0.08, cell*0.20, cell*0.04); ctx.fill();
rr(ctx, cx+cell*0.03, baseY+cell*0.10, cell*0.08, cell*0.20, cell*0.04); ctx.fill();
// body
ctx.fillStyle = f.coat;
rr(ctx, cx-cell*0.16, baseY-cell*0.06, cell*0.32, cell*0.26, cell*0.10); ctx.fill();
// head
ctx.fillStyle = f.skin;
ctx.beginPath(); ctx.arc(cx, baseY-cell*0.17, cell*0.115, 0, 7); ctx.fill();
// hat / hair
ctx.fillStyle = f.hat;
ctx.beginPath(); ctx.arc(cx, baseY-cell*0.19, cell*0.115, Math.PI*1.05, Math.PI*1.95); ctx.fill();
// suitcase
ctx.fillStyle = f.case;
rr(ctx, cx+cell*0.20, baseY+cell*0.10, cell*0.16, cell*0.14, cell*0.04); ctx.fill();
}
```
Hmm the suitcase at x+0.20*cell with width 0.16 → extends to 0.36*cell from center, within the 0.42 tile. OK.
Wait, the head circle radius 0.115*cell at y = -0.17*cell. The body top is at -0.06*cell. So there's a gap between -0.06 and -0.055 (head bottom = -0.17+0.115 = -0.055). Good, they touch.
Total person height: from -0.285 (top of head) to +0.30 (feet) = 0.585*cell. At cell=38 that's 22px tall. Good.
The tile is 0.84*cell = 32px. The person fits.
Colors for passengers: random from a palette.
Now the HUD updates. Let's update DOM text each frame only when changed (cheap enough to do every frame, but let's only update on change).
Actually, simplest: update in the loop with a check.
```js
if (hudScore.textContent != score) ...
```
Let's just set them each frame — it's 3 elements, fine. But setting textContent every frame causes layout thrash. Let's guard with a cached value.
Now the overlay.
HTML:
```html
<div id="overlay">
<div class="card" id="card">
<div class="badge">🐤 DEMO</div>
<h2>Click or press a key to start</h2>
<p>Drive the train with <b>↑ ↓ ← →</b> or <b>WASD</b>. Every passenger you pick up hops aboard as a new wagon.</p>
</div>
</div>
```
Show/hide with a class.
On game over, change the badge to "💥 DERAILED", h2 to "Click or press a key to restart", and p to "You scored N — best N".
Let me write `showOverlay(kind, score)`.
Positioning: The overlay is a flex container aligned to flex-end (bottom) with padding, pointer-events none, and the card has pointer-events auto.
Hmm, but if the overlay is bottom-aligned it covers the bottom of the board. The board is centered in the stage, so the bottom of the board might be near the bottom. Card height ~90px. It would overlap the lower part of the board. Acceptable? The train might be hidden under it.
Alternative: put the card in the HUD area at the top? But there's not much room.
Alternative: put the overlay card centered on the board with a semi-transparent background — it'd cover the middle.
Hmm. Let's place the card at the bottom but make it small and semi-transparent... Actually let's put it right below the HUD bar, as a fixed banner above the board? That would just be part of the page, not overlaying the board. But then it's not really an "overlay", though it satisfies "display a clear message".
Idea: make the header area contain the message when in demo/over state. Hmm, but the header is small.
Better idea: Overlay the card at the top-center of the board, floating above with a semi-transparent style and a slight blur. It would cover the top ~80px of the board. The AI starts at the left-middle so it stays visible.
Hmm, honestly: the cleanest is to put the card at the bottom, with the board shifted up a bit. Since the board is sized to fit the stage, we can reserve space at the bottom for the card by making the stage's padding-bottom larger when the overlay is visible. Then the board is smaller. That's a layout change based on state — meh.
Alternative: Just make the card compact and positioned at the bottom-center, overlapping the bottom 70px of the board. The game is still ~85% visible. And in demo mode the train wanders. I think it's fine. Many demos do this.
Actually, let me reconsider: make the overlay card appear at the bottom but the stage has extra bottom padding of 84px always (reserved space). Then the board never extends into that area. That's clean! The board is slightly smaller, but there's always room for the card.
Stage padding-bottom: 96px. The card sits absolutely in that area.
On mobile, the card is taller; let's make the card shrink and the padding responsive. Use CSS: `#stage { padding-bottom: 100px }` and on small screens `padding-bottom: 120px` and the card font smaller.
Hmm, but on a 375x800 viewport, the board would be 375-40 = 335 wide → cell 13 (24 cols). Board height = 16*13 = 208. Plus HUD ~80 + padding-bottom 120 + padding top 20 = 428 < 800. Plenty of room. Actually then cell could be bigger... cell = min(335/24, availH/16). availH = 800 - header - 120 - 40. Header maybe 90. So availH = 550 → 550/16 = 34 → but width limits to 13.9. So cell=13. Board 312x208. Fine, lots of empty space at the bottom. That's ok.
Alright.
Let me also add: when the card is showing, add a subtle dark scrim over the board? Let's add `#overlay::before` with a very light gradient. Actually keep it clean — no scrim.
Maybe add a small pulsing animation to the card to draw attention.
Now CSS design.
Body:
```css
html,body{height:100%;margin:0}
body{
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
background: radial-gradient(circle at 50% 0%, #2a3550 0%, #141a28 45%, #0b0f18 100%);
color:#e8eefc;
display:flex; flex-direction:column;
height:100vh; overflow:hidden;
}
```
Layout:
```html
<div id="app">
<header id="hud">
<div class="brand"><span class="logo">🚂</span><div><h1>Snake Train</h1><span class="sub">Pick up every passenger</span></div></div>
<div class="stats">
<div class="stat"><span class="k">Passengers</span><span class="v" id="score">0</span></div>
<div class="stat"><span class="k">Wagons</span><span class="v" id="wagons">0</span></div>
<div class="stat"><span class="k">Best</span><span class="v" id="best">0</span></div>
</div>
</header>
<main id="stage">
<div id="boardWrap"><canvas id="c"></canvas></div>
<div id="overlay"><div class="card">...</div></div>
</main>
</div>
```
Wait, `#overlay` should be positioned absolutely relative to `#stage`.
```css
#app{display:flex;flex-direction:column;height:100%;}
#hud{display:flex;justify-content:space-between;align-items:center;padding:14px 20px;gap:12px;flex-wrap:wrap}
#stage{position:relative;flex:1;min-height:0;display:flex;align-items:center;justify-content:center;padding:8px 12px 104px;}
#boardWrap{...}
#overlay{position:absolute;left:0;right:0;bottom:14px;display:flex;justify-content:center;pointer-events:none;padding:0 14px;}
```
Hmm, with `#stage` being a flex container with padding-bottom 104, the board centers in the remaining area. Good.
For small screens, reduce padding-bottom to ~118 since the card wraps to 3 lines.
Let's use a media query.
Stats styling: pill cards with a label and value.
Let me write the CSS with care for a polished look.
```css
:root{
--ink:#e9eefb;
--muted:#8b98b8;
--gold:#ffc857;
}
*{box-sizing:border-box}
```
Header:
```css
#hud{
display:flex;align-items:center;justify-content:space-between;
gap:16px;padding:16px 26px 8px;
}
.brand{display:flex;align-items:center;gap:12px;min-width:0}
.logo{font-size:30px;filter:drop-shadow(0 3px 6px rgba(0,0,0,.5))}
h1{margin:0;font-size:20px;letter-spacing:.02em;font-weight:800;
background:linear-gradient(90deg,#ffd166,#ff8f5e);-webkit-background-clip:text;background-clip:text;color:transparent}
.sub{font-size:12px;color:var(--muted);letter-spacing:.06em;text-transform:uppercase}
.stats{display:flex;gap:10px}
.stat{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.09);
border-radius:14px;padding:7px 14px;min-width:82px;text-align:center;backdrop-filter:blur(6px)}
.stat .k{display:block;font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted)}
.stat .v{display:block;font-size:19px;font-weight:800;color:#fff;font-variant-numeric:tabular-nums}
```
Score value could pulse on change; skip or add a class toggle. Let's add a simple CSS transition on the value... skip.
Board wrap:
```css
#boardWrap{
padding:8px;border-radius:22px;
background:linear-gradient(160deg,#3a4761,#1c2334 60%,#141a26);
box-shadow: 0 22px 50px -12px rgba(0,0,0,.75), inset 0 1px 0 rgba(255,255,255,.14);
}
canvas{display:block;border-radius:14px;background:#efe8d8}
```
Card:
```css
.card{
pointer-events:auto;
background:rgba(18,24,38,.9);
border:1px solid rgba(255,255,255,.14);
border-radius:18px;
padding:14px 22px;
text-align:center;
max-width:560px;
box-shadow:0 16px 40px rgba(0,0,0,.55);
backdrop-filter: blur(10px);
animation: pop .35s ease;
}
.card .badge{font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--gold);font-weight:800}
.card h2{margin:4px 0 4px;font-size:19px;font-weight:800;color:#fff}
.card p{margin:0;font-size:13px;color:#9fb0cf;line-height:1.45}
.card b{color:#dfe8fb}
```
Animation: pulse the card border or a small glow.
Add `.card{animation: bob 2.2s ease-in-out infinite}` maybe a subtle scale. Hmm, could be distracting. Let's do a subtle pulsing glow on the card.
OK. Now, on mobile (max-width 620px): stack the HUD (brand on top, stats below), reduce font sizes, reduce padding.
Let me write:
```css
@media (max-width: 680px){
#hud{padding:12px 14px 4px;flex-direction:column;align-items:flex-start;gap:10px}
.stats{width:100%;justify-content:space-between}
.stat{flex:1;min-width:0;padding:6px 8px}
.stat .v{font-size:16px}
h1{font-size:17px}
#stage{padding:6px 10px 128px}
.card{padding:12px 16px}
.card h2{font-size:16px}
.card p{font-size:12px}
}
```
Now let's write the JS fully.
```js
(function(){
'use strict';
const COLS=24, ROWS=16;
const canvas=document.getElementById('c');
const ctx=canvas.getContext('2d');
const stage=document.getElementById('stage');
const boardWrap=document.getElementById('boardWrap');
const overlay=document.getElementById('overlay');
const cardEl=document.getElementById('card');
const scoreEl=document.getElementById('score');
const wagonsEl=document.getElementById('wagons');
const bestEl=document.getElementById('best');
let cell=24, dpr=1;
let snake=[], prevSnake=[], dir={x:1,y:0}, nextDir={x:1,y:0};
let foods=[], score=0, best=0, state='demo', acc=0, last=0;
let puffs=[], sparks=[], floaters=[];
let demoDeadAt=0;
let groundCache=null;
const DIRS={up:{x:0,y:-1},down:{x:0,y:1},left:{x:-1,y:0},right:{x:1,y:0}};
const DLIST=[DIRS.up,DIRS.right,DIRS.down,DIRS.left];
...
})();
```
resize:
```js
function resize(){
const padding = 40; // approx: stage padding + wrap padding
const availW = stage.clientWidth - 36;
const availH = stage.clientHeight - 36;
let c2 = Math.floor(Math.min(availW/COLS, availH/ROWS));
c2 = Math.max(10, c2);
if (c2 === cell && groundCache) return; // no change
cell = c2;
const w = cell*COLS, h = cell*ROWS;
dpr = Math.min(window.devicePixelRatio||1, 2);
canvas.style.width = w+'px';
canvas.style.height = h+'px';
canvas.width = Math.round(w*dpr);
canvas.height = Math.round(h*dpr);
ctx.setTransform(dpr,0,0,dpr,0,0);
buildGround();
}
```
Careful: `stage.clientHeight` — the stage has padding included in clientHeight. clientHeight includes padding but not border. Yes, clientHeight = content + padding. So subtracting 36 for the padding (8+8 stage padding is 8 top... let's see: #stage padding: 8px 12px 104px → left/right 12, top 8, bottom 104). Plus wrap padding 8 each.
availW = stage.clientWidth - 12*2 - 8*2 - small = -40.
availH = stage.clientHeight - 8 - 104 - 8*2 = -128.
So I should subtract exactly those. Let me just hardcode:
```js
const availW = stage.clientWidth - 40;
const availH = stage.clientHeight - 136;
```
Hmm, if the media query changes padding, this breaks. Let me compute from the wrap element instead? The wrap size depends on the canvas...
Alternative: set the canvas size based on the wrap's parent... circular.
Let's just use constants matching CSS, and update them in the media query by reading getComputedStyle? Overkill. I'll use available space = stage.getBoundingClientRect() minus known paddings, and handle mobile with a smaller bottom padding via CSS, so I'll just read the computed padding:
```js
const cs = getComputedStyle(stage);
const pl = parseFloat(cs.paddingLeft), pr = parseFloat(cs.paddingRight),
pt = parseFloat(cs.paddingTop), pb = parseFloat(cs.paddingBottom);
const availW = stage.clientWidth - pl - pr - 20;
const availH = stage.clientHeight - pt - pb - 20;
```
That's robust. Good. (20 = wrap padding 8*2 + 4 slack.)
Then buildGround creates an offscreen canvas at device resolution.
```js
function buildGround(){
const w = cell*COLS, h = cell*ROWS;
const g = document.createElement('canvas');
g.width = Math.round(w*dpr); g.height=Math.round(h*dpr);
const gx = g.getContext('2d');
gx.setTransform(dpr,0,0,dpr,0,0);
gx.fillStyle='#efe8d8';
gx.fillRect(0,0,w,h);
// subtle checker
gx.fillStyle='rgba(150,130,95,0.055)';
for(let y=0;y<ROWS;y++) for(let x=0;x<COLS;x++) if((x+y)&1) gx.fillRect(x*cell,y*cell,cell,cell);
// grid
gx.strokeStyle='rgba(120,100,70,0.14)'; gx.lineWidth=1;
for(let x=0;x<=COLS;x++){ gx.beginPath(); gx.moveTo(x*cell+0.5,0); gx.lineTo(x*cell+0.5,h); gx.stroke(); }
for(let y=0;y<=ROWS;y++){ gx.beginPath(); gx.moveTo(0,y*cell+0.5); gx.lineTo(w,y*cell+0.5); gx.stroke(); }
// speckles (deterministic)
let seed=12345;
const rnd=()=>{ seed=(seed*1103515245+12345)&0x7fffffff; return seed/0x7fffffff; };
for(let i=0;i<260;i++){
const x=rnd()*w, y=rnd()*h, r=rnd()*cell*0.06+0.4;
gx.beginPath(); gx.arc(x,y,r,0,7);
gx.fillStyle=`rgba(120,100,70,${0.03+rnd()*0.05})`; gx.fill();
}
// vignette
const vg = gx.createRadialGradient(w/2,h/2,Math.min(w,h)*0.25, w/2,h/2, Math.max(w,h)*0.72);
vg.addColorStop(0,'rgba(0,0,0,0)');
vg.addColorStop(1,'rgba(70,55,35,0.16)');
gx.fillStyle=vg; gx.fillRect(0,0,w,h);
groundCache=g;
}
```
Then in draw: `ctx.drawImage(groundCache, 0,0, cell*COLS, cell*ROWS)`.
Since groundCache has the dpr transform baked, drawing it at 0,0 with width cell*COLS, height cell*ROWS will scale it correctly. Good.
Now the main draw:
```js
function draw(t, now){
const W=cell*COLS, H=cell*ROWS;
ctx.clearRect(0,0,W,H);
if(groundCache) ctx.drawImage(groundCache,0,0,W,H);
else { ctx.fillStyle='#efe8d8'; ctx.fillRect(0,0,W,H); }
for(const f of foods) drawPassenger(f, now);
drawPuffs();
drawTrain(t, now);
drawSparks();
drawFloaters();
}
```
Order: puffs behind the train? Smoke should be above/behind... let's draw puffs after the train so smoke is visible on top. Actually smoke comes out of the chimney above the train, so on top is fine. Let's do train then puffs then sparks. Hmm, sparks at the pickup location should be above. OK: train, puffs, sparks, floaters.
Let me restructure: drawPassengers, drawTrain, drawPuffs, drawSparks, drawFloaters.
Now, `drawPuffs` uses world coords already computed.
Let me write the puff update:
```js
function updateParticles(dt){
const k = dt/1000;
for(let i=puffs.length-1;i>=0;i--){
const p=puffs[i];
p.life -= dt;
if(p.life<=0){ puffs.splice(i,1); continue; }
p.x += p.vx*k; p.y += p.vy*k;
p.vx *= 0.96; p.vy *= 0.96;
p.r += p.grow*k;
}
...
}
```
Draw puff:
```js
const a = Math.max(0, p.life/p.max);
ctx.globalAlpha = a*0.55;
ctx.fillStyle='#ffffff';
ctx.beginPath(); ctx.arc(p.x,p.y,p.r,0,7); ctx.fill();
ctx.globalAlpha=1;
```
Maybe use a soft radial gradient for each puff. That'd be nicer but slower. With ~10 puffs it's fine. Let's use a simple flat circle with globalAlpha — it'll look like cartoon smoke. Good enough, and add a slight gray.
Let's create the radial gradient per puff... 10 gradients per frame is fine. Let's do it for quality:
```js
const g = ctx.createRadialGradient(p.x,p.y,0,p.x,p.y,p.r);
g.addColorStop(0,`rgba(255,255,255,${a*0.85})`);
g.addColorStop(1,`rgba(255,255,255,0)`);
```
That's 10 gradient creations per frame — acceptable.
OK.
Now the smoke spawn. In step(), after computing the new head:
```js
if (puffs.length < 80) {
const cx=(nx+0.5)*cell, cy=(ny+0.5)*cell;
const ang = Math.atan2(dir.y, dir.x);
const off = cell*0.30;
puffs.push({
x: cx + Math.cos(ang)*off,
y: cy + Math.sin(ang)*off,
vx: (Math.random()-0.5)*18 - dir.x*10,
vy: (Math.random()-0.5)*18 - dir.y*10 - 14,
r: cell*0.10,
grow: cell*0.55,
life: 620+Math.random()*260,
max: 620
});
}
```
Hmm, `life` and `max` mismatch; let me set max = life at creation.
Also spawn only every other step to reduce count? Every step at 135ms is fine.
Now the sparks (pickup burst):
```js
function spawnBurst(gx,gy,color){
const cx=(gx+0.5)*cell, cy=(gy+0.5)*cell;
for(let i=0;i<14;i++){
const a=Math.random()*Math.PI*2, s=cell*(1.2+Math.random()*2.2);
sparks.push({x:cx,y:cy,vx:Math.cos(a)*s,vy:Math.sin(a)*s,life:520,max:520,r:cell*(0.06+Math.random()*0.06),color});
}
floaters.push({x:cx,y:cy-cell*0.3,life:900,max:900,text:'+1'});
}
```
Sparks with gravity? They'd fly out and fade. Let's add slight drag.
Floaters: text rising and fading, font bold cell*0.5, color gold.
Let's write drawSparks:
```js
ctx.globalCompositeOperation='source-over';
for(const s of sparks){
const a=s.life/s.max;
ctx.globalAlpha=a;
ctx.fillStyle=s.color;
ctx.beginPath(); ctx.arc(s.x,s.y,s.r,0,7); ctx.fill();
}
ctx.globalAlpha=1;
```
Floaters:
```js
ctx.font = `800 ${Math.round(cell*0.55)}px system-ui, sans-serif`;
ctx.textAlign='center';
for(const f of floaters){
const a = f.life/f.max;
ctx.globalAlpha = a;
ctx.fillStyle='#f0a92e';
ctx.strokeStyle='rgba(255,255,255,.9)';
ctx.lineWidth = Math.max(2, cell*0.07);
ctx.strokeText(f.text, f.x, f.y);
ctx.fillText(f.text, f.x, f.y);
}
ctx.globalAlpha=1;
```
Good.
Now the step function with all the logic. And the death.
Let's write `die()`:
```js
function die(){
if(state==='play'){
state='over';
if(score>best) best=score;
showOverlay('over');
} else {
state='demoDead';
demoDeadAt=performance.now();
}
acc=0;
}
```
Loop:
```js
function loop(now){
requestAnimationFrame(loop);
if(!last) last=now;
let dt=now-last; last=now;
if(dt>250) dt=250;
if(state==='demo'||state==='play'){
const stepMs=currentStepMs();
acc+=dt;
let guard=0;
while(acc>=stepMs && guard++<4){
acc-=stepMs;
if(state==='demo'){ const d=aiDir(); nextDir=d; }
step();
if(state!=='demo'&&state!=='play'){ acc=0; break; }
}
} else if(state==='demoDead'){
if(now-demoDeadAt>800){ state='demo'; resetGame(); }
}
updateParticles(dt);
const t = (state==='demo'||state==='play') ? Math.min(1, acc/currentStepMs()) : 1;
draw(t, now);
updateHud();
}
```
Wait — when state is 'over', t=1 which means the snake is drawn at its final positions. But the die() happened before the move, so prevSnake == snake basically, except prevSnake was set at the start of step() before the collision check. Let me check: in step(), I set prevSnake = snake.slice() at the top, then check collisions and return on failure. So snake hasn't moved but prevSnake is a copy of it — identical, so t=1 renders the same. Fine.
Hmm, but there's a subtle issue: on the step before death, t was between 0 and 1, and the death happens at the start of the next step. The train would visually "snap" to the full cell. Minor.
Also, `aiDir()` is called every step before `step()`. It uses `dir` (the current direction) — but `nextDir` was set. In step(), `dir = nextDir`. So the AI should consider `dir` as the direction it's about to face... Actually at the time aiDir is called, `dir` is the direction of the last move, which equals nextDir from the previous step. So it's consistent. Fine.
Now `currentStepMs` uses `score` which changes. Fine.
Let's handle the `state==='over'` case where we still draw. Good.
Input handling:
```js
function handleStart(){
if(state==='demo'||state==='over'){
state='play';
resetGame();
hideOverlay();
}
}
window.addEventListener('keydown', e=>{
const k=e.key;
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(k)) e.preventDefault();
if(state==='play'){
if(k==='ArrowUp'||k==='w'||k==='W') setDir(DIRS.up);
else if(k==='ArrowDown'||k==='s'||k==='S') setDir(DIRS.down);
else if(k==='ArrowLeft'||k==='a'||k==='A') setDir(DIRS.left);
else if(k==='ArrowRight'||k==='d'||k==='D') setDir(DIRS.right);
else if(k===' ') { /* pause? */ }
} else {
handleStart();
}
});
window.addEventListener('pointerdown', e=>{
handleStart();
});
```
Hmm, careful: if state==='play' and the user presses a key, handleStart isn't called (good). If state is 'demo' or 'over', any key starts.
But the keydown listener for 'play' mode: pressing 'r' does nothing. Fine.
Also, on pointerdown while playing — nothing happens. Good.
Wait, there's an issue: the pointerdown listener on window would fire when clicking anywhere, including the card. That's fine since the card is inside.
Also should we prevent the page from scrolling? overflow:hidden on body. Yes.
Now `resetGame()`:
```js
function resetGame(){
const cy = Math.floor(ROWS/2);
snake=[{x:5,y:cy},{x:4,y:cy},{x:3,y:cy}];
prevSnake=snake.slice();
dir={x:1,y:0}; nextDir={x:1,y:0};
score=0; acc=0;
foods=[]; puffs=[]; sparks=[]; floaters=[];
for(let i=0;i<FOOD_COUNT;i++) spawnFood();
}
```
FOOD_COUNT=3.
spawnFood:
```js
function spawnFood(){
const occupied = new Set();
for(const s of snake) occupied.add(s.y*COLS+s.x);
for(const f of foods) occupied.add(f.y*COLS+f.x);
const head = snake[0];
const free=[];
for(let y=0;y<ROWS;y++) for(let x=0;x<COLS;x++){
const id=y*COLS+x;
if(occupied.has(id)) continue;
if(Math.abs(x-head.x)+Math.abs(y-head.y) < 4) continue;
free.push({x,y});
}
if(!free.length) return;
const p = free[(Math.random()*free.length)|0];
const pal = PASSENGERS[(Math.random()*PASSENGERS.length)|0];
foods.push({x:p.x,y:p.y,phase:Math.random()*6.28, ...pal});
}
```
PASSENGERS palette: array of {coat, pants, skin, hat, case}.
```js
const SKINS=['#f6c9a0','#e0a878','#b87a52','#8a5a3b','#f2d5b8'];
const COATS=['#e8543f','#3f7ae8','#28a06e','#e8a23f','#9b5ce8','#e84f8f','#2fb6c9'];
const PANTS=['#3b4252','#2c3550','#4a4038','#2d4a3e'];
const CASES=['#c0392b','#2d6cdf','#6b4a2f','#1f8a70'];
const HATS=['#2b2f3a','#5a3b2a','#b03a48','#2f5f8a'];
```
Build the palette array.
Now the HUD update:
```js
let lastScore=-1,lastWagons=-1,lastBest=-1;
function updateHud(){
if(score!==lastScore){ scoreEl.textContent=score; lastScore=score; }
const w = Math.max(0, snake.length-1);
if(w!==lastWagons){ wagonsEl.textContent=w; lastWagons=w; }
if(best!==lastBest){ bestEl.textContent=best; lastBest=best; }
}
```
Overlay:
```js
function showOverlay(kind){
const badge = cardEl.querySelector('.badge');
const h2 = cardEl.querySelector('h2');
const p = cardEl.querySelector('p');
if(kind==='demo'){
badge.textContent='🚂 Demo — the little train drives itself';
h2.textContent='Click or press a key to start';
p.innerHTML='Use <b>↑ ↓ ← →</b> or <b>WASD</b>. Every passenger you collect hops aboard as a new wagon.';
} else {
badge.textContent='💥 Derailed';
h2.textContent='Click or press a key to try again';
p.innerHTML=`You picked up <b>${score}</b> passenger${score===1?'':'s'} and pulled <b>${Math.max(1,score+1)}</b> cars. Best: <b>${best}</b>.`;
}
overlay.classList.add('show');
}
function hideOverlay(){ overlay.classList.remove('show'); }
```
Hmm, the wagone count: at score 0 the train has 3 segments = 1 loco + 2 wagons. So "wagons" = snake.length-1 = 2 at start. That's fine — it starts with 2 wagons.
Wait, but "Best" only updates on death in play mode. Also update best when score exceeds it? Let's update best at death only. Fine. Actually also update live: if score > best, best = score. Then display. Let's do it in step() when eating: if(score>best) best=score. That makes "Best" update live. Good, do that.
Now `resetGame` in demo mode should also clear and restart. Yes.
Initial: state='demo', resetGame(), showOverlay('demo').
Also on page load, we need `resize()` before `resetGame()`? resetGame doesn't depend on cell. But drawing does. Call resize() first.
Let me also handle window resize → resize().
One thing: `#stage` size might be 0 during initial layout if fonts load. Let's call resize() after a rAF and also on 'load'. I'll use ResizeObserver on the stage — that's clean and handles everything.
```js
if (window.ResizeObserver) {
new ResizeObserver(()=>resize()).observe(stage);
}
```
Careful about loops: resize() changes the canvas size, which changes #boardWrap size, which could change #stage size? #stage is flex:1 in a column flex with height 100vh, so its height is determined by the container, not the content... Actually, flex:1 with min-height:0 and the parent having a definite height → the stage's height is fixed regardless of content. But if the content overflows, `flex:1` uses `flex-basis:0%` and grows... The parent is `display:flex; flex-direction:column; height:100vh`. The children: header (auto) and stage (flex:1, min-height:0). The stage's height = remaining space. Content overflow doesn't change it since min-height:0 allows shrinking. But could the content force it bigger? With flex-shrink and min-height:0, no. So no loop. But to be safe, guard in resize: if the computed cell is unchanged, return early.
Alright, I think we're good. Let me also handle the case where the canvas is bigger than the wrap max — add `max-width:100%` to boardWrap. Eh, if cell computation is right it fits.
One more consideration: `#stage.clientHeight` during the initial ResizeObserver callback should be correct.
Let's write the final code.
Let me write out drawTrain and the car drawing functions carefully.
```js
function drawTrain(t, now){
const n = snake.length;
for (let i=n-1;i>=0;i--){
const cur = snake[i];
const prev = prevSnake[i] || cur;
const cx = (prev.x + (cur.x-prev.x)*t + 0.5)*cell;
const cy = (prev.y + (cur.y-prev.y)*t + 0.5)*cell;
let dx = cur.x-prev.x, dy = cur.y-prev.y;
if (dx===0 && dy===0){
const nb = snake[i-1] || snake[i+1];
if (nb){ dx = cur.x-nb.x; dy = cur.y-nb.y; }
else { dx = dir.x; dy = dir.y; }
}
const ang = Math.atan2(dy,dx);
ctx.save();
ctx.translate(cx,cy);
ctx.rotate(ang);
if (i===0) drawLoco(now); else drawWagon(i-1, now);
ctx.restore();
}
}
```
Hmm, for `prevSnake[i] || cur` — prevSnake[i] is an object {x,y}, always truthy. Good.
drawLoco(now):
```js
function drawLoco(now){
const L = cell*0.96, W = cell*0.84;
const x0=-L/2, y0=-W/2;
// ground shadow
ctx.fillStyle='rgba(40,30,20,0.18)';
rr(ctx, x0+cell*0.02, y0+cell*0.10, L, W, W*0.30); ctx.fill();
// cowcatcher
ctx.fillStyle='#39404f';
ctx.beginPath();
ctx.moveTo(L*0.46,-W*0.34);
ctx.lineTo(L*0.46+L*0.16, 0);
ctx.lineTo(L*0.46, W*0.34);
ctx.closePath(); ctx.fill();
// chassis
ctx.fillStyle='#2b303c';
rr(ctx, x0, y0, L, W, W*0.28); ctx.fill();
// boiler
const g = ctx.createLinearGradient(0,-W*0.34,0,W*0.34);
g.addColorStop(0,'#ff8a5c');
g.addColorStop(0.45,'#e2462a');
g.addColorStop(1,'#a72d18');
ctx.fillStyle=g;
rr(ctx, x0+L*0.30, y0+W*0.18, L*0.68, W*0.64, W*0.24); ctx.fill();
// cab
ctx.fillStyle='#c53d24';
rr(ctx, x0+L*0.04, y0+W*0.06, L*0.30, W*0.88, W*0.22); ctx.fill();
// cab roof
ctx.fillStyle='#2b303c';
rr(ctx, x0+L*0.08, y0+W*0.14, L*0.22, W*0.72, W*0.18); ctx.fill();
// windows
ctx.fillStyle='#a8e4ff';
rr(ctx, x0+L*0.13, y0+W*0.24, L*0.09, W*0.18, W*0.06); ctx.fill();
rr(ctx, x0+L*0.13, y0+W*0.58, L*0.09, W*0.18, W*0.06); ctx.fill();
// dome
ctx.beginPath(); ctx.arc(x0+L*0.42, 0, W*0.11, 0, 6.2832);
ctx.fillStyle='#f3c14b'; ctx.fill();
// chimney
ctx.beginPath(); ctx.arc(x0+L*0.62, 0, W*0.21, 0, 6.2832);
ctx.fillStyle='#2b303c'; ctx.fill();
ctx.beginPath(); ctx.arc(x0+L*0.62, 0, W*0.11, 0, 6.2832);
ctx.fillStyle='#61697a'; ctx.fill();
// headlamp
const lg = ctx.createRadialGradient(x0+L*0.88,0,0,x0+L*0.88,0,W*0.22);
lg.addColorStop(0,'rgba(255,240,180,1)');
lg.addColorStop(1,'rgba(255,220,120,0)');
ctx.fillStyle=lg;
ctx.beginPath(); ctx.arc(x0+L*0.88,0,W*0.22,0,6.2832); ctx.fill();
ctx.fillStyle='#ffe9a8';
ctx.beginPath(); ctx.arc(x0+L*0.88,0,W*0.09,0,6.2832); ctx.fill();
}
```
Wait, x0+L*0.88 = -L/2 + 0.88L = 0.38L. And the boiler spans from x0+0.30L = -0.2L to -0.2L+0.68L = 0.48L. So the headlamp at 0.38L is inside the boiler. Good. The cowcatcher starts at 0.46L. Hmm, the cowcatcher overlaps the boiler end (0.48L). Fine.
Chimney at x0+0.62L = 0.12L. Dome at x0+0.42L = -0.08L. Hmm, the dome should be between the cab and chimney. Cab spans from x0+0.04L=-0.46L to -0.46L+0.30L=-0.16L. So the dome at -0.08L is between the cab and the chimney (0.12L). Good.
The boiler starts at -0.2L, which overlaps the cab (ends at -0.16L). Fine, the boiler is drawn after the cab... no wait, I draw the boiler before the cab. Order: shadow, cowcatcher, chassis, boiler, cab, cab roof, windows, dome, chimney, headlamp. So the cab is drawn on top of the boiler. Good.
Hmm, but the boiler rect spans y from -W*0.32 to +W*0.32 (height W*0.64 centered), and the cab spans W*0.88. Good.
Now drawWagon:
```js
function drawWagon(idx, now){
const c = WAGON_COLORS[idx % WAGON_COLORS.length];
const L = cell*0.86, W = cell*0.74;
const x0=-L/2, y0=-W/2;
// shadow
ctx.fillStyle='rgba(40,30,20,0.16)';
rr(ctx, x0+cell*0.02, y0+cell*0.09, L, W, W*0.3); ctx.fill();
// body gradient
const g = ctx.createLinearGradient(0,-W/2,0,W/2);
g.addColorStop(0,c.light);
g.addColorStop(0.5,c.main);
g.addColorStop(1,c.dark);
ctx.fillStyle=g;
rr(ctx, x0,y0,L,W,W*0.30); ctx.fill();
// roof
ctx.fillStyle='rgba(255,255,255,0.42)';
rr(ctx, x0+cell*0.07, y0+cell*0.07, L-cell*0.14, W-cell*0.14, W*0.24); ctx.fill();
// skylight strip
ctx.fillStyle='rgba(180,230,255,0.85)';
rr(ctx, -L*0.20, -W*0.13, L*0.40, W*0.26, W*0.13); ctx.fill();
// coupling
ctx.fillStyle='#39404f';
rr(ctx, x0-cell*0.06, -W*0.10, cell*0.10, W*0.20, W*0.06); ctx.fill();
}
```
Hmm, the roof `rgba(255,255,255,0.42)` over the gradient lightens the center a lot. It might wash out the color. Let's use 0.28.
And the skylight strip in light blue.
Coupling at the rear: x from x0-0.06cell to x0+0.04cell. OK.
Hmm, since the cars are drawn tail→head, the coupling of car i is drawn before car i-1's body, so the head car covers it. That's fine.
Wait, no: I draw from tail (i=n-1) to head (i=0). So the coupling of car i is drawn, then later car i-1 (in front of it) is drawn on top. The coupling sticks out toward the rear of car i (away from the head). So it points at car i+1, which was drawn earlier (it's behind). So the coupling is drawn ON TOP of car i+1's front. Good, that's what we want.
Now, colors:
```js
const WAGON_COLORS=[
{main:'#f0b429', light:'#ffd97a', dark:'#b8801a'},
{main:'#3fbfa0', light:'#85e6cb', dark:'#248a72'},
{main:'#5b8def', light:'#9dbcff', dark:'#3a5fc0'},
{main:'#e2607e', light:'#ff9cb2', dark:'#a83c56'},
{main:'#a97ce0', light:'#cfa9f5', dark:'#774fb0'},
{main:'#f2913b', light:'#ffbc78', dark:'#b96a1e'},
{main:'#77d94f', light:'#b0f08e', dark:'#4ea332'},
];
```
Good.
Now let's double check the passenger drawing relative to cell sizes — at cell=13 on mobile things get tiny but it's fine.
Let me now also make sure the food tile doesn't look like it's floating off-grid. Fine.
Edge: foods may spawn where the train will immediately hit. The Manhattan distance >= 4 filter helps.
Now the AI's reverse check: `if (dx === -dir.x && dy === -dir.y) continue;` — dir is the current direction. But at the very first call after reset, dir = right, snake = [(5,cy),(4,cy),(3,cy)]. Reverse = left = into the neck. Blocked by `occ` anyway. Fine.
Let me double-check the BFS `first` array: I initialize `first[id]=d` for the neighbor cells. Then during BFS, `first[nid]=first[id]`. Good.
But there's a bug: two different starting neighbors could reach the same cell; the `dist[nid]!==-1` check prevents overwriting. Fine.
Also, the initial neighbor loop checks `if (occ[nid]) continue;` — the head cell itself is occupied but we don't visit it. Good.
Now safeMove uses a fresh occ without the tail. And floodArea.
Let me write floodArea:
```js
function floodArea(sx,sy,occ){
const N=COLS*ROWS;
const seen=new Uint8Array(N);
const q=new Int32Array(N);
let qh=0, qt=0, count=0;
const start=sy*COLS+sx;
if(occ[start]) return 0;
seen[start]=1; q[qt++]=start;
while(qh<qt){
const id=q[qh++]; count++;
const x=id%COLS, y=(id-x)/COLS;
for(let d=0;d<4;d++){
const nx=x+DLIST[d].x, ny=y+DLIST[d].y;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
const nid=ny*COLS+nx;
if(seen[nid]||occ[nid]) continue;
seen[nid]=1; q[qt++]=nid;
}
}
return count;
}
```
Good.
And `safeMove`:
```js
function safeMove(mv){
const hx=snake[0].x+mv.x, hy=snake[0].y+mv.y;
if(hx<0||hy<0||hx>=COLS||hy>=ROWS) return false;
const N=COLS*ROWS;
const occ=new Uint8Array(N);
for(let i=0;i<snake.length-1;i++) occ[snake[i].y*COLS+snake[i].x]=1;
if(occ[hy*COLS+hx]) return false;
return floodArea(hx,hy,occ) >= snake.length+4;
}
```
Threshold: the free area must be at least the snake length + 4. Hmm, the head's new cell is counted in the flood area (it's free), and the snake occupies length cells. So the reachable area should be >= length for safety. Let's require >= snake.length + 3.
Hmm, when the snake is 3 long, we need area >= 6. The board has 384 cells. Fine.
Actually there's a flaw: the flood area is computed with the snake's current body as obstacles, but the snake will move. It's a heuristic. Fine.
Now let me double check the AI won't get stuck oscillating. If no food is reachable, the fallback picks the max-area move. Should be OK.
Alright, one more thing: in demo mode, after `aiDir()` returns a direction, we set nextDir. `step()` sets `dir = nextDir` — but if the AI returns a reverse direction (shouldn't), it'd die. The AI never returns a reverse since we skip it in both branches.
Let me now write everything out.
Also need `rr` helper.
Also, I should double-check the initial `prevSnake` length equals `snake` length so `prevSnake[i]` is defined for all i.
resetGame: prevSnake = snake.slice() → same length. ✓
After a step with growth, snake.length = prevSnake.length+1, so for i = snake.length-1, prevSnake[i] is undefined → uses cur. ✓
Now, the render loop's `t`: `Math.min(1, acc/stepMs)`. Since acc < stepMs after the while loop, t ∈ [0,1). Good.
But when state is 'over' or 'demoDead', t=1. For demoDead, the snake is at its last positions. Fine.
Hmm, for demoDead the train freezes at position t=1 which is where it died... but it died before moving, so `snake` is unchanged from the previous step and prevSnake is a copy → looks correct.
Actually wait: on death I return early from step() AFTER setting prevSnake = snake.slice(). But snake hasn't moved. So prevSnake == snake content → the render at t=1 shows the same positions. ✓.
Let me now write the final HTML.
One more: the initial call order.
```js
resize();
resetGame();
showOverlay('demo');
requestAnimationFrame(loop);
```
But resize() needs the layout to be done. Since the script is at the end of the body, layout should be computed. ResizeObserver will also fire.
Actually, `stage.clientHeight` requires layout. At script execution time (end of body), the layout may be computed on demand. getBoundingClientRect forces layout. Fine.
Let me also make sure `#stage` has a definite height. `#app{height:100vh; display:flex; flex-direction:column}` and `#stage{flex:1; min-height:0}`. With `body{height:100vh; overflow:hidden}` and `#app{height:100%}`.
Using 100vh on mobile can be problematic but fine here.
Let me write it.
Also, `body{margin:0}`.
Let me write out the final code.
For the CSS `#stage` padding-bottom, I'll use 112px on desktop and 132px on mobile.
Let me now assemble.
One detail on `drawPassenger`: I use `ctx.save()/restore()` around the shadow. Careful with shadowBlur performance for 3 passengers — fine.
Also I should reset `ctx.shadowColor='transparent'` after.
Let me use an explicit save/restore.
Writing final code now.
Also, `floaters` need `text` and the draw uses `f.y` decreasing over time.
updateParticles handles puffs, sparks, floaters.
```js
function updateParticles(dt){
const k = dt/1000;
for(let i=puffs.length-1;i>=0;i--){
const p=puffs[i]; p.life-=dt;
if(p.life<=0){puffs.splice(i,1);continue;}
p.x+=p.vx*k; p.y+=p.vy*k;
p.vx*=0.97; p.vy*=0.97;
p.r+=p.grow*k;
}
for(let i=sparks.length-1;i>=0;i--){
const s=sparks[i]; s.life-=dt;
if(s.life<=0){sparks.splice(i,1);continue;}
s.x+=s.vx*k; s.y+=s.vy*k;
s.vx*=0.93; s.vy*=0.93;
}
for(let i=floaters.length-1;i>=0;i--){
const f=floaters[i]; f.life-=dt;
if(f.life<=0){floaters.splice(i,1);continue;}
f.y -= 42*k;
}
}
```
Good.
Now writing the whole file.
Regarding `--` in CSS variables, fine.
Final check on the overlay show/hide: `#overlay{opacity:0; transition:opacity .25s; pointer-events:none}` and `.show{opacity:1}`. Since the card has pointer-events:auto... but the overlay has pointer-events:none, so children can't receive events unless they set pointer-events:auto. Let's set `.card{pointer-events:auto}` — but then when hidden (opacity 0) it would still catch clicks. Add `#overlay:not(.show){visibility:hidden}` or set pointer-events:none on the card when not shown. Let's do:
```css
#overlay{opacity:0;visibility:hidden;transition:opacity .25s, visibility .25s}
#overlay.show{opacity:1;visibility:visible}
```
And the card can have pointer-events:auto, but it doesn't matter since we listen on window anyway. Let's just keep pointer-events none everywhere and rely on the window listener. Simpler.
Alright. Writing.
Also small detail: the `+1` floater might be better as "1 passenger"? Too long. "+1" is good.
Let me write the final file now.
Actually one more thought on the demo AI and the initial position: snake starts at (5,8) heading right with 3 segments on a 24x16 board. Fine.
Let me write it.
For the header, on very small screens the stats might overflow. With flex-wrap and flex:1 they should be fine.
Writing now. I'll be careful with the template literal for `p.innerHTML` in showOverlay('over').
Final code:
```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>...</style>
</head>
<body>
<div id="app">
<header id="hud">
<div class="brand">
<div class="logo">🚂</div>
<div class="titles">
<h1>Snake Train</h1>
<div class="sub">All aboard the passenger express</div>
</div>
</div>
<div class="stats">
<div class="stat"><span class="k">Passengers</span><span class="v" id="score">0</span></div>
<div class="stat"><span class="k">Wagons</span><span class="v" id="wagons">0</span></div>
<div class="stat"><span class="k">Best</span><span class="v" id="best">0</span></div>
</div>
</header>
<main id="stage">
<div id="boardWrap"><canvas id="c"></canvas></div>
<div id="overlay">
<div class="card" id="card">
<div class="badge">Demo</div>
<h2>Click or press a key to start</h2>
<p>...</p>
</div>
</div>
</main>
</div>
<script>...</script>
</body>
</html>
```
Note: `#overlay` is absolutely positioned in `#stage`.
Let me write the CSS carefully.
```css
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{height:100%}
body{
margin:0;
font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
background:radial-gradient(120% 90% at 50% -10%, #2b3a5c 0%, #1a2338 40%, #0d1220 100%);
color:#e9eefb;
overflow:hidden;
user-select:none;
}
#app{height:100%;display:flex;flex-direction:column}
```
Add a subtle noise/pattern? Not needed.
Header styles as above.
OK, writing for real now.
One thing about the canvas: `background:#efe8d8` so before the first ground render it's not black.
Let me write.
Also: `ctx.textAlign` for floaters needs to be set each time (it's global state). Set it in drawFloaters.
Let me finalize the code and double check the `rr` function signature: `rr(ctx,x,y,w,h,r)`.
Writing out.
Also need to handle `ctx.arc(...,0,6.2832)` or use `Math.PI*2`. I'll use Math.PI*2 for clarity.
Ready.