Train snakeOne-line
Kimi K3.1 Pro
by MoonshotAIreasoning defaultClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 4 min 46 s
- Cost
- $0.319
- Output tokens
- 36,295
- Spent thinking
- 18,991 tokens
- Speed
- 127.5 tok/s
- Console errors
- 0
- First token after
- 1.4 s
- First visible text after
- 3 min 16 s
- Prompt tokens
- 448
- Code size
- 52.5 KB
- Finish reason
- stop
- Served by
- Relace
- Model id
moonshotai/kimi-k3.1-pro- Reasoning sent
model default- Max output
- 943,718 tokens
- Generated
- Nov 12, 2026
What it was asked
The prompt
“make me a snake game where the snake is a little train with a locomotive and colorful wagons, picking up passengers waiting on the platform”
System prompt, sent with every prompt
You are generating a self-contained web demo. It will be rendered automatically in a sandboxed iframe and compared side by side with the output of other AI models given the exact same task. Output rules: - Reply with exactly ONE fenced code block containing the complete file: ```html (or ```svg when the task asks for an SVG). No text before or after the code block. - Everything must be inline: CSS in <style>, JavaScript in <script>. No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task. - Do not use localStorage, sessionStorage, cookies, IndexedDB, alert(), confirm(), prompt() or window.open(): they are blocked in the sandbox and will throw. - The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar. - Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction. - Everything that makes the demo interesting must be visible within the first seconds: nothing hidden behind a high score, a later level or a long play session. - Games must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, so the game can be judged in action without playing. - The iframe only receives keyboard events after the user clicks it: if the demo uses the keyboard, display a clear "Click or press a key to start" message. - The code must run with zero errors in the console of the latest Chrome.
Code run-1.html · 1860 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Little Line Express — Train Snake</title>
<style>
:root {
--ink: #173a40;
--deep: #08242d;
--deep-2: #10343b;
--cream: #fff9e9;
--paper: #f8f1df;
--gold: #f4c85a;
--coral: #ee705b;
--mint: #8cc8a0;
--muted: #a9c0bb;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
overflow-x: hidden;
color: var(--cream);
font-family: Inter, ui-rounded, "Trebuchet MS", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background:
radial-gradient(circle at 10% 0%, rgba(47, 116, 109, .32), transparent 35%),
radial-gradient(circle at 92% 88%, rgba(46, 102, 92, .25), transparent 34%),
linear-gradient(145deg, #061b24 0%, #0b2b33 56%, #0b252c 100%);
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
opacity: .16;
background-image:
linear-gradient(rgba(255,255,255,.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.035) 1px, transparent 1px);
background-size: 38px 38px;
mask-image: linear-gradient(to bottom, black, transparent 80%);
}
button { font: inherit; }
.app {
position: relative;
width: min(1140px, calc(100% - 32px));
margin: 0 auto;
padding: 12px 0 10px;
}
.topbar {
min-height: 54px;
margin-bottom: 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.brand {
display: flex;
align-items: center;
min-width: 0;
gap: 12px;
}
.brand-mark {
width: 48px;
height: 48px;
flex: 0 0 auto;
display: grid;
place-items: center;
border-radius: 15px;
color: var(--deep);
background: var(--gold);
border: 1px solid rgba(255,255,255,.35);
box-shadow: 0 7px 20px rgba(0,0,0,.2), inset 0 1px rgba(255,255,255,.5);
transform: rotate(-2deg);
}
.brand-mark svg { width: 34px; height: 34px; }
.brand-copy { min-width: 0; }
.brand-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 9px;
}
h1 {
margin: 0;
font-size: 25px;
line-height: 1;
letter-spacing: -.7px;
font-weight: 900;
}
.brand-badge {
padding: 4px 7px 3px;
border: 1px solid rgba(244,200,90,.38);
border-radius: 6px;
color: var(--gold);
font-size: 9px;
line-height: 1;
letter-spacing: 1.5px;
font-weight: 900;
}
.brand-copy p {
margin: 6px 0 0;
color: var(--muted);
font-size: 12px;
letter-spacing: .25px;
}
.route-chip {
display: flex;
align-items: center;
gap: 9px;
white-space: nowrap;
padding: 9px 13px;
border: 1px solid rgba(255,255,255,.1);
border-radius: 999px;
background: rgba(255,255,255,.045);
color: #d9e5dc;
font-size: 11px;
font-weight: 750;
letter-spacing: .55px;
box-shadow: inset 0 1px rgba(255,255,255,.035);
}
.route-chip .sun {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--gold);
box-shadow: 0 0 0 4px rgba(244,200,90,.1);
}
.game-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 260px;
gap: 16px;
align-items: stretch;
}
.stage-card {
min-width: 0;
padding: 8px;
border-radius: 24px;
background: linear-gradient(145deg, rgba(34,75,77,.95), rgba(15,48,54,.97));
border: 1px solid rgba(255,255,255,.12);
box-shadow: 0 20px 55px rgba(0,0,0,.29), inset 0 1px rgba(255,255,255,.08);
}
.canvas-wrap {
position: relative;
width: 100%;
aspect-ratio: 4 / 3;
overflow: hidden;
border-radius: 17px;
background: #83af72;
box-shadow: inset 0 0 0 1px rgba(0,0,0,.2);
}
canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
cursor: pointer;
}
.status-pill, .weather-pill {
position: absolute;
top: 13px;
z-index: 2;
display: flex;
align-items: center;
gap: 7px;
pointer-events: none;
border: 1px solid rgba(255,255,255,.18);
background: rgba(8,35,40,.79);
color: #fff9e8;
box-shadow: 0 5px 15px rgba(0,0,0,.14);
backdrop-filter: blur(8px);
}
.status-pill {
left: 13px;
padding: 7px 10px;
border-radius: 999px;
font-size: 9px;
font-weight: 900;
letter-spacing: 1.1px;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--gold);
box-shadow: 0 0 0 3px rgba(244,200,90,.16);
animation: statusPulse 1.5s ease-in-out infinite;
}
.status-pill.player .status-dot {
background: #8fe0a5;
box-shadow: 0 0 0 3px rgba(143,224,165,.16);
}
.status-pill.ended .status-dot {
background: #f48a74;
box-shadow: 0 0 0 3px rgba(244,138,116,.16);
animation: none;
}
@keyframes statusPulse {
50% { transform: scale(.72); opacity: .65; }
}
.weather-pill {
right: 13px;
padding: 7px 10px;
border-radius: 9px;
color: #f7edc8;
font-size: 10px;
font-weight: 700;
}
.weather-pill svg { width: 14px; height: 14px; color: var(--gold); }
.start-hint {
position: absolute;
z-index: 3;
left: 50%;
bottom: 17px;
transform: translateX(-50%);
width: max-content;
max-width: calc(100% - 28px);
display: flex;
align-items: center;
gap: 11px;
padding: 10px 12px;
border: 1px solid rgba(255,245,197,.36);
border-radius: 14px;
color: #fffbed;
background: rgba(7,35,40,.88);
box-shadow: 0 9px 30px rgba(0,0,0,.27), inset 0 1px rgba(255,255,255,.08);
backdrop-filter: blur(10px);
pointer-events: none;
transition: opacity .25s ease, transform .25s ease;
}
.start-hint.is-hidden {
opacity: 0;
transform: translate(-50%, 10px);
}
.hint-icon {
width: 34px;
height: 34px;
flex: 0 0 auto;
display: grid;
place-items: center;
border-radius: 10px;
color: var(--deep);
background: var(--gold);
}
.hint-icon svg { width: 20px; height: 20px; }
.hint-copy { min-width: 0; }
.hint-copy strong {
display: block;
font-size: 12px;
line-height: 1.2;
letter-spacing: .1px;
}
.hint-copy small {
display: block;
margin-top: 4px;
color: #b9cbc2;
font-size: 10px;
}
.key-cap {
margin-left: 5px;
padding: 5px 7px;
border-radius: 6px;
border: 1px solid rgba(255,255,255,.16);
border-bottom-width: 3px;
color: #e8eddf;
background: rgba(255,255,255,.07);
font-size: 12px;
}
.sidebar {
min-width: 0;
display: flex;
flex-direction: column;
gap: 13px;
padding: 19px;
border-radius: 24px;
color: var(--ink);
background:
radial-gradient(circle at 100% 0%, rgba(244,200,90,.18), transparent 28%),
var(--paper);
border: 1px solid rgba(255,255,255,.5);
box-shadow: 0 20px 55px rgba(0,0,0,.25), inset 0 1px white;
}
.score-block { padding-bottom: 12px; border-bottom: 1px solid #dcd7c5; }
.section-label {
display: block;
color: #6d7f77;
font-size: 9px;
line-height: 1;
letter-spacing: 1.65px;
font-weight: 900;
}
.score-value {
display: block;
margin: 6px 0 5px;
color: #153d40;
font-size: 55px;
line-height: 1;
letter-spacing: -3px;
font-weight: 950;
font-variant-numeric: tabular-nums;
transform-origin: left center;
}
.score-value.bump { animation: scoreBump .3s ease; }
@keyframes scoreBump {
45% { transform: scale(1.09) rotate(-1deg); color: #d66c44; }
}
.score-meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
color: #698078;
font-size: 10px;
font-weight: 700;
}
.score-meta span:first-child { color: #b15b43; }
.stat-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.stat {
padding: 10px 11px;
border-radius: 12px;
background: rgba(23,58,64,.055);
border: 1px solid rgba(23,58,64,.07);
}
.stat b {
display: block;
font-size: 20px;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.stat span {
display: block;
margin-top: 5px;
color: #728078;
font-size: 9px;
font-weight: 800;
letter-spacing: .5px;
text-transform: uppercase;
}
.next-stop {
position: relative;
overflow: hidden;
padding: 12px;
border-radius: 14px;
background: #ffdf85;
border: 1px solid #e9c464;
box-shadow: inset 0 1px rgba(255,255,255,.5);
}
.next-stop::after {
content: "";
position: absolute;
width: 70px;
height: 70px;
right: -30px;
top: -32px;
border: 12px solid rgba(255,255,255,.2);
border-radius: 50%;
}
.stop-heading {
display: flex;
align-items: center;
gap: 6px;
color: #7b5b21;
font-size: 8px;
font-weight: 950;
letter-spacing: 1.4px;
}
.stop-heading svg { width: 13px; height: 13px; }
.next-stop strong {
position: relative;
z-index: 1;
display: block;
overflow: hidden;
margin-top: 7px;
text-overflow: ellipsis;
white-space: nowrap;
color: #3c462f;
font-size: 15px;
line-height: 1.1;
}
.next-stop small {
display: block;
margin-top: 5px;
color: #80662e;
font-size: 9px;
font-weight: 700;
}
.manifest { padding-top: 1px; }
.manifest-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 9px;
}
.manifest-head small { color: #809087; font-size: 9px; font-weight: 700; }
.wagon-dots {
min-height: 19px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 7px;
}
.mini-engine, .mini-wagon {
position: relative;
width: 20px;
height: 14px;
flex: 0 0 auto;
border: 2px solid rgba(17,51,54,.25);
border-radius: 4px;
box-shadow: 0 2px 0 rgba(17,51,54,.15);
}
.mini-engine {
width: 23px;
background: #e36750;
border-radius: 7px 4px 4px 7px;
}
.mini-engine::before {
content: "";
position: absolute;
width: 4px;
height: 6px;
right: 3px;
top: -6px;
border-radius: 2px 2px 0 0;
background: #173a40;
}
.mini-wagon::after, .mini-engine::after {
content: "";
position: absolute;
width: 5px;
height: 2px;
right: -7px;
top: 4px;
background: #859087;
}
.more-wagons { color: #6b7c74; font-size: 10px; font-weight: 900; }
.how-to {
padding-top: 12px;
border-top: 1px solid #dcd7c5;
}
.how-to ul {
list-style: none;
padding: 0;
margin: 10px 0 0;
display: grid;
gap: 8px;
}
.how-to li {
display: flex;
align-items: center;
gap: 8px;
color: #5f716a;
font-size: 10px;
line-height: 1.25;
font-weight: 650;
}
.how-to li::before {
content: "";
width: 6px;
height: 6px;
flex: 0 0 auto;
border-radius: 50%;
background: #e88a58;
box-shadow: 0 0 0 3px rgba(232,138,88,.12);
}
.key-row {
display: flex;
align-items: center;
gap: 4px;
margin-top: 12px;
}
.key-row kbd {
min-width: 24px;
height: 23px;
display: grid;
place-items: center;
padding: 0 5px;
border: 1px solid #c9c7b8;
border-bottom-width: 3px;
border-radius: 5px;
color: #39544f;
background: #fffdf4;
font-family: inherit;
font-size: 10px;
font-weight: 900;
}
.key-row span { margin-left: 4px; color: #829087; font-size: 9px; }
.restart-btn {
width: 100%;
min-height: 39px;
margin-top: auto;
border: 0;
border-radius: 11px;
color: #f9f3df;
background: #173f42;
font-size: 10px;
font-weight: 850;
letter-spacing: .35px;
cursor: pointer;
box-shadow: 0 4px 0 #0b2d31;
transition: transform .15s ease, background .15s ease;
}
.restart-btn:hover { background: #215358; }
.restart-btn:active { transform: translateY(3px); box-shadow: 0 1px 0 #0b2d31; }
.restart-btn:focus-visible, .touch-controls button:focus-visible { outline: 3px solid var(--gold); outline-offset: 2px; }
.touch-controls {
display: none;
width: 154px;
margin: 9px auto 2px;
grid-template-columns: repeat(3, 46px);
grid-template-rows: repeat(2, 38px);
justify-content: center;
gap: 5px;
}
.touch-controls button {
border: 1px solid rgba(255,255,255,.14);
border-radius: 10px;
color: #fff7dd;
background: #1d4b4e;
box-shadow: 0 3px 0 #092e34;
font-size: 19px;
font-weight: 900;
touch-action: none;
cursor: pointer;
}
.touch-controls button:active { transform: translateY(2px); box-shadow: 0 1px 0 #092e34; background: #2b6262; }
.touch-controls .up { grid-column: 2; grid-row: 1; }
.touch-controls .left { grid-column: 1; grid-row: 2; }
.touch-controls .down { grid-column: 2; grid-row: 2; }
.touch-controls .right { grid-column: 3; grid-row: 2; }
footer {
min-height: 24px;
padding-top: 9px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #8da8a1;
font-size: 9px;
letter-spacing: .3px;
}
footer strong { color: #d3ddcf; font-weight: 750; }
.footer-route { white-space: nowrap; color: #c4ae68; font-weight: 800; letter-spacing: 1px; }
@media (max-width: 800px) {
.app { width: min(680px, calc(100% - 22px)); padding-top: 10px; }
.game-layout { grid-template-columns: minmax(0, 1fr); }
.touch-controls { display: grid; }
.sidebar {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 11px;
padding: 13px;
border-radius: 19px;
}
.score-block { padding: 4px 7px; border-bottom: 0; }
.score-value { font-size: 43px; }
.stat-grid, .manifest, .how-to { display: none; }
.next-stop { display: flex; flex-direction: column; justify-content: center; }
.restart-btn { grid-column: 1 / -1; margin-top: 0; }
footer { justify-content: center; text-align: center; }
.footer-route { display: none; }
}
@media (max-width: 520px) {
.app { width: calc(100% - 18px); }
.topbar { min-height: 47px; margin-bottom: 10px; }
.brand { gap: 9px; }
.brand-mark { width: 41px; height: 41px; border-radius: 13px; }
.brand-mark svg { width: 29px; height: 29px; }
h1 { font-size: 21px; }
.brand-badge { font-size: 7px; letter-spacing: 1px; }
.brand-copy p { margin-top: 4px; font-size: 10px; }
.route-chip { display: none; }
.stage-card { padding: 6px; border-radius: 18px; }
.canvas-wrap { border-radius: 13px; }
.status-pill { top: 8px; left: 8px; padding: 6px 8px; font-size: 7px; }
.weather-pill { top: 8px; right: 8px; padding: 6px 7px; font-size: 8px; }
.weather-pill svg { width: 11px; height: 11px; }
.start-hint { bottom: 9px; gap: 8px; padding: 8px 9px; border-radius: 11px; }
.hint-icon { width: 28px; height: 28px; border-radius: 8px; }
.hint-icon svg { width: 17px; height: 17px; }
.hint-copy strong { font-size: 10px; }
.hint-copy small { font-size: 8px; margin-top: 3px; }
.key-cap { display: none; }
.next-stop strong { font-size: 13px; }
footer { font-size: 8px; }
}
</style>
</head>
<body>
<div class="app">
<header class="topbar">
<div class="brand">
<div class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 40 40" fill="none">
<path d="M10 27V13c0-5 4-8 10-8s10 3 10 8v14" fill="currentColor"/>
<path d="M13 14c0-3 2.5-5 7-5s7 2 7 5v5H13v-5Z" fill="#F4C85A"/>
<path d="M15 12h10v6H15z" fill="#D9F1DD"/>
<path d="M7 29h26l-3 5H10l-3-5Z" fill="currentColor"/>
<circle cx="14" cy="25" r="2.4" fill="#F4C85A"/>
<circle cx="26" cy="25" r="2.4" fill="#F4C85A"/>
<path d="M17 3h6M12 37l-3 3M28 37l3 3" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
</svg>
</div>
<div class="brand-copy">
<div class="brand-line">
<h1>Little Line Express</h1>
<span class="brand-badge">SNAKE ON RAILS</span>
</div>
<p>Collect passengers. Grow your train. Stay on track.</p>
</div>
</div>
<div class="route-chip"><span class="sun"></span> MEADOW VALLEY · ROUTE 01</div>
</header>
<main class="game-layout">
<section class="stage-card" id="stage" aria-label="Little Line Express game">
<div class="canvas-wrap">
<canvas id="gameCanvas" width="960" height="720" aria-label="A train snake game. Use arrow keys or WASD to steer and collect passengers at railway platforms.">
Your browser needs canvas support to play Little Line Express.
</canvas>
<div class="status-pill" id="statusPill">
<span class="status-dot"></span>
<span id="statusText">AUTOPILOT · DEMO</span>
</div>
<div class="weather-pill">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="4" fill="currentColor"/>
<path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
Sunny Meadow
</div>
<div class="start-hint" id="startHint">
<span class="hint-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none">
<path d="M8 3h8a4 4 0 0 1 4 4v6a8 8 0 0 1-16 0V7a4 4 0 0 1 4-4Z" stroke="currentColor" stroke-width="2"/>
<path d="M12 3v6M8 9h8" stroke="currentColor" stroke-width="2"/>
<path d="m9 17 3 3 3-3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
<span class="hint-copy">
<strong id="hintTitle">Click or press a key to start</strong>
<small id="hintSub">Autopilot is driving until you take over</small>
</span>
<span class="key-cap">↵</span>
</div>
</div>
<div class="touch-controls" aria-label="Touch steering controls">
<button class="up" data-dir="up" aria-label="Steer up">↑</button>
<button class="left" data-dir="left" aria-label="Steer left">←</button>
<button class="down" data-dir="down" aria-label="Steer down">↓</button>
<button class="right" data-dir="right" aria-label="Steer right">→</button>
</div>
</section>
<aside class="sidebar">
<section class="score-block">
<span class="section-label">ROUTE SCORE</span>
<strong class="score-value" id="scoreValue">000</strong>
<div class="score-meta">
<span id="passengerValue">0 passengers</span>
<span>Best <b id="bestValue">0</b></span>
</div>
</section>
<div class="stat-grid">
<div class="stat"><b id="wagonCount">4</b><span>Wagons</span></div>
<div class="stat"><b id="paceValue">1.0×</b><span>Pace</span></div>
</div>
<section class="next-stop">
<div class="stop-heading">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 21V4M5 5h12l-2 4 2 4H5" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
NEXT PLATFORM
</div>
<strong id="nextStopName">Central Meadow</strong>
<small><span id="waitingCount">3</span> passengers waiting · all aboard!</small>
</section>
<section class="manifest">
<div class="manifest-head">
<span class="section-label">YOUR TRAIN</span>
<small id="trainLength">5 cars</small>
</div>
<div class="wagon-dots" id="wagonDots"></div>
</section>
<section class="how-to">
<span class="section-label">CONDUCTOR'S GUIDE</span>
<ul>
<li>Stop beside the waiting passengers</li>
<li>Every pickup adds a colorful wagon</li>
<li>Avoid scenery and your own train</li>
</ul>
<div class="key-row">
<kbd>↑</kbd><kbd>←</kbd><kbd>↓</kbd><kbd>→</kbd>
<span>or WASD</span>
</div>
</section>
<button class="restart-btn" id="restartBtn">↻ Restart route</button>
</aside>
</main>
<footer>
<span><strong>Arrow keys / WASD</strong> to steer · Swipe on touch screens · Don’t bump the buffers!</span>
<span class="footer-route">EST. 2025 · MEADOW VALLEY RAILWAY</span>
</footer>
</div>
<script>
(() => {
"use strict";
const canvas = document.getElementById("gameCanvas");
const ctx = canvas.getContext("2d");
const stage = document.getElementById("stage");
const startHint = document.getElementById("startHint");
const hintTitle = document.getElementById("hintTitle");
const hintSub = document.getElementById("hintSub");
const statusPill = document.getElementById("statusPill");
const statusText = document.getElementById("statusText");
const scoreValue = document.getElementById("scoreValue");
const passengerValue = document.getElementById("passengerValue");
const bestValue = document.getElementById("bestValue");
const wagonCount = document.getElementById("wagonCount");
const paceValue = document.getElementById("paceValue");
const nextStopName = document.getElementById("nextStopName");
const trainLength = document.getElementById("trainLength");
const wagonDots = document.getElementById("wagonDots");
const restartBtn = document.getElementById("restartBtn");
const COLS = 24;
const ROWS = 18;
const CELL = 40;
const W = COLS * CELL;
const H = ROWS * CELL;
const DPR = Math.min(2, window.devicePixelRatio || 1);
canvas.width = W * DPR;
canvas.height = H * DPR;
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
const DIRS = {
right: { x: 1, y: 0 },
down: { x: 0, y: 1 },
left: { x: -1, y: 0 },
up: { x: 0, y: -1 }
};
const DIR_LIST = [DIRS.right, DIRS.down, DIRS.left, DIRS.up];
const wagonColors = [
{ body: "#ed6c58", light: "#ff9b7f" },
{ body: "#e7aa38", light: "#ffd771" },
{ body: "#438dbf", light: "#84c9e1" },
{ body: "#8a6cc1", light: "#bfa7e5" },
{ body: "#3da176", light: "#82d1a1" },
{ body: "#dd75a0", light: "#ffb2ca" },
{ body: "#dd813d", light: "#ffb66f" }
];
const stops = [
{ x: 12, y: 8, name: "Central Meadow", side: -1 },
{ x: 17, y: 4, name: "Daisy Crossing", side: 1 },
{ x: 20, y: 12, name: "Willow Halt", side: -1 },
{ x: 6, y: 14, name: "Acorn Square", side: -1 },
{ x: 4, y: 5, name: "Clover Fields", side: 1 },
{ x: 12, y: 14, name: "Apple Orchard", side: -1 },
{ x: 20, y: 7, name: "Sunny Side", side: 1 },
{ x: 8, y: 3, name: "Bluebell Bend", side: 1 }
];
const treeCells = [
{ x: 1, y: 1, v: 0 }, { x: 5, y: 1, v: 1 },
{ x: 14, y: 1, v: 2 }, { x: 22, y: 2, v: 0 },
{ x: 1, y: 11, v: 1 }, { x: 22, y: 15, v: 2 },
{ x: 2, y: 16, v: 0 }, { x: 9, y: 16, v: 1 },
{ x: 18, y: 16, v: 2 }, { x: 10, y: 5, v: 0 },
{ x: 15, y: 11, v: 1 }, { x: 22, y: 9, v: 2 }
];
const pondCells = [
{ x: 18, y: 1 }, { x: 19, y: 1 },
{ x: 18, y: 2 }, { x: 19, y: 2 }
];
function key(p) { return p.x + "," + p.y; }
function same(a, b) { return a.x === b.x && a.y === b.y; }
function center(p) { return { x: (p.x + .5) * CELL, y: (p.y + .5) * CELL }; }
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
const blockedKeys = new Set([...treeCells, ...pondCells].map(key));
function mulberry32(seed) {
return function() {
let t = seed += 0x6D2B79F5;
t = Math.imul(t ^ t >>> 15, t | 1);
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
const seeded = mulberry32(7421);
const flowers = Array.from({ length: 74 }, (_, i) => ({
x: (1 + seeded() * 22) * CELL,
y: (1 + seeded() * 16) * CELL,
c: ["#f8e695", "#f4a99a", "#e6ddf6", "#fff5d7"][i % 4],
s: .65 + seeded() * .65
}));
let snake = [];
let prevSnake = [];
let direction = DIRS.right;
let inputQueue = [];
let wagons = [];
let activeStop = 0;
let passenger = stops[0];
let score = 0;
let best = 0;
let passengers = 0;
let stopsServed = 0;
let demo = true;
let gameOver = false;
let autoResetAt = 0;
let accumulator = 0;
let smoke = [];
let confetti = [];
let floaters = [];
let lastSmoke = 0;
function getTickInterval() {
return Math.max(84, 152 - stopsServed * 3);
}
function roundedPath(x, y, w, h, r) {
const radius = Math.min(r, Math.abs(w) / 2, Math.abs(h) / 2);
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + w - radius, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + radius);
ctx.lineTo(x + w, y + h - radius);
ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h);
ctx.lineTo(x + radius, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
}
function fillRound(x, y, w, h, r, color) {
roundedPath(x, y, w, h, r);
ctx.fillStyle = color;
ctx.fill();
}
function circle(x, y, r, color) {
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
}
function resetGame(asDemo) {
demo = asDemo;
gameOver = false;
autoResetAt = 0;
accumulator = 0;
direction = DIRS.right;
inputQueue = [];
snake = [
{ x: 7, y: 9 }, { x: 6, y: 9 }, { x: 5, y: 9 },
{ x: 4, y: 9 }, { x: 3, y: 9 }
];
prevSnake = snake.map(p => ({ ...p }));
wagons = wagonColors.slice(0, 4).map((color, i) => ({
color,
occupied: false,
shirt: ["#f8d665", "#5f9fd0", "#e87678"][i % 3]
}));
activeStop = 0;
passenger = stops[0];
score = 0;
passengers = 0;
stopsServed = 0;
smoke = [];
confetti = [];
floaters = [];
lastSmoke = 0;
updateUI();
updateStatus();
if (demo) {
showHint("Click or press a key to start", "Autopilot is driving until you take over", true);
} else {
showHint("", "", false);
}
}
function showHint(title, subtitle, visible) {
hintTitle.textContent = title;
hintSub.textContent = subtitle;
startHint.classList.toggle("is-hidden", !visible);
}
function updateStatus() {
statusPill.classList.toggle("player", !demo && !gameOver);
statusPill.classList.toggle("ended", gameOver);
if (gameOver) statusText.textContent = demo ? "RESETTING DEMO" : "END OF THE LINE";
else statusText.textContent = demo ? "AUTOPILOT · DEMO" : "YOU’RE DRIVING";
}
function updateUI() {
scoreValue.textContent = String(score).padStart(3, "0");
passengerValue.textContent = passengers + (passengers === 1 ? " passenger" : " passengers");
bestValue.textContent = best;
wagonCount.textContent = wagons.length;
trainLength.textContent = snake.length + (snake.length === 1 ? " car" : " cars");
paceValue.textContent = (152 / getTickInterval()).toFixed(1) + "×";
nextStopName.textContent = passenger.name;
const visibleWagons = wagons.slice(0, 7);
let html = '<span class="mini-engine" title="Locomotive"></span>';
html += visibleWagons.map(w =>
`<span class="mini-wagon" style="background:${w.color.body}" title="${w.occupied ? "Passenger wagon" : "Empty wagon"}"></span>`
).join("");
if (wagons.length > 7) html += `<span class="more-wagons">+${wagons.length - 7}</span>`;
wagonDots.innerHTML = html;
}
function beginPlayer() {
if (!demo && !gameOver) return;
resetGame(false);
}
function queueDirection(next) {
const last = inputQueue.length ? inputQueue[inputQueue.length - 1] : direction;
if (next.x === -last.x && next.y === -last.y) return;
if (next.x === last.x && next.y === last.y) return;
if (inputQueue.length < 2) inputQueue.push(next);
}
function inBounds(p) {
return p.x >= 0 && p.x < COLS && p.y >= 0 && p.y < ROWS;
}
function legalForAI(p) {
if (!inBounds(p) || blockedKeys.has(key(p))) return false;
for (let i = 0; i < snake.length - 1; i++) {
if (same(snake[i], p)) return false;
}
return true;
}
function findPathFirst(target) {
const start = snake[0];
const blocked = new Set(blockedKeys);
for (let i = 0; i < snake.length - 1; i++) blocked.add(key(snake[i]));
blocked.delete(key(start));
const ordered = [...DIR_LIST].sort((a, b) => {
const da = Math.abs(start.x + a.x - target.x) + Math.abs(start.y + a.y - target.y);
const db = Math.abs(start.x + b.x - target.x) + Math.abs(start.y + b.y - target.y);
return da - db;
});
const queue = [{ x: start.x, y: start.y, first: null }];
const visited = new Set([key(start)]);
let q = 0;
while (q < queue.length) {
const node = queue[q++];
for (const d of ordered) {
const n = { x: node.x + d.x, y: node.y + d.y };
const k = key(n);
if (!inBounds(n) || blocked.has(k) || visited.has(k)) continue;
const first = node.first || d;
if (same(n, target)) return first;
visited.add(k);
queue.push({ x: n.x, y: n.y, first });
}
}
return null;
}
function floodFill(start) {
const blocked = new Set(blockedKeys);
for (let i = 0; i < snake.length - 1; i++) blocked.add(key(snake[i]));
blocked.delete(key(start));
const queue = [start];
const seen = new Set([key(start)]);
let q = 0;
while (q < queue.length && seen.size < 450) {
const p = queue[q++];
for (const d of DIR_LIST) {
const n = { x: p.x + d.x, y: p.y + d.y };
const k = key(n);
if (!inBounds(n) || blocked.has(k) || seen.has(k)) continue;
seen.add(k);
queue.push(n);
}
}
return seen.size;
}
function chooseAIDirection() {
const pathDirection = findPathFirst(passenger);
const legal = DIR_LIST.filter(d => {
if (d.x === -direction.x && d.y === -direction.y) return false;
return legalForAI({ x: snake[0].x + d.x, y: snake[0].y + d.y });
});
if (!legal.length) return direction;
if (pathDirection && legal.includes(pathDirection)) return pathDirection;
let bestDirection = legal[0];
let bestScore = -Infinity;
for (const d of legal) {
const n = { x: snake[0].x + d.x, y: snake[0].y + d.y };
const distance = Math.abs(n.x - passenger.x) + Math.abs(n.y - passenger.y);
const straight = d === direction ? 4 : 0;
const value = floodFill(n) * 4 - distance + straight;
if (value > bestScore) {
bestScore = value;
bestDirection = d;
}
}
return bestDirection;
}
function chooseNextStop() {
let candidates = stops
.map((s, index) => ({ s, index }))
.filter(o => o.index !== activeStop && !snake.some(p => same(p, o.s)));
const distant = candidates.filter(o =>
Math.abs(o.s.x - snake[0].x) + Math.abs(o.s.y - snake[0].y) > 5
);
if (distant.length) candidates = distant;
if (!candidates.length) candidates = stops.map((s, index) => ({ s, index }));
const chosen = candidates[Math.floor(Math.random() * candidates.length)];
activeStop = chosen.index;
passenger = chosen.s;
}
function spawnConfetti(cell) {
const p = center(cell);
const colors = ["#f7cf58", "#f17962", "#6ab4cf", "#f7f0d2", "#9a7bd0", "#64b785"];
for (let i = 0; i < 28; i++) {
const a = Math.random() * Math.PI * 2;
const speed = 35 + Math.random() * 95;
confetti.push({
x: p.x, y: p.y,
vx: Math.cos(a) * speed,
vy: Math.sin(a) * speed - 35,
life: 650 + Math.random() * 500,
maxLife: 1150,
size: 2.5 + Math.random() * 3.5,
color: colors[i % colors.length],
spin: Math.random() * Math.PI
});
}
floaters.push({ x: p.x, y: p.y - 18, life: 1200, text: "ALL ABOARD! +150" });
}
function endGame() {
gameOver = true;
accumulator = 0;
best = Math.max(best, score);
updateUI();
updateStatus();
if (demo) {
autoResetAt = performance.now() + 1150;
showHint("Autopilot is reversing…", "Click or press a key to take over", true);
} else {
showHint("End of the line — click to restart", `${passengers} passengers · Route score ${score}`, true);
}
}
function updateGame() {
prevSnake = snake.map(p => ({ ...p }));
direction = demo ? chooseAIDirection() : (inputQueue.length ? inputQueue.shift() : direction);
const next = { x: snake[0].x + direction.x, y: snake[0].y + direction.y };
const eating = same(next, passenger);
const bodyLength = eating ? snake.length : snake.length - 1;
let crashed = !inBounds(next) || blockedKeys.has(key(next));
if (!crashed) {
for (let i = 0; i < bodyLength; i++) {
if (same(next, snake[i])) {
crashed = true;
break;
}
}
}
if (crashed) {
endGame();
return;
}
snake.unshift(next);
if (eating) {
snake.length = Math.max(snake.length, prevSnake.length + 1);
stopsServed++;
passengers += 3;
score += 150;
best = Math.max(best, score);
const color = wagonColors[(wagons.length) % wagonColors.length];
wagons.push({
color,
occupied: true,
shirt: ["#f7d366", "#69a8d0", "#e67778", "#785da8"][stopsServed % 4]
});
spawnConfetti(next);
chooseNextStop();
updateUI();
scoreValue.classList.remove("bump");
void scoreValue.offsetWidth;
scoreValue.classList.add("bump");
} else {
snake.pop();
}
}
function updateFX(dt) {
const seconds = dt / 1000;
for (const p of smoke) {
p.x += p.vx * seconds;
p.y += p.vy * seconds;
p.r += 7 * seconds;
p.life -= dt;
}
smoke = smoke.filter(p => p.life > 0);
for (const p of confetti) {
p.vy += 180 * seconds;
p.x += p.vx * seconds;
p.y += p.vy * seconds;
p.spin += seconds * 7;
p.life -= dt;
}
confetti = confetti.filter(p => p.life > 0);
for (const f of floaters) {
f.y -= 20 * seconds;
f.life -= dt;
}
floaters = floaters.filter(f => f.life > 0);
}
function drawBackground() {
ctx.fillStyle = "#89b777";
ctx.fillRect(0, 0, W, H);
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
ctx.fillStyle = (x + y) % 2 ? "rgba(255,248,190,.025)" : "rgba(25,88,58,.025)";
ctx.fillRect(x * CELL, y * CELL, CELL, CELL);
}
}
ctx.strokeStyle = "rgba(35,88,55,.055)";
ctx.lineWidth = 1;
for (let x = 1; x < COLS; x++) {
ctx.beginPath();
ctx.moveTo(x * CELL, 0);
ctx.lineTo(x * CELL, H);
ctx.stroke();
}
for (let y = 1; y < ROWS; y++) {
ctx.beginPath();
ctx.moveTo(0, y * CELL);
ctx.lineTo(W, y * CELL);
ctx.stroke();
}
for (const f of flowers) {
ctx.save();
ctx.translate(f.x, f.y);
ctx.scale(f.s, f.s);
ctx.fillStyle = "rgba(37,98,54,.24)";
ctx.fillRect(-.6, 0, 1.2, 5);
for (let i = 0; i < 4; i++) {
const a = i * Math.PI / 2;
circle(Math.cos(a) * 2.2, Math.sin(a) * 2.2, 1.55, f.c);
}
circle(0, 0, 1.25, "#d79c45");
ctx.restore();
}
const shade = ctx.createRadialGradient(W / 2, H / 2, 160, W / 2, H / 2, 610);
shade.addColorStop(0, "rgba(255,255,220,.035)");
shade.addColorStop(1, "rgba(22,66,44,.14)");
ctx.fillStyle = shade;
ctx.fillRect(0, 0, W, H);
}
function drawPerson(x, y, shirt, phase, now) {
const bob = Math.sin(now / 260 + phase) * .8;
ctx.save();
ctx.translate(x, y + bob);
ctx.fillStyle = "rgba(32,51,43,.2)";
ctx.beginPath();
ctx.ellipse(0, 7, 5.5, 2.3, 0, 0, Math.PI * 2);
ctx.fill();
fillRound(-4.2, -1.5, 8.4, 10, 3.5, shirt);
circle(0, -4.5, 4.1, ["#f0bf91", "#d99a70", "#f4cba7"][phase % 3]);
ctx.beginPath();
ctx.arc(0, -5.6, 4.1, Math.PI, Math.PI * 2);
ctx.fillStyle = ["#5b4134", "#ddb164", "#334545"][phase % 3];
ctx.fill();
if (phase === 2) fillRound(5.2, 2, 4.2, 5.5, 1, "#98613d");
ctx.restore();
}
function drawStations(now) {
stops.forEach((s, i) => {
const p = center(s);
const active = i === activeStop;
const platformY = p.y + s.side * CELL * .67;
const platformH = CELL * .50;
const platformW = CELL * 1.68;
ctx.save();
if (active) {
const pulse = .5 + .5 * Math.sin(now / 270);
ctx.shadowColor = `rgba(255,221,112,${.35 + pulse * .25})`;
ctx.shadowBlur = 12 + pulse * 8;
}
fillRound(
p.x - platformW / 2 + 2,
platformY - platformH / 2 + 3,
platformW, platformH, 6,
"rgba(44,62,49,.18)"
);
fillRound(
p.x - platformW / 2,
platformY - platformH / 2,
platformW, platformH, 6,
active ? "#f2d58d" : "#c8c39f"
);
ctx.shadowBlur = 0;
ctx.strokeStyle = active ? "#fff0ba" : "rgba(255,255,238,.45)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(p.x - platformW / 2 + 5, platformY - s.side * (platformH / 2 - 4));
ctx.lineTo(p.x + platformW / 2 - 5, platformY - s.side * (platformH / 2 - 4));
ctx.stroke();
for (let j = -2; j <= 2; j++) {
ctx.fillStyle = active ? "rgba(143,106,50,.22)" : "rgba(75,84,66,.16)";
ctx.fillRect(p.x + j * 11 - 1, platformY - platformH / 2 + 3, 2, platformH - 6);
}
if (active) {
ctx.save();
ctx.setLineDash([4, 4]);
ctx.lineWidth = 2;
ctx.strokeStyle = `rgba(255,244,172,${.55 + .3 * Math.sin(now / 240)})`;
ctx.beginPath();
ctx.arc(p.x, p.y, CELL * .39 + Math.sin(now / 250) * 2, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
drawPerson(p.x - 20, platformY, "#df6e5d", 0, now);
drawPerson(p.x, platformY, "#4f8fb7", 1, now);
drawPerson(p.x + 20, platformY, "#886ab0", 2, now);
const labelY = platformY + s.side * 20;
ctx.font = "800 9px system-ui, sans-serif";
const label = "NEXT · " + s.name.toUpperCase();
const tw = Math.min(174, ctx.measureText(label).width + 18);
fillRound(p.x - tw / 2, labelY - 8, tw, 17, 6, "rgba(17,57,57,.9)");
ctx.fillStyle = "#fff3c5";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(label, p.x, labelY + .5);
} else {
ctx.fillStyle = "#675b42";
ctx.fillRect(p.x + platformW / 2 - 8, platformY - 10, 2, 17);
fillRound(p.x + platformW / 2 - 16, platformY - 13, 18, 10, 2, "#345e52");
ctx.fillStyle = "#f5e8bb";
ctx.font = "900 7px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(s.name.charAt(0), p.x + platformW / 2 - 7, platformY - 8);
}
ctx.restore();
});
}
function drawPond() {
const x = 18 * CELL + 2;
const y = 1 * CELL + 2;
const w = CELL * 2 - 4;
const h = CELL * 2 - 4;
fillRound(x - 3, y - 2, w + 6, h + 5, 22, "#d7c894");
fillRound(x, y, w, h, 20, "#5c9fa9");
fillRound(x + 5, y + 5, w - 10, h - 10, 17, "#70b5b6");
ctx.strokeStyle = "rgba(225,249,226,.38)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(x + 31, y + 25, 16, .15, 2.4);
ctx.stroke();
ctx.beginPath();
ctx.arc(x + 52, y + 50, 12, 3.3, 5.7);
ctx.stroke();
circle(x + 22, y + 48, 7, "#6c9d62");
circle(x + 54, y + 22, 6, "#75a66a");
circle(x + 55, y + 20, 2, "#f6d587");
ctx.strokeStyle = "#496e47";
ctx.lineWidth = 2;
for (let i = 0; i < 4; i++) {
ctx.beginPath();
ctx.moveTo(x + w - 6 + i * 2, y + h - 15);
ctx.lineTo(x + w - 10 + i * 3, y + h - 30 - i * 2);
ctx.stroke();
}
}
function drawTree(t, now) {
const p = center(t);
const sway = Math.sin(now / 850 + t.x) * .8;
ctx.save();
ctx.translate(p.x + sway, p.y);
ctx.fillStyle = "rgba(35,65,44,.22)";
ctx.beginPath();
ctx.ellipse(3, 10, 18, 9, 0, 0, Math.PI * 2);
ctx.fill();
fillRound(-3, 3, 6, 14, 2, "#755438");
const palettes = [
["#2f694b", "#4d8954", "#72a85d"],
["#326e50", "#56945b", "#83b667"],
["#2c6549", "#468454", "#6aa35c"]
][t.v];
circle(-8, -3, 13, palettes[0]);
circle(8, -2, 14, palettes[0]);
circle(0, -11, 16, palettes[1]);
circle(-8, -10, 10, palettes[1]);
circle(6, -13, 10, palettes[2]);
circle(-2, -17, 7, palettes[2]);
circle(-6, -16, 3, "rgba(225,241,159,.24)");
ctx.restore();
}
function drawScenery(now) {
drawPond();
for (const t of treeCells) drawTree(t, now);
}
function strokeLink(a, b, offset, color, width) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy);
if (len < .1) return;
const nx = -dy / len;
const ny = dx / len;
ctx.beginPath();
ctx.moveTo(a.x + nx * offset, a.y + ny * offset);
ctx.lineTo(b.x + nx * offset, b.y + ny * offset);
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.lineCap = "round";
ctx.stroke();
}
function drawTrackLink(a, b, alpha = 1) {
ctx.save();
ctx.globalAlpha = alpha;
strokeLink(a, b, 0, "#786d57", CELL * .72);
strokeLink(a, b, 0, "#93866a", CELL * .56);
strokeLink(a, b, -CELL * .22, "#d8d4bd", 2.5);
strokeLink(a, b, CELL * .22, "#d8d4bd", 2.5);
strokeLink(a, b, -CELL * .22, "rgba(255,255,255,.45)", .65);
strokeLink(a, b, CELL * .22, "rgba(255,255,255,.45)", .65);
ctx.restore();
}
function drawTies(p, angle, alpha = 1) {
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(angle);
ctx.globalAlpha = alpha;
ctx.fillStyle = "#604b39";
for (const x of [-CELL * .28, 0, CELL * .28]) {
fillRound(x - CELL * .045, -CELL * .36, CELL * .09, CELL * .72, 1.5, "#674f39");
ctx.fillStyle = "rgba(240,206,142,.18)";
ctx.fillRect(x - CELL * .03, -CELL * .31, CELL * .018, CELL * .62);
}
ctx.restore();
}
function drawTracks(points) {
const futureCell = {
x: snake[0].x + direction.x,
y: snake[0].y + direction.y
};
if (inBounds(futureCell) && !blockedKeys.has(key(futureCell))) {
const head = points[0];
const future = { x: head.x + direction.x * CELL * .82, y: head.y + direction.y * CELL * .82 };
drawTrackLink(head, future, .48);
const mid = { x: (head.x + future.x) / 2, y: (head.y + future.y) / 2 };
drawTies(mid, Math.atan2(direction.y, direction.x), .5);
}
for (let i = points.length - 2; i >= 0; i--) {
drawTrackLink(points[i + 1], points[i]);
}
for (let i = points.length - 1; i >= 0; i--) {
const other = i === 0 ? points[Math.min(1, points.length - 1)] : points[i - 1];
const angle = i === 0
? Math.atan2(points[0].y - other.y, points[0].x - other.x)
: Math.atan2(other.y - points[i].y, other.x - points[i].x);
drawTies(points[i], angle);
}
}
function drawWagon(p, angle, wagon) {
const c = CELL;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(angle);
fillRound(-c * .44 + 1, -c * .29 + 4, c * .88, c * .58, c * .11, "rgba(25,47,43,.25)");
ctx.fillStyle = "#263b3b";
for (const x of [-c * .25, c * .25]) {
fillRound(x - c * .09, -c * .36, c * .18, c * .10, 2, "#263b3b");
fillRound(x - c * .09, c * .26, c * .18, c * .10, 2, "#263b3b");
}
fillRound(-c * .46, -c * .30, c * .92, c * .60, c * .10, "#193b3d");
fillRound(-c * .41, -c * .265, c * .82, c * .53, c * .085, wagon.color.body);
fillRound(-c * .33, -c * .205, c * .66, c * .41, c * .065, wagon.color.light);
ctx.strokeStyle = "rgba(255,255,255,.35)";
ctx.lineWidth = 1.2;
roundedPath(-c * .37, -c * .23, c * .74, c * .46, c * .06);
ctx.stroke();
if (wagon.occupied) {
for (let j = -1; j <= 1; j++) {
const x = j * c * .18;
circle(x, c * .035, c * .075, j === 0 ? wagon.shirt : (j < 0 ? "#e46e62" : "#548bad"));
circle(x, -c * .055, c * .058, ["#edbd8d", "#d9976e", "#f0c6a0"][j + 1]);
ctx.beginPath();
ctx.arc(x, -c * .07, c * .059, Math.PI, Math.PI * 2);
ctx.fillStyle = ["#5a4034", "#e1b258", "#38433e"][j + 1];
ctx.fill();
}
} else {
for (const x of [-c * .18, 0, c * .18]) {
fillRound(x - c * .06, -c * .09, c * .12, c * .18, 2, "rgba(255,248,213,.60)");
}
}
ctx.fillStyle = "rgba(255,255,255,.32)";
ctx.fillRect(-c * .28, -c * .23, c * .42, 1.5);
ctx.restore();
}
function drawLocomotive(p, angle) {
const c = CELL;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(angle);
fillRound(-c * .47 + 1, -c * .30 + 4, c * .94, c * .60, c * .12, "rgba(22,45,41,.28)");
ctx.fillStyle = "#243a3b";
for (const x of [-c * .27, c * .05, c * .29]) {
fillRound(x - c * .075, -c * .36, c * .15, c * .11, 2, "#243a3b");
fillRound(x - c * .075, c * .25, c * .15, c * .11, 2, "#243a3b");
circle(x, -c * .31, c * .035, "#b4aa84");
circle(x, c * .31, c * .035, "#b4aa84");
}
fillRound(-c * .48, -c * .30, c * .91, c * .60, c * .11, "#153b3e");
ctx.beginPath();
ctx.moveTo(c * .50, 0);
ctx.lineTo(c * .33, -c * .31);
ctx.lineTo(c * .33, c * .31);
ctx.closePath();
ctx.fillStyle = "#efb84d";
ctx.fill();
ctx.strokeStyle = "#b77b31";
ctx.lineWidth = 1.3;
for (const y of [-.19, 0, .19]) {
ctx.beginPath();
ctx.moveTo(c * .34, c * y);
ctx.lineTo(c * .47, 0);
ctx.stroke();
}
fillRound(-c * .13, -c * .225, c * .51, c * .45, c * .18, "#df614c");
fillRound(-c * .07, -c * .16, c * .42, c * .32, c * .14, "#ed7558");
ctx.fillStyle = "rgba(255,220,163,.4)";
ctx.fillRect(-c * .03, -c * .16, c * .055, c * .32);
ctx.fillRect(c * .16, -c * .17, c * .035, c * .34);
circle(c * .35, 0, c * .18, "#ca4f40");
circle(c * .39, 0, c * .125, "#f0825d");
circle(c * .45, 0, c * .064, "#fff0a5");
circle(c * .465, -c * .012, c * .025, "#fffde5");
fillRound(-c * .43, -c * .255, c * .32, c * .51, c * .07, "#1d6964");
fillRound(-c * .40, -c * .22, c * .27, c * .44, c * .055, "#287d72");
fillRound(-c * .335, -c * .17, c * .12, c * .34, c * .035, "#b9ded2");
ctx.fillStyle = "rgba(255,255,255,.45)";
ctx.fillRect(-c * .318, -c * .14, c * .025, c * .25);
fillRound(c * .10, -c * .115, c * .18, c * .23, c * .035, "#273c3b");
fillRound(c * .075, -c * .14, c * .22, c * .28, c * .045, "#344a46");
ctx.fillStyle = "#d4a645";
ctx.fillRect(c * .09, -c * .14, c * .20, c * .035);
circle(-c * .025, 0, c * .075, "#f0bb4d");
circle(-c * .04, -c * .02, c * .028, "#ffe49a");
ctx.restore();
}
function drawTrain(points) {
for (let i = 0; i < points.length - 1; i++) {
const a = points[i];
const b = points[i + 1];
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.strokeStyle = "#233c3c";
ctx.lineWidth = 4;
ctx.stroke();
circle((a.x + b.x) / 2, (a.y + b.y) / 2, 2.2, "#d6b468");
}
for (let i = points.length - 1; i >= 1; i--) {
const towardFront = points[i - 1];
const angle = Math.atan2(towardFront.y - points[i].y, towardFront.x - points[i].x);
drawWagon(points[i], angle, wagons[i - 1]);
}
let headAngle = Math.atan2(direction.y, direction.x);
if (points.length > 1) {
const dx = points[0].x - points[1].x;
const dy = points[0].y - points[1].y;
if (Math.hypot(dx, dy) > 1) headAngle = Math.atan2(dy, dx);
}
drawLocomotive(points[0], headAngle);
return headAngle;
}
function drawSmoke() {
for (const p of smoke) {
const a = clamp(p.life / p.maxLife, 0, 1);
ctx.globalAlpha = a * .48;
circle(p.x, p.y, p.r, "#f8f3df");
ctx.globalAlpha = a * .19;
circle(p.x - p.r * .25, p.y - p.r * .22, p.r * .55, "#ffffff");
}
ctx.globalAlpha = 1;
}
function drawFX() {
for (const p of confetti) {
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.spin);
ctx.globalAlpha = clamp(p.life / 280, 0, 1);
ctx.fillStyle = p.color;
ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * .65);
ctx.restore();
}
for (const f of floaters) {
ctx.save();
ctx.globalAlpha = clamp(f.life / 300, 0, 1);
ctx.font = "900 13px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.lineWidth = 4;
ctx.strokeStyle = "rgba(20,54,51,.75)";
ctx.strokeText(f.text, f.x, f.y);
ctx.fillStyle = "#fff4b5";
ctx.fillText(f.text, f.x, f.y);
ctx.restore();
}
}
function drawBorder() {
ctx.save();
roundedPath(5, 5, W - 10, H - 10, 14);
ctx.strokeStyle = "rgba(38,78,52,.7)";
ctx.lineWidth = 10;
ctx.stroke();
roundedPath(8, 8, W - 16, H - 16, 12);
ctx.strokeStyle = "rgba(239,221,165,.75)";
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = "rgba(255,245,207,.55)";
for (let x = 24; x < W; x += 64) {
circle(x, 8, 2, "rgba(255,245,207,.7)");
circle(x, H - 8, 2, "rgba(255,245,207,.7)");
}
for (let y = 24; y < H; y += 64) {
circle(8, y, 2, "rgba(255,245,207,.7)");
circle(W - 8, y, 2, "rgba(255,245,207,.7)");
}
ctx.restore();
}
function interpolatedPoints(alpha) {
const tail = prevSnake[prevSnake.length - 1] || snake[snake.length - 1];
return snake.map((p, i) => {
const old = prevSnake[i] || tail || p;
return {
x: (old.x + (p.x - old.x) * alpha + .5) * CELL,
y: (old.y + (p.y - old.y) * alpha + .5) * CELL
};
});
}
function render(now, alpha) {
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
ctx.clearRect(0, 0, W, H);
drawBackground();
drawStations(now);
const points = interpolatedPoints(alpha);
drawTracks(points);
drawScenery(now);
drawSmoke();
const headAngle = points.length > 1
? Math.atan2(points[0].y - points[1].y, points[0].x - points[1].x)
: Math.atan2(direction.y, direction.x);
if (!gameOver && now - lastSmoke > 112) {
lastSmoke = now;
const angle = Number.isFinite(headAngle) ? headAngle : 0;
const chimneyX = points[0].x + Math.cos(angle) * CELL * .19;
const chimneyY = points[0].y + Math.sin(angle) * CELL * .19;
smoke.push({
x: chimneyX, y: chimneyY,
vx: (Math.random() - .5) * 9 - direction.x * 3,
vy: -13 - Math.random() * 7,
r: 4 + Math.random() * 2,
life: 850 + Math.random() * 250,
maxLife: 1100
});
if (smoke.length > 32) smoke.shift();
}
drawTrain(points);
drawFX();
drawBorder();
}
let lastFrame = performance.now();
function frame(now) {
const dt = Math.min(40, Math.max(0, now - lastFrame));
lastFrame = now;
if (demo && gameOver && autoResetAt && now >= autoResetAt) {
resetGame(true);
}
if (!gameOver) {
accumulator += dt;
let guard = 0;
while (!gameOver && accumulator >= getTickInterval() && guard < 3) {
const interval = getTickInterval();
updateGame();
accumulator -= interval;
guard++;
}
if (gameOver) accumulator = 0;
}
updateFX(dt);
const alpha = gameOver ? 1 : clamp(accumulator / getTickInterval(), 0, 1);
render(now, alpha);
requestAnimationFrame(frame);
}
const keyDirections = {
arrowup: DIRS.up, w: DIRS.up,
arrowdown: DIRS.down, s: DIRS.down,
arrowleft: DIRS.left, a: DIRS.left,
arrowright: DIRS.right, d: DIRS.right
};
window.addEventListener("keydown", e => {
const k = e.key.toLowerCase();
const d = keyDirections[k];
if (d) {
e.preventDefault();
if (demo || gameOver) beginPlayer();
queueDirection(d);
return;
}
if (k === "r") {
e.preventDefault();
resetGame(false);
return;
}
if (demo || gameOver) {
e.preventDefault();
beginPlayer();
}
}, { passive: false });
stage.addEventListener("click", e => {
if (e.target instanceof Element && e.target.closest("button")) return;
if (demo || gameOver) beginPlayer();
});
let pointerStart = null;
canvas.addEventListener("pointerdown", e => {
pointerStart = { x: e.clientX, y: e.clientY };
try { canvas.setPointerCapture(e.pointerId); } catch (_) {}
});
canvas.addEventListener("pointerup", e => {
if (!pointerStart) return;
const dx = e.clientX - pointerStart.x;
const dy = e.clientY - pointerStart.y;
pointerStart = null;
if (Math.hypot(dx, dy) > 22) {
if (demo || gameOver) beginPlayer();
if (Math.abs(dx) > Math.abs(dy)) queueDirection(dx > 0 ? DIRS.right : DIRS.left);
else queueDirection(dy > 0 ? DIRS.down : DIRS.up);
}
});
canvas.addEventListener("pointercancel", () => { pointerStart = null; });
document.querySelectorAll(".touch-controls button").forEach(button => {
button.addEventListener("pointerdown", e => {
e.preventDefault();
if (demo || gameOver) beginPlayer();
queueDirection(DIRS[button.dataset.dir]);
});
});
restartBtn.addEventListener("click", () => resetGame(false));
resetGame(true);
requestAnimationFrame(frame);
})();
</script>
</body>
</html>Reasoning trace (18,991 tokens)
I need create self contained web demo exactly code block html. Should game snake train locomotive colorful wagons picking passengers platform. Must self-playing demo behind click or key start message. Should rich visuals within 3 secs. Canvas likely. Should functional interactive snake controls keyboard arrows/WASD swipe/click buttons perhaps. Train grid classic snake, pick passengers grows wagon. Should show platforms and passengers waiting, rails stylized scenery. The train actual connected visual cars oriented per segment, locomotive at head and colorful wagons. Should have high polish.
I need design 1280x800 and responsive down to 375 no horiz. Canvas with pixel ratio. Layout page dark perhaps illustrated isometric-ish top-down railway station diorama. Main panel game area central, sidebar scoreboard. Since likely screenshot compares. Should show meaningful at first paint self playing. Should have initial train moving with passenger platform and wagons. Overlay message bottom center "Click or press a key to drive" not blocking view. HUD with score, passengers, length, speed, next passenger perhaps. Game background green and station platform diagonal? Simpler grid board with green landscape and central railway ties. Snake path cells. Render rails under train? Could render tracks along entire snake path segments and prospective? Classic Snake but rails grow? Should logical visually.
I can design top-down game field 24x18 grid. Green stylized park with paths and trees. Station platform horizontal at top? Passengers spawn waiting on platform which is a cell within grid. Train moves on grid on ground, tracks under it. Pick up passengers, add colorful wagon. Should avoid own body and boundaries. Obstacles trees/lamps/ benches perhaps.
Perhaps board 26x18, side panel 280. Container max 1280. Canvas whole visual with HTML overlay. Better full HTML layout using canvas and side info. Main game container with canvas ratio 1.5 perhaps 840x580. At 1280x800 with header 80 + game area ~590 + footer fits. Side panel 250. At mobile, sidebar becomes compact HUD and canvas scales. Use ResizeObserver to set dimensions responsive.
Make sure self playing demo initial immediate. State demo=true; AI plays. On click/key press set demo false and reset, starts controls. Overlay text changes. Could initial overlay "Click or press a key to drive" and tiny "Autopilot is giving a scenic tour". If pressing arrows might start and set direction. Mouse click anywhere starts. Should avoid control buttons causing reset? Fine. Start overlay pointer perhaps. Could handle global keydown.
Game loop fixed ticks ~140ms. Render smooth interpolation so train glides along rails. Should support trains segments smoothly and curves. I can use cell center positions interpolation between prev and current segments for each. Each tick snake head moves, prevSnake = copies, update. During render t=(now-lastTick)/tickRate. For each segment current cells and prev corresponding; draw at lerp. But growth differs lengths. Keep prev for each. Simpler render snake at current cell center with per frame interpolation: maintain display positions perhaps. prevSnake same length padded last. When update prevSnake = snake.map... before moving; after growth append previous tail? Typical: newSnake=[head,...snake], if not eat pop. If eat, no pop length increases. prevSnake for index new: prevSnake[i] for i<prev len else old tail. Works. During render segment 0 at lerp(prevSnake[0], snake[0]); segment 1 from prevSnake[1] to snake[1] etc. Wrap no wrapping? Collision wall game over. No wrap. directions no reverse. AI choose path avoiding collisions and targeting passenger. Should have robust enough self play, no early crash. BFS shortest path to passenger avoiding body, then choose safe. Also avoid walls. Train can move in grid and platform cell passenger. Should have obstacles? To not complicate AI, static obstacles only visuals outside playable cells or no collision trees at edges. Could have station platform cells? Passenger target on open grid. Better path target BFS obstacles includes body excluding tail. BFS each tick. If no path choose safe with most space. Demo reset after crash automatically. Use BFS target.
Passengers should be visible waiting on platform. Spawn a "passenger group" at target cell with tiny platform (a square paved cream colored) and 2-3 figures, sign. When train reaches, add wagon score. Then next target random far from body, ensure reachable perhaps random open. Should keep continuous. Could define fixed platform pads at various positions: list of platform locations with orientation, each is tile within board. Target is one station stop. Render small platform region around target cell and tiny waiting passengers. When picked, animate floating +1 and passenger icons into train? Add particle and popup.
The game background can have looping rail track? If only draw rails underneath train segments, appears train on track that follows its body. Better game field is dark navy railway model with pale ballast. Board grass green. Draw track on every grid cell? Too busy. I can draw track only beneath train path and target approach? The train follows invisible grid but rails can fade behind tail? Classic snake with train and rails perhaps rails under current train body, making coherent connected train. Draw track bed connecting centers between each adjacent segment with sleepers. Curved joins possible via lines. Rails under each segment based on path; track links rendered thick dark brown/gray, two steel rails and ties. But train itself covers. Add rails ahead one cell from head direction to suggest. At each cell ties perpendicular to local travel.
Train visual: top-down 2.5D locomotive and wagons, using Canvas shapes with shadows. Should attractive. Use local coordinates car along +x or -? Draw based on direction angle computed from current to next? Locomotive nose leads in direction. At position head center, angle = direction to current movement (head-current neck). Draw train car length 0.85 cell width and width 0.62 cell. Transform rotate. Shape locomotive with black/dark chassis, red/navy/teal boiler front. Could draw side/top-down: front at +x, rounded rectangle base; cowcatcher triangular at front, boiler green/teal with smoke stack near front, cabin at back with glowing yellow windows. Wagons colorful rectangle with rounded corners, lighter roof, small windows, wheels on sides (top-down wheels little dark rectangles). Couplers between segments lines. Add smoke particles from locomotive chimney as it moves, white translucent circles trailing in screen space (world coordinates) with physics. Should direction dependent smoke.
Canvas use logical board width cell size computed from actual CSS width. Instead perhaps fixed internal 1040x680 coordinates and scale canvas to container. Board 26x18 cell 34? width 884, height 612. Canvas 900x612. Layout side panel. Responsive scaling done style width 100%; internal logical canvas 960x640? Draw coordinate 26*34=884; 18*34=612. Top/bottom perhaps no. Could use 28x18 at cell 32 =896x576. Ratio 1.55. Main board at desktop width around 890 and height 573, with side panel 270 and gaps 24 =1184 + padding 40. At 1280 works. Header 76 + main 610 + footer 30 =716. Mobile width 375 minus 24=351, board height 226 too small; canvas ratio can be 1.35 e.g 24x18 (4:3), width 351 height263. Should touch controls. Desktop board width 840 height630 if cell35 and 24x18. Main side 260 total1124. Fine. 24x18 grid, canvas 840x630. At 1280 main max1200: canvas flex remaining ~890? If fixed aspect ratio 4/3, 840x630. Height viewport ~800 header 78 + main 630 + margin 20 + footer =760 fits. At 1280 container padding 24. Side panel 250. At widths below 900, layout single column with HUD sidebar as cards row and board width min(100%, 720), then height mobile perhaps 260. Nice.
Perhaps canvas board has 24 columns x18 rows. Render tile checker grass variations, little speckles. Border rounded. Board coordinates include full canvas. No HUD inside except status chip "SCENIC LINE • AUTOPILOT" top left and target callout at top right perhaps. Main actual page background warm cream or dark navy? Design: Midnight navy app, gold accents, canvas pastel scenic green. Title "PUFFERBELLY EXPRESS" with small logo train icon, subtitle "A tiny railway snake adventure". HUD side panel cream card? Aim polished modern playful.
Could use CSS fonts system no external. Use font-family 'Trebuchet MS', system. Header has brand icon circle with inline svg? Icons can be CSS/inline SVG. Allowed no external. Main card background #102b35? Page gradient #071c25 to #0c3034 with decorative stars/noise? Want screenshot rich. Use warm paper side panel with dark text perhaps. Main container dark translucent.
I'll think visual composition:
body background #071e26, radial gradients teal.
header .topbar max-width 1240 display brand and top stats perhaps.
logo emblem golden square with tiny train front using CSS or emoji? Emoji rendering varied. Inline SVG can draw train icon. Title uppercase letter spacing.
Right "ROUTE 01 · SUNNY MEADOW" chip and sound toggle perhaps sound not needed.
main .game-shell display grid columns minmax(0, 1fr) 260px gap 18.
.stage position relative background #163e43 border 1 etc padding 10 border radius 24 box shadow. Canvas display block width 100%, aspect-ratio 4/3.
stage top floating pill status.
overlay start bottom center pointer-events none: glass dark with mouse icon and text.
Game over panel perhaps hidden.
sidebar .panel background #f8f3e7, border radius24 color dark. Score label and huge number "000"; passengers row.
Next stop mini card with icon and station name. "TRAIN MANIFEST" colored tiny wagon icons perhaps dynamic count. "HOW TO PLAY" arrow keys chip, collect passengers, don't bump train or edge. Buttons sound and reset. Mobile hide some.
Bottom control touch pad under canvas perhaps display at all sizes? Four direction buttons bottom right on mobile, can include simple control bar at bottom of stage: arrows in center? But canvas already. Should mobile gameplay possible with swipe and visible touch D-pad. Could add .mobile-controls below canvas hidden desktop shown <=700. Yet stage aspect ratio plus controls may extend beyond viewport but scroll allowed vertical. Fine. Main meaningful first seconds.
Should have no external resources. Audio perhaps WebAudio optional sound toggle but no need. Could add sound generated whistles on pickup/click with mute toggle, but browsers audio only after gesture. In demo no sound to avoid errors. Add sound button toggles enabled; on start audio context created on user gesture, play sounds. Extra complexity okay but not necessary. Could add animated day clouds, butterflies, tree swaying.
Game code architecture:
const COLS=24, ROWS=18, CELL=40 maybe canvas 960x720. Use internal 960x720. Crisp. Board backgrounds detailed.
Positions in pixels = c*40+20. Grid border at outer. Train starting horizontal near left-middle with head at {x:8,y:9}, body x decreasing to4 etc. dir {1,0}; length initial 5 (locomotive +4 wagons) colors [coral,yellow,blue,purple]. Passenger target {x:17,y:5} station. AI gets there quickly ~? 12 ticks at 140ms=1.7s perhaps first pickup near screenshot. Nice. Start train at x5? Should travel and pickup within 3 sec. Could target x12 y9 distance 4 initial. Demo at tick 150, first pickup after 0.75 sec, second etc. Initial length 5. "visible interesting" screenshot at 3 sec, enough growth. Passenger at {x:14,y:7}; from head x7 y9 dist8 =1.2sec. Good. Use initial target fixed {x:14,y:6} dist10=1.5 sec. screenshot around after first pickup perhaps. Have initial passengers visible.
AI BFS each tick:
function chooseAIDirection() {
const target=passenger;
const start=snake[0];
blocked Set snake.slice(0,-1) keys maybe tail safe, but to be safe include all except head? For path next, body positions after movement shift. Use snake.slice(0,-1). BFS queue from head, visited; iterate dirs maybe toward target sorted. Find first direction path. To avoid path to unreachable, choose.
After path choose first step. Also avoid getting trapped later: BFS target often works but can trap in own body. Add survival check: after simulate next? Basic snake AI with BFS and if no path choose move maximizing flood fill. Demo can reset after collision. Self-playing likely okay with length moderate. Should have long screenshot no early. Use hamiltonian? BFS chasing on empty board okay until long. Could use follow path if path exists and safe; evaluate flood fill after move and if space < snake.length+4 choose alternate. The target path can still trap after collecting but likely fine. Could ensure "tail chase" if no target path. Fine.
Human update queuedDir. keyboard input prevents opposite. If multiple keys between ticks queue. Keep directionQueue max2; function valid compared last queued/current. Demo starts on any key then reset and applies key. If user clicks stage start but demo running; reset. Control button click should start and set dir, stopPropagation? Global window click could start all. Add start button? Overlay says click anywhere; stage click calls beginPlayer. Side button "Restart route" calls reset with demo false.
State demo true initial. run game always. Game over: state gameOver true, overlay message "Route ended — Click to try again" and after demo 1200ms reset true. For human remains until click. Update score. Best cannot localStorage. Keep best variable session only allowed memory. score count. Passenger pick increments score perhaps +10*combo and speed decreases tickRate min75. length add one. But snake conventional grows one. Add colorful wagon per passenger. Manifest dynamic up to six color dots/wagon mini icons.
Passenger stations: fixed platform list locations away edges:
[{x:17,y:4,name:'Daisy Crossing'}, {x:19,y:13,name:'Willow Halt'}, {x:6,y:14,name:'Acorn Square'}, {x:4,y:4,name:'Clover Fields'}, {x:12,y:9,name:'Central Meadow'}, {x:20,y:8,name:'Sunny Side'}]. But random targets can be body. Passenger target should be platform cell. Render all station pads? Render all station structures could confuse. Render permanent platforms all stops, with waiting people only active target, other signs subtle. These station pads occupy cell visually but not collision (train can drive over tracks). Draw a small rectangular platform behind at cell center with path rails? Could be platform adjacent to track not on cell. If target cell is track, platform located above or right based on location, passenger stands there and can hop in when train arrives. On canvas top-down, a station stop cell has track bed; platform offset vertically ±0.65 cell. But if adjacent train passes could overlap. Fine. Define each stop orientation 'top'/'bottom', target cell track. Draw paved rounded rect center at (px, py+/- CELL*0.55) width 1.55 cell height .55 cell. This may cross adjacent cells but decorative. Sign and people at platform. Other stops perhaps only signs with no people to reduce. active stop has warm pulsing ring around cell and dotted path? "Next stop" marker.
Board scenery:
- background #88b978 with checker alternating slightly.
- small grid lines subtle.
- path? river? Avoid too many.
- outer border fence / hedges. Because wall collision, border visible via hedges/fence. Draw along edges dark green bushes at cell boundaries perhaps at x0.2 cell etc. But train can occupy edge cells; fence just outside canvas impossible. Could draw boundary inner 8px dark brown and golden sleepers. Danger clear. Use canvas clipped rounded.
- trees at predefined cell centers, but if no collision they'd be confusing. Place trees at edge cells and mark them obstacles? AI and player need collision with obstacles perhaps but complexity. Could render trees in corners outside grid playable area by restricting train to x=1..22,y=1..16 and trees x=0 or23 y etc. Set collision if next x<0 or >=cols etc, but scenery occupies edges. Better playable area full and scenery in areas that are collision obstacles. Include obstacles set of coordinates for trees; AI BFS avoids. Human collisions with trees makes sense. But how-to says avoid trees. Should train not spawn on. Fixed obstacles: around edges and select interior e.g {2,2},{21,2},{2,15},{21,15}, {12,2}, {12,15}, perhaps ponds with cells. More complexity path BFS includes obstacle set. Visual richness good.
Could have 8 trees and a pond at top right with 2x2 obstacle cells. Should not obstruct. Mark ponds as blocked cells. Station stops no obstacle.
I'll define scenery:
trees = [{x:1,y:1,type...},{x:5,y:1},{x:22,y:2},{x:1,y:9},{x:22,y:15},{x:8,y:16},{x:15,y:1},{x:1,y:16}] etc. blocked set keys. Pond at {18,1},{19,1},{18,2},{19,2} but nearby stop 17,4 okay. Some benches perhaps nonblocking in platforms? Fine.
However initial grid has scenery and train can hit trees. AI avoids.
Could make background stylized tiles with little flowers scattered deterministic (seeded pseudo random) avoiding track? Use array with coordinates continuous and draw small flowers in empty cells, non-collision. Since nonblocking tiny deco okay. Clouds shadows slowly moving perhaps not.
Track rendering:
I need smooth track under train. Build points for interpolated segments. For each pair i from 0 to len-2, draw track bed line between p[i] and p[i+1] with width CELL*.66 color #786e5b; linecap round. But if train segments at adjacent cells and train body width .65, track bed mostly visible at couplings. Ties perhaps at each p[i] orientation based on neighbor. Draw beneath train:
For each segment i p, angle from path direction. Draw sleeper rect perpendicular (width? local). At car center, rotate angle, draw 3 ties at x=-CELL*.4,0,.4? Ties extend across width. Actually track along x, sleepers perpendicular: rectangles at local x positions, y from -0.45c to0.45c, width0.12c height0.9c brown. Draw rail lines along link at local y±0.25c. Should have tracks continuous through snake path with corners. I can draw line segments p[i] to p[i+1] for rails with per segment angle, but turns would kink. Use generic track segment between pair: angle, draw bed line width0.65c, rails as two parallel lines offset perp ±0.23c. Draw for all pairs. Ties at each segment center rotated based on direction to neighbor. Rails ahead of head: from head point to head + dir*0.75c and perhaps next target? Just one cell ahead perhaps track "magically" lays ahead. Draw future one tile with faint track. But human turning means rails update. Fine. Track under whole train gives clear.
Rendering track ahead might run into obstacle but okay just faint.
Track behind tail perhaps last tail remains fading? Maintain trackTrail array of recent positions? Train path itself enough.
Draw wagons far-to-near so head on top. For couplings before bodies draw line between cars p[i],p[i+1] dark with circles. Draw each car based on angle: for wagon i, orientation along vector from p[i] to p[i-1] (toward front), using interpolated points. At curves, good. Head angle current dir interpolation? Compute vector p[0]-p[1], but when segments same? fallback dir. Rotate.
Car dims c*0.88 length and .62 width; centers one cell apart, coupler gap .12c. Wheels on sides top-down as dark little rects at x±0.22c, y±0.36c.
Wagon body roundedRect from -L/2,-W/2 etc fill dark outline/shadow. Roof fill color; center stripe lighter. Windows 3 small pale rectangles along top? Since top-down, passenger wagon can have open roof showing passengers. User asks picking up passengers, could show tiny passenger heads in each wagon: after each pickup add passengers visible sitting in wagon. Make open-top colorful wagons with 2 seats and tiny heads. But classic wagons colorful with roof windows. I can draw each as open carriage: base colored, inner cream, two tiny circular heads with body colors. Then every wagon visibly carries passengers. Locomotive distinct red engine with cabin, chimney and smoke. Each new wagon random colors. Use wagonPassengers array of color/face etc. At initial wagons perhaps passengers? Start 3 wagons? User says picking up passengers, perhaps initial locomotive +2 wagons empty, each pickup adds occupied wagon. But snake train should colorful wagons at first paint. Start 4 wagons, some occupied, okay.
Wagon drawing: shadow roundedRect dark under offset. chassis dark #18353a. body color. roof lighter band. 3 windows pale #d9f0e9 with tiny head dot perhaps. Could draw passengers as circles in windows. top-down visible heads in open carriage looks delightful. I'll do open-top wooden/coaster:
chassis roundRect -0.46c,-0.31c, .92c,.62c fill #17343a
body roundRect -0.40c,-.27c,.8c,.54c fill color
inside roundRect -0.25c,-.20c,.5c,.4c fill rgba(255,255,255,.28)
draw two seats at x ±.13c with heads circles at y0 and small body rectangle. Wheels.
Locomotive: chassis same, cabin at back (-x) red #e45745, boiler at front teal #1f6d68? Use engine red body + mustard trim. Nose front cowcatcher gold, circular front. Chimney black at x .23c, smoke source. Cabin window cyan.
Perhaps engine green with red cabin and brass. Fine.
Smoke particles: spawn every ~120ms at locomotive front based on current interpolated head position and angle. Store {x,y,vx,vy,life,maxLife,r}. In demo always. On update render dt, spawn at head + dir*.25c - vertical? Top-down smoke rises so draw upward drift and expand. Realistic puffs light #f5f0db alpha. Should not be too many. Draw before train but after tracks and scenery. Use globalAlpha.
People on platform: draw 3 tiny stylized figures in top-down 2.5D: shadow ellipse, body colored circle/rounded, head skin circle. Passenger waiting at active stop. They bob slightly. Draw little one with suitcase. Platform label sign.
When pickup: target stop changes, particles colored confetti; floating texts array "+1 PASSENGER", "ALL ABOARD!" near head. Update sidebar numeric with slight pulse CSS class.
Score etc:
score=0, passengers=0, best variable perhaps best=0. Initial train length=5 (engine +4 carriages). Score perhaps 0, passengers 0, wagons4. Manifest label "4 wagons" and color bars.
Sidebar:
.section.score-card background dark teal perhaps entire sidebar cream.
"PASSENGERS" huge 00? Score display 000.
Grid two stats score and best? Main score "0" big, label "passengers aboard". Stats "Train length 5", "Best route 0".
Next stop card light yellow with green icon, name dynamic.
"YOUR TRAIN" wagon dots.
Instructions.
Small button "↻ Restart route" and sound toggle perhaps.
Top status current "AUTOPILOT" and speed.
Header brand with small badge "SNAKE ON RAILS" and route.
Footer "Arrow keys / WASD to steer · Swipe on touch screens" perhaps.
Overlay:
<div id="startHint" class="start-hint"><span class=mouse>... inline SVG</span><div><strong>Click or press a key to drive</strong><small>Autopilot is running the demo</small></div><span class=key>↵</span></div>
Position bottom 18px left50%. On player mode hide? Should game clearly started; hide after. Since initial autoplay behind. Stage click triggers. On keyboard start, prevent arrow scroll. Focus not needed iframe clicked per instruction but key before click? sandbox might key no; click works. Button text perhaps exactly "Click or press a key to start" per instructions. User specifically says behind a "Click or press a key to start" message. Use exact phrase, subtext "Autopilot is driving until you take over". Good. It can be displayed bottom, train visible.
On gameover overlay center perhaps <div id=gameOver> etc.
Touch swipe on canvas pointerdown records; pointerup if swipe>20 start and direction. But click stage starts and pointerup may reset then steer. Use stage.addEventListener('click', e=> if clicked button? d-pad outside); beginPlayer. canvas pointer events for swipe. If pointerdown on canvas, track; pointerup if dist>20 then beginPlayer once and queue direction; prevent click double via suppressClick flag.
D-pad buttons pointerdown call beginPlayer then queue dir. Buttons outside stage click global? Add only stage click so no issue.
Global keydown: if Arrow etc prevent default; if demo or gameover beginPlayer; queue direction. Space toggles pause if player? Could include P pause. Should avoid key any causing start then specific P weird. "press a key" any; start. R resets. Sound no.
Pause perhaps not necessary. Add space pause with small badge? Could complicate. Not needed.
Loop:
let lastTime=performance.now(), acc=0;
function frame(now) {
dt=Math.min(50, now-lastTime)
lastTime=now
if(!paused) {
acc+=dt;
const interval = current speed; if acc>=interval { while acc>=interval update(); acc-=interval; }
}
render(now, acc/interval)
requestAnimationFrame(frame)
}
When reset acc=0. gameOver no updates but smoke continues. render t clamp.
Speed based on passengers tickInterval=Math.max(82,155-passengers*3). During demo perhaps 145. Should have interpolation when update exactly. prevSnake.
Potential issue after game over, alpha t, no.
beginPlayer() if demo: demo=false; resetGame(false), hide start; update status. If gameOver: resetGame(false).
Autopilot resets after collision: in endGame, if demo set autoResetAt=performance.now()+1000. Frame checks.
For demo overlay status pill "● AUTO DRIVING"; player "● YOU'RE DRIVING". Sidebar "Route score".
Passenger pick:
passengers++;
score += 100 + Math.max(0, Math.floor((tick???)))? Keep score passengers*100 and distance bonus. Use score += 100.
new wagon color wagonColors[(snake.length-1)%].
Because snake updated before check? update:
prevSnake = deep copy
const head=...
if collision -> endGame return
snake.unshift(next)
let ate = same next passenger
if !ate snake.pop()
else {score... spawn; chooseNextPassenger(); }
If ate new length prev+1; rendering prevSnake for new last index fallback prevSnake[prev.length-1].
Station choose next random station not same and not snake and not blocked. BFS to target perhaps. If station under body choose next. Could select any random free cell? User platform means use predefined stops. Good.
Stop list 8 positions. Should avoid adjacency causing platforms overlap with trees. I'll define stops carefully:
COLS=24, ROWS=18.
stops:
{x:12,y:8,name:'Central Meadow',side:1}
{x:17,y:4,name:'Daisy Crossing',side:1} (platform below y+)
{x:20,y:12,name:'Willow Halt',side:-1}
{x:6,y:14,name:'Acorn Square',side:-1}
{x:4,y:5,name:'Clover Fields',side:1}
{x:12,y:14,name:'Apple Orchard',side:-1}
{x:20,y:7,name:'Sunny Side',side:1}
{x:8,y:3,name:'Bluebell Bend',side:1}.
Trees obstacles at:
(1,1),(5,1),(14,1),(22,1),(1,7),(22,9),(1,16),(9,16),(18,16),(22,16)
Pond at (17,15),(18,15),(17,16?) conflict tree18,16. Perhaps pond 15,1 etc no. Avoid too many.
Make sure stop platform visual at side ±1 doesn't overlap tree:
Clover 4,5 side1 platform y5.65 okay.
Bluebell8,3 side1.
Daisy17,4 side1.
Central12,8 perhaps side=-1.
Willow20,12 side=-1.
Acorn6,14 side=-1.
Apple12,14 side=-1.
Sunny20,7 side1.
All good.
Trees around outer positions and some interior:
(1,1),(4,1),(15,1),(22,2),(1,11),(22,15),(2,16),(9,16),(18,16),(21,4?) near Sunny x20,7 no. Add tree at (10,5) perhaps blocks center and station path but fine; (15,11) perhaps.
Pond 2x2 at (18,1),(19,1),(18,2),(19,2). Tree 22,2 separate.
Tunnel? no.
Flowers random open tiles not important.
Initial train head {x:7,y:9}, body [6,9],[5,9],[4,9],[3,9] (5 total). dir right. target Daisy17,4 perhaps BFS routes; first pickup after 15 ticks ~2.3 sec. Could initial target Central12,8 distance6 ->0.9 sec. Station list index0. Great. After first next random. Initial train on y9, target12,8, easy. At 3 sec next target visible.
Initial wagon colors coral, gold, sky, violet.
AI BFS obstacle:
function getPath(start,target) {
blocked=new Set([...blockedScenery]);
snake.slice(0,-1).forEach(add)
queue; dirs sorted by Manhattan to target perhaps.
parent map key -> previous key.
Start allowed. target not blocked.
BFS until end. Return first cell.
}
To account movement and growth tail, okay.
Safe selection fallback: candidate directions not reverse, collision next; score flood fill free count + distance target negative; choose max. If BFS step results low space perhaps evaluate other moves? I can choose BFS always but perhaps trap. For robust self demo, pathfind with tail. At each tick 432 nodes trivial. Use safe scoring:
path=BFS. candidates sorted with path first. For each candidate simulate newSnake, flood fill free count from next with obstacles + newSnake except head? Flood fill on board ignoring head? Space should be >= snake.length perhaps. Prefer path if space > Math.min(snake.length+3, 30) perhaps. If path candidate low choose max. However if path candidate has flood fill huge usual.
Could simply path BFS. Good enough first seconds.
Human input collision with self and scenery. Game over screen small card centered with score and "Click or press a key to restart". Underlying train still visible.
Bounds: if next out. EndGame with particles red perhaps.
No localStorage.
Responsive:
CSS:
* box-sizing
body margin0 min-height100vh
.page max-width:1240 margin auto padding:20px 24px 16px
header height perhaps 60 margin-bottom16
.game-layout display:grid grid-template-columns:minmax(0,1fr) 270px gap18
.stage-card min-width0 background...
.canvas-wrap position:relative border-radius18 overflow:hidden aspect-ratio:4/3
canvas width100%;height100%
.sidebar ...
At max1280 page content 1232. sidebar270 gap18 leaves944 stage width, canvas content ~920 => height690. Header70 + padding40 + main stage 714 =824 too tall. Should limit canvas card width to perhaps840. Page max-width1180: content1132; sidebar254 gap16 ->862 stage outer, inner838 height628.5 +20=648.5. header58+16 + page top16 + footer24 =762 fits. Great max-width1180. At 1280 viewport margin 50. Stage inner 840. Use max-width1180.
At desktop main grid stage width ~860, side 254. Canvas 840x630. Header64. Total 16+58+14+650+12+? =750.
Page max width1180 includes padding 20? box-sizing border-box. width calc(100% - 32px). At1280 width1180, inner1148? If padding 0 perhaps. I'll set .app{width:min(1180px,calc(100% - 32px));margin:auto;padding:16px 0 12px}. grid 1fr 254. available1180, gap16, stage910? 1180-254-16=910 outer, inner886 and height664.5 +20=684; header56+14 + pads =770. Fine. At 1280x800 footer perhaps fit. 886*0.75=664.5, stage outer684.5. app top12 header58 gap14 =84, main684=768, footer24 =>792. Just fits. Could set grid sidebar260 and max-width1140 gives stage864 inner840 height630 +20=650; total12+54+14+650+8+20=758. Great. .app max-width1140. At 1280 centered 70 margins. Stage canvas 840x630. Sidebar260. 1140-260-16=864 outer, inner840. Good.
At 1024 app 992, grid stage716 inner692 height519 +20; sidebar260, nice. Header etc fit.
At <=850 switch single column? Canvas width perhaps full, sidebar below. At 850 viewport, app818, canvas794 height595, then sidebar below, screenshot initial viewport only header+canvas but HUD hidden below. Better at 700 switch and compact stats above? User viewport 1280 mostly and mobile 375. On mobile need visible score. Could keep sidebar as top score strip? Use order. At <=800:
.game-layout grid-template-columns:1fr;
.sidebar display:grid grid-template-columns repeat?; order:-? If sidebar above stage occupies ~120 then canvas, okay. But instructions perhaps hide. Could create top mobile scoreboard in stage overlay. Simpler stage has .mobile-hud visible <=800 with score and stop in floating at top, and sidebar below. Desktop status pill also. At mobile canvas height (351-16)*.75=251, plus header48+stage16+controls48 =370 and sidebar below. Score overlay top left. Good. Main status top center might clutter. I can position top pill and overlay hint bottom. Canvas itself can draw small target indicator? I'll add canvas text station name? But HTML side hidden below; still okay. Mobile start overlay may cover most bottom but compact.
At <=800 sidebar grid 3 columns score etc, instructions hide, next stop visible. Could be below but user can scroll. Fine.
At <=520 app width calc(100%-20px), header title font20, route chip hide; stage padding6 radius16; canvas. startHint max width calc, font11, padding8. Dpad visible. .sidebar score row compact.
No horizontal scrollbar from dpad etc.
Canvas accessible role label.
CSS animations overlay pulse small.
Should have icons inline SVG perhaps logo in header and button. Canvas no assets.
Potential first paint: JS runs immediately after DOM, resetGame(true), requestAnimationFrame. Canvas draws first frame. Everything visible. Even if JS issue no. Should have zero errors.
Implement helpers roundRect with ctx.roundRect supported latest Chrome but for safe custom path. Use function roundedPath(ctx,x,y,w,h,r) etc.
Canvas draw with actual internal 960x720. On high DPI canvas width perhaps 960*DPR? If use fixed internal 960x720 and CSS scales, at desktop 840 wide okay retina slight less sharp but fine. Could set canvas.width=W*dpr and ctx.setTransform(dpr...). But W,H logical 960x720, canvas CSS aspect ratio. Initialize dpr=Math.min(2,devicePixelRatio||1); canvas.width=W*dpr; canvas.height=H*dpr; ctx.setTransform(dpr,0,0,dpr,0,0). At every frame set transform perhaps maintain. No resize needed. This makes 1920x1440 drawing manageable. All good.
Canvas style aspect-ratio 4/3.
Pointer coordinate not needed except swipe.
Draw background:
ctx.clearRect etc fill #81ac70.
for y rows for x cols fill alternating rgba perhaps checker. Use fillRect slight. draw tiny grid plus decorative flower list. Can precompute decorative flower positions with deterministic function. E.g seeded RNG mulberry32(1234). for i 0..80 choose pixel positions and if not near obstacle, draw 2-4 petals. Static each frame perhaps okay.
Add mown stripes alternating rows? Use colors (x+y)%2 #8fbc7b/#89b675. Outer vignette radial? Canvas createLinearGradient each frame perhaps cache. Fine.
Draw paths? Track under train.
Trees should appear above train if train collides no overlap. Draw scenery before train. Draw station platforms before track so train above.
Boundary: fill rounded? Canvas wrap clips. draw 10px outline #466c4a and small dashed cream fencing? top left.
Board labels perhaps "MEADOW VALLEY RAILWAY" faint at top center using font.
Draw station:
for each stop draw track cell base perhaps no. platform side:
cx, cy. platform center = cy + side*0.62C. rect x=cx-.78C, y=pcy-.27C,w1.56C,h.54C. fill active #f3d99a else #d5cda9. Add border. Draw platform stripes. Label only active with small sign above perhaps text name at cy+side*1.05C. But might be outside at stops near edge? all enough.
Other station draw small sign circle.
Waiting passengers active 3 at positions along platform x ±.35C and y pcy, bob. Should have figures don't appear tiny: cell40 internal; heads r3.5 body h10. Good.
Draw glowing ring on target track cell: ellipse/circle pulse yellow alpha and dashed line. Draw target small down arrow at track perhaps.
Passengers should be "waiting on platform" obvious: platform with 3 figures, roof sign. Sidebar stop.
Could label "NEXT STOP" above active platform with tiny dark translucent bubble. But canvas top-down text can be small. Draw sign at x center+55 perhaps.
Station stop pick triggers group replaced.
Track:
points=current interpolated each frame. Compute prev point for i. If prevSnake[i] missing use tail. function interpCell. Since train moving each tick, t can be close 1. At reset prev=snake so initially static then after first tick interpolation from old to new. Good.
Track ahead uses head current rendered plus dir*C*(0.65?), but at t moving, dir current. Draw from p0 to p0+dir*C*.85 faint. Avoid crossing border no.
Track path draw pairs from tail to head. For each pair (a=points[i], b=points[i+1]), track bed and rails. pair length ~40. Use drawTrackLink. draw bed stroke #756c56 width0.68c; rails #d4d1b9 width2.4 at offset ±.22c. Note rails should be inside bed. Draw bed then rails.
Ties for each point based on orientation: save translate rotate; fill #6c4e35 rectangles at local x perhaps 0 only? A sleeper at each cell plus links. Draw at each point 3 ties x=-.3C,0,.3C across y ±.36C. But cars cover most, only couplings show. Fine.
Track future one cell draw links from p0 to next but perhaps should be underneath status.
Train:
couplers draw between pair using stroke #2b3837 width4 and small circles at midpoint.
Draw from tail down to0. Direction for each:
head: atan2(dir.y,dir.x) perhaps head current direction, good. On turns head angle snaps; could smooth via p0-p1, which reflects previous segment and head move. At right turn, p0 moves new direction while p1 moves old, vector diagonal during interpolation, smooth turns. Use if points length>1 angle=atan2(p[i-1].y-p[i].y,...). For last wagon use points[i-1]-points[i]. This gives orientations toward front. Head same. Good.
Smoke source based on locomotive local (0.23C,0) rotated; store world coords at actual interpolation. Spawn every 110ms using now. ctx global alpha.
Draw locomotive:
shadow ctx fill rgba at offset 0,3
wheels before body: dark rect positions.
base roundRect
front cowcatcher triangle
boiler elongated roundRect at x=-.05? dimensions .58C x .40C at x=-.02C? Front at +.42C. Cabin back x=-.33C.
I'll code:
ctx.rotate(angle)
wheels at x±.22c, y±.32c size .18c,.10c perhaps.
base rounded(-.46,-.29,.92,.58, .12) fill #17343c
cowcatcher path (.49,0),(.34,-.30),(.34,.30) fill #f0b84c
boiler rounded(-.13,-.22,.52,.44,.18) fill #e45c47? Engine red boiler and teal cabin? use boiler #df5b48 with stripes.
front circle at .35,0 r.18 fill #f37a58, inner headlight .43,0 r.065 #fff0a5.
cabin rounded(-.40,-.25,.31,.5,.07) fill #196b68; roof #f4bf52 perhaps.
window at -.30,-.17,.13,.34 fill #bce4dd.
chimney at .17,0 dark circle? Top-down chimney draw rect x.12,y-.11,w.17,h.22 fill #263a3b and rim. Smoke spawns x.25.
brass dome at -.02,0 small circle gold.
Wagons:
wheels; base; body color; inner roof. Add outline.
Two passenger heads only if wagon.full perhaps every wagon has one/two. Initial full true for all? passengers stat 0 then initial aboard contradictory if wagon passengers. Could initial wagons empty with seats visible, after pickup new wagon full. But screenshot colorful empty wagons still fine. For each wagon store occupied boolean. Start 4 empty? Train picking up one group adds one new occupied. Existing empty remain, weird but acceptable as snake. Could start 2 wagons empty and initial stats passengers0. Train visibly locomotive +3 wagons. New wagon contains 2 figures. Perhaps each wagon corresponds group collected. Start train 4 with last three occupied? Score could begin 3 passengers then. Better initialize passengers=3 and score=300 to reflect but game starts score0 expected not necessary. Start length=4 (loco+3 wagons), passengers=0, wagons have little seats but no people. After pickup added new wagon with passengers. Fine.
Rendering wagon passengers if car.occupied. For initial perhaps first car occupied? no.
Should have each pickup growth +1 so at first, new wagon at tail occupied while pickup at head conceptually but fine.
Wagon array mapping index snake segment-1. On reset wagonData initial 4 objects occupied false. On pick push {color, occupied:true, skin...}.
When train moves no relation issues.
Sidebar manifest update all dots.
Particles:
confetti positions in canvas logical pixels, velocities per second. updateFX(dt) always:
for p, p.x+=vx*dt/1000 etc life-=dt.
smoke update.
floating texts y -=20*dt sec.
Render confetti after train perhaps.
Update smoke spawn uses render points each frame and now; even gameOver? if not gameOver and perhaps always moving. spawn if now-lastSmoke>110 and points.
Could produce at first frame lastSmoke=0 and now large >110, okay.
Particles limit length 40.
On passenger pickup add confetti at target center colors. Perhaps whistle animation banner? Floating +100 / ALL ABOARD!.
Floating HTML toast near top center stage "All aboard! +100" perhaps use canvas float. Good.
Update UI every pickup/reset not every frame.
score text padStart(3,'0'), passenger text etc.
Sidebar huge score perhaps label "ROUTE SCORE", passengers count small.
DOM:
scoreValue, passengerCount, trainLength, bestValue, nextStop, wagonDots.
best session best = Math.max(best,score) at pickup/gameover.
Speed level perhaps "1.0×". update.
Status text and dot.
Header route chip "MEADOW VALLEY • ROUTE 01". Button sound? Omit to reduce.
Sidebar sections:
<div class=score-block><span>ROUTE SCORE</span><strong id=score>000</strong><div><span id=passengers>0 passengers</span><span id=best>Best 0</span></div></div>
<div class=stat-grid><div><b id=wagonCount>4</b><span>wagons</span></div><div><b id=pace>1.0×</b><span>pace</span></div></div>
next stop card...
manifest row.
instructions perhaps footer.
button restart.
At mobile stats side layout hide manifest and howto.
Should avoid emoji perhaps use Unicode arrow fine. Logo inline SVG train front. Could use small CSS track icon.
Visual details:
page background decorative radial gradients, subtle grid via background-image linear gradients allowed.
body color #f5f2df.
Header .brand h1 perhaps "Little Line" and badge "SNAKE EXPRESS". User expects train snake. Title "Little Line Express". tagline "A snake game on the scenic railway". Could be catchy.
Title "PUFFERBELLY" big uppercase, subtitle "THE PASSENGER LINE". Use system sans, letter spacing.
Logo badge warm yellow with dark train SVG.
Route chip with green dot "Meadow Valley · Demo running".
No huge text taking space.
Stage overlay top bar:
.status-pill top14 left14 with dot.
.weather-pill top14 right14 "☀ Sunny Meadow" perhaps unicode sun supported. Could use circle icon CSS.
StartHint bottom16 with glass. Add small pulsing yellow border. Hint shouldn't cover train if around y9 and bottom y18 so fine.
At desktop sidebar.
Draw mini compass? no.
D-pad:
<div class=touch-controls aria-label...>
<button data-dir=up aria-label...>↑</button>
<div><button left>←</button><button down>↓</button><button right>→</button></div> perhaps diamond grid 3x2? CSS grid 3 columns two rows: up grid col2, left row2 col1 down row2 col2 right row2 col3. Height88. At mobile under canvas. It will make stage overall340 high. Fine.
Show only (pointer:coarse), max-width:800? For testing 1280 no. Use @media(max-width:800px){.touch-controls{display:grid}} even desktop resized can click.
Could always display compact "steer" buttons beneath canvas? Stage height would exceed. Hide desktop.
Interaction global stage click: StartHint pointer-events none, stage listener click if target canvas or wrap. Since dpad inside stage, exclude if closest('button'). Use if(e.target.closest('button')) return.
Keyboard begin if demo/gameOver. Space start no steering. P not pause.
R restart if player. Escape no.
User can press arrow and immediate queue. beginPlayer resets and then queue. Direction from initial right; left is reverse invalid. Good.
When clicking to start at arbitrary time, reset and initial target, fine. Could perhaps takeover current demo rather than reset to avoid jarring. Requirements simple. Reset expected.
Game over if demo: show overlay perhaps auto resets. StartHint text should change. In demo game over set startHint strong "Autopilot is turning around…" but within 1 sec. In human "Route ended · Click to restart".
I need functions setHint(main,sub,visible). On start hide. On reset demo show.
GameOver separate? Use same startHint bottom and a center gameover card perhaps not necessary. Could show startHint at center? At collision message bottom okay. Add overlay .crash-card hidden? Better simple central card:
<div id=gameOverCard class=game-over hidden><span>END OF THE LINE</span><strong id=finalScore>0</strong><small>Click or press a key to restart</small></div>
But if demo, flashes may obscure screenshot if crash within 3 sec unlikely. No need.
I can use hint at bottom and canvas draw collision flash. Human gets score in sidebar.
Make sure card hidden attribute CSS? [hidden]{display:none!important}.
Game update details:
function resetGame(asDemo=true) {
demo=asDemo; gameOver=false; snake =...
prevSnake = clone
direction={x:1,y:0}; inputQueue=[];
passengers=0; score=0; wagonData=...; activeStop=0; passenger={...stops[0]}; tickInterval...
particles=[], floaters=[] perhaps don't clear smoke? clear all.
updateUI; set overlays.
}
Frame autoReset uses now.
When switching from demo to user best retained.
For keypress during demo start, global listener calls beginPlayer(); if directional then enqueue. If R perhaps reset.
Direction queue:
function queueDirection(nd) {
const last = inputQueue.length ? inputQueue[inputQueue.length-1] : direction;
if(nd.x===-last.x&&nd.y===-last.y) return;
if(nd.x===last.x&&nd.y===last.y) return;
if(inputQueue.length<2) inputQueue.push(nd);
}
Update if !demo and inputQueue length direction=inputQueue.shift(); if demo direction=chooseAI.
If queue invalid relative current checked at enqueue but multiple queue may allow reversal of actual? Eg moving right, queue up then left is valid relative up; after update up, next left valid. Fine.
Collision body:
const bodyToCheck=snake.slice(0,-1) if not growing? Can't know if next passenger. If moving into tail cell when not eating should be valid. Determine eating = next equals passenger. const collisionBody = eating ? snake : snake.slice(0,-1). If next tail and eating impossible passenger perhaps no. includes head? next can't current head unless zero. Fine.
Bounds and blocked.
When gameover prevSnake set before move, snake unchanged. t interpolation will run from prev to same (snake same) but prevSnake identical copy so static.
When collision demo schedule reset. set gameOver true.
When passenger:
snake.unshift(next) no pop, prevSnake length old; new last maps old tail.
wagonData.push...
activeStop index chosen.
score, passengers, best.
spawnConfetti(next)
floater at next x,y-20.
Perhaps smoke extra.
Tick interval dynamic getInterval() Math.max(82,150-passengers*3); in frame if interval changes acc interpolation okay.
Use while acc >= interval max3 updates to avoid spiral. Since dt capped and tab returns no huge.
BFS:
dirs = [{1,0},{0,1},{-1,0},{0,-1}]
function aiDirection(){
const pathFirst=findPathFirst(snake[0], passenger)
const legal=dirs.filter not reverse and valid next)
if pathFirst and legal includes candidate, return
if legal empty return direction (collision)
score candidates by floodFill(next)+ -Manhattan*0.2 + (straight bonus)
return highest
}
findPathFirst BFS:
blocked = new Set(sceneryBlocked); for i=0;i<snake.length-1;i++ blocked.add(key(snake[i])); blocked.delete(key(start));
queue [start], parent Map with start null, while...
When found target reconstruct: cur=target; prev=parent.get(key(cur)); while prev && prev!==key(start) {cur=parse...;prev=...}; first=cur. Return delta.
Simpler store first in queue nodes {x,y,first}. BFS start first=null. For each n, nf = node.first||d. If n target return nf. visited. Works.
Sort directions by distance from start+d to target to speed. Fine.
No reverse candidate path automatically perhaps BFS could back into neck? blocked contains snake except tail so neck blocked. Good.
Flood fill includes snake body positions all except tail perhaps, next head. Use queue and visited. Bound max500.
Safe validation uses scenery blocked.
I can avoid path choosing instant death due to tail? BFS no collisions.
Demo target selection ensure activeStop reachable. Any stop likely. Choose random among stops not current and not on snake. BFS could unreachable due body, fallback; next pickups continue.
Stations and obstacles conflict central:
initial target central {12,8}; platform side -1 at y7.38. Obstacles at10,5 no issue.
Initial path from7,9 to12,8, AI shortest. First pickup after6 ticks ~900ms. At 3 sec perhaps 2 pickups.
Each pickup grows tail, train looks good.
Passenger count semantics each stop group perhaps "+3 passengers" but score increments1. Better each stop one passenger icon group symbolic. HUD says "passengers" increments by1; figures 3 could represent waiting group, add 3 per pickup and one wagon. Score +150 perhaps. User just pick passengers no strict. Use passengerCount +=3 and score+=100? Then train grows 1 per group. Label "3 passengers" after first. But classic score usually score. Simplicity +1 passenger, draw 1-3 figures. Draw 2 figures active, one pickup counts1? Could draw three but count "stops served" separate. Use score passengers+=3, display "3 passengers", next station group3. Each wagon can hold 3. Nice. Score+=150 (50 each) perhaps big. But display "Passengers 3". Better passengers +=2 active two figures. Initial waiting draw3? Set3. "All aboard! +3" and +150. HUD score perhaps "000". Fine. On first pickup 3 people. Wagon has 3 heads. Nice.
Perhaps score = passengers*50, update. speed per pickups separate stopsServed variable. Use stopsServed for pace. wagonData each occupied holds3.
Stats passengers count, score.
Initial score 0, best0.
Floating "+150 ALL ABOARD!".
Speed Math.max(84,152-stopsServed*3).
Sidebar stat train "5 cars" includes locomotive? label "wagons" wagonData.length.
Next stop info "3 waiting".
Manifest dots number perhaps max8 with +n.
Platform people visual:
drawPerson(x,y,shirt,scale,phase):
ctx.save translate(x,y+bob)
shadow ellipse at 0,4
body roundRect(-4,-2,8,10) shirt
head circle(0,-5,4) skin
hair arc perhaps
restore.
Top-downish front-facing icons on platform while train top-down. Acceptable illustrative.
Suitcase small rect next to one.
Each active platform draw3 persons x -0.35C,0,+.35C perhaps with platform width1.6C.
Platform active pulse ring around whole platform.
Draw little station sign text with dark board and cream text. For all stops, label perhaps only active to avoid clutter; inactive has sign board with stop initials.
Target marker track draw dashed yellow circle and arrow.
Make sure draw station labels doesn't block train but okay.
Scenery trees:
drawTree(cx,cy,variant) shadow ellipse, canopy multiple circles #2f6c4d,#4e8b57,#70a85d, trunk. Top-down no collision question signaled by full trees.
Pond blocked 2x2 draw organic shape around cells 18,1 etc with blue #68aeb4, stroke sand, lily pads. But at top rows 1,2 near Daisy17,4 safe. Cells pond = [{18,1},{19,1},{18,2},{19,2}]. Draw rounded rect x17.6C? precise bounding around cells: minx18*C+3,miny1*C+3,width2C-6,height2C-6 with rounded 22, add wavy shapes. It occupies those cells. Add reed.
Other tree at15,1 near pond no overlap (cell15).
Fence boundary: small posts around perimeter perhaps first paint polished. But train can move through cell0 until boundary at -1, fence drawn on outer canvas edge 4px. Draw rounded rect inset5 stroke #e7d6a4 width3, then inset10 dark green width2; dashed perhaps.
Should have clipping round canvas handled CSS.
Canvas text font system.
Render station before scenery? platform should be ground before trees. order background -> decorative flowers -> stations -> track -> scenery? If track goes through trees impossible due collision. For platforms and track overlap, track after platform. Scenery after track may cover if adjacent but no same cells. people after track perhaps before train. Fine:
background
stations base (including people)
track
scenery (trees could overhang train adjacent giving depth)
smoke
train
particles
border
Perhaps people should be above train if adjacent, okay draw stations before train so train can cover if overlapping on pick. Fine.
Future track should not appear through obstacle; draw only if next cell valid. Use direction current, head next cell based actual snake head not interp. if not blocked/out and not body? draw link p0 to p0+dir*C*.9 alpha0.55. This telegraphs route.
AI demo perhaps direction updates. Good.
Track link rails offset:
function strokeParallel(a,b,offset,color,width) {
dx=b.x-a.x etc len; nx=-dy/len,ny=dx/len
begin move a+nx*offset...
}
Track bed draws line width C*.72. Sleepers at points all.
If link len tiny (initial points? unique yes) skip.
Ties also ahead at future midpoint? optional.
Smoke: world coords logical. When spawn from current engine transform, particle velocity = {-dir.x*5 + random, -12 - random} but y up independent of direction looks steam drifting north. Good. life 900ms. Draw circles with maxLife. White alpha 0.35*(life/max). Some smoke rings.
At game starts, spawn few immediate puffs? First render lastSmoke 0 spawns1, at 3sec 20. Good. Could initialize smoke with several around head to show but train begins moving soon. Fine.
Performance 60fps, drawing <100 shapes, background checker432 fillrect each frame + flowers, okay.
UI wagonDots dynamic innerHTML safe values only generated. Could avoid repeated HTML from untrusted no issue.
`dots.innerHTML=wagonData.slice(0,7).map(...).join('')` style background colors. Fine.
ARIA live for announcements perhaps score not.
I should include sound? no.
Could add subtle animated station clock in sidebar CSS no.
Make sure use no forbidden storage etc. No network. Fine.
Potential issue keyboard event any key starts including arrows and then page scroll prevented only recognized. On unrecognized key, start no prevent. okay.
Click `restartBtn` while demo calls beginPlayer? event listener resetGame(false), hide; stage not ancestor sidebar, no stage click. Good.
Start hint exact text.
Gamepad no.
Game over handling:
function endGame() {
gameOver=true; best=Math.max...
updateUI
if(demo) {autoResetAt=performance.now()+1100; showHint("Autopilot is reversing…","Click or press a key to take over",true);}
else showHint("End of the line — click to restart",`${passengers} passengers · Score ${score}`,true);
status "ROUTE ENDED" red.
spawnConfetti at head gray? perhaps.
}
Stage click if gameOver beginPlayer resets.
Global key if gameOver beginPlayer and direction.
Auto reset in frame before acc? If demo&&gameOver&&now>autoResetAt resetGame(true).
Demo no user best overwritten? Best updates in demo so "Best" includes autopilot. Fine perhaps label best route.
When beginPlayer demo resets score but best remains demo's perhaps okay.
Status on initial "AUTOPILOT · DEMO". Header route perhaps.
Use `document.getElementById`.
DOM loaded script at end.
Game state updateUI score animation:
element.classList.remove('bump'); void element.offsetWidth; classList.add('bump') on pickup perhaps if UI update reset also animation not bad. Implement `bump(scoreValue)` only in pickup.
No errors from `e.target.closest` if target Text? Event target Element always for click likely; use e.target instanceof Element.
Pointer:
let pointerStart=null;
canvas.addEventListener('pointerdown', e=>{pointerStart={x:e.clientX,y:e.clientY}; canvas.setPointerCapture?.(e.pointerId);});
canvas.addEventListener('pointerup', e=>{
if(!pointerStart)return; dx...; if dist>24 {swiped=true; if(demo||gameOver) beginPlayer(); queueDirection(...);} pointerStart=null;
});
stage click listener fires after pointerup and if swiped `ignoreClick=true; setTimeout...` but beginPlayer already. If click after swipe then beginPlayer sees demo false, gameOver false no action. Stage click handler always `if(demo||gameOver) beginPlayer()`, so no reset second. fine.
For simple click pointerup no swipe then click begin.
On desktop clicking stage start.
Direction controls pointerdown preventDefault to avoid click and call. Stage handler excludes button.
CSS touch-action:none on canvas and buttons to prevent scrolling while swiping. If touch-action none canvas vertical scrolling on game disabled expected.
Mobile viewport vertical can scroll outside.
No horizontal scrollbar: body overflow-x:hidden; grid minmax 0; sidebar min-width0; button etc.
Detailed CSS layout. I'll compose.
Canvas accessible fallback text perhaps.
HTML structure:
<div class="app">
<header>...
<main class=game-layout>
<section class=stage-card id=stage>
<div class=canvas-wrap>
<canvas...></canvas>
<div class=stage-top> etc
<div id=startHint ...>
</div>
<div class=touch-controls>...
</section>
<aside class=sidebar>...
</aside>
</main>
<footer>...
</div>
No external SVG besides logo and mouse icon inline.
Logo SVG 38x38 top-down train front: path etc.
Header .eyebrow perhaps h1. Title "Little Line" and small pill "SNAKE EXPRESS".
Route right chip with status. Header may be flex.
At 375 title 20 and logo40, route hide. header height50.
Score panel text sizes.
Side button shows restart icon via inline svg? Unicode ↻ okay.
Instruction keys spans.
Perhaps "Best" no storage so initial 0.
Manifest color dots perhaps tiny rectangles with couplers, CSS `.mini-wagon` width18 height12 border radius3 position relative; :after coupler`.
Next stop card has platform icon CSS with small circle people.
Use station symbol inline SVG.
Canvas top-right pill .weather could hide mobile to make space. top status black translucent.
Footer text `Arrow keys / WASD · Swipe to steer · Reach the platform, don't hit your train!`
Desktop start hint already explains.
Perhaps track snake collision if trees on grid. Sidebar howto bullets:
1 Collect waiting passengers
2 Add a wagon at every stop
3 Avoid trees, ponds & your own train
Fits.
Side panel height630 matching stage; with content perhaps 620. Should calculate sidebar:
padding18*2=36
score label14 + number58 + row18 + margins=105
stats 62 + margin12
next card80 + margin14
manifest50+margin
howto105
restart42
sum36+105+62+12+80+14+50+14+105+42 gaps perhaps620. Good.
Set display flex column gap14, score 100, stats60, next74, manifest50, howto flex, button42 total ~36+100+60+74+50+100+42+6*12=534. Good.
Stage 650.
Sidebar align-stretch.
At desktop .sidebar min-height0.
Colors cream card provides contrast.
Canvas initial scene perhaps title on field not needed.
I'll reason AI and rendering interpolation with track paths at growth:
At update prevSnake assigned snake.map before unshift. Suppose old snake len5, after not ate new len5. new snake[i]=old snake[i-1] for i≥1. prevSnake[i]=old snake[i]. So each car moves from old cell i to old cell i-1. Perfect. Head old0 to next. At ate new len6, new last index5 = old4 (old tail); prevSnake[5] missing fallback prevSnake[4]=old4, so new wagon appears at old tail stationary while old tail index4 moves old4->old3. Good.
WagonData after ate push, mapping new last. Good.
When choose passenger activeStop at next head and platform people disappear immediately at tick start while train interpolates approaching target; at render passenger already changed before train visually arrives, slightly early but standard. Could delay until t>0.6 no need. Confetti at target appears while train approaches. Fine.
UI next stop changes just before visual arrival, okay.
Could render using prevActiveStop during interpolation? not needed.
Passenger target ring moved early. Acceptable.
Smoke source each render points available computed. `getInterpolatedPoints(alpha)` invoked once per render and used.
Current `direction` for head future. Head angle using p0-p1 can at initial right good. At first tick direction up but p0 old→new, p1 old1→old0, p0-p1 average may rotate diagonal; nice.
Render wheels etc.
Track bed direction pairs `p[i]` to p[i+1]`, at curves diagonal temporary links may make smooth curve albeit track body. Fine.
Draw order for train tail-to-head. Smoke before train.
Particle confetti after.
Floating text draw with black stroke and cream fill.
Vignette boundary last.
Canvas station active ring pulse now/300. Use ctx.setLineDash reset.
Avoid canvas `ctx.roundRect` use custom.
Color utility lighten wagon perhaps set palette objects {body,light}. Wagon colors = [
{body:'#ef6a5b',light:'#ff9b79'},
{body:'#e9ad3c',light:'#ffd772'},
{body:'#468dc4',light:'#82c6df'},
{body:'#8d6ec9',light:'#bea6e8'},
{body:'#42a77b',light:'#80d2a0'},
{body:'#e97eaa',light:'#ffb5cb'}
].
Initial wagonData uses palette0..3.
UI dots body.
Passengers shirts varied.
Station people active color arrays.
Deterministic random decorations:
function mulberry32 seed. generate 70 flower positions with x=C*(1+rand*22) etc perhaps avoid outer. Use flowers tiny.
Tree positions perhaps `blocked` includes pond. Also define reeds.
No random target each reset same? Use Math.random fine. Initial fixed.
Demo randomness visually dynamic.
Status pill update text via `statusText`. status dot class player. CSS.
Start hint hide with class .is-hidden opacity0 transform but still in DOM pointer-events none. `.start-hint{opacity:1}` `.start-hint.is-hidden{opacity:0; transform:translate(-50%,10px);}`. On reset show. If gameover message perhaps display.
Could still be visible 3 seconds yes.
Sidebar "AUTO DEMO" label perhaps update nextStop.
Speed display pace = (150/getInterval()).toFixed(1)+"×" could show1.0. Good.
Restart button event `resetGame(false)` with hint hidden status player. If clicked before start user wants play not demo. Fine.
Header top small "SNAKE ON RAILS" in brand perhaps.
Footer version no.
Use hidden mobile HUD? Not needed because sidebar below. But on mobile screenshot initial at 800 height can show header50 + stage(canvas251+controls94+padding) ~360 + sidebar perhaps350 =760, score visible below stage. Good. At375x800 yes. Layout app top10 header50+10 stage (canvas (355-12=343)*.75=257 +12 + controls82=351) =>421; sidebar perhaps compact180 =>620. Fine. Set mobile sidebar grid with score + next stop etc not too tall:
@media max800:
.sidebar {display:grid;grid-template-columns:1fr 1fr;padding:12px;gap:10px}
.score-block padding? score-block no background separate; score strong40.
.stat-grid perhaps display none? manifest/howto hide.
.next-stop margin0
.restart grid-column:span2
Then 160 height. Great.
At desktop score-block etc.
At <=800 .score-block border-bottom none perhaps; scoreboard. .stat-grid hidden? Could keep inside score. passengers visible.
`.manifest,.how-to{display:none}`.
`.restart-btn{grid-column:1/-1}`.
Next stop card height same.
Mobile layout screenshot good.
At desktop sidebar all.
Touch controls only max800. 375 controls display grid width150 height76. Stage startHint bottom of canvas wrap, separate controls below. Fine.
Should have initial canvas might render in aspect ratio even no explicit HTML width? set canvas width 960 height720 attributes then JS overrides dpr, CSS width100%;height:auto; .canvas-wrap aspect-ratio. If canvas height 100% ensures. Set canvas{display:block;width:100%;height:100%;}. Wrap aspect-ratio 4/3. Works.
Canvas backing internal logical aspect4/3.
Stage-card contains canvas wrap and controls display none, so height good.
No text before after code. Respond complete.
I can enhance with score mini stars, etc but okay.
I'll detail functions to avoid errors:
`function rr(x,y,w,h,r)` uses ctx and global; sometimes want context path. define:
function pathRoundRect(x,y,w,h,r){const radius=Math.min(r,w/2,h/2);ctx.beginPath();ctx.moveTo...;ctx.closePath();}
function fillRound(...){path...;ctx.fill();}
No need ctx parameter global.
Drawing transforms and path helpers use current transform fine.
drawPerson uses fillRound with negative dims? w/h positive yes.
drawCar uses fillRound.
Scenery pond:
round rect bounding 18*C+2 etc. active flowers might underneath.
DrawTree circles after translation. fill circle helper.
Flower draw absolute positions.
`drawStations(now)` activeStop index object; stops can be current. for each platform:
px=(s.x+.5)*C etc. `side=s.side`.
active ring `ctx.strokeStyle`.
Platform label sign at x=cx+0.55C? text might fit. Better small board centered at cx, platform center y+side*.17C? People overlap. Put sign at cx-0.65C, pcy-side? Actually draw small sign to right of platform x=cx+.65C,y=pcy-12. Text only active above track at cy-side? Simpler active label in dark bubble at cx, cy-side*.65C (opposite platform) with "NEXT · CENTRAL MEADOW". But this lies opposite track and not people. At Central side -1 platform above, label below track perhaps could overlap train path at y8.65. Avoid. Could put above platform y=pcy+side*.35C (further out). e.g side-1, label y=cy-0.97C. Fine. width160, may fit x12. For edge stop x20, label width150 center20.5*40=820, to895 within960. x4 center180 ±75 fits.
Other stops label sign just small rounded roof and post, no text.
Passengers at active platform x offsets and y=pcy + (side>0?-2:2) no issue.
Track ring at target cell drawn separately after platform.
Draw trees after stations may obscure labels if nearby but okay.
Board station target track is visible ring even before track; track drawn over ring. Draw ring after track perhaps target indicator function separate. Could in drawTrack call ring before bed. I'll draw station ring in drawStations after platform before track, still edges visible.
Particles update: use dt seconds. Frame calls updateFX(dt) before render. smoke spawn in render changes array but okay. Remove via filter creates arrays each frame manageable. `smoke = smoke.filter(p=>p.life>0)`.
Confetti has gravity.
Floaters update.
When reset floats empty. Fine.
`lastSmoke=0`, reset but performance now large.
At game over, updateFX continues smoke dissipates and no spawn condition `if(!gameOver && now-lastSmoke>...)`.
Should have points for spawn computed even gameover.
FX dt from frame when first dt perhaps0.
Score update UI called on reset before elements perhaps defined yes.
`nextStopName.textContent=passenger.name`.
`nextStopCount` perhaps static "3 waiting".
wagonDots with title. For more than8 add span.
`trainLength.textContent=snake.length` perhaps sidebar wants "cars" includes loco. Use `wagonCount`.
`paceValue`.
`bestValue`.
`document.body.dataset` not needed.
Status class.
Global key names lower:
const k=e.key.toLowerCase(); map arrowup/w etc.
if directional {e.preventDefault(); if(demo||gameOver) beginPlayer(); queueDirection(dir);}
else if(k==='r'){e.preventDefault();resetGame(false);}
else if(!demo? perhaps space no action) if(demo||gameOver){e.preventDefault(); beginPlayer();}
"press a key" includes shift perhaps okay. Avoid keyboard auto-repeat causing queue weird; if e.repeat and !directional no. fine.
If human presses non-direction after playing no action.
D-pad data-dir values.
`Object` map.
Touch controls buttons click after pointerdown might stage excluded, button own click no additional. Use pointerdown only and e.preventDefault. Browser click still perhaps but no listener on buttons except pointerdown, restart separate.
Set `aria-label`.
Autoplay AI does not use input queue.
If human starts click direction right. Good.
`endGame` called if collision. Set prevSnake copy before collision so no movement.
Track collision own train message. Good.
Best update demo every pickup; side best. Fine.
Perhaps implement collision grace at start? no.
Station target selection after pickup could choose stop adjacent current, next update quickly. choose far enough:
candidates filter !same and not body and Manhattan>5 if possible; if none any. Random. This makes pacing.
Initial target index0.
At random perhaps unreachable; AI handles.
No need spawn on blocked (stops not blocked).
At length nearly full no stops free then reset win? improbable. chooseNext if none endGame.
Flood fill fallback:
`isCellOpen(p, ignoreTail=true)` bounds !blocked and !snake.slice(0,-1).some.
In AI legal excludes reverse and check.
PathFirst could return direction reverse? neck blocked so no.
If path first candidate legal choose directly.
Fallback score flood fill. Compute blockedSet including snake except tail and obstacle; flood from next requires blocked not contains next. Legal ensures.
Use `floodFill(next)` building blocked all body except tail; start next allowed even if next tail? legal.
Take straight +5.
No errors.
Train station pickup at next which might be body? choose not body. good.
Game speed dynamic and frame:
let accumulator=0;
function frame(now) {
const dt=Math.min(40,now-lastFrame||16); lastFrame=now;
if(autoReset...)...
if(!gameOver){
accumulator+=dt;
let guard=0;
while(accumulator>=getTickInterval() && guard<3){updateGame();accumulator-=getTickInterval();guard++; if(gameOver){accumulator=0;break;}}
}
updateFX(dt);
render(now, gameOver?1:Math.min(1,accumulator/getTickInterval()));
requestAnimationFrame(frame)
}
At reset within frame autoReset accumulator=0.
Potential interval changes inside while subtraction uses new interval instead of old slight, okay. Better const interval before each.
At initial lastFrame=performance.now; request. Good.
At gameOver accumulator=0 and alpha1 would render train at current cells not interpolated (snap to endpoint on collision). If collision occurs at tick boundary previous render alpha~.99, no issue. Use gameOver?1.
Demo reset and hint.
Update smoke using alpha. spawn if moving. On initial frame alpha0 and points initial. Smoke at chimney.
`render` set ctx.setTransform(DPR...) perhaps resizing not. `ctx.clearRect(0,0,W,H)` under scaled transform clears logical only results full backing yes because transform scales clear. Fine. Fill.
Should line dash resets after target ring. Use ctx.save/restore.
Add time-based flower sway not needed.
Use `Math.sin(now/250 + ...)`.
Header pill green dot CSS animation pulse, respects no motion? no requirement.
Could include `@media(prefers-reduced-motion)` but canvas still moves needed game. no.
Potential stage card click handler on canvas; when user clicks restart not stage. good.
At game over clicking anywhere not just stage? requirement "click or key" overlay within stage so okay.
Perhaps user click sidebar not starts no issue.
First screenshot at3s startHint visible and train action. Good.
I'll now formulate full code in the response. Should have exact one fenced block. Ensure code not too huge but polished perhaps ~1000 lines. I can produce ~700 lines. I have lots token budget. Should inspect for mistakes.
I'll write HTML.
CSS desktop height:
.app max1140.
header `.topbar{margin-bottom:14px; min-height:54px}`
main.
stage pad8? inner stage width864-16=848, canvas height636; stage total652. Header68 + margins top12 =? total 12+54+14+652+footer28+12=772. Great.
sidebar width260, outer.
Canvas 848 width vs internal960 scaling. fine.
At viewport1280x800 fits.
At 1140 max exactly body width calc; good.
Footer 22.
StartHint width auto max calc, white-space perhaps strong no wrap desktop. mobile width calc and text left.
`.key-cap` hidden at small.
Score strong54 line-height1.
All fonts system.
Brand SVG icon perhaps track.
Use no images external.
Footer avoid overlap.
Canvas drawing station people front-facing on green field.
Wagon passenger heads if occupied:
for j -1,0,1 at x=j*.15C, draw head at local y0? car orientation +x towards front, seats arranged along length x. Draw small body circle etc. All inside.
Open car inner .62 length and .4 width, three people x positions -.17,0,.17. Head r.055C=2.2, visible. body r.075=3. Good.
Empty car seats little #f4dcaa rectangles.
Locomotive more visually train. Should chimney smoke.
Track tie orientation using angle from forward direction. `drawSleeper(p,angle,alpha)` translate rotate fillRect(-.06C,-.39C,.12C,.78C) at three positions? If at each point draw one tie and links rails, spacing 40 enough. Use three per car might track dense. At p draw tie centered and ±.28C. Fine. Track behind wagons hidden. Future track ahead tie at midpoint.
`drawTrackLink(a,b,alpha)` includes bed only + rails, no sleepers; separate ties.
If train turns, rails cross weird but train covers.
Draw border after all means if train at outer cell partially near edge no cut. okay.
One issue if background canvas size uses DPR and ctx operations `ctx.setTransform(DPR,...)` then canvas width attr triggers CSS intrinsic ratio 1920/1440 still4/3. okay.
Input click start and focus no need.
No alerts etc.
I'll ensure icons HTML `&` escape fine.
I can put CSS `font-variant-numeric: tabular-nums`.
Status class `.status-pill.player .status-dot` etc.
On game over `.status-pill.ended`.
Function updateStatus uses className? `statusPill.classList.toggle('player',!demo)` etc and ended. Text.
Status dot initial.
When reset demo `startHint.classList.remove('is-hidden')`, strong exact. human add.
`hintTitle`, `hintSub`.
End game auto demo still startHint says click to start perhaps okay.
Restart human hides.
`beginPlayer`:
if(!demo&&!gameOver)return;
resetGame(false);
No need separate.
If gameOver demo and stage clicked resets human, correct.
Reset keeps `best`.
`resetGame(asDemo)` sets activeStop=0 passenger={...stops[0]}; if `floater` etc.
When reset after gameover auto, overlay initial. good.
On score bump CSS keyframe. `scoreValue.classList.remove('bump'); void scoreValue.offsetWidth; scoreValue.classList.add('bump')`. Allowed.
Could use void expression lint okay.
`chooseNextStop` if candidate empty no target? fallback all. `passenger` global object.
At pickup after setting passenger update UI uses new next stop.
Spawn confetti before passenger changed using picked coords.
Update best.
StopsServed++.
Pace uses stopsServed.
Draw active station from `passenger` not stops[activeStop] to be safe. Compare `stops[index]===passenger` fails because passenger clone. Use activeStop index. choose sets activeStop. Good.
In drawStations active i===activeStop.
Reset passenger object perhaps direct stops[0], no mutations. fine.
When pick nextStop choose activeStop.
Floating texts coordinates from next pixels, if near top y 5 etc enough.
Confetti helper x,y center.
Smoke random dx uses Math.random.
Colors confetti `wagonColors.map(c=>c.body).concat`.
At game over no smoke spawn, train still.
I can draw tiny track mileage sign top left perhaps status pill enough.
Scenery obstacles `blockedKeys = new Set([...treeCells,...pondCells].map(...))` key function declared before? function declarations hoisted. Tree arrays const. Good.
Decorative flower generator uses `isBlocked`? no.
BFS `key(p)` accepts {x,y}; `keyXY`. use consistent.
`validNext(p)` checks scenery and body except tail; if next is passenger and tail? not relevant.
For human collision includes eating logic. Fine.
One subtle bug queueDirection on initial start ArrowLeft ignored (opposite right), user may expect turn left impossible classic snake. okay.
Up/down work.
Swiping direction with dx/dy.
No explicit pause when window hidden but rAF stops and dt cap avoids huge. fine.
I'll consider if head gets to active passenger cell but station people stand offset platform, not exactly on track, visual train arrives next to people. Good.
Instructions sidebar "Stop beside the waiting passengers" but collision target track. "Collect passengers at each platform" good.
Potential user sees demo score, then clicking resets as expected.
Perhaps user asked only game, no explanation needed. Code only.
I'll prepare the response now with all code.