Train snakeOne-line
Nemotron 3.5 Super VL (free)
by NVIDIAReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 3 min 10 s
- Cost
- $0
- Output tokens
- 21,770
- Spent thinking
- 17,504 tokens
- Speed
- 114.6 tok/s
- Console errors
- 0
- First token after
- 472 ms
- First visible text after
- 2 min 44 s
- Prompt tokens
- 379
- Code size
- 11.0 KB
- Finish reason
- stop
- Served by
- Nvidia
- Model id
nvidia/nemotron-3.5-super-vl:free- Reasoning sent
model default- Max output
- 32,000 tokens
- Generated
- Nov 9, 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 · 429 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Train Snake — Pick Up Passengers</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #1e2a38;
color: #fff;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
overflow: hidden;
}
#game-container {
position: relative;
display: inline-block;
}
canvas {
display: block;
background: #2d4054;
border-radius: 10px;
box-shadow: 0 14px 40px rgba(0,0,0,0.55);
cursor: pointer;
max-width: 95vw;
max-height: 72vh;
width: auto;
height: auto;
}
#score {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
font-size: 1.25rem;
font-weight: 800;
letter-spacing: 1px;
pointer-events: none;
opacity: 0.9;
text-shadow: 0 2px 6px rgba(0,0,0,0.5);
}
#overlay {
position: absolute;
inset: 0;
border-radius: 10px;
background: rgba(30,42,56,0.75);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 0 24px;
pointer-events: none;
opacity: 1;
transition: opacity 0.25s ease;
backdrop-filter: blur(4px);
}
#overlay.hidden { opacity: 0; pointer-events: none; }
h1 { font-size: min(6vw, 2.2rem); margin-bottom: 6px; text-shadow: 0 2px 8px rgba(0,0,0,0.4); }
p { font-size: min(4vw, 1.15rem); margin-bottom: 14px; opacity: 0.9; }
.hint { font-size: min(3.5vw, 0.95rem); opacity: 0.8; margin-top: 6px; }
@media (max-width: 480px) {
canvas { max-height: 60vh; }
}
</style>
</head>
<body>
<div id="game-container">
<canvas id="game" width="480" height="480"></canvas>
<div id="score">Passengers: 0</div>
<div id="overlay">
<h1 id="overlay-title">Train Snake</h1>
<p id="overlay-text">Pick up passengers waiting on the platform</p>
<div class="hint">Click or press any key to start</div>
</div>
</div>
<script>
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const overlay = document.getElementById('overlay');
const overlayTitle = document.getElementById('overlay-title');
const overlayText = document.getElementById('overlay-text');
const scoreEl = document.getElementById('score');
const GRID = 20;
const CELL = canvas.width / GRID;
const WAGON_COLORS_PALETTE = [
'#e74c3c', '#e67e22', '#f1c40f', '#2ecc71', '#3498db',
'#9b59b6', '#e91e63', '#1abc9c', '#f39c12', '#16a085'
];
let train = [];
let wagonColors = [];
let dir = { x: 0, y: -1 };
let nextDir = { x: 0, y: -1 };
let food = { x: 0, y: 0 };
let score = 0;
let gameOver = false;
let demo = true;
let tickRate = 140;
let lastTime = 0;
let tickAccumulator = 0;
function randomWagonColor() {
return WAGON_COLORS_PALETTE[Math.floor(Math.random() * WAGON_COLORS_PALETTE.length)];
}
function spawnFood() {
let pos;
do {
pos = { x: Math.floor(Math.random() * GRID), y: Math.floor(Math.random() * GRID) };
} while (train.some(s => s.x === pos.x && s.y === pos.y));
food = pos;
}
function resetGame() {
train = [
{ x: 10, y: 11 },
{ x: 10, y: 12 },
{ x: 10, y: 13 }
];
wagonColors = [randomWagonColor(), randomWagonColor()];
dir = { x: 0, y: -1 };
nextDir = { x: 0, y: -1 };
score = 0;
gameOver = false;
scoreEl.textContent = `Passengers: ${score}`;
spawnFood();
}
function startGame() {
demo = false;
resetGame();
overlay.classList.add('hidden');
}
function showOverlay(title, text) {
overlayTitle.textContent = title;
overlayText.textContent = text;
overlay.classList.remove('hidden');
}
function setDir(dx, dy) {
if (dx === -dir.x && dy === -dir.y) return;
nextDir = { x: dx, y: dy };
}
window.addEventListener('keydown', e => {
if (e.key === 'ArrowUp' || e.key === 'w') {
if (demo || gameOver) startGame();
setDir(0, -1);
e.preventDefault();
} else if (e.key === 'ArrowDown' || e.key === 's') {
if (demo || gameOver) startGame();
setDir(0, 1);
e.preventDefault();
} else if (e.key === 'ArrowLeft' || e.key === 'a') {
if (demo || gameOver) startGame();
setDir(-1, 0);
e.preventDefault();
} else if (e.key === 'ArrowRight' || e.key === 'd') {
if (demo || gameOver) startGame();
setDir(1, 0);
e.preventDefault();
} else {
if (demo || gameOver) startGame();
}
});
canvas.addEventListener('mousedown', () => {
if (demo || gameOver) startGame();
});
function isSafeCell(x, y, ignoreTail) {
if (x < 0 || x >= GRID || y < 0 || y >= GRID) return false;
for (let i = 0; i < train.length; i++) {
if (ignoreTail && i === train.length - 1) continue;
if (train[i].x === x && train[i].y === y) return false;
}
return true;
}
function getAIDir() {
const head = train[0];
const candidates = [
{ x: 0, y: -1 },
{ x: 0, y: 1 },
{ x: -1, y: 0 },
{ x: 1, y: 0 }
];
const safe = candidates
.filter(d => !(d.x === -dir.x && d.y === -dir.y))
.filter(d => isSafeCell(head.x + d.x, head.y + d.y, true));
if (safe.length === 0) {
const any = candidates.filter(d => !(d.x === -dir.x && d.y === -dir.y));
return any.length ? any[0] : dir;
}
safe.sort((a, b) => {
const da = Math.abs(head.x + a.x - food.x) + Math.abs(head.y + a.y - food.y);
const db = Math.abs(head.x + b.x - food.x) + Math.abs(head.y + b.y - food.y);
return da - db;
});
return safe[0];
}
function update() {
if (gameOver) return;
if (demo) nextDir = getAIDir();
const head = train[0];
const nx = head.x + nextDir.x;
const ny = head.y + nextDir.y;
const eaten = nx === food.x && ny === food.y;
let collide = false;
for (let i = 0; i < train.length; i++) {
if (!eaten && i === train.length - 1) continue;
if (train[i].x === nx && train[i].y === ny) { collide = true; break; }
}
if (nx < 0 || nx >= GRID || ny < 0 || ny >= GRID || collide) {
gameOver = true;
demo = true;
showOverlay('Game Over', `Passengers picked up: ${score}`);
return;
}
train.unshift({ x: nx, y: ny });
dir = nextDir;
if (eaten) {
score++;
scoreEl.textContent = `Passengers: ${score}`;
wagonColors.push(randomWagonColor());
spawnFood();
tickRate = Math.max(60, 140 - score * 4);
} else {
train.pop();
}
}
function drawRoundedRect(x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
function drawTrack() {
ctx.strokeStyle = '#455363';
ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i <= GRID; i++) {
const v = i * CELL;
ctx.moveTo(v, 0);
ctx.lineTo(v, canvas.height);
ctx.moveTo(0, v);
ctx.lineTo(canvas.width, v);
}
ctx.stroke();
}
function drawPlatform(x, y) {
const cx = x * CELL + CELL / 2;
const cy = y * CELL + CELL / 2;
const w = CELL * 0.72;
const h = CELL * 0.34;
ctx.fillStyle = '#7f8c8d';
drawRoundedRect(cx - w / 2, cy - h / 2, w, h, 5);
ctx.fill();
ctx.fillStyle = '#f1c40f';
const dot = 3;
const gap = 6;
let dy = cy - h / 2 + 4;
while (dy < cy + h / 2 - dot) {
ctx.fillRect(cx - dot / 2, dy, dot, dot);
dy += gap;
}
}
function drawPassenger(x, y) {
const cx = x * CELL + CELL / 2;
const cy = y * CELL + CELL / 2 - 2;
ctx.fillStyle = '#ecf0f1';
ctx.fillRect(cx - 5, cy + 2, 10, 10);
ctx.beginPath();
ctx.arc(cx, cy - 3, 5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#34495e';
ctx.fillRect(cx - 4, cy + 11, 4, 6);
ctx.fillRect(cx, cy + 11, 4, 6);
}
function drawWheels(cx, cy, s) {
ctx.fillStyle = '#1a252f';
const r = s * 0.13;
const off = s * 0.34;
ctx.beginPath();
ctx.arc(cx - off, cy + off, r, 0, Math.PI * 2);
ctx.arc(cx + off, cy + off, r, 0, Math.PI * 2);
ctx.fill();
}
function drawLocomotive(x, y, d) {
const cx = x * CELL + CELL / 2;
const cy = y * CELL + CELL / 2;
const s = CELL * 0.86;
ctx.fillStyle = '#2c3e50';
drawRoundedRect(cx - s / 2, cy - s / 2, s, s, 7);
ctx.fill();
ctx.fillStyle = '#3d566e';
let cabX, cabY, cabW = s / 3, cabH = s / 3;
if (d.x === 1) { cabX = cx + s / 7; cabY = cy - cabH / 2; }
else if (d.x === -1) { cabX = cx - s / 7 - cabW; cabY = cy - cabH / 2; }
else if (d.y === -1) { cabX = cx - cabW / 2; cabY = cy - s / 7 - cabH; }
else { cabX = cx - cabW / 2; cabY = cy + s / 7; }
drawRoundedRect(cabX, cabY, cabW, cabH, 4);
ctx.fill();
ctx.fillStyle = '#85c1e9';
ctx.fillRect(cabX + cabW / 5, cabY + cabH / 5, cabW / 2.5, cabH / 2.5);
ctx.fillStyle = '#1a252f';
const chW = s / 9, chH = s / 3.5;
let chX, chY;
if (d.x === 1) { chX = cx - s / 2 + 3; chY = cy - s / 2 + 4; }
else if (d.x === -1) { chX = cx + s / 2 - 3 - chW; chY = cy - s / 2 + 4; }
else if (d.y === -1) { chX = cx - chW / 2; chY = cy - s / 2 + 4; }
else { chX = cx - chW / 2; chY = cy + s / 2 - 4 - chH; }
ctx.fillRect(chX, chY, chW, chH);
ctx.fillStyle = '#bdc3c7';
ctx.beginPath();
if (d.x === 1) {
ctx.moveTo(cx + s / 2, cy - s / 4);
ctx.lineTo(cx + s / 2 + 7, cy);
ctx.lineTo(cx + s / 2, cy + s / 4);
} else if (d.x === -1) {
ctx.moveTo(cx - s / 2, cy - s / 4);
ctx.lineTo(cx - s / 2 - 7, cy);
ctx.lineTo(cx - s / 2, cy + s / 4);
} else if (d.y === -1) {
ctx.moveTo(cx - s / 4, cy - s / 2);
ctx.lineTo(cx, cy - s / 2 - 7);
ctx.lineTo(cx + s / 4, cy - s / 2);
} else {
ctx.moveTo(cx - s / 4, cy + s / 2);
ctx.lineTo(cx, cy + s / 2 + 7);
ctx.lineTo(cx + s / 4, cy + s / 2);
}
ctx.fill();
drawWheels(cx, cy, s);
}
function drawWagon(x, y, color) {
const cx = x * CELL + CELL / 2;
const cy = y * CELL + CELL / 2;
const s = CELL * 0.8;
ctx.fillStyle = color;
drawRoundedRect(cx - s / 2, cy - s / 2, s, s, 5);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.18)';
ctx.fillRect(cx - s / 2 + 5, cy - s / 2 + 5, s - 10, 6);
ctx.fillStyle = '#bdc3c7';
const winW = s / 5, winH = s / 5;
ctx.fillRect(cx - s / 2 + s / 5, cy - winH / 2, winW, winH);
ctx.fillRect(cx + s / 2 - s / 5 - winW, cy - winH / 2, winW, winH);
drawWheels(cx, cy, s);
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawTrack();
drawPlatform(food.x, food.y);
drawPassenger(food.x, food.y);
for (let i = train.length - 1; i >= 0; i--) {
const seg = train[i];
if (i === 0) {
drawLocomotive(seg.x, seg.y, dir);
} else {
drawWagon(seg.x, seg.y, wagonColors[i - 1]);
}
}
}
function loop(timestamp) {
if (!lastTime) lastTime = timestamp;
const dt = timestamp - lastTime;
lastTime = timestamp;
tickAccumulator += dt;
while (tickAccumulator >= tickRate) {
tickAccumulator -= tickRate;
update();
}
draw();
requestAnimationFrame(loop);
}
resetGame();
draw();
requestAnimationFrame(loop);
</script>
</body>
</html>Reasoning trace (17,504 tokens)
The user wants a Snake game themed as a train:
- Snake is a train with locomotive and colorful wagons
- Picking up passengers waiting on a platform (instead of apples/food)
- Self-contained web demo
- Must run with zero errors
- Must look right in 1280x800 and adapt down to 375px wide (responsive)
- Something meaningful visible on first paint
- Games must start in self-playing demo mode behind a "Click or press a key to start" message
- Iframe only receives keyboard events after click, so show clear message
- No external files except CDN URLs explicitly allowed (none specified, so inline everything)
- No localStorage, sessionStorage, cookies, IndexedDB, alert/confirm/prompt/window.open
Design considerations:
- Canvas-based game
- Grid-based movement like snake
- Train head (locomotive) at front, wagons follow
- Each "passenger" picked up adds a colorful wagon
- Platform: maybe passengers wait at specific spawn points? Or just food items that look like people on platforms?
- "Passengers waiting on the platform" - maybe the food is a little person icon on a platform segment? Let's make food spawn as a small person on a platform tile (like a bench/shelter).
- Wagon colors: cycle through colors or random bright colors per wagon.
- Self-playing demo mode: AI that moves toward food, avoiding walls/itself.
- Start screen overlay with "Click or press a key to start".
- After game over, maybe show restart option / go back to demo.
Technical details:
- Canvas resize to fit container, maintain aspect ratio or scale. Viewport 1280x800 but adapt to 375px. Use CSS to make canvas max-width 100%, height auto? But canvas internal resolution fixed, CSS scales it. Or dynamically set canvas size based on window. Let's make a game container that fits screen, canvas internal resolution maybe 400x400 or 500x500, scaled via CSS. Actually simpler: canvas width/height set to e.g. 480x480, container centered with max-width 90vw, max-height 80vh. CSS handles responsiveness. Internal grid maybe 20x20 or 24x24.
- Cell size computed from canvas size / grid size.
- Train: array of segments. Head has direction. Each segment follows previous.
- Locomotive: draw a train engine front. Wagons: colorful rectangles with wheels maybe.
- Passengers: small circles or person shapes on platform tiles.
- Platform: when food spawns, draw a platform rectangle under it? Maybe all tiles are tracks, and food spawns with a little platform graphic. Let's just draw food as a passenger icon (simple person) on a small platform (bench) to match theme.
- Colors: locomotive dark blue/black with red details. Wagons: random from palette: red, orange, yellow, green, cyan, purple, pink.
- Movement: setInterval or requestAnimationFrame with accumulator. Speed increases slightly as train grows? Maybe constant.
- AI demo: pathfinding? Simple greedy: choose direction that reduces Manhattan distance to food, avoiding collision. If stuck, try any safe direction. Since it's demo, doesn't need to be perfect.
Game states:
- START: show overlay, train idles or moves in demo? "Self-playing demo mode behind a Click or press a key to start message". So before start, the AI plays automatically in the background. Overlay covers canvas or is semi-transparent? Let's have overlay centered, canvas visible behind maybe dimmed. AI plays.
- PLAYING: user controls with arrow keys or WASD.
- GAME OVER: show "Game Over" and maybe score, with click/key to restart (goes back to demo mode).
Input handling:
- Prevent default on arrow keys to avoid scrolling.
- Add event listener for keydown. Only active after click? The requirement says iframe only receives keyboard events after user clicks it. But we can still listen; just show message. To be safe, we can require a click or key to start. Once started, keyboard works. Since it's an iframe, user must click to focus anyway. We'll display "Click anywhere or press a key to start".
Responsiveness:
- Use viewport units or percentage container. Canvas internal size fixed (e.g., 400x400). CSS: width: min(90vw, 400px); height: auto? But canvas is square, height follows width if we set width and height auto? Actually canvas element with width and height attributes set to 400. CSS width: 90vw; height: auto; will maintain aspect ratio if intrinsic aspect ratio is preserved (it is by default for replaced elements? Canvas is replaced, yes aspect ratio from attributes). So set canvas { width: min(90vw, 80vh, 500px); height: auto; display: block; margin: 0 auto; }. But on very short screens, 80vh might limit. Let's use container with display flex, center. Canvas max-width: 95vw; max-height: 70vh; width: auto; height: auto? To preserve aspect ratio, setting one dimension and auto for other works. Let's set canvas { max-width: 95vw; max-height: 70vh; width: auto; height: auto; display: block; margin: 0 auto; background: ...; }. Since intrinsic ratio is 1:1, the browser will scale down uniformly.
Wait, if we set width: auto and height: auto with max constraints, it uses intrinsic size (400x400) and scales down if needed. That works.
Grid: 20x20 cells. Cell size = canvasWidth / 20.
Drawing train:
- Head (locomotive): draw at head position. Shape: rectangle with cabin, chimney, maybe cowcatcher. Directional: face direction of movement.
- Wagons: draw behind head, each offset along body. Each wagon a different color. Could draw as rectangle with wheels (small circles) and windows.
- Since grid movement is discrete, drawing exactly at grid positions might look jerky. We can interpolate positions for smooth movement between ticks. Let's implement smooth interpolation: each segment has current grid pos and target grid pos, lerp factor based on time accumulator. Or simpler: draw head at interpolated position between last and current cell, wagons follow with slight delay. For simplicity and robustness, draw at exact grid centers but movement is on timer. It's fine.
Let's do smooth movement:
- Game tick every `speed` ms (e.g., 150ms).
- Between ticks, render interpolation `t` from 0 to 1. Positions: pos = prevPos + (currPos - prevPos) * t. But snake body segments follow with delay. Standard snake interpolation: store array of positions with timestamps? Too complex. Let's just snap to grid. It's a retro game.
AI:
- At each tick, compute possible moves (up, down, left, right) from head.
- Filter out moves that hit wall or body (except tail might move, but check current body).
- Choose move with min Manhattan distance to food.
- If no safe moves, pick any move (will die).
Score: number of passengers picked up = number of wagons. Display score.
Passenger/platform drawing:
- Platform: a rounded rectangle beneath the food tile, maybe brown/gray.
- Passenger: small circle head, rectangle body, or simple "P"? Let's draw a little person: circle for head, rect for body, two legs. Colorful? Maybe all passengers same color but distinct from train.
Let's sketch draw functions:
- drawCell(x, y, color) for track background? Maybe draw grid lines.
- drawPlatform(x, y): draw a rounded rect slightly smaller than cell, color #8b7355 (wooden platform) with yellow tactile paving strips?
- drawPassenger(x, y): stick figure or simple icon.
- drawLocomotive(x, y, dir): body rect, cabin, chimney, front buffer.
- drawWagon(x, y, color): wagon body, wheels, windows.
Since head direction matters for locomotive drawing, we need to know direction. The train head's direction is current movement direction. Wagons don't need direction.
Train structure:
```
let train = [
{x: 10, y: 10}, // head
{x: 10, y: 11},
{x: 10, y: 12}
];
let dir = {x:0, y:-1}; // moving up
let nextDir = {x:0, y:-1};
```
Input queues nextDir to prevent 180 turns.
Movement:
- newHead = {x: head.x + nextDir.x, y: head.y + nextDir.y}
- Check collision: walls or body (excluding tail if it's moving? Standard snake: check all body except maybe tail because tail moves. But if length doesn't grow, tail moves so new head can occupy tail's old position. Simplest: check collision against all body segments; if new head equals tail and we didn't grow, it's okay. But since we check before moving tail, tail is still there. So exclude tail index if not growing.)
- If food eaten: don't pop tail, add new wagon with random color, score++, spawn new food.
- Else: pop tail.
- Push new head.
- dir = nextDir.
Colors for wagons: when adding, push random color to wagonColors array. Wagon at index i (0 is head, 1 is first wagon) uses wagonColors[i-1].
Rendering order:
- Clear canvas.
- Draw grid/track.
- Draw food (platform + passenger).
- Draw train from tail to head so head is on top? Or head to tail? Draw tail first then head last. Actually wagons behind head, head front. Draw from tail to head so head overlaps previous wagon. That looks right.
Demo mode:
- `demo = true` initially.
- AI computes nextDir each tick.
- Overlay visible with "Click or press a key to start".
- When user clicks or presses key, `demo = false`, hide overlay, start game.
- On game over, show overlay again with "Game Over - Score: X. Click or press key to restart", reset game, demo = true.
Keyboard:
- Arrow keys and WASD.
- Prevent default for arrows.
- In start screen, any key starts. In game, keys change direction.
First paint: overlay + canvas with demo running. So meaningful on first paint.
No external assets. Everything drawn with Canvas API.
Let's write code.
HTML structure:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Train Snake</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #2c3e50; color: white; font-family: system-ui, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; overflow: hidden; }
#game-container { position: relative; display: inline-block; }
canvas { display: block; background: #34495e; border-radius: 8px; max-width: 95vw; max-height: 70vh; width: auto; height: auto; box-shadow: 0 10px 30px rgba(0,0,0,0.5); cursor: pointer; }
#overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.6); display: flex; flex-direction: column; align-items: center; justify-content: center; border-radius: 8px; text-align: center; pointer-events: none; transition: opacity 0.2s; }
#overlay.hidden { opacity: 0; pointer-events: none; }
h1 { font-size: min(5vw, 2rem); margin-bottom: 0.5rem; text-shadow: 0 2px 4px rgba(0,0,0,0.5); }
p { font-size: min(4vw, 1rem); margin-bottom: 1rem; opacity: 0.9; }
.hint { font-size: min(3.5vw, 0.9rem); opacity: 0.75; margin-top: 0.5rem; }
#score { position: absolute; top: 10px; left: 50%; transform: translateX(-50%); font-size: 1.2rem; font-weight: bold; pointer-events: none; opacity: 0.9; }
</style>
</head>
<body>
<div id="game-container">
<canvas id="game" width="480" height="480"></canvas>
<div id="score">Passengers: 0</div>
<div id="overlay">
<h1 id="overlay-title">Train Snake</h1>
<p id="overlay-text">Pick up passengers waiting on the platform</p>
<div class="hint">Click or press any key to start</div>
</div>
</div>
<script>
// code here
</script>
</body>
</html>
```
JS:
Constants:
- GRID = 20 (cells per side). Canvas 480x480 => cell 24px.
- TICK_SPEED = 150 (ms). Maybe decrease as score increases? Could do 150 - score*2, min 70.
- Colors: WAGON_COLORS = ['#e74c3c', '#f39c12', '#f1c40f', '#2ecc71', '#3498db', '#9b59b6', '#e91e63', '#1abc9c'].
State:
```js
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const overlay = document.getElementById('overlay');
const overlayTitle = document.getElementById('overlay-title');
const overlayText = document.getElementById('overlay-text');
const scoreEl = document.getElementById('score');
const GRID = 20;
const CELL = canvas.width / GRID;
let train = []; // {x,y}
let wagonColors = [];
let dir = {x:0, y:-1};
let nextDir = {x:0, y:-1};
let food = {x:15, y:15};
let score = 0;
let gameOver = false;
let demo = true;
let lastTime = 0;
let acc = 0;
let tickRate = 150;
function resetGame() {
train = [{x:10, y:10}, {x:10, y:11}, {x:10, y:12}];
wagonColors = [randomWagonColor(), randomWagonColor()];
dir = {x:0, y:-1};
nextDir = {x:0, y:-1};
score = 0;
gameOver = false;
scoreEl.textContent = `Passengers: ${score}`;
spawnFood();
}
```
Wait, if train length 3 (head + 2 wagons), wagonColors length should be 2. Index mapping: train[0] head, train[1] wagon color[0], train[2] color[1].
spawnFood: random cell not on train.
randomWagonColor: pick from palette.
Input:
```js
function setDir(dx, dy) {
// prevent 180
if (dx === -dir.x && dy === -dir.y) return;
// also prevent same? no need
nextDir = {x: dx, y: dy};
}
window.addEventListener('keydown', e => {
if (e.key === 'ArrowUp' || e.key === 'w') setDir(0, -1);
else if (e.key === 'ArrowDown' || e.key === 's') setDir(0, 1);
else if (e.key === 'ArrowLeft' || e.key === 'a') setDir(-1, 0);
else if (e.key === 'ArrowRight' || e.key === 'd') setDir(1, 0);
else {
// any other key starts if not started
if (demo || gameOver) startGame();
}
// prevent scroll for arrows
if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.key)) e.preventDefault();
});
// click anywhere on container starts/restarts
canvas.addEventListener('mousedown', () => {
if (demo || gameOver) startGame();
});
```
startGame:
```js
function startGame() {
if (demo) {
demo = false;
resetGame();
overlay.classList.add('hidden');
} else if (gameOver) {
resetGame();
overlay.classList.add('hidden');
}
}
```
But if gameOver, resetGame then hide overlay. If demo, hide overlay and start. Actually if demo, resetGame sets game state, hide overlay. Good.
But initial state: demo=true, overlay visible. AI plays behind overlay? We need game loop running even in demo. So loop runs always. `demo` just controls overlay and input interpretation. AI controls train when demo true. When game over, demo=true again? Let's say on game over, show overlay with game over text, set demo=true, but game loop still runs AI in background? Or freeze? The requirement: games must start in self-playing demo mode behind a click message. So after game over, we can return to demo mode where AI plays again. That's nice. So `gameOver` triggers overlay update, `demo = true`, but game loop continues (AI plays). The train can keep moving behind overlay. Or we can freeze. Let's continue AI demo behind overlay.
Overlay update:
```js
function showOverlay(title, text) {
overlayTitle.textContent = title;
overlayText.textContent = text;
overlay.classList.remove('hidden');
}
```
AI:
```js
function getAIDir() {
const head = train[0];
const dirs = [{x:0,y:-1},{x:0,y:1},{x:-1,y:0},{x:1,y:0}];
// sort by manhattan distance to food
dirs.sort((a,b) => {
const da = Math.abs(head.x+a.x-food.x)+Math.abs(head.y+a.y-food.y);
const db = Math.abs(head.x+b.x-food.x)+Math.abs(head.y+b.y-food.y);
return da-db;
});
for (const d of dirs) {
if (d.x === -dir.x && d.y === -dir.y) continue;
if (isSafe(head.x+d.x, head.y+d.y)) return d;
}
// no safe move, return any non-reverse
for (const d of dirs) {
if (d.x === -dir.x && d.y === -dir.y) continue;
return d;
}
return dir;
}
function isSafe(x, y) {
if (x < 0 || x >= GRID || y < 0 || y >= GRID) return false;
// check body, allow tail if tail will move (not growing this tick)
// but we don't know if growing yet. Simplest: check all except maybe last index if not eating.
for (let i = 0; i < train.length; i++) {
if (train[i].x === x && train[i].y === y) {
// allow if it's tail and we are not eating food at this position?
// food is at food.x/y. if x,y === tail and x,y === food, impossible because food not on train.
// so just return false for any body collision. AI might be too conservative but fine.
return false;
}
}
return true;
}
```
Actually, standard snake allows moving into tail's current cell because tail moves away. If AI avoids tail entirely, it might be overly cautious but safer. Let's implement more precise: body without tail if not eating. But food could be at tail? No, spawnFood avoids train. So tail cell is safe if we don't grow. But if head moves into tail and we eat food? Can't because food isn't on train. So moving into tail is always safe if we don't grow? Wait, if we move into tail and don't grow, tail pops, so safe. If we move into tail and grow (food at tail? impossible). So safe to allow tail. But if we move into tail and the food is elsewhere, tail pops, so safe. Let's allow tail index `train.length-1` as safe. But if we grow (food at new head), tail doesn't pop, so moving into tail would collide. But food isn't on train, so new head != tail if food not on train? Actually new head could equal tail even if food is elsewhere; then we don't grow (food not at new head), tail pops, safe. So yes, tail is safe.
So `isSafe`:
```js
function isSafe(x, y) {
if (x < 0 || x >= GRID || y < 0 || y >= GRID) return false;
for (let i = 0; i < train.length - 1; i++) { // exclude tail
if (train[i].x === x && train[i].y === y) return false;
}
return true;
}
```
This is fine.
Update logic:
```js
function update() {
if (gameOver && demo) {
// AI still moves in demo mode behind overlay
nextDir = getAIDir();
}
// move
const head = train[0];
const nx = head.x + nextDir.x;
const ny = head.y + nextDir.y;
// collision with body (excluding tail if not eating?)
let eaten = (nx === food.x && ny === food.y);
let collide = false;
for (let i = 0; i < train.length; i++) {
if (i === train.length - 1 && !eaten) continue; // tail moves away
if (train[i].x === nx && train[i].y === ny) { collide = true; break; }
}
if (nx < 0 || nx >= GRID || ny < 0 || ny >= GRID || collide) {
gameOver = true;
demo = true;
showOverlay('Game Over', `Passengers picked up: ${score}`);
return;
}
train.unshift({x: nx, y: ny});
dir = nextDir;
if (eaten) {
score++;
scoreEl.textContent = `Passengers: ${score}`;
wagonColors.push(randomWagonColor());
spawnFood();
// speed up slightly
tickRate = Math.max(60, 150 - score * 3);
} else {
train.pop();
}
if (demo) nextDir = getAIDir();
}
```
Wait: if gameOver occurs in demo mode (AI dies), we show overlay and set demo=true. Loop continues, AI will restart? No, reset only on click/key. In demo mode after death, train remains in collision state? We return from update, so train stops moving. But game loop keeps calling update, returns immediately because gameOver true. That's fine; the train is static at death position behind overlay. But requirement says self-playing demo mode. Maybe after game over in demo, it should restart automatically? Not required. But it's nicer if demo continues indefinitely. Let's make demo mode restart automatically after a short delay? Or simply when gameOver in demo, call resetGame() after 1 second? But overlay says Game Over. Hmm. Let's keep it simple: if demo and gameOver, auto reset after 1.5s. Use a timer.
Actually simpler: game loop continues, if gameOver and demo, after a short delay reset. But we don't want complex timers. Let's just have `gameOver` freeze and overlay shows. User can click to restart, which goes to demo mode. That's acceptable.
Wait, initial state is demo true, overlay visible. AI is not running until startGame? No, loop runs update regardless. In update, if demo, `nextDir = getAIDir()` then move. So AI plays behind overlay from the start. Good.
Drawing:
```js
function draw() {
ctx.clearRect(0,0,canvas.width,canvas.height);
// draw grid/track
ctx.strokeStyle = '#4a5a6a';
ctx.lineWidth = 1;
for (let i=0; i<=GRID; i++) {
ctx.beginPath(); ctx.moveTo(i*CELL,0); ctx.lineTo(i*CELL,canvas.height); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0,i*CELL); ctx.lineTo(canvas.width,i*CELL); ctx.stroke();
}
// draw platform under food
drawPlatform(food.x, food.y);
drawPassenger(food.x, food.y);
// draw train tail to head
for (let i = train.length - 1; i >= 0; i--) {
const seg = train[i];
if (i === 0) drawLocomotive(seg.x, seg.y, dir);
else drawWagon(seg.x, seg.y, wagonColors[i-1]);
}
}
function drawPlatform(x,y) {
const cx = x*CELL + CELL/2;
const cy = y*CELL + CELL/2;
const w = CELL * 0.7;
const h = CELL * 0.35;
ctx.fillStyle = '#7f8c8d';
ctx.beginPath();
ctx.roundRect(cx-w/2, cy-h/2, w, h, 4);
ctx.fill();
// tactile paving
ctx.fillStyle = '#f1c40f';
for (let dy=-h/2+4; dy<h/2; dy+=6) {
ctx.fillRect(cx-2, cy+dy, 4, 4);
}
}
function drawPassenger(x,y) {
const cx = x*CELL + CELL/2;
const cy = y*CELL + CELL/2 - 2;
// body
ctx.fillStyle = '#ecf0f1';
ctx.fillRect(cx-5, cy+2, 10, 10);
// head
ctx.beginPath(); ctx.arc(cx, cy-3, 5, 0, Math.PI*2); ctx.fill();
// legs
ctx.fillStyle = '#34495e';
ctx.fillRect(cx-4, cy+11, 4, 6);
ctx.fillRect(cx+0, cy+11, 4, 6);
}
function drawLocomotive(x, y, d) {
const cx = x*CELL + CELL/2;
const cy = y*CELL + CELL/2;
const s = CELL * 0.85;
// body
ctx.fillStyle = '#2c3e50';
ctx.beginPath(); ctx.roundRect(cx-s/2, cy-s/2, s, s, 6); ctx.fill();
// cabin
ctx.fillStyle = '#34495e';
let cabX, cabY;
if (d.x === 1) { cabX = cx + s/6; cabY = cy - s/6; } // facing right
else if (d.x === -1) { cabX = cx - s/6 - s/3; cabY = cy - s/6; }
else if (d.y === -1) { cabX = cx - s/6; cabY = cy - s/6 - s/3; } // facing up
else { cabX = cx - s/6; cabY = cy + s/6; } // facing down
ctx.beginPath(); ctx.roundRect(cabX, cabY, s/3, s/3, 4); ctx.fill();
// chimney
ctx.fillStyle = '#1a252f';
ctx.fillRect(cx - 3, cy - s/2 + 2, 6, s/3);
// front buffer/cowcatcher based on direction
ctx.fillStyle = '#bdc3c7';
ctx.beginPath();
if (d.x === 1) {
ctx.moveTo(cx+s/2, cy-s/3); ctx.lineTo(cx+s/2+6, cy); ctx.lineTo(cx+s/2, cy+s/3);
} else if (d.x === -1) {
ctx.moveTo(cx-s/2, cy-s/3); ctx.lineTo(cx-s/2-6, cy); ctx.lineTo(cx-s/2, cy+s/3);
} else if (d.y === -1) {
ctx.moveTo(cx-s/3, cy-s/2); ctx.lineTo(cx, cy-s/2-6); ctx.lineTo(cx+s/3, cy-s/2);
} else {
ctx.moveTo(cx-s/3, cy+s/2); ctx.lineTo(cx, cy+s/2+6); ctx.lineTo(cx+s/3, cy+s/2);
}
ctx.fill();
// window
ctx.fillStyle = '#85c1e9';
ctx.fillRect(cabX + s/12, cabY + s/12, s/6, s/6);
// draw direction indicator? no need.
// wheels
drawWheels(cx, cy, s);
}
function drawWagon(x, y, color) {
const cx = x*CELL + CELL/2;
const cy = y*CELL + CELL/2;
const s = CELL * 0.8;
ctx.fillStyle = color;
ctx.beginPath(); ctx.roundRect(cx-s/2, cy-s/2, s, s, 5); ctx.fill();
// stripe
ctx.fillStyle = 'rgba(255,255,255,0.2)';
ctx.fillRect(cx-s/2+4, cy-s/2+4, s-8, 6);
// windows
ctx.fillStyle = '#bdc3c7';
const gap = s/4;
ctx.fillRect(cx - s/2 + gap, cy - s/4, s/6, s/6);
ctx.fillRect(cx + s/2 - gap - s/6, cy - s/4, s/6, s/6);
drawWheels(cx, cy, s);
}
function drawWheels(cx, cy, s) {
ctx.fillStyle = '#2c3e50';
const r = s * 0.12;
const offset = s * 0.35;
ctx.beginPath();
ctx.arc(cx-offset, cy+offset, r, 0, Math.PI*2);
ctx.arc(cx+offset, cy+offset, r, 0, Math.PI*2);
ctx.fill();
}
```
Game loop:
```js
function loop(timestamp) {
if (!lastTime) lastTime = timestamp;
const dt = timestamp - lastTime;
lastTime = timestamp;
acc += dt;
if (acc >= tickRate) {
acc -= tickRate;
update();
}
draw();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
```
Need to handle initial lastTime properly.
Edge cases:
- `ctx.roundRect` is supported in modern Chrome? Yes, Chrome supports roundRect since 2021? Actually Canvas 2D `roundRect` was added in Chrome 124? Hmm, maybe not universally in latest Chrome? It's now widely supported but to be safe, I can implement rounded rect manually or just use regular rect. `roundRect` is part of Canvas 2D and supported in Chrome 124+, Firefox 127+, Safari 17.4. Latest Chrome likely supports it. But to be safe and avoid console errors, I'll avoid `roundRect` and draw regular rects or use `arc` for corners? Simpler: just use `fillRect` and maybe `strokeRect`. Visuals still okay. Or create helper `roundedRect`. Let's avoid potential issues and just use `fillRect` with slightly smaller size to simulate rounded look, or draw with `arc`. I'll just use `fillRect` and not worry about roundRect. No console errors.
- `canvas` sizing: width=480 height=480 attributes. CSS max-width/max-height scales it. Good.
- No horizontal scrollbar: body overflow hidden, flex column center. Canvas doesn't overflow because max constraints.
- Touch devices: click works.
- Keyboard only after click: The overlay says click or press key. We listen to keydown globally; if user presses key before focusing iframe, it might not work because iframe doesn't have focus. But that's standard. We can add `tabindex` to container? Not needed. We can just say click to start. The requirement says "if the demo uses the keyboard, display a clear 'Click or press a key to start' message." We do.
Let's refine overlay text:
- Initially: "Train Snake", "Pick up passengers waiting on the platform", "Click or press any key to start"
- Game Over: "Game Over", `Passengers picked up: X`, "Click or press any key to restart"
When gameOver and not demo? We set demo=true on game over anyway, so it's same state. But if user was playing and dies, we show game over overlay, demo=true. AI won't restart until user clicks. But loop is paused? update returns early if gameOver. So train stays frozen. That's fine.
Actually if gameOver is true and demo is true, update returns early. So train doesn't move. But initially demo true, gameOver false, so AI moves. After game over, we want to show static game over. Good.
One issue: when gameOver triggered by player, we set demo=true. But if we then click to start, startGame sees demo=true, calls resetGame, hides overlay. Good.
Speed increase: tickRate decreases. `acc` logic handles variable tick rate? If tickRate changes mid-acc, we might skip. It's okay. Simpler: use setInterval for game ticks and requestAnimationFrame for draw. But mixing is fine. Let's do a simpler approach: fixed timestep using `setInterval` for update and `requestAnimationFrame` for draw. That avoids accumulator complexity.
```js
let tickTimer;
function startTickLoop() {
if (tickTimer) clearInterval(tickTimer);
tickTimer = setInterval(() => {
if (!gameOver || demo) { // if game over and not demo, don't update
update();
}
}, tickRate);
}
```
But tickRate changes when eating. Need to restart interval. We can just use the accumulator rAF method, it's fine. Let's stick to rAF accumulator but reset acc when tickRate changes? Or just recalculate. Let's keep it simple: `tickRate` variable read each frame. `acc` accumulates real time. When `acc >= tickRate`, update and subtract tickRate. If tickRate drops, next update happens sooner. Fine.
One subtlety: `lastTime` initial. Set `lastTime = performance.now()` before loop.
Also prevent context menu? Not needed.
Now, ensure no console errors:
- All DOM elements exist.
- `ctx` methods valid.
- No `alert`, `localStorage`, etc.
Responsiveness:
- CSS body min-height 100vh, overflow hidden. Flex column center.
- Canvas max-width 95vw max-height 70vh. Since intrinsic 480x480, on small screens it scales down. No horizontal scrollbar because body overflow hidden and canvas is block centered.
On first paint: overlay and canvas visible, AI running. Good.
Let's write final HTML.
One thing: When demo mode is active and user hasn't started, the score says "Passengers: 0". Fine.
When food spawns, make sure not on train:
```js
function spawnFood() {
let pos;
do {
pos = {x: Math.floor(Math.random()*GRID), y: Math.floor(Math.random()*GRID)};
} while (train.some(s => s.x === pos.x && s.y === pos.y));
food = pos;
}
```
AI: sort dirs by distance. Also prefer continuing straight? Could add small bias but not needed.
Let's add a little "track" visual: background dark gray, grid lines darker. Maybe draw rails: two parallel lines across cells? Not necessary.
Let's make locomotive more train-like: draw a rectangle body, sloped front? Hard with direction. We can just draw a rectangle with a smokestack and cabin. Good enough.
Wagons: colorful rectangles with windows and wheels. Looks like a train.
Add a little "steam" from chimney? Could draw small puffs occasionally. Not necessary.
Let's make sure `dir` and `nextDir` are consistent. In update:
```
const head = train[0];
const nx = head.x + nextDir.x;
...
dir = nextDir;
```
If demo, after update, set nextDir = getAIDir() for next tick. But we also need to set nextDir at start of update if demo. So:
```
function update() {
if (gameOver) return;
if (demo) nextDir = getAIDir();
...
}
```
Yes, call getAIDir at start of update if demo. That way nextDir is fresh each tick.
But input from user sets nextDir. If demo is false, user input is used. Good.
When switching from demo to playing, `nextDir` might be AI's last direction. User can immediately override with keydown. Fine.
Double buffer issue: none.
Let's code it all.
One more detail: `ctx` might need to save/restore? Not necessary.
I will write the code now.
Check for any use of forbidden APIs: none.
No external images/fonts. Use system-ui.
Viewport meta included.
Let's write the final HTML string.
Potential issue: `Math.random` food could spawn under overlay but that's fine.
Potential issue: canvas internal resolution 480x480 but CSS scales it. Coordinates are in internal resolution. Fine.
Let's ensure `tickRate` min is 60. At score 30, tickRate = 60.
Game over collision check: exclude tail if not eaten.
```
for (let i = 0; i < train.length; i++) {
if (i === train.length - 1 && !eaten) continue;
if (...) collide = true;
}
```
But `eaten` is computed before moving. If new head === tail and eaten is false, we skip tail, so no collision, then we pop tail. Good.
If new head === tail and eaten is true? Can't happen because food not on train, so new head (food) !== tail. Fine.
Walls: check nx,ny.
Rendering train: draw from tail (i=train.length-1) down to head (i=0). So head drawn last on top. Good.
Wagon colors: wagonColors array length = train.length - 1. When eaten, push new color. When not eaten and pop tail, we also need to remove the color corresponding to the popped tail! Otherwise colors shift. Very important.
If train.pop() removes last segment (tail), we must also remove last color from wagonColors.
When we unshift new head, train length grows by 1. If not eaten, we pop tail, so length stays same. wagonColors length should stay same (head has no color). Wait:
- train[0] = head, no color.
- train[1..end] = wagons, each has color wagonColors[i-1].
- When moving without eating: unshift new head (index 0), old head becomes index 1 (wagon). So wagonColors needs to shift? Actually old head was not a wagon before; now it is a wagon. It should get a color? No, in snake, segments don't change identity; the tail moves to new head position? Standard snake array: each element is a segment. When moving, new head added, tail removed. The segment that was head remains head? No, array order: [head, body1, body2, ..., tail]. After move: [newHead, oldHead, body1, ..., bodyN-1] (tail removed). So old head becomes a wagon. But in our visual, locomotive should always be at front. If old head becomes wagon, that means the locomotive morphs into a wagon? That's not right for a train. The locomotive should remain the locomotive at the front, and new segment added behind? Wait, snake moves forward; the head moves into new cell, the rest of body follows. The head is always the first element. So array [head, segment1, segment2]. After move, head is at new cell, segment1 moves to old head position, segment2 moves to old segment1 position, tail removed. So the locomotive is still at index 0 (new position). The old locomotive position is now occupied by segment1 (first wagon). So visually, the locomotive is always index 0, wagons are indices 1+. The wagon colors stay attached to their indices? Let's see: before move, index1 is wagon A at pos P1. After move, index1 is at old head position P0. So wagon A moved to P0. Its color stays with index1. So we don't need to shift colors. We just add new head at index0. If not eaten, we pop tail (last index). We should also pop the last wagon color because that wagon is removed. Wait: if train length stays same, number of wagons stays same. train.pop() removes last segment. That segment had a color at wagonColors[lastIndex-1]. We should remove that color. But if we just pop train and don't pop wagonColors, then wagonColors length = train.length, which is wrong because index0 is head. Actually if train.length stays same (e.g., 5), wagonColors should be 4. If we pop train (length 5->4 then unshift makes 5 again), let's trace:
- train: [H, W1, W2, W3] (len 4). wagonColors: [c1,c2,c3] (len 3).
- Move no eat: unshift newH => [newH, H, W1, W2, W3] (len 5). Then pop => [newH, H, W1, W2] (len 4). wagonColors still [c1,c2,c3]. Now index1=H (old head) should be wagon with color? Previously H was locomotive, no color. Now H is wagon. It should get c1? And W1 gets c2, W2 gets c3, W3 removed. So colors need to shift: new wagonColors = [c1, c2, c3] but now assigned to indices 1,2,3? Wait index1=H gets c1, index2=W1 gets c2, index3=W2 gets c3. W3 is gone. So colors stay aligned with indices 1..end. The old head H enters wagon pool at index1, taking c1? But H wasn't a wagon before. In snake visual, the segment that was head becomes body. Its color should perhaps be the color of the first wagon? But then wagon colors shift backward. It's okay visually: the train grows by adding new wagons at back? No, in snake the body follows. The visual effect: locomotive moves forward, the wagon behind it moves into the locomotive's previous cell, etc. So each wagon keeps its color but moves to the position of the segment ahead of it. Therefore wagonColors should remain fixed order: wagonColors[0] is always the wagon immediately behind locomotive (index1). When we move without eating:
- train becomes [newHead, oldHead, W1, W2] (W3 popped).
- wagonColors should be [c_for_oldHead, c1, c2]. But oldHead was locomotive. What color? We could just keep wagonColors same length by popping last color and unshifting a new color? That would mean the locomotive turns into a wagon with a new random color? That would look like the train is constantly changing. Better: when not eating, we don't add a wagon. The number of wagons stays same. So wagonColors length stays same. But indices shift: index1 now is old head. We can simply NOT pop a color; instead, we leave wagonColors as is and draw train[1] with wagonColors[0], train[2] with wagonColors[1], etc. Wait after unshift+pop, train length is same as before. train[1] is old head. It should be drawn as wagon with wagonColors[0]. train[2] is W1 with wagonColors[1]. train[3] is W2 with wagonColors[2]. So we don't need to modify wagonColors at all! Because wagonColors array maps to indices 1..train.length-1. The length matches. The content of wagonColors stays same, but the wagon that was at index1 (color c1) moves to index2? Let's trace carefully with positions:
Before: train indices:
0: H at P0 (locomotive)
1: W1 at P1 (color c1)
2: W2 at P2 (color c2)
3: W3 at P3 (color c3)
wagonColors = [c1, c2, c3]
After move no eat:
new head at Pnew.
train.unshift(Pnew) -> [Pnew, P0, P1, P2, P3]
pop -> [Pnew, P0, P1, P2]
So:
index0: locomotive at Pnew
index1: at P0 (was H). Draw with wagonColors[0]=c1.
index2: at P1 (was W1). Draw with wagonColors[1]=c2.
index3: at P2 (was W2). Draw with wagonColors[2]=c3.
W3 at P3 is gone.
This means W1 changed color from c1 to c2? Visually, the wagon at P1 now has color c2. That's okay; it's like wagons are just carriages and their colors don't need to be persistent per physical wagon, just per slot. But it might look weird if colors shift along the train. However, since all wagons are colorful and similar, it's fine. Alternatively, to keep colors attached to wagons, we would need to shift colors array too: unshift a dummy? But head has no color. Actually we can treat wagonColors as attached to train indices 1..end. When we unshift a new head, we don't add a color. When we pop tail, we pop last color. But we also need to insert a color for the old head at index1? No, because number of wagons stays same. So we shouldn't push or pop colors when not eating. The mapping index i -> wagonColors[i-1] remains valid because length of train-1 equals length of wagonColors. Colors don't shift relative to indices; they just get drawn at new positions. So W1 (index1 before move) had color c1. After move, index1 is P0 (old head). We draw it with c1. So the color c1 moved from P1 to P0. That's fine: the slot behind locomotive always has c1. The physical wagon identity isn't tracked. Good. So no need to modify wagonColors on non-eat move.
When eating:
train.unshift(Pnew) -> length increases by 1. No pop. wagonColors.push(newColor). Now train length = old+1, wagonColors length = old+1. Mapping works: new wagon at tail has new color. Good.
So logic:
- if eaten: wagonColors.push(randomColor())
- else: train.pop() (no color array change)
Wait, if we don't pop color array, but train.pop() removes last segment. Then train.length-1 == wagonColors.length. Correct.
Let's verify:
Initial train len 3 (H, W1, W2). wagonColors len 2.
Move eat: unshift -> len 4. push color -> wagonColors len 3. train len 4. OK.
Move no eat: unshift -> len 4. pop -> len 3. wagonColors len 3. Mismatch! Because we pushed on eat, then on next no-eat we pop train but not color. So we need to pop color when not eating too? Let's trace:
Initial: train [H,W1,W2] len3, colors [c1,c2] len2.
Eat: unshift Pnew -> [Pnew,H,W1,W2] len4. push c3 -> colors [c1,c2,c3] len3. train len4, colors len3 (matches train-1). Good.
Next move no eat: unshift -> [Pnew2, Pnew, H, W1, W2] len5. pop -> [Pnew2, Pnew, H, W1] len4. Colors still [c1,c2,c3] len3. train-1 = 3. Match! Great. So no need to pop color on no-eat. Because train length didn't net change (unshift+pop), color length unchanged. Good.
Next move eat: unshift -> len5. push c4 -> colors len4. train len5. OK.
So algorithm:
```
train.unshift(newHead);
if (eaten) { score++; wagonColors.push(color); spawnFood(); } else { train.pop(); }
```
This maintains invariant: wagonColors.length === train.length - 1.
Excellent.
Now AI collision check with tail: `for i=0 to train.length-2` because tail is at last index and will pop if not eaten. But if eaten, tail doesn't pop, so new head colliding with tail would be collision? Actually if eaten, we don't pop, so new head cannot be tail because food isn't on train. But if new head == tail and eaten false, tail pops so safe. So excluding tail when !eaten is correct. But our collision loop runs before we know if we pop? We know `eaten` already. So:
```
let collide = false;
for (let i=0; i<train.length; i++) {
if (!eaten && i === train.length-1) continue;
if (train[i].x===nx && train[i].y===ny) { collide=true; break; }
}
```
Good.
Let's ensure `nextDir` is set properly on user input when game is running. `setDir` prevents 180.
On `keydown` for any key when demo/gameOver, we call `startGame()`. But arrow keys also call `setDir`. If demo and user presses arrow, `startGame()` will be called by the `else` branch? Let's structure keydown:
```
window.addEventListener('keydown', e => {
if (e.key === 'ArrowUp' ...) { setDir(...); e.preventDefault(); }
else if (... arrows) ...
else {
if (demo || gameOver) startGame();
}
});
```
But if game is playing and user presses a non-arrow key, nothing happens. Good.
If demo and user presses arrow, setDir is called (queues direction) but game remains demo? Actually if demo is true and we call setDir, it changes nextDir, but then update will overwrite nextDir with AI direction at start of update if demo. So user input ignored in demo until startGame called. That's fine. But maybe if demo and user presses arrow, we should start game AND apply that direction. So:
```
function handleStart() {
if (demo || gameOver) {
demo = false;
resetGame();
overlay.classList.add('hidden');
}
}
```
Then in keydown arrow handler, call handleStart() if demo/gameOver, then setDir. Let's do:
```
if (demo || gameOver) {
// start game
demo = false;
resetGame();
overlay.classList.add('hidden');
}
setDir(dx, dy);
e.preventDefault();
```
But if gameOver and we press arrow, we reset and then set direction. Good.
For click on canvas:
```
canvas.addEventListener('mousedown', () => {
if (demo || gameOver) {
demo = false;
resetGame();
overlay.classList.add('hidden');
}
});
```
If demo and user clicks, reset and hide overlay. Game starts with current AI direction.
When game over, `gameOver=true`, `demo=true` maybe? Let's set `demo=true` on game over so overlay shows "Click to restart". But update returns early due to gameOver. So train stops. Click resets.
Need to make sure overlay text updates on game over.
Let's also display score overlay? Not needed.
Let's write the code.
I'll avoid `roundRect`. Just `fillRect` and `arc`.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Train Snake — Pick Up Passengers</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #1e2a38;
color: #fff;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
overflow: hidden;
}
#game-container {
position: relative;
display: inline-block;
}
canvas {
display: block;
background: #2d4054;
border-radius: 10px;
box-shadow: 0 14px 40px rgba(0,0,0,0.55);
cursor: pointer;
max-width: 95vw;
max-height: 72vh;
width: auto;
height: auto;
}
#score {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
font-size: 1.25rem;
font-weight: 800;
letter-spacing: 1px;
pointer-events: none;
opacity: 0.9;
text-shadow: 0 2px 6px rgba(0,0,0,0.5);
}
#overlay {
position: absolute;
inset: 0;
border-radius: 10px;
background: rgba(30,42,56,0.75);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 0 24px;
pointer-events: none;
opacity: 1;
transition: opacity 0.25s ease;
backdrop-filter: blur(4px);
}
#overlay.hidden { opacity: 0; pointer-events: none; }
h1 { font-size: min(6vw, 2.2rem); margin-bottom: 6px; text-shadow: 0 2px 8px rgba(0,0,0,0.4); }
p { font-size: min(4vw, 1.15rem); margin-bottom: 14px; opacity: 0.9; }
.hint { font-size: min(3.5vw, 0.95rem); opacity: 0.8; margin-top: 6px; }
@media (max-width: 480px) {
canvas { max-height: 60vh; }
}
</style>
</head>
<body>
<div id="game-container">
<canvas id="game" width="480" height="480"></canvas>
<div id="score">Passengers: 0</div>
<div id="overlay">
<h1 id="overlay-title">Train Snake</h1>
<p id="overlay-text">Pick up passengers waiting on the platform</p>
<div class="hint">Click or press any key to start</div>
</div>
</div>
<script>
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const overlay = document.getElementById('overlay');
const overlayTitle = document.getElementById('overlay-title');
const overlayText = document.getElementById('overlay-text');
const scoreEl = document.getElementById('score');
const GRID = 20;
const CELL = canvas.width / GRID;
const WAGON_COLORS_PALETTE = [
'#e74c3c', '#e67e22', '#f1c40f', '#2ecc71', '#3498db',
'#9b59b6', '#e91e63', '#1abc9c', '#f39c12', '#16a085'
];
let train = [];
let wagonColors = [];
let dir = { x: 0, y: -1 };
let nextDir = { x: 0, y: -1 };
let food = { x: 0, y: 0 };
let score = 0;
let gameOver = false;
let demo = true;
let tickRate = 140;
let lastTime = 0;
function randomWagonColor() {
return WAGON_COLORS_PALETTE[Math.floor(Math.random() * WAGON_COLORS_PALETTE.length)];
}
function spawnFood() {
let pos;
do {
pos = { x: Math.floor(Math.random() * GRID), y: Math.floor(Math.random() * GRID) };
} while (train.some(s => s.x === pos.x && s.y === pos.y));
food = pos;
}
function resetGame() {
train = [
{ x: 10, y: 11 },
{ x: 10, y: 12 },
{ x: 10, y: 13 }
];
wagonColors = [randomWagonColor(), randomWagonColor()];
dir = { x: 0, y: -1 };
nextDir = { x: 0, y: -1 };
score = 0;
gameOver = false;
scoreEl.textContent = `Passengers: ${score}`;
spawnFood();
}
function startGame() {
demo = false;
resetGame();
overlay.classList.add('hidden');
}
function showOverlay(title, text) {
overlayTitle.textContent = title;
overlayText.textContent = text;
overlay.classList.remove('hidden');
}
function setDir(dx, dy) {
if (dx === -dir.x && dy === -dir.y) return;
nextDir = { x: dx, y: dy };
}
window.addEventListener('keydown', e => {
if (e.key === 'ArrowUp' || e.key === 'w') {
if (demo || gameOver) startGame();
setDir(0, -1);
e.preventDefault();
} else if (e.key === 'ArrowDown' || e.key === 's') {
if (demo || gameOver) startGame();
setDir(0, 1);
e.preventDefault();
} else if (e.key === 'ArrowLeft' || e.key === 'a') {
if (demo || gameOver) startGame();
setDir(-1, 0);
e.preventDefault();
} else if (e.key === 'ArrowRight' || e.key === 'd') {
if (demo || gameOver) startGame();
setDir(1, 0);
e.preventDefault();
} else {
if (demo || gameOver) startGame();
}
});
canvas.addEventListener('mousedown', () => {
if (demo || gameOver) startGame();
});
function isSafeCell(x, y, ignoreTail) {
if (x < 0 || x >= GRID || y < 0 || y >= GRID) return false;
for (let i = 0; i < train.length; i++) {
if (ignoreTail && i === train.length - 1) continue;
if (train[i].x === x && train[i].y === y) return false;
}
return true;
}
function getAIDir() {
const head = train[0];
const candidates = [
{ x: 0, y: -1 },
{ x: 0, y: 1 },
{ x: -1, y: 0 },
{ x: 1, y: 0 }
];
// prefer not reversing
const safe = candidates.filter(d => !(d.x === -dir.x && d.y === -dir.y))
.filter(d => isSafeCell(head.x + d.x, head.y + d.y, true));
if (safe.length === 0) {
// desperate: any non-reverse
const any = candidates.filter(d => !(d.x === -dir.x && d.y === -dir.y));
if (any.length) return any[0];
return dir;
}
// choose closest to food (manhattan)
safe.sort((a, b) => {
const da = Math.abs(head.x + a.x - food.x) + Math.abs(head.y + a.y - food.y);
const db = Math.abs(head.x + b.x - food.x) + Math.abs(head.y + b.y - food.y);
return da - db;
});
return safe[0];
}
function update() {
if (gameOver) return;
if (demo) nextDir = getAIDir();
const head = train[0];
const nx = head.x + nextDir.x;
const ny = head.y + nextDir.y;
const eaten = nx === food.x && ny === food.y;
let collide = false;
for (let i = 0; i < train.length; i++) {
if (!eaten && i === train.length - 1) continue; // tail will move away
if (train[i].x === nx && train[i].y === ny) { collide = true; break; }
}
if (nx < 0 || nx >= GRID || ny < 0 || ny >= GRID || collide) {
gameOver = true;
demo = true;
showOverlay('Game Over', `Passengers picked up: ${score}`);
return;
}
train.unshift({ x: nx, y: ny });
dir = nextDir;
if (eaten) {
score++;
scoreEl.textContent = `Passengers: ${score}`;
wagonColors.push(randomWagonColor());
spawnFood();
tickRate = Math.max(60, 140 - score * 4);
} else {
train.pop();
}
}
function drawRoundedRect(x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
function drawTrack() {
ctx.strokeStyle = '#455363';
ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i <= GRID; i++) {
const v = i * CELL;
ctx.moveTo(v, 0);
ctx.lineTo(v, canvas.height);
ctx.moveTo(0, v);
ctx.lineTo(canvas.width, v);
}
ctx.stroke();
}
function drawPlatform(x, y) {
const cx = x * CELL + CELL / 2;
const cy = y * CELL + CELL / 2;
const w = CELL * 0.72;
const h = CELL * 0.34;
ctx.fillStyle = '#7f8c8d';
drawRoundedRect(cx - w / 2, cy - h / 2, w, h, 5);
ctx.fill();
// tactile paving dots
ctx.fillStyle = '#f1c40f';
const dot = 3;
const gap = 6;
let dy = cy - h / 2 + 4;
while (dy < cy + h / 2 - dot) {
ctx.fillRect(cx - dot / 2, dy, dot, dot);
dy += gap;
}
}
function drawPassenger(x, y) {
const cx = x * CELL + CELL / 2;
const cy = y * CELL + CELL / 2 - 2;
ctx.fillStyle = '#ecf0f1';
ctx.fillRect(cx - 5, cy + 2, 10, 10);
ctx.beginPath();
ctx.arc(cx, cy - 3, 5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#34495e';
ctx.fillRect(cx - 4, cy + 11, 4, 6);
ctx.fillRect(cx, cy + 11, 4, 6);
}
function drawWheels(cx, cy, s) {
ctx.fillStyle = '#1a252f';
const r = s * 0.13;
const off = s * 0.34;
ctx.beginPath();
ctx.arc(cx - off, cy + off, r, 0, Math.PI * 2);
ctx.arc(cx + off, cy + off, r, 0, Math.PI * 2);
ctx.fill();
}
function drawLocomotive(x, y, d) {
const cx = x * CELL + CELL / 2;
const cy = y * CELL + CELL / 2;
const s = CELL * 0.86;
ctx.fillStyle = '#2c3e50';
drawRoundedRect(cx - s / 2, cy - s / 2, s, s, 7);
ctx.fill();
// cabin
ctx.fillStyle = '#3d566e';
let cabX, cabY, cabW = s / 3, cabH = s / 3;
if (d.x === 1) { cabX = cx + s / 7; cabY = cy - cabH / 2; }
else if (d.x === -1) { cabX = cx - s / 7 - cabW; cabY = cy - cabH / 2; }
else if (d.y === -1) { cabX = cx - cabW / 2; cabY = cy - s / 7 - cabH; }
else { cabX = cx - cabW / 2; cabY = cy + s / 7; }
drawRoundedRect(cabX, cabY, cabW, cabH, 4);
ctx.fill();
// window
ctx.fillStyle = '#85c1e9';
ctx.fillRect(cabX + cabW / 5, cabY + cabH / 5, cabW / 2.5, cabH / 2.5);
// chimney
ctx.fillStyle = '#1a252f';
const chW = s / 9, chH = s / 3.5;
let chX, chY;
if (d.x === 1) { chX = cx - s / 2 + 3; chY = cy - s / 2 + 4; }
else if (d.x === -1) { chX = cx + s / 2 - 3 - chW; chY = cy - s / 2 + 4; }
else if (d.y === -1) { chX = cx - chW / 2; chY = cy - s / 2 + 4; }
else { chX = cx - chW / 2; chY = cy + s / 2 - 4 - chH; }
ctx.fillRect(chX, chY, chW, chH);
// front buffer / cowcatcher
ctx.fillStyle = '#bdc3c7';
ctx.beginPath();
if (d.x === 1) {
ctx.moveTo(cx + s / 2, cy - s / 4);
ctx.lineTo(cx + s / 2 + 7, cy);
ctx.lineTo(cx + s / 2, cy + s / 4);
} else if (d.x === -1) {
ctx.moveTo(cx - s / 2, cy - s / 4);
ctx.lineTo(cx - s / 2 - 7, cy);
ctx.lineTo(cx - s / 2, cy + s / 4);
} else if (d.y === -1) {
ctx.moveTo(cx - s / 4, cy - s / 2);
ctx.lineTo(cx, cy - s / 2 - 7);
ctx.lineTo(cx + s / 4, cy - s / 2);
} else {
ctx.moveTo(cx - s / 4, cy + s / 2);
ctx.lineTo(cx, cy + s / 2 + 7);
ctx.lineTo(cx + s / 4, cy + s / 2);
}
ctx.fill();
drawWheels(cx, cy, s);
}
function drawWagon(x, y, color) {
const cx = x * CELL + CELL / 2;
const cy = y * CELL + CELL / 2;
const s = CELL * 0.8;
ctx.fillStyle = color;
drawRoundedRect(cx - s / 2, cy - s / 2, s, s, 5);
ctx.fill();
// roof stripe
ctx.fillStyle = 'rgba(255,255,255,0.18)';
ctx.fillRect(cx - s / 2 + 5, cy - s / 2 + 5, s - 10, 6);
// windows
ctx.fillStyle = '#bdc3c7';
const winW = s / 5, winH = s / 5;
ctx.fillRect(cx - s / 2 + s / 5, cy - winH / 2, winW, winH);
ctx.fillRect(cx + s / 2 - s / 5 - winW, cy - winH / 2, winW, winH);
drawWheels(cx, cy, s);
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawTrack();
drawPlatform(food.x, food.y);
drawPassenger(food.x, food.y);
// draw train from tail to head so head is on top
for (let i = train.length - 1; i >= 0; i--) {
const seg = train[i];
if (i === 0) {
drawLocomotive(seg.x, seg.y, dir);
} else {
drawWagon(seg.x, seg.y, wagonColors[i - 1]);
}
}
}
function loop(timestamp) {
if (!lastTime) lastTime = timestamp;
const dt = timestamp - lastTime;
lastTime = timestamp;
let acc = 0;
// simple fixed step using closure variable? We'll use a module-level accumulator.
// To keep it simple without global acc, we can just use setInterval logic inside rAF via time check.
// Instead, use a variable `tickAccumulator` in outer scope.
// We'll declare it outside.
window.tickAccumulator = (window.tickAccumulator || 0) + dt;
while (window.tickAccumulator >= tickRate) {
window.tickAccumulator -= tickRate;
update();
}
draw();
requestAnimationFrame(loop);
}
resetGame();
// initial draw so something is visible immediately
draw();
requestAnimationFrame(loop);
</script>
</body>
</html>
```
Wait, using `window.tickAccumulator` is a bit hacky but works. Better to use a local variable in loop with closure. But `loop` is recursive via rAF, so local variable resets each call. Need an outer variable. Let's just declare `let tickAccumulator = 0;` in the script scope.
Also `lastTime` initial: set `lastTime = performance.now()` before loop to avoid huge first dt. Or handle in loop: `if (!lastTime) lastTime = timestamp;` then `dt` might be 0 first frame. Fine.
Let's define `let tickAccumulator = 0;` outside.
Update `loop`:
```js
let lastTime = 0;
let tickAccumulator = 0;
function loop(timestamp) {
if (!lastTime) lastTime = timestamp;
const dt = timestamp - lastTime;
lastTime = timestamp;
tickAccumulator += dt;
while (tickAccumulator >= tickRate) {
tickAccumulator -= tickRate;
update();
}
draw();
requestAnimationFrame(loop);
}
```
This is solid.
Check: On game over, `update()` returns early, so tickAccumulator keeps growing but no update. draw still runs showing static game over. Good.
On startGame(), tickRate reset to 140, tickAccumulator not reset but that's fine.
Does `ctx.quadraticCurveTo` exist? Yes.
Does `ctx.arc` exist? Yes.
No forbidden APIs.
Let's test mentally for first paint: `resetGame()` called, `draw()` called immediately, overlay visible. Canvas shows train and food. Good.
Responsiveness: CSS max-width 95vw max-height 72vh. At 375px width, canvas scales down to ~356px wide, cell ~17.8px. Everything visible, maybe small but okay. No horizontal scrollbar because body overflow hidden and canvas fits.
One issue: `canvas` intrinsic size 480x480. CSS `max-width: 95vw` on a 375px screen => 356px. Height auto => 356px. `max-height: 72vh` on 800vh? Actually 72vh of 800px = 576px, so width constraint wins. Fine. On very short screen (e.g., 375x600), 72vh=432px, width constraint 356px, height auto 356px < 432, fine.
Score position absolute top 12px, might overlay canvas top. It's centered, pointer-events none. Fine.
Overlay pointer-events none but we need click on canvas to start. Since overlay is on top with pointer-events none, clicks pass through to canvas. Good. But `mousedown` on canvas works. However overlay has `pointer-events: none` except when hidden? Actually hidden only changes opacity. So clicks pass through always. Good. But when game over, we want click to restart. Canvas click handler handles it because overlay is transparent to pointer events. Good.
Keyboard: window listener works regardless of focus? In iframe, key events only after click. But we show message. Fine.
Potential issue: If user clicks canvas to start, `mousedown` triggers `startGame()`, but `mousedown` might also select text? No text on canvas. Fine.
`setDir` prevents reversing. AI also prevents reversing.
Let's double-check collision logic when eating: if new head === food, eaten=true. Tail not ignored in collision loop because `!eaten` false. So if food is on tail? spawnFood avoids train, so food not on tail. Fine.
If not eaten and new head === tail, tail ignored, no collision. Then train.pop() removes tail. So snake can move into its own tail cell. Standard.
Performance fine.
Let's refine colors and visuals. Locomotive color #2c3e50 dark blue-gray. Wagons random bright.
Maybe add a little "smoke" puff from chimney occasionally? Could add but not needed.
Let's ensure no console errors: `ctx.quadraticCurveTo` is available. `drawRoundedRect` uses it.
`canvas.addEventListener('mousedown', ...)` fine.
`window.addEventListener('keydown', ...)` fine.
`requestAnimationFrame(loop)` fine.
`Math.random` fine.
`performance.now`? Not used.
I think it's good.
Wait, one issue: In `loop`, `tickRate` can change while `tickAccumulator` is high. If tickRate drops to 60 and accumulator is 140, it might do two updates in one frame. That's fine, just slightly faster. Could spiral if update takes long, but update is trivial.
Let's consider making `draw` not call `update`, only loop does. Yes.
Should we call `resetGame()` before initial `draw()`? Yes, done.
Initial overlay text says "Click or press any key to start". AI is running behind overlay because update() called in loop even when demo=true and gameOver=false. Let's verify: `update()` starts with `if (gameOver) return;` (false). Then `if (demo) nextDir = getAIDir();` (true). So AI moves train behind overlay. Good. That satisfies self-playing demo mode.
When gameOver triggered during demo, `gameOver=true`, `demo=true`, overlay shows Game Over. `update()` returns early because gameOver. So train freezes. Is that okay? The demo mode is paused after death until user clicks. Acceptable.
But requirement: "Games must start in a self-playing demo mode (a simple AI plays) behind a 'Click or press a key to start' message". Initial state does that.
Let's consider adding a small "score" to overlay when game over. Already there.
Now final code formatting: exactly one fenced code block with ```html. No text before or after.
Let's write it.