Models 44
Train snakeOne-line

MiMo-V2.6-Pro

by Xiaomireasoning defaultClean
Open in new window

Running live in a sandboxed frame. Click inside it before using the keyboard.

How it went

Total time
5 min 44 s
Cost
$0.027
Output tokens
34,762
Spent thinking
18,227 tokens
Speed
101.2 tok/s
Console errors
0
First token after
796 ms
First visible text after
4 min 17 s
Prompt tokens
366
Code size
52.1 KB
Finish reason
stop
Served by
DeepInfra
Model id
xiaomi/mimo-v2.6-pro
Reasoning sent
model default
Max output
131,072 tokens
Generated
Nov 13, 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 · 1611 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PLATFORM 7 — Model Railway Dispatch Board</title>
<style>
:root{
  --cream:#EDE3CE;
  --sand:#E2D4B7;
  --board:#241E1B;
  --ballast:#171310;
  --ink:#1B1714;
  --ink-muted:#7A6B58;
  --board-cream:#F3EADA;
  --green:#1F7A4D;
  --red:#C2352B;
  --orange:#FF6A13;
  --brass:#C9A227;
  --display:"Oswald","Arial Narrow",Impact,"Haettenschweiler",sans-serif;
  --mono:"JetBrains Mono",ui-monospace,"SF Mono",Menlo,Consolas,monospace;
  --body:"Inter",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0;height:100%}
body{
  background:var(--cream);
  color:var(--ink);
  font-family:var(--body);
  overflow-x:hidden;
  overflow-y:hidden;
  -webkit-font-smoothing:antialiased;
}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}

/* ---------- CABINET ---------- */
.cabinet{
  height:100vh;
  padding:32px;
  display:flex;
  flex-direction:column;
  position:relative;
  overflow:hidden;
}

/* ---------- CONTACT RAIL (grid break) ---------- */
.contact-rail{
  position:absolute;
  left:0; right:0;
  bottom:78px;
  height:30px;
  z-index:0;
  pointer-events:none;
  opacity:.6;
}
.rail-line{position:absolute;left:0;right:0;height:2px;background:var(--ink)}
.rail-line::after{
  content:"";position:absolute;left:0;right:0;top:-2px;height:6px;
  background-image:radial-gradient(circle at 3px 3px, var(--ink) 2.6px, transparent 3px);
  background-size:46px 6px;
}
.rail-line.a{top:2px}
.rail-line.b{top:13px}
.rail-line.c{top:24px}

/* ---------- HEADER ---------- */
.header-rail{
  flex:0 0 auto;
  position:relative;z-index:2;
  display:flex;align-items:flex-end;justify-content:space-between;
  gap:28px;
  border-bottom:2px solid var(--ink);
  padding-bottom:10px;
  min-height:96px;
}
.brand{display:flex;flex-direction:column;gap:2px;min-width:0}
.wordmark{
  margin:0;
  font-family:var(--display);
  font-weight:700;
  font-size:clamp(42px,5.9vw,88px);
  line-height:.92;
  letter-spacing:.015em;
  text-transform:uppercase;
}
.kicker{
  margin:0;
  font-family:var(--mono);
  font-size:11px;font-weight:500;letter-spacing:.14em;
  text-transform:uppercase;color:var(--ink-muted);
}
.header-mid{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:6px;padding-bottom:4px}
.eyebrow{
  font-family:var(--mono);font-size:11px;font-weight:500;letter-spacing:.14em;
  text-transform:uppercase;color:var(--ink-muted);
}
.msg{
  font-family:var(--mono);font-size:clamp(13px,1.35vw,20px);font-weight:500;
  line-height:1.3;letter-spacing:.02em;text-transform:uppercase;
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
.header-right{display:flex;align-items:flex-end;gap:16px;flex:0 0 auto}
.chip{
  background:var(--board-cream);border:2px solid var(--ink);box-shadow:4px 4px 0 var(--ink);
  padding:6px 12px;display:flex;flex-direction:column;gap:2px;
}
.chip-label{font-family:var(--mono);font-size:11px;letter-spacing:.14em;color:var(--ink-muted);text-transform:uppercase}
.chip-val{font-family:var(--mono);font-size:18px;font-weight:500;letter-spacing:.02em}
.controls{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end}
.btn{
  border:2px solid var(--ink);box-shadow:4px 4px 0 var(--ink);
  background:var(--board-cream);
  padding:8px 12px;
  font-family:var(--mono);font-size:11px;font-weight:500;letter-spacing:.14em;
  text-transform:uppercase;
  transition:transform 80ms linear, box-shadow 80ms linear, background 80ms linear;
}
.btn:hover{background:var(--orange)}
.btn:active{transform:translate(4px,4px);box-shadow:0 0 0 var(--ink)}
.btn.on{background:var(--green);color:var(--board-cream)}

/* ---------- STAGE ---------- */
.stage{
  flex:1 1 auto;min-height:0;
  display:grid;
  grid-template-columns:240px minmax(0,1fr) 220px;
  gap:32px;
  padding:26px 0;
  position:relative;z-index:1;
}

/* ---------- HUD ---------- */
.hud{
  background:var(--board-cream);
  border:2px solid var(--ink);
  padding:14px 16px;
  display:flex;flex-direction:column;gap:12px;
  min-height:0;overflow:hidden;
}
.panel-head{
  font-family:var(--mono);font-size:11px;font-weight:500;letter-spacing:.14em;
  text-transform:uppercase;color:var(--ink-muted);
}
.brow{
  display:flex;align-items:baseline;justify-content:space-between;gap:8px;
  border-bottom:2px solid var(--ink);padding-bottom:5px;
}
.brow .lab{
  font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;
  color:var(--ink);white-space:nowrap;
}
.brow .val{
  font-family:var(--mono);font-size:clamp(17px,1.5vw,22px);font-weight:500;
  letter-spacing:.02em;line-height:1.1;white-space:nowrap;
}
.brow .val.hot{color:var(--orange)}
.legend{display:flex;flex-wrap:wrap;gap:4px;align-content:flex-start;overflow:hidden}
.wchip{
  width:20px;height:14px;border:2px solid var(--ink);position:relative;
  display:flex;align-items:center;justify-content:center;gap:2px;
}
.wchip i{width:3px;height:3px;background:var(--ink);display:block;border-radius:50%}
.keycaps{display:flex;gap:6px;align-items:center;margin-top:auto}
.cap{
  border:2px solid var(--ink);min-width:24px;height:22px;padding:0 5px;
  display:flex;align-items:center;justify-content:center;
  font-family:var(--mono);font-size:10px;letter-spacing:.06em;
}
.keycaps span{
  font-family:var(--mono);font-size:11px;letter-spacing:.12em;
  color:var(--ink-muted);text-transform:uppercase;
}

/* ---------- BOARD ---------- */
.board-stage{
  min-width:0;min-height:0;
  display:flex;align-items:center;justify-content:center;
  position:relative;
}
.board-frame{
  position:relative;
  content-box;
  border:10px solid var(--ink);
  padding:4px;
  background:var(--sand);
  box-shadow:0 8px 0 var(--ballast);
  transform-origin:50% 50%;
  will-change:transform;
}
.board-chassis{
  position:relative;
  background:var(--board);
  box-shadow:inset 0 0 0 2px var(--ballast);
  overflow:hidden;
}
#board{display:block}
.grain{
  position:absolute;inset:0;pointer-events:none;opacity:.04;mix-blend-mode:overlay;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E");
}
.flash{
  position:absolute;inset:0;background:var(--red);opacity:0;pointer-events:none;
}
.flash.go{animation:flash 320ms steps(1,end) forwards}
@keyframes flash{
  0%{opacity:.55}
  18%{opacity:.55}
  19%{opacity:0}
  38%{opacity:.4}
  39%{opacity:0}
  100%{opacity:0}
}
.shake{animation:shake 80ms linear 4}
@keyframes shake{
  0%{transform:translate(3px,-2px)}
  25%{transform:translate(-3px,2px)}
  50%{transform:translate(2px,3px)}
  75%{transform:translate(-2px,-3px)}
  100%{transform:translate(0,0)}
}

/* shed plate */
.shed-plate{
  position:absolute;left:-44px;top:-44px;width:88px;height:88px;z-index:5;
  pointer-events:none;
}

/* overlays */
.start-banner{
  position:absolute;left:50%;bottom:16px;transform:translateX(-50%);
  background:var(--orange);border:2px solid var(--ink);box-shadow:4px 4px 0 var(--ink);
  padding:9px 18px;text-align:center;white-space:nowrap;pointer-events:none;
  transition:opacity 120ms linear;
}
.start-banner .l1{
  font-family:var(--mono);font-size:13px;font-weight:500;letter-spacing:.14em;
  text-transform:uppercase;color:var(--ink);
}
.start-banner .l2{
  font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;
  color:var(--ink);opacity:.72;margin-top:2px;
}
.start-banner.hidden{opacity:0}
.pause-banner{
  position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
  background:var(--board-cream);border:2px solid var(--ink);box-shadow:6px 6px 0 var(--ink);
  padding:12px 22px;display:none;
  font-family:var(--mono);font-size:18px;letter-spacing:.24em;text-transform:uppercase;
}
.pause-banner.show{display:block}
.gameover{
  position:absolute;inset:0;background:rgba(23,19,16,.9);
  display:none;flex-direction:column;align-items:center;justify-content:center;gap:14px;
  padding:16px;text-align:center;
}
.gameover.show{display:flex}
.go-title{
  font-family:var(--display);font-weight:700;
  font-size:clamp(34px,6vw,72px);line-height:.92;letter-spacing:.02em;
  color:var(--red);text-transform:uppercase;
}
.go-lines{font-family:var(--mono);font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:var(--board-cream);line-height:1.9}
.btn-slab{
  background:var(--orange);border:2px solid var(--ink);box-shadow:4px 4px 0 var(--ink);
  padding:12px 26px;font-family:var(--mono);font-size:13px;font-weight:500;
  letter-spacing:.16em;text-transform:uppercase;
}
.btn-slab:active{transform:translate(4px,4px);box-shadow:0 0 0 var(--ink)}

/* ---------- RIGHT RAIL ---------- */
.right-rail{display:flex;flex-direction:column;gap:18px;min-height:0;overflow:hidden}
.rr-\boxed{border:2px solid var(--ink);background:var(--board-cream);padding:12px}
.rr-title{
  font-family:var(--mono);font-size:11px;font-weight:500;letter-spacing:.14em;
  text-transform:uppercase;color:var(--ink-muted);margin-bottom:10px;
}
#miniMap{
  display:grid;grid-template-columns:repeat(38,1fr);gap:1px;width:100%;
  background:var(--ballast);border:2px solid var(--ink);
}
#miniMap div{width:100%;aspect-ratio:1/1;background:#2E2A26}
.station-row{
  display:flex;justify-content:space-between;align-items:baseline;gap:8px;
  padding:7px 0;border-bottom:2px solid var(--ink);
  font-family:var(--mono);font-size:11px;letter-spacing:.11em;text-transform:uppercase;
}
.station-row:last-child{border-bottom:0}
.station-row .n{color:var(--ink-muted)}
.station-row .v{font-size:14px}
.station-row .v.hot{color:var(--orange)}

/* ---------- FOOTER ---------- */
.footer-ribbon{
  flex:0 0 auto;height:64px;position:relative;z-index:2;
  display:flex;align-items:center;justify-content:space-between;gap:24px;
  border-top:2px solid var(--ink);padding-top:8px;
}
.timetable{
  font-family:var(--body);font-size:12px;line-height:1.42;color:var(--ink);
  max-width:52ch;
}
.foot-ticker{
  font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;
  color:var(--ink-muted);text-align:center;flex:1 1 auto;min-width:0;
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
.barcode{display:flex;gap:3px;align-items:flex-end;flex:0 0 auto}
.barcode i{display:block;width:14px;border:2px solid var(--ink)}

/* ---------- TOUCH ---------- */
.touch{display:none}
.touch-pad{
  width:160px;height:160px;display:grid;
  grid-template-columns:repeat(3,1fr);grid-template-rows:repeat(3,1fr);gap:6px;
}
.touch-pad button{
  border:2px solid var(--ink);box-shadow:3px 3px 0 var(--ink);
  background:var(--board-cream);
  font-family:var(--mono);font-size:16px;
}
.touch-pad button:active{transform:translate(3px,3px);box-shadow:0 0 0 var(--ink);background:var(--orange)}

/* ---------- RESPONSIVE ---------- */
@media (max-width:1180px){
  .stage{grid-template-columns:216px minmax(0,1fr) 196px;gap:22px}
  .cabinet{padding:24px}
}
@media (max-width:1020px){
  body{overflow-y:auto}
  .cabinet{height:auto;min-height:100vh;padding:20px}
  .stage{grid-template-columns:minmax(0,1fr);grid-template-rows:auto auto auto;padding:18px 0}
  .hud{flex-direction:row;flex-wrap:wrap;align-items:center;gap:14px 20px}
  .hud .brow{flex:1 1 150px;border-bottom:0;border-top:2px solid var(--ink);padding-top:5px;padding-bottom:0}
  .hud .panel-head{flex:1 1 100%}
  .legend{flex:1 1 100%}
  .keycaps{margin-top:0}
  .board-stage{order:-1}
  .right-rail{flex-direction:row;flex-wrap:wrap}
  .rr-\boxed{flex:1 1 260px}
  .contact-rail{display:none}
}
@media (max-width:700px){
  .cabinet{padding:14px}
  .header-rail{min-height:64px;flex-wrap:wrap;gap:12px}
  .header-mid{order:3;flex:1 1 100%}
  .header-right{gap:10px}
  .chip{padding:5px 9px}
  .chip-val{font-size:15px}
  .footer-ribbon{height:auto;flex-direction:column;align-items:flex-start;gap:10px}
  .foot-ticker{text-align:left;width:100%}
  .touch{display:flex;flex-direction:column;align-items:center;gap:12px;padding:4px 0 18px}
  .shed-plate{width:64px;height:64px;left:-32px;top:-32px}
  .board-frame{border-width:6px;padding:3px}
}
@media (prefers-reduced-motion:reduce){
  .shake{animation:none}
}
</style>
</head>
<body>

<div class="cabinet">

  <div class="contact-rail" aria-hidden="true">
    <div class="rail-line a"></div>
    <div class="rail-line b"></div>
    <div class="rail-line c"></div>
  </div>

  <!-- ============ HEADER RAIL ============ -->
  <header class="header-rail">
    <div class="brand">
      <h1 class="wordmark">Platform&nbsp;7</h1>
      <p class="kicker">Model Railway Dispatch Board &middot; Est. 1998</p>
    </div>

    <div class="header-mid">
      <div class="eyebrow">Now Departing</div>
      <div class="msg" id="msg">Platform 7 is clear. Mind the gap between the locomotive and the platform edge.</div>
    </div>

    <div class="header-right">
      <div class="chip">
        <div class="chip-label">Top Speed</div>
        <div class="chip-val" id="topSpeed">43 KM/H</div>
      </div>
      <div class="controls">
        <button class="btn" id="btnPause">Pause</button>
        <button class="btn on" id="btnSound">Sound On</button>
        <button class="btn" id="btnReset">Reset</button>
      </div>
    </div>
  </header>

  <!-- ============ STAGE ============ -->
  <main class="stage">

    <!-- HUD -->
    <aside class="hud">
      <div class="panel-head">Departures &middot; Live</div>

      <div class="brow"><div class="lab">Fares Collected</div><div class="val hot" id="statFares">0000</div></div>
      <div class="brow"><div class="lab">Pax Aboard</div><div class="val" id="statPax">000</div></div>
      <div class="brow"><div class="lab">Wagons</div><div class="val" id="statWagons">03</div></div>
      <div class="brow"><div class="lab">Top Speed</div><div class="val" id="statSpeed">43 KM/H</div></div>
      <div class="brow"><div class="lab">Tick Rate</div><div class="val" id="statTick">140 MS</div></div>
      <div class="brow"><div class="lab">Delays</div><div class="val" id="statDelays">00</div></div>

      <div class="panel-head">Train 07 &middot; Consist</div>
      <div class="legend" id="legend"></div>

      <div class="keycaps">
        <div class="cap">&#8593;</div><div class="cap">&#8592;</div><div class="cap">&#8595;</div><div class="cap">&#8594;</div>
        <span>Drive</span>
        <div class="cap">SPC</div><span>Pause</span>
      </div>
    </aside>

    <!-- BOARD -->
    <section class="board-stage" id="boardStage">

      <div class="shed-plate" aria-hidden="true">
        <svg viewBox="0 0 88 88" width="100%" height="100%">
          <circle cx="44" cy="44" r="43" fill="#C9A227" stroke="#1B1714" stroke-width="2"/>
          <circle cx="44" cy="44" r="34" fill="none" stroke="#1B1714" stroke-width="1.5"/>
          <circle cx="44" cy="44" r="27" fill="none" stroke="#1B1714" stroke-width="1" stroke-dasharray="2 3"/>
          <path id="plateRing" d="M44,44 m-30,0 a30,30 0 1,1 60,0 a30,30 0 1,1 -60,0" fill="none"/>
          <text font-family="ui-monospace,Menlo,Consolas,monospace" font-size="7.4" letter-spacing="1.5" fill="#1B1714">
            <textPath href="#plateRing" startOffset="4%">SHED No. 7 &#183; 001 &#183; SHED No. 7 &#183; 001</textPath>
          </text>
          <text x="44" y="41" text-anchor="middle" font-family="ui-monospace,Menlo,Consolas,monospace" font-size="8" letter-spacing="1" fill="#1B1714">EST.</text>
          <text x="44" y="53" text-anchor="middle" font-family="ui-monospace,Menlo,Consolas,monospace" font-size="11" letter-spacing="1" fill="#1B1714">1998</text>
        </svg>
      </div>

      <div class="board-frame" id="boardFrame">
        <div class="board-chassis" id="boardChassis">
          <canvas id="board" width="912" height="576"></canvas>
          <div class="grain"></div>
          <div class="flash" id="flash"></div>

          <div class="pause-banner" id="pauseBanner">Signals Held</div>

          <div class="gameover" id="gameover">
            <div class="go-title" id="goTitle">Draw</div>
            <div class="go-lines" id="goLines">Train 07 has been delayed.</div>
            <button class="btn-slab" id="btnRestart">Restart</button>
          </div>

          <div class="start-banner" id="startBanner">
            <div class="l1">Click or press a key to start</div>
            <div class="l2">Demo mode &middot; Train 07 is running itself</div>
          </div>
        </div>
      </div>
    </section>

    <!-- RIGHT RAIL -->
    <aside class="right-rail">
      <div class="rr-box">
        <div class="rr-title">Yard Diagram</div>
        <div id="miniMap"></div>
      </div>
      <div class="rr-box">
        <div class="rr-title">Waiting Rooms</div>
        <div class="station-row"><div class="n">Plat. 1</div><div class="v hot" id="plat1Count">0 Waiting</div></div>
        <div class="station-row"><div class="n">Plat. 2</div><div class="v hot" id="plat2Count">0 Waiting</div></div>
        <div class="station-row"><div class="n">Shed</div><div class="v">Closed</div></div>
        <div class="station-row"><div class="n">Signal</div><div class="v" id="signalState">Green</div></div>
      </div>
    </aside>
  </main>

  <!-- ============ FOOTER ============ -->
  <footer class="footer-ribbon">
    <div class="timetable">
      this railway runs on patience.<br>
      passengers wait exactly where the timetable says they will.<br>
      do not lean out of the locomotive.
    </div>
    <div class="foot-ticker" id="footTicker">19:42 &#183; TRAIN 07 &#183; 3 WGN &#183; 0 PAX &#183; ON TIME</div>
    <div class="barcode" aria-hidden="true">
      <i style="height:44px;background:#2E86C1"></i>
      <i style="height:34px;background:#F2B21C"></i>
      <i style="height:50px;background:#7B4FA8"></i>
      <i style="height:38px;background:#2FA07A"></i>
    </div>
  </footer>
</div>

<!-- touch controls -->
<div class="touch">
  <div class="touch-pad">
    <span></span>
    <button data-dir="up">&#8593;</button>
    <span></span>
    <button data-dir="left">&#8592;</button>
    <button data-dir="pause">II</button>
    <button data-dir="right">&#8594;</button>
    <span></span>
    <button data-dir="down">&#8595;</button>
    <span></span>
  </div>
</div>

<script>
(function(){
  "use strict";

  /* ============================================================
     CONSTANTS
  ============================================================ */
  const COLS = 38, ROWS = 24;
  const TAU = Math.PI * 2;

  const INK        = "#1B1714";
  const BOARD_BG   = "#241E1B";
  const BALLAST    = "#171310";
  const SLEEPER    = "#4A423B";
  const RAIL       = "#8D8A85";
  const CREAM      = "#F3EADA";
  const ORANGE     = "#FF6A13";
  const ORANGE_DK  = "#D9500B";
  const RED        = "#C2352B";
  const GREEN      = "#1F7A4D";
  const SKIN       = "#E4C29B";

  const LIVERY = ["#2E86C1","#F2B21C","#7B4FA8","#2FA07A","#D6456B","#F26B21"];

  const DISPLAY = '"Oswald","Arial Narrow",Impact,sans-serif';
  const MONO    = '"JetBrains Mono",ui-monospace,Menlo,Consolas,monospace';

  const PLATFORMS = [
    { name:"PLAT. 1", x1:2,  y1:2,  x2:15, y2:5,  labelCols:5 },
    { name:"PLAT. 2", x1:22, y1:18, x2:35, y2:21, labelCols:5 }
  ];

  const MESSAGES = [
    "Platform 7 is clear. Mind the gap between the locomotive and the platform edge.",
    "The 19:42 departure is now boarding at Platform 2.",
    "Luggage carts report for loading at the yard throat.",
    "All wagons coupled. Signal at danger until the line is clear.",
    "Passengers are reminded that this is a model railway."
  ];

  const reduceMotion = window.matchMedia &&
    window.matchMedia("(prefers-reduced-motion: reduce)").matches;

  /* ============================================================
     DOM
  ============================================================ */
  const canvas      = document.getElementById("board");
  const ctx         = canvas.getContext("2d");
  const boardStage  = document.getElementById("boardStage");
  const boardFrame  = document.getElementById("boardFrame");
  const boardChassis= document.getElementById("boardChassis");
  const flashEl     = document.getElementById("flash");
  const startBanner = document.getElementById("startBanner");
  const pauseBanner = document.getElementById("pauseBanner");
  const gameoverEl  = document.getElementById("gameover");
  const goTitle     = document.getElementById("goTitle");
  const goLines     = document.getElementById("goLines");
  const miniMap     = document.getElementById("miniMap");
  const legendEl    = document.getElementById("legend");
  const msgEl       = document.getElementById("msg");
  const footTicker  = document.getElementById("footTicker");

  const el = {
    fares:  document.getElementById("statFares"),
    pax:    document.getElementById("statPax"),
    wagons: document.getElementById("statWagons"),
    speed:  document.getElementById("statSpeed"),
    tick:   document.getElementById("statTick"),
    delays: document.getElementById("statDelays"),
    top:    document.getElementById("topSpeed"),
    p1:     document.getElementById("plat1Count"),
    p2:     document.getElementById("plat2Count"),
    signal: document.getElementById("signalState")
  };

  const btnPause  = document.getElementById("btnPause");
  const btnSound  = document.getElementById("btnSound");
  const btnReset  = document.getElementById("btnReset");
  const btnRestart= document.getElementById("btnRestart");

  /* ============================================================
     AUDIO (lazy, gesture-gated)
  ============================================================ */
  let actx = null, soundOn = true;
  function ensureAudio(){
    if (actx) { if (actx.state === "suspended") actx.resume().catch(function(){}); return; }
    try {
      const AC = window.AudioContext || window.webkitAudioContext;
      if (AC) actx = new AC();
    } catch(e){ actx = null; }
  }
  function tone(freq, dur, type, gain, slideTo){
    if (!soundOn || !actx) return;
    try {
      const t = actx.currentTime;
      const o = actx.createOscillator(), g = actx.createGain();
      o.type = type || "square";
      o.frequency.setValueAtTime(freq, t);
      if (slideTo) o.frequency.exponentialRampToValueAtTime(Math.max(20,slideTo), t + dur);
      g.gain.setValueAtTime(0.0001, t);
      g.gain.exponentialRampToValueAtTime(gain || 0.05, t + 0.008);
      g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
      o.connect(g); g.connect(actx.destination);
      o.start(t); o.stop(t + dur + 0.02);
    } catch(e){}
  }
  function steam(){
    if (!soundOn || !actx) return;
    try {
      const dur = 0.16, n = actx.sampleRate * dur;
      const buf = actx.createBuffer(1, n, actx.sampleRate);
      const d = buf.getChannelData(0);
      for (let i=0;i<n;i++) d[i] = (Math.random()*2-1) * (1 - i/n);
      const src = actx.createBufferSource(); src.buffer = buf;
      const f = actx.createBiquadFilter(); f.type="bandpass"; f.frequency.value=1400;
      const g = actx.createGain(); g.gain.value = 0.06;
      src.connect(f); f.connect(g); g.connect(actx.destination);
      src.start();
    } catch(e){}
  }

  /* ============================================================
     GAME STATE
  ============================================================ */
  let snake = [];
  let dir = {x:1, y:0};
  let dirQueue = [];
  let passengers = [];
  let state = "demo";            // demo | playing | paused | over
  let score = 0, pax = 0, delays = 0;
  let streak = 0, colorIdx = 0;
  let tickMs = 140, topSpeed = 43;
  let sparks = [];
  let clockMin = 19*60 + 42;
  let departures = [];
  let cell = 24, dpr = 1;
  let animT = 0;

  function newSnake(){
    snake = [];
    const cy = 11, cx = 8;
    snake.push({x:cx, y:cy});
    for (let i=1;i<=3;i++){
      snake.push({x:cx-i, y:cy, color:LIVERY[(i-1)%LIVERY.length], pax:0});
    }
    colorIdx = 3;
    dir = {x:1,y:0};
    dirQueue = [];
    streak = 0;
    passengers = [];
    for (let i=0;i<5;i++) spawnPassenger();
  }

  function nextColor(){
    const c = LIVERY[colorIdx % LIVERY.length];
    colorIdx++;
    return c;
  }

  function tileBlocked(x,y){
    if (passengers.some(function(p){return p.x===x && p.y===y;})) return true;
    if (snake.some(function(s){return s.x===x && s.y===y;})) return true;
    return false;
  }

  function spawnPassenger(){
    for (let tries=0;tries<240;tries++){
      const plat = PLATFORMS[Math.floor(Math.random()*PLATFORMS.length)];
      const span = plat.x2 - plat.x1 + 1 - plat.labelCols;
      const x = plat.x1 + plat.labelCols + Math.floor(Math.random()*span);
      const y = plat.y1 + Math.floor(Math.random()*(plat.y2 - plat.y1 + 1));
      if (tileBlocked(x,y)) continue;
      passengers.push({
        x:x, y:y,
        type:"pax",
        color: LIVERY[Math.floor(Math.random()*LIVERY.length)],
        phase: Math.random()*TAU
      });
      return;
    }
  }

  function spawnLuggage(){
    for (let tries=0;tries<240;tries++){
      const plat = PLATFORMS[Math.floor(Math.random()*PLATFORMS.length)];
      const span = plat.x2 - plat.x1 + 1 - plat.labelCols;
      const x = plat.x1 + plat.labelCols + Math.floor(Math.random()*span);
      const y = plat.y1 + Math.floor(Math.random()*(plat.y2 - plat.y1 + 1));
      if (tileBlocked(x,y)) continue;
      passengers.push({x:x, y:y, type:"luggage", color:"#F2B21C", phase:Math.random()*TAU});
      return;
    }
  }

  /* ============================================================
     TICK
  ============================================================ */
  function currentTick(){
    const w = snake.length - 1;
    if (w >= 35) return 95;
    if (w >= 20) return 115;
    return 140;
  }

  function step(){
    // apply queued direction
    while (dirQueue.length){
      const d = dirQueue.shift();
      if (d.x !== -dir.x || d.y !== -dir.y){ dir = d; break; }
    }

    const head = { x: snake[0].x + dir.x, y: snake[0].y + dir.y };

    if (head.x < 0 || head.x >= COLS || head.y < 0 || head.y >= ROWS){
      crash();
      return;
    }

    snake.unshift(head);

    // pickup
    let got = -1;
    for (let i=0;i<passengers.length;i++){
      if (passengers[i].x === head.x && passengers[i].y === head.y){ got = i; break; }
    }

    if (got >= 0){
      const p = passengers.splice(got,1)[0];
      const tail = snake[snake.length-1];
      if (p.type === "luggage"){
        score += 250;
        snake.push({x:tail.x, y:tail.y, color:nextColor(), pax:0});
        snake.push({x:tail.x, y:tail.y, color:nextColor(), pax:0});
      } else {
        score += 50;
        pax += 1;
        snake.push({x:tail.x, y:tail.y, color:nextColor(), pax:1});
      }
      streak++;
      if (streak % 5 === 0) spawnLuggage();
      onPickup(p);
      steam();
      tone(660, 0.09, "square", 0.045, 880);
      setTimeout(function(){ tone(520, 0.10, "triangle", 0.04, 380); }, 70);
    } else {
      snake.pop();
    }

    // keep the platform busy
    if (passengers.length < 5 && Math.random() < 0.32) spawnPassenger();

    tickMs = currentTick();
    topSpeed = Math.max(topSpeed, Math.round(6000/tickMs));
    updateHUD();
    renderMini();
    updateFoot();
  }

  /* ============================================================
     EVENTS / FX
  ============================================================ */
  function onPickup(p){
    // board tilt toward the platform that served the train
    if (!reduceMotion){
      const tilt = (p.y < ROWS/2) ? 3 : -3;
      boardFrame.style.transition = "transform 90ms ease-out";
      boardFrame.style.transform  = "perspective(900px) rotateX(" + tilt + "deg)";
      setTimeout(function(){
        boardFrame.style.transition = "transform 220ms cubic-bezier(.2,1.4,.4,1)";
        boardFrame.style.transform  = "perspective(900px) rotateX(" + (-tilt*0.5) + "deg)";
      }, 95);
      setTimeout(function(){
        boardFrame.style.transition = "transform 180ms ease-out";
        boardFrame.style.transform  = "perspective(900px) rotateX(0deg)";
      }, 320);

      // sparks
      const head = snake[0];
      for (let i=0;i<6;i++){
        sparks.push({
          x: head.x*cell + cell/2,
          y: head.y*cell + cell/2,
          a: Math.random()*TAU,
          life: 1
        });
      }
    }
    flashBrief(GREEN);
  }

  function flashBrief(color){
    flashEl.style.background = color;
    flashEl.classList.remove("go");
    void flashEl.offsetWidth;
    flashEl.classList.add("go");
  }

  function crash(){
    if (state === "over") return;
    const wasDemo = (state === "demo");
    delays++;
    tone(220, 0.42, "sawtooth", 0.055, 70);
    flashBrief(RED);

    if (!reduceMotion){
      boardChassis.classList.remove("shake");
      void boardChassis.offsetWidth;
      boardChassis.classList.add("shake");
    }

    pushDeparture(false);
    updateHUD();
    updateFoot();

    if (wasDemo){
      state = "over";
      el.signal.textContent = "Danger";
      el.signal.classList.add("hot");
      setTimeout(function(){
        startDemo();
      }, 1500);
    } else {
      state = "over";
      el.signal.textContent = "Danger";
      el.signal.classList.add("hot");
      goTitle.textContent = "Draw";
      goLines.innerHTML =
        "Train 07 has been delayed.<br>" +
        String(score).padStart(4,"0") + " fares collected &middot; " +
        String(pax).padStart(3,"0") + " passengers delivered &middot; " +
        String(snake.length-1).padStart(2,"0") + " wagons in the consist.";
      gameoverEl.classList.add("show");
      startBanner.classList.add("hidden");
    }
  }

  /* ============================================================
     DEPARTURES
  ============================================================ */
  function fmtTime(m){
    const h = Math.floor(m/60) % 24, mm = m % 60;
    return String(h).padStart(2,"0") + ":" + String(mm).padStart(2,"0");
  }
  function pushDeparture(onTime){
    clockMin += 1 + Math.floor(Math.random()*3);
    departures.unshift({
      time: fmtTime(clockMin),
      train: "TRAIN 07",
      wgn: String(Math.max(1, snake.length-1)).padStart(2,"0"),
      pax: String(pax).padStart(2,"0"),
      status: onTime ? "ON TIME" : "DELAYED"
    });
    if (departures.length > 6) departures.pop();
  }
  function seedDepartures(){
    const seeds = [
      ["19:38","TRAIN 04","5 WGN","09 PAX","ON TIME"],
      ["19:31","TRAIN 11","3 WGN","04 PAX","ON TIME"],
      ["19:24","TRAIN 02","7 WGN","16 PAX","ON TIME"],
      ["19:16","TRAIN 09","4 WGN","07 PAX","DELAYED"],
      ["19:08","TRAIN 06","6 WGN","11 PAX","ON TIME"],
      ["18:57","TRAIN 03","2 WGN","03 PAX","ON TIME"]
    ];
    departures = seeds.map(function(s){
      return {time:s[0], train:s[1], wgn:s[2].replace(" WGN",""), pax:s[3].replace(" PAX",""), status:s[4]};
    });
    renderDepartures();
  }
  function renderDepartures(){
    const live = [{
      time: fmtTime(clockMin),
      train: "TRAIN 07",
      wgn: String(Math.max(1, snake.length-1)).padStart(2,"0"),
      pax: String(pax).padStart(2,"0"),
      status: (state === "over") ? "DELAYED" : "ON TIME"
    }];
    const rows = live.concat(departures).slice(0,6);
    let html = "";
    rows.forEach(function(r){
      const hot = (r.train === "TRAIN 07");
      html += '<div class="station-row">' +
        '<div class="n">' + r.time + " &middot; " + r.train + " &middot; " + r.wgn + " WGN &middot; " + r.pax + " PAX</div>" +
        '<div class="v' + (hot ? " hot" : "") + '">' + r.status + "</div></div>";
    });
    document.getElementById("departuresBoard") &&
      (document.getElementById("departuresBoard").innerHTML = html);
    footTicker.innerHTML = rows[0].time + " &#183; " + rows[0].train + " &#183; " +
      rows[0].wgn + " WGN &#183; " + rows[0].pax + " PAX &#183; " + rows[0].status;
  }
  function updateFoot(){
    renderDepartures();
  }

  /* ============================================================
     HUD
  ============================================================ */
  function updateHUD(){
    el.fares.textContent  = String(score).padStart(4,"0");
    el.pax.textContent    = String(pax).padStart(3,"0");
    el.wagons.textContent = String(Math.max(0, snake.length-1)).padStart(2,"0");
    el.speed.textContent  = Math.round(6000/tickMs) + " KM/H";
    el.tick.textContent   = tickMs + " MS";
    el.delays.textContent = String(delays).padStart(2,"0");
    el.top.textContent    = topSpeed + " KM/H";

    let c1 = 0, c2 = 0;
    passengers.forEach(function(p){
      if (p.y < ROWS/2) c1++; else c2++;
    });
    el.p1.textContent = c1 + " Waiting";
    el.p2.textContent = c2 + " Waiting";

    renderLegend();
  }

  function renderLegend(){
    const wagons = snake.slice(1);
    let html = "";
    wagons.forEach(function(w){
      const dots = Math.min(3, w.pax || 0);
      let d = "";
      for (let i=0;i<dots;i++) d += "<i></i>";
      html += '<div class="wchip" style="background:' + (w.color || LIVERY[0]) + '">' + d + "</div>";
    });
    legendEl.innerHTML = html;
  }

  /* ============================================================
     MINI MAP
  ============================================================ */
  let miniCells = [];
  let miniPrev = [];
  function buildMini(){
    miniMap.innerHTML = "";
    miniCells = [];
    miniPrev = [];
    for (let y=0;y<ROWS;y++){
      for (let x=0;x<COLS;x++){
        const d = document.createElement("div");
        miniMap.appendChild(d);
        miniCells.push(d);
        miniPrev.push("");
      }
    }
  }
  function isPlatformCell(x,y){
    for (let i=0;i<PLATFORMS.length;i++){
      const p = PLATFORMS[i];
      if (x>=p.x1 && x<=p.x2 && y>=p.y1 && y<=p.y2) return true;
    }
    return false;
  }
  function renderMini(){
    if (miniCells.length !== COLS*ROWS) return;
    const colors = new Array(COLS*ROWS).fill("#2E2A26");
    for (let y=0;y<ROWS;y++){
      for (let x=0;x<COLS;x++){
        if (isPlatformCell(x,y)) colors[y*COLS+x] = CREAM;
      }
    }
    passengers.forEach(function(p){ colors[p.y*COLS+p.x] = p.type==="luggage" ? "#F2B21C" : p.color; });
    for (let i=snake.length-1;i>=1;i--){
      const s = snake[i];
      if (s.y>=0 && s.y<ROWS && s.x>=0 && s.x<COLS) colors[s.y*COLS+s.x] = s.color || LIVERY[0];
    }
    const h = snake[0];
    if (h.y>=0 && h.y<ROWS && h.x>=0 && h.x<COLS) colors[h.y*COLS+h.x] = ORANGE;

    for (let i=0;i<colors.length;i++){
      if (miniPrev[i] !== colors[i]){
        miniCells[i].style.background = colors[i];
        miniPrev[i] = colors[i];
      }
    }
  }

  /* ============================================================
     CANVAS RENDERING
  ============================================================ */
  function rr(c,x,y,w,h,r){
    r = Math.min(r, w/2, h/2);
    c.beginPath();
    c.moveTo(x+r,y);
    c.arcTo(x+w,y,x+w,y+h,r);
    c.arcTo(x+w,y+h,x,y+h,r);
    c.arcTo(x,y+h,x,y,r);
    c.arcTo(x,y,x+w,y,r);
    c.closePath();
  }

  function drawBoard(){
    const W = COLS*cell, H = ROWS*cell;
    ctx.setTransform(dpr,0,0,dpr,0,0);
    ctx.clearRect(0,0,W,H);

    // ballast
    ctx.fillStyle = BOARD_BG;
    ctx.fillRect(0,0,W,H);

    // sleepers
    ctx.fillStyle = SLEEPER;
    ctx.globalAlpha = 0.18;
    for (let y=0;y<ROWS;y+=2){
      ctx.fillRect(0, y*cell + cell*0.5 - 1.5, W, 3);
    }
    ctx.globalAlpha = 1;

    // track diagram grid
    ctx.strokeStyle = RAIL;
    ctx.globalAlpha = 0.12;
    ctx.lineWidth = 1;
    ctx.beginPath();
    for (let x=0;x<=COLS;x++){ ctx.moveTo(x*cell+0.5,0); ctx.lineTo(x*cell+0.5,H); }
    for (let y=0;y<=ROWS;y++){ ctx.moveTo(0,y*cell+0.5); ctx.lineTo(W,y*cell+0.5); }
    ctx.stroke();
    ctx.globalAlpha = 1;

    drawPlatforms();
    drawPassengers();
    drawHeadlight();
    drawTrain();
    drawSparks();
  }

  function drawPlatforms(){
    PLATFORMS.forEach(function(p){
      const x = p.x1*cell, y = p.y1*cell;
      const w = (p.x2-p.x1+1)*cell, h = (p.y2-p.y1+1)*cell;

      ctx.fillStyle = CREAM;
      ctx.fillRect(x,y,w,h);

      // 45deg ink hatch on the top and bottom edge strips
      const strip = 6;
      ctx.save();
      ctx.beginPath(); ctx.rect(x,y,w,strip); ctx.clip();
      ctx.strokeStyle = INK; ctx.lineWidth = 1.5;
      ctx.beginPath();
      for (let i=-h;i<w+h;i+=6){ ctx.moveTo(x+i,y+strip); ctx.lineTo(x+i+strip,y); }
      ctx.stroke();
      ctx.restore();

      ctx.save();
      ctx.beginPath(); ctx.rect(x,y+h-strip,w,strip); ctx.clip();
      ctx.strokeStyle = INK; ctx.lineWidth = 1.5;
      ctx.beginPath();
      for (let i=-h;i<w+h;i+=6){ ctx.moveTo(x+i,y+h); ctx.lineTo(x+i+strip,y+h-strip); }
      ctx.stroke();
      ctx.restore();

      // rails facing the track
      ctx.fillStyle = RAIL;
      ctx.fillRect(x,y,w,3);
      ctx.fillRect(x,y+h-3,w,3);

      // edge
      ctx.strokeStyle = INK;
      ctx.lineWidth = 2;
      ctx.strokeRect(x+1,y+1,w-2,h-2);

      // label
      ctx.fillStyle = INK;
      ctx.font = "700 " + Math.round(cell*0.62) + "px " + DISPLAY;
      ctx.textAlign = "left";
      ctx.textBaseline = "middle";
      ctx.fillText(p.name, x + cell*0.45, y + h*0.5);

      ctx.font = "500 " + Math.max(8, Math.round(cell*0.34)) + "px " + MONO;
      ctx.fillStyle = "#7A6B58";
      ctx.fillText("WAITING", x + cell*0.45, y + h*0.5 + cell*0.72);
    });
  }

  function drawPassengers(){
    const bob = reduceMotion ? 0 : Math.sin(animT/420) * 1.2;
    passengers.forEach(function(p){
      const px = p.x*cell + cell/2;
      const py = p.y*cell + cell/2 + (reduceMotion ? 0 : Math.sin(animT/360 + p.phase)*1.2);
      const s = cell/24;

      if (p.type === "luggage"){
        ctx.save();
        ctx.translate(px,py);
        ctx.scale(s,s);
        // cart
        rr(ctx,-11,-7,22,13,2.5);
        ctx.fillStyle = "#F2B21C"; ctx.fill();
        ctx.lineWidth = 1.8; ctx.strokeStyle = INK; ctx.stroke();
        ctx.fillStyle = INK;
        ctx.fillRect(-8,-4,6,7);
        ctx.fillRect(1,-4,8,3);
        // wheels
        ctx.beginPath(); ctx.arc(-6,8,2.6,0,TAU); ctx.arc(6,8,2.6,0,TAU);
        ctx.fillStyle = INK; ctx.fill();
        ctx.restore();
        return;
      }

      ctx.save();
      ctx.translate(px,py);
      ctx.scale(s,s);
      // shadow
      ctx.fillStyle = "rgba(27,23,20,0.18)";
      ctx.beginPath(); ctx.ellipse(0,11,7,2.2,0,0,TAU); ctx.fill();
      // legs
      ctx.fillStyle = INK;
      ctx.fillRect(-3.4,5,2.6,6);
      ctx.fillRect(0.8,5,2.6,6);
      // coat
      rr(ctx,-4,-3,8,9,1.6);
      ctx.fillStyle = p.color; ctx.fill();
      ctx.lineWidth = 1.3; ctx.strokeStyle = INK; ctx.stroke();
      // head
      ctx.beginPath(); ctx.arc(0,-6.4,3,0,TAU);
      ctx.fillStyle = SKIN; ctx.fill();
      ctx.lineWidth = 1.2; ctx.strokeStyle = INK; ctx.stroke();
      // case
      rr(ctx,5.5,1.5,6.5,5.5,1.2);
      ctx.fillStyle = INK; ctx.fill();
      ctx.restore();

      // tiny wait indicator
      if (!reduceMotion){
        const a = 0.35 + 0.25*Math.sin(animT/300 + p.phase);
        ctx.globalAlpha = a;
        ctx.strokeStyle = CREAM;
        ctx.lineWidth = 1.2;
        ctx.beginPath();
        ctx.arc(px, py + bob*0.2, cell*0.34, 0, TAU);
        ctx.stroke();
        ctx.globalAlpha = 1;
      }
    });
  }

  function drawHeadlight(){
    if (snake.length === 0) return;
    const h = snake[0];
    const ang = Math.atan2(dir.y, dir.x);
    const cx = h.x*cell + cell/2, cy = h.y*cell + cell/2;
    ctx.save();
    ctx.translate(cx,cy);
    ctx.rotate(ang);
    ctx.fillStyle = "rgba(255,106,19,0.16)";
    ctx.beginPath();
    ctx.moveTo(cell*0.35,0);
    ctx.lineTo(cell*0.35 + 90, -34);
    ctx.lineTo(cell*0.35 + 90,  34);
    ctx.closePath();
    ctx.fill();
    ctx.restore();
  }

  function drawTrain(){
    // coupling bars first
    const s = cell/24;
    ctx.strokeStyle = INK;
    ctx.lineWidth = Math.max(2, 4*s);
    ctx.lineCap = "butt";
    for (let i=1;i<snake.length;i++){
      const a = snake[i-1], b = snake[i];
      const ax = a.x*cell + cell/2, ay = a.y*cell + cell/2;
      const bx = b.x*cell + cell/2, by = b.y*cell + cell/2;
      ctx.beginPath();
      ctx.moveTo(ax,ay); ctx.lineTo(bx,by);
      ctx.stroke();
    }

    for (let i=snake.length-1;i>=0;i--){
      const seg = snake[i];
      const prev = (i === 0) ? {x:seg.x + dir.x, y:seg.y + dir.y} : snake[i-1];
      const ang = Math.atan2(prev.y - seg.y, prev.x - seg.x);

      let bobY = 0;
      if (!reduceMotion && i > 0){
        bobY = Math.sin(animT/120 - i*0.7) * 2 * (cell/24);
      }

      ctx.save();
      ctx.translate(seg.x*cell + cell/2, seg.y*cell + cell/2 + bobY);
      ctx.rotate(ang);
      ctx.scale(s,s);
      if (i === 0) drawLoco(); else drawWagon(seg.color, seg.pax || 0, i);
      ctx.restore();
    }
  }

  function drawLoco(){
    // soft ground shadow
    ctx.fillStyle = "rgba(0,0,0,0.28)";
    ctx.beginPath(); ctx.ellipse(0,11,12,3,0,0,TAU); ctx.fill();

    // cowcatcher
    ctx.beginPath();
    ctx.moveTo(10,-6); ctx.lineTo(15,0); ctx.lineTo(10,6); ctx.closePath();
    ctx.fillStyle = INK; ctx.fill();

    // cab
    rr(ctx,-12,-10,10,18,2.5);
    ctx.fillStyle = ORANGE_DK; ctx.fill();
    ctx.lineWidth = 1.6; ctx.strokeStyle = INK; ctx.stroke();

    // cab window
    rr(ctx,-9.5,-7,5,6,1);
    ctx.fillStyle = CREAM; ctx.fill();
    ctx.lineWidth = 1.1; ctx.strokeStyle = INK; ctx.stroke();

    // boiler
    rr(ctx,-3,-6.5,13,12,3.2);
    ctx.fillStyle = ORANGE; ctx.fill();
    ctx.lineWidth = 1.6; ctx.strokeStyle = INK; ctx.stroke();

    // boiler bands
    ctx.strokeStyle = "rgba(27,23,20,0.55)";
    ctx.lineWidth = 1.1;
    ctx.beginPath();
    ctx.moveTo(0,-6.5); ctx.lineTo(0,5.5);
    ctx.moveTo(4,-6.5); ctx.lineTo(4,5.5);
    ctx.stroke();

    // chimney
    ctx.fillStyle = ORANGE_DK;
    ctx.fillRect(3,-13,4.6,7);
    ctx.fillRect(2,-14.2,6.6,2.4);
    ctx.lineWidth = 1.4; ctx.strokeStyle = INK;
    ctx.strokeRect(3,-13,4.6,7);
    ctx.strokeRect(2,-14.2,6.6,2.4);

    // dome
    ctx.beginPath(); ctx.arc(-1,-6.4,3,Math.PI,0);
    ctx.fillStyle = ORANGE_DK; ctx.fill();
    ctx.lineWidth = 1.3; ctx.strokeStyle = INK; ctx.stroke();

    // headlight
    ctx.beginPath(); ctx.arc(8.6,-3.6,2.2,0,TAU);
    ctx.fillStyle = CREAM; ctx.fill();
    ctx.lineWidth = 1.2; ctx.strokeStyle = INK; ctx.stroke();

    // running board
    ctx.fillStyle = INK;
    ctx.fillRect(-11,5.2,22,1.8);

    // wheels
    const wheels = [-7.4,-1,5.6];
    wheels.forEach(function(wx,i){
      ctx.beginPath(); ctx.arc(wx,8.6, i===1?3.6:3.1, 0, TAU);
      ctx.fillStyle = INK; ctx.fill();
      ctx.beginPath(); ctx.arc(wx,8.6, 1.1, 0, TAU);
      ctx.fillStyle = CREAM; ctx.fill();
    });

    // steam plume
    if (!reduceMotion){
      ctx.fillStyle = "rgba(243,234,218,0.16)";
      for (let i=0;i<3;i++){
        const t = (animT/900 + i*0.33) % 1;
        const r = 2 + t*5;
        ctx.beginPath();
        ctx.arc(5.3 + t*11, -15 - t*10, r, 0, TAU);
        ctx.fill();
      }
    }
  }

  function drawWagon(color, paxCount, idx){
    // shadow
    ctx.fillStyle = "rgba(0,0,0,0.26)";
    ctx.beginPath(); ctx.ellipse(0,11,11,2.8,0,0,TAU); ctx.fill();

    // body
    rr(ctx,-11,-7.5,22,15,3.4);
    ctx.fillStyle = color || LIVERY[0];
    ctx.fill();
    ctx.lineWidth = 1.7; ctx.strokeStyle = INK; ctx.stroke();

    // roof line
    ctx.strokeStyle = "rgba(27,23,20,0.5)";
    ctx.lineWidth = 1.2;
    ctx.beginPath(); ctx.moveTo(-8,-4.2); ctx.lineTo(8,-4.2); ctx.stroke();

    // passenger silhouettes in the wagon
    const n = Math.min(3, Math.max(1, paxCount || (idx % 3)));
    for (let i=0;i<n;i++){
      const px = -6.5 + i*6.4;
      ctx.fillStyle = "rgba(27,23,20,0.62)";
      ctx.beginPath(); ctx.arc(px, -1.2, 1.9, 0, TAU); ctx.fill();
      rr(ctx,px-2.2,1,4.4,5.2,1.2);
      ctx.fill();
    }

    // running board + wheels
    ctx.fillStyle = INK;
    ctx.fillRect(-10,6.2,20,1.6);
    [-5.6,5.6].forEach(function(wx){
      ctx.beginPath(); ctx.arc(wx,8.8,2.9,0,TAU);
      ctx.fillStyle = INK; ctx.fill();
      ctx.beginPath(); ctx.arc(wx,8.8,1,0,TAU);
      ctx.fillStyle = CREAM; ctx.fill();
    });

    // coupling knuckle
    ctx.fillStyle = INK;
    ctx.fillRect(10.4,-1.4,3,2.8);
    ctx.fillRect(-13.4,-1.4,3,2.8);
  }

  function drawSparks(){
    if (!sparks.length) return;
    sparks.forEach(function(s){
      const len = 6 + (1 - s.life)*10;
      const x2 = s.x + Math.cos(s.a)*len;
      const y2 = s.y + Math.sin(s.a)*len;
      ctx.strokeStyle = s.life > 0.5 ? RED : CREAM;
      ctx.globalAlpha = Math.max(0, s.life);
      ctx.lineWidth = 2;
      ctx.beginPath(); ctx.moveTo(s.x,s.y); ctx.lineTo(x2,y2); ctx.stroke();
      ctx.globalAlpha = 1;
    });
  }

  /* ============================================================
     LAYOUT
  ============================================================ */
  function layoutBoard(){
    const rect = boardStage.getBoundingClientRect();
    const frameExtra = 28; // 10px border + 4px padding, both sides
    const availW = Math.max(120, rect.width  - frameExtra);
    const availH = Math.max(120, rect.height - frameExtra);
    const c = Math.max(6, Math.floor(Math.min(availW/COLS, availH/ROWS)));
    cell = c;
    const w = c*COLS, h = c*ROWS;

    dpr = Math.min(2, window.devicePixelRatio || 1);
    canvas.style.width  = w + "px";
    canvas.style.height = h + "px";
    canvas.width  = Math.round(w*dpr);
    canvas.height = Math.round(h*dpr);

    boardFrame.style.width  = w + "px";
    boardFrame.style.height = h + "px";
  }

  /* ============================================================
     INPUT
  ============================================================ */
  const KEYMAP = {
    ArrowUp:{x:0,y:-1}, ArrowDown:{x:0,y:1}, ArrowLeft:{x:-1,y:0}, ArrowRight:{x:1,y:0},
    w:{x:0,y:-1}, s:{x:0,y:1}, a:{x:-1,y:0}, d:{x:1,y:0},
    W:{x:0,y:-1}, S:{x:0,y:1}, A:{x:-1,y:0}, D:{x:1,y:0}
  };

  function pushDir(nd){
    if (dirQueue.length < 2) dirQueue.push(nd);
  }

  function startGame(){
    ensureAudio();
    if (state === "playing") return;
    if (state === "paused"){ togglePause(); return; }
    newSnake();
    score = 0; pax = 0; delays = 0;
    tickMs = 140;
    state = "playing";
    gameoverEl.classList.remove("show");
    startBanner.classList.add("hidden");
    pauseBanner.classList.remove("show");
    btnPause.textContent = "Pause";
    el.signal.textContent = "Green";
    el.signal.classList.remove("hot");
    updateHUD(); renderMini(); updateFoot();
    tone(440,0.08,"square",0.04,660);
    setTimeout(function(){ tone(660,0.12,"square",0.04,880); },90);
  }

  function startDemo(){
    newSnake();
    state = "demo";
    gameoverEl.classList.remove("show");
    pauseBanner.classList.remove("show");
    startBanner.classList.remove("hidden");
    el.signal.textContent = "Green";
    el.signal.classList.remove("hot");
    updateHUD(); renderMini();
  }

  function togglePause(){
    ensureAudio();
    if (state === "playing"){
      state = "paused";
      pauseBanner.classList.add("show");
      btnPause.textContent = "Resume";
    } else if (state === "paused"){
      state = "playing";
      pauseBanner.classList.remove("show");
      btnPause.textContent = "Pause";
      lastT = 0;
    }
  }

  window.addEventListener("keydown", function(e){
    const k = e.key;
    if (KEYMAP[k]){
      e.preventDefault();
      ensureAudio();
      if (state === "demo" || state === "over") startGame();
      if (state === "playing") pushDir(KEYMAP[k]);
      return;
    }
    if (k === " " || k === "Spacebar"){
      e.preventDefault();
      ensureAudio();
      if (state === "playing" || state === "paused") togglePause();
      else startGame();
    }
    if (k === "Enter"){
      e.preventDefault();
      startGame();
    }
  }, {passive:false});

  boardStage.addEventListener("click", function(){
    ensureAudio();
    if (state === "demo" || state === "over") startGame();
    else if (state === "paused") togglePause();
  });

  document.querySelectorAll(".touch-pad button").forEach(function(b){
    b.addEventListener("click", function(){
      ensureAudio();
      const d = b.getAttribute("data-dir");
      if (d === "pause"){ if (state==="playing"||state==="paused") togglePause(); else startGame(); return; }
      const map = {up:{x:0,y:-1},down:{x:0,y:1},left:{x:-1,y:0},right:{x:1,y:0}};
      if (state === "demo" || state === "over") startGame();
      if (state === "playing" && map[d]) pushDir(map[d]);
    });
  });

  btnPause.addEventListener("click", function(){
    ensureAudio();
    if (state === "demo" || state === "over"){ startGame(); return; }
    togglePause();
  });
  btnSound.addEventListener("click", function(){
    ensureAudio();
    soundOn = !soundOn;
    btnSound.textContent = soundOn ? "Sound On" : "Sound Off";
    btnSound.classList.toggle("on", soundOn);
    if (soundOn) tone(520,0.08,"square",0.04,720);
  });
  btnReset.addEventListener("click", function(){
    ensureAudio();
    startGame();
  });
  btnRestart.addEventListener("click", function(){
    ensureAudio();
    startGame();
  });

  /* ============================================================
     MESSAGES
  ============================================================ */
  let msgIdx = 0;
  setInterval(function(){
    msgIdx = (msgIdx + 1) % MESSAGES.length;
    msgEl.style.opacity = "0";
    setTimeout(function(){
      msgEl.textContent = MESSAGES[msgIdx];
      msgEl.style.opacity = "1";
    }, 160);
  }, 8000);
  msgEl.style.transition = "opacity 160ms linear";

  /* ============================================================
     AI (demo mode)
  ============================================================ */
  function aiStep(){
    const head = snake[0];
    const opts = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}]
      .filter(function(d){ return !(d.x === -dir.x && d.y === -dir.y); });

    let best = null, bestScore = -1e9;

    for (let i=0;i<opts.length;i++){
      const d = opts[i];
      const nx = head.x + d.x, ny = head.y + d.y;
      if (nx < 0 || nx >= COLS || ny < 0 || ny >= ROWS) continue;

      let free = 0;
      for (let j=0;j<opts.length;j++){
        const ex = nx + opts[j].x, ey = ny + opts[j].y;
        if (ex >= 0 && ex < COLS && ey >= 0 && ey < ROWS) free++;
      }
      let score = free * 3;

      if (passengers.length){
        let md = 1e9, hit = false;
        for (let j=0;j<passengers.length;j++){
          const p = passengers[j];
          const dd = Math.abs(p.x-nx) + Math.abs(p.y-ny);
          if (dd < md) md = dd;
          if (p.x === nx && p.y === ny) hit = true;
        }
        score -= md * 2.4;
        if (hit) score += 120;
      }

      if (d.x === dir.x && d.y === dir.y) score += 1.2;
      score += Math.random()*0.6;

      if (score > bestScore){ bestScore = score; best = d; }
    }

    if (best) dirQueue.push(best);
  }

  /* ============================================================
     MAIN LOOP
  ============================================================ */
  let lastT = 0, acc = 0;

  function loop(t){
    if (!lastT) lastT = t;
    let dt = t - lastT;
    lastT = t;
    if (dt > 120) dt = 120;
    animT += dt;

    if (state === "playing" || state === "demo"){
      if (state === "demo" && acc === 0) aiStep();
      acc += dt;
      let guard = 0;
      while (acc >= tickMs && guard < 4){
        acc -= tickMs;
        guard++;
        if (state === "demo") aiStep();
        if (state === "playing" || state === "demo") step();
      }
    } else {
      acc = 0;
    }

    // sparks decay
    if (sparks.length){
      for (let i=sparks.length-1;i>=0;i--){
        sparks[i].life -= dt/80;
        if (sparks[i].life <= 0) sparks.splice(i,1);
      }
    }

    drawBoard();
    requestAnimationFrame(loop);
  }

  /* ============================================================
     BOOT
  ============================================================ */
  function boot(){
    buildMini();
    seedDepartures();
    newSnake();
    layoutBoard();
    updateHUD();
    renderMini();
    updateFoot();
    requestAnimationFrame(loop);
  }

  window.addEventListener("resize", function(){
    layoutBoard();
  });

  if (window.ResizeObserver){
    const ro = new ResizeObserver(function(){ layoutBoard(); });
    ro.observe(boardStage);
  }

  if (document.readyState === "complete") boot();
  else window.addEventListener("load", boot);

  // first paint safety
  setTimeout(function(){
    if (!cell || cell < 6) { layoutBoard(); drawBoard(); }
  }, 300);

})();
</script>

<script>
/* departures board lives in the left rail; mount it below the consist legend
   so the whole departure board is present on first paint */
(function(){
  var hud = document.querySelector(".hud");
  if (!hud) return;
  var head = document.createElement("div");
  head.className = "panel-head";
  head.textContent = "Departures \u00B7 Board";
  var board = document.createElement("div");
  board.id = "departuresBoard";
  board.style.overflow = "hidden";
  hud.insertBefore(board, document.querySelector(".keycaps"));
  hud.insertBefore(head, board);
})();
</script>

</body>
</html>
Reasoning trace (18,227 tokens)
Let me build a snake game styled as a train picking up passengers from station platforms.

**The core ask.** "A snake game where the snake is a little train picking up passengers waiting on the platform." That's a toy-box brief, and the right answer is not a snake skin with train sprites bolted on — it's a railway dispatch board that happens to play snake. The whole screen is a cabinet; the playfield is a track diagram; the HUD is a departure board screwed to the frame. The tension I want is **flat 2D mechanical precision against 3D toy volume**: every control, label and diagram is hard-edged, flat, printed; the only rounded, shaded, shadowed things in the entire composition are the locomotive, the wagons and the passengers. That contrast *is* the art direction. Break it and the piece turns into generic browser chrome.

**Palette.** Warm enamel, not dark-mode. Page base `#EDE3CE` (cream enamel), cabinet body `#E2D4B7` sand, board interior `#241E1B` warm near-black, rail bed `#171310`. Text ink `#1B1714`, muted `#7A6B58`, board type `#F3EADA`. Rails `#8D8A85` with sleepers `#4A423B` at 18%. Signal green `#1F7A4D` for GO, `#C2352B` signal red for STOP/delay/sparks.

The voltage is `#FF6A13` signal orange, and its discipline is absolute: it fires only on **locomotive, rails that are occupied, the headlight cone, the fare readout and the CTA slab**. Never body text, never a section background. The station furniture — platform, signs, label chips — is platform cream `#F3EADA` on ink.

**And then the one high-chroma signal: consist livery.** Wagons draw from `#2E86C1` sky, `#F2B21C` marigold, `#7B4FA8` plum, `#2FA07A` mint, `#D6456B` rose, `#F26B21` tangerine. These colors appear *only* on rolling stock and on the matching legend chip. This is the trick that makes the game legible: the player reads their own snake by colour without a single word of instruction, and the HUD legend line becomes a live, growing colour bar rather than a stats row.

**Typography.** Three voices, never blurred. **Stencil** = Oswald 700 uppercase, the station board (88px display, 34px board values). **Mono** = JetBrains Mono, the dispatcher's typewriter, everything 12px or under, tracked 0.14em, uppercase (11px labels, 13px value rows, 20px signature). **Humanist** = Inter 400/500 at 17px, lowercase, and it is *forbidden* on the board — that rule is what makes the few lowercase sentences read as the friendly voice of the railway rather than more signage. The scale jump 88 → 34 → 17 → 11 with no intermediate headings is deliberate: this is signage, not a document.

**Layout system.** One 32px modular unit, a fixed cabinet filling 100vh with 32px inset padding. Three horizontal zones: header rail (88px), main stage (flex), footer ribbon (64px). The stage is a three-column grid: `240px | 1fr | 220px` with 32px gutters — timetable HUD left, track board centre, mini-map and legend right. The board takes roughly 70% of the stage width and is the only element with depth: a `10px` black frame, `4px` sand rule, inset dark chassis, `0 8px 0 #171310` hard print block under it, no blur anywhere.

Radius is 0 everywhere except the rolling stock. Not 2px. Zero. Shadows are solid offsets only — `4px 4px 0 #1B1714` for chips and buttons, `0 8px 0 #171310` for the board. No blur, ever.

**Two deliberate grid breaks**, because a symmetric three-column page would read as a dashboard. Break one: the contact rail — three 2px ink lines with 6px wheel dots sitting on them — starts at the left page margin, crosses *under* the HUD and the board, and exits past the right margin. The grid is drawn on top of it. Break two: an 88px circular brass shed plate (`#C9A227`, `2px` ink ring, `SHED No. 7 · 001` around the ring) overlapping the board's top-left frame corner by half its diameter. That stamp is the emblem of the piece and the only ornament besides two ink hairline rules.

**Board geometry.** Grid 38 × 24 cells at 24px nominal = 912 × 576. That sits perfectly inside a 1280-wide stage column and scales down cleanly to 375 via `min()`-clamped `--cell`, so the aspect is locked and nothing reflows. The 38×24 grid divides as 8 | 20 | 8 | 2 | 8 | 2 columns for the two platforms (8 tiles wide each) and the yard throat below — nice and balanced, which means the geometry itself confirms the layout call.

**Platform tiles:** cream, 0 radius, 6px inset 45° ink hatch on the top and bottom edge rows only, a `PLAT. 1` stencil label tile, 3px rail lines on the edges facing the track. **Sleepers** are 3px lines every 2 cells, rails at 2px `#8D8A85`. The platforms are cream *blocks*, so there is no such thing as "rail on platform" — the rails simply terminate against them. Cleaner, and it removes the edge case.

**Motion.** Two modes, and the contrast between them is the personality. **Roll** is snappy and mechanical: the train advances one cell per tick, `linear`, never eased — easing on train motion would instantly destroy the toy-train illusion. Coupling lag is the signature move: each wagon's position is sampled from the locomotive's history with *k* segments of delay, so it follows the exact path around corners, and on direction change the wagons visibly swing through the arc one after another. Body bob is `2px`, wagon-only, 120ms `cubic-bezier(.5,0,.5,1)`, phase-offset 40ms per wagon.

**Board tilt** is the signature moment: on pickup, `rotateX(±3deg)` toward the platform, then springs back with one 1.5° overshoot. Camera shake of the *inner board only* (frame stays bolted) 3px, 80ms, plus a signal-red spark. The three headline effects — tilt, shake, sparks — run on rAF with manual easing rather than the Web Animations API, so the spring is exactly mine and there is no engine dependency to reason about.

**Ambient**: a 90px orange headlight cone from the smokestack at 35% opacity, rotated live to the locomotive's heading; a CSS `feTurbulence` grain plate at 4% over the board only; a slow steam plume from the chimney. Under `prefers-reduced-motion`, the train still rolls (it's the game), but shake, tilt, bob and sparks are off.

**Sections.** *Header rail* — wordmark `PLATFORM 7` in 88px stencil over a mono kicker, a rule, a running message, then the high-score chip and three mono control buttons. *HUD* — the departure board: six rows on 2px ink rules, big mono values, then the TRAIN 07 consist legend of livery chips with passenger dots, then two square key caps. *Board* — the game. *Right rail* — the mini-map (pure CSS grid, isomorphic to the board, head cell orange, wagons livery, platforms cream) and an uppercase station-sign list of what's waiting at each platform. *Footer* — three lines of lowercase timetable copy on the left, a mono ticker of live stats in the centre, a four-colour barcode block at the right terminus.

**Voice.** Station announcer deadpan: proper nouns, units, no exclamation marks. `DEPARTED ON TIME`. `DO NOT LEAN OUT`. Every failure has a railway name. And no emoji anywhere — icons are inline SVG at 1.5px stroke, square caps.

**Game rules.** Move one cell per tick, tick 140ms, slow to 115ms at 20 wagons and 95ms at 35. A passenger waiting on a platform tile is collected on contact. *The classic snake constraint does not apply:* wagons may pass through themselves and through the locomotive — the platform handles the traffic — so the only death conditions are the buffer stops at the board edge and the platform ramps. That keeps the game forgiving and keeps the loop about the pickups. After 5 consecutive uncollected passengers, the next one is a luggage cart worth 250 and length +2. Arrow keys / WASD, Space to pause and to restart, plus a proper touch D-pad on small screens.

**Responsive.** ≥1100px the full cabinet. 768–1099 the HUD drops to a horizontal strip above the board, the mini-map drops below it. <768 the header shrinks to 48px, the board keeps 38×24 at a smaller cell, the right rail becomes a two-up card row, and a 160px touch D-pad appears. The first playable frame must never be below the fold.

**Build conventions.** One self-contained HTML file. Fonts from Google Fonts (Oswald, JetBrains Mono, Inter) with system fallbacks so first paint is never blocked. **No image assets at all** — rolling stock, platform, rails, grain and icons are all procedural CSS/inline SVG, exactly as the concept demands. Game loop is canvas 2D at `devicePixelRatio`, overlay chrome is DOM/SVG.

Now, the build.

Key technical pieces:
- Fixed cabinet layout, 32px inset, header rail 88px, main stage three columns, footer 64px.
- Canvas track board, 38×24 cells, 24px = 912×576. Canvas sized responsively — better to use a canvas with CSS width 100% and internal resolution scaled, or draw with fixed 912×576 backing and scale via CSS. Use fixed internal resolution 912×576 with devicePixelRatio scaling... simpler: canvas.width = 38*24*dpr etc., style aspect-ratio.
- Game: snake moves one cell per tick. Direction queue, no reversing. Body = locomotive + wagons. Length starts 4 (locomotive + 3 wagons). Pickup: passenger on platform → +1 wagon, score. Luggage cart every 5th → +2 wagons, +250.
- Platforms at y=2..5 and y=18..21, x=2..15 and x=22..35. Hmm, but the train moves on the whole grid; platforms are the regions where passengers wait. The train presumably can travel over the platform tiles? In snake, the snake travels everywhere. Let's say the track is the whole board; platforms are painted zones where passengers wait. Passenger spawn only on platform tiles. That's the interpretation. But if the train drives over the platform it's fine — it "pulls in". Maybe simpler: track is the whole board, platforms are zones. I'll do that; it's legible and playable. Actually wait — it'd look odd to have rails across the platform. Let me draw rails everywhere but on platform tiles draw the platform surface (cream) with rail edges at y=1 and y=6 boundaries... Hmm.

Alternative cleaner design: the board is a station throat: platforms are blocks the train cannot enter (obstacles). But snake with obstacles is fine — passengers wait on the platform edge... then how does the train pick them up? It stops alongside. That complicates.

Best: keep it simple and readable — the whole board is track, platform tiles are cream-colored platform surfaces with rails drawn on them too (like a platform road), passengers stand on platform tiles, train rolls over them and collects. Actually let me instead do: platform rows are cream blocks with hatch, and rails are drawn only on non-platform cells... then the train on platform tiles sits on cream which is fine visually — it's "at the platform". And the train can still move through. Hmm, but visually the snake traveling over cream looks like it's on the platform which is a bit weird but acceptable and reads well.

Let me reconsider geometry: 38 wide × 24 tall. Platforms x 2-15 (14 wide) rows 2-5 (4 tall), and x 22-35 rows 18-21. That leaves track corridors. Fine.

Actually, to make it look like a real station: draw rails on every cell (sleeper pattern), and draw platform as cream blocks *with rail lines running through the middle*? Hmm. Let me do: platform blocks are cream with ink hatch top/bottom edges, and the rails run along rows 1 and 6 (the tracks alongside)... too complex.

Decision: The board is a track diagram. Rails drawn on all cells as a subtle grid of sleeper lines. Platform tiles drawn as cream blocks with hatch on their outer edges and a "PLAT. 1" label. Train drawn on top of everything. Passengers sit on platform tiles. This reads fine and the contrast is good.

Hmm, but train over cream platform: the train is orange/colorful so it reads. Good.

Wait — one more consideration for the "rails stop at platforms" detail: I'll draw rails only on non-platform cells, and on platform tiles draw 2px ink rails along the tile edges facing the track (i.e., rows 2-5 all have rails... no). Let me just draw rails everywhere but on platform tiles draw them in a lighter tone (#8D8A85 at low alpha). Simpler: rails everywhere except platform tiles where I draw the cream block. Then the train travels on cream. It's fine. Actually I think the cleanest read: platform blocks are cream, and there's a rail line running along the row just below/above the platform... no, passengers are IN the platform area.

Final: Draw grid of rails everywhere. Then draw platform blocks (cream + hatch). Passengers on platform tiles. Done. The rails on the platform are covered by the platform — so effectively rails stop at platform edges and the train is "on the platform road". Actually that's realistic-ish for a diagram. Good enough.

Hmm, let me refine: draw platform cream block, then draw 2px rail lines along the two horizontal boundaries of each platform (top row edge and bottom row edge), representing the platform road tracks. And keep general rails everywhere else. I'll implement: rails drawn on cells that are NOT platform cells. Plus within platform, draw a thin rail at the vertical center rows? No. Keep simple.

Let me now think about rendering details.

Canvas draw function:
1. Fill #241E1B.
2. Sleepers: for each cell, draw 3px line every 2 cells in #4A423B at 18% opacity. I'll draw horizontal-ish sleeper lines: every 2 cells vertically, a 3px horizontal line across the board at low alpha. And rails: 2px lines forming a grid? Real track diagram: rails as two parallel lines along rows. Let's do: for each row y, draw two horizontal 2px lines at y*cell + 8 and y*cell + 16 in #8D8A85 at 22% alpha — that gives many track lines. Hmm too busy.

Simpler and prettier: draw the track grid as a faint cell grid (1px lines #8D8A85 at 10%), plus sleepers: vertical 3px ticks every 2 columns on rows... Honestly a classic snake board with subtle grid + sleeper texture works.

Let me do: background #241E1B. Then draw "sleeper" horizontal bands: every 2 cells, a 3px line of #4A423B at 18% across full width. Then rails: vertical 2px lines at every cell boundary? no.

OK I'll design the track as a set of horizontal rail pairs along each row? That's 24 tracks — a yard! That's actually charming: a marshalling yard seen from above. Each row is a track: two rail lines at 2px #8D8A85 (at ~35% alpha) spaced 12px apart within the row, with sleepers 3px #4A423B perpendicular every 2 cells. Then vertical movement crosses tracks — visually the snake moves on a grid anyway. Hmm, the snake moving vertically over horizontal rails looks odd.

Better: a plain grid of rails in both directions is a "track diagram" abstraction — acceptable and clean. I'll draw: faint 1px cell grid lines in #8D8A85 at 12%, and sleeper ticks (3px, #4A423B, 18%) along every 2nd cell on both axes at cell edges. Plus platform blocks. Fine.

Actually, let me make it look nicer: draw the "ballast" as dark, and draw a rail pair along every cell center line horizontally and vertically at very low alpha... enough deliberation. Implementation plan:

- fill board dark
- for each cell not platform: draw 2px horizontal line at cell top+? Hmm.

FINAL DECISION (visual): 
- Ballast: #241E1B.
- Sleepers: for every cell, draw a 3px line perpendicular to... draw a 3px horizontal line spanning the cell width at cellY + cell/2, color #4A423B, alpha 0.18. Every cell → dense hatch, gives texture.
- Rails: two vertical 2px lines per cell column at x+6 and x+18 running full height? That's a full vertical rail grid — with horizontal sleepers that literally reads as a rail yard rotated. And horizontal rails too? no.

OK simplest that reads great: draw the grid lines only — 1px #8D8A85 @ 0.12 both directions (the "track diagram"), plus sleeper bands every 2 rows: 3px #4A423B @ 0.18 full width. It's a subtle board texture. Move on. The train and platforms carry the design.

Platform rendering:
- cream #F3EADA block with 1px ink border.
- Hatch: top 6px and bottom 6px strips of 45° ink lines (draw with clip + diagonal lines).
- Label "PLAT. 1" stencil 13px ink, drawn in a small ink-filled chip at the left of the platform? Better: draw label text in ink at platform's left-bottom area. I'll draw an ink rectangle chip with cream text "PLAT. 1" — but label type is stencil ink over platform. I'll draw ink text directly on cream. Good.
- 3px rail line along the edges facing the track: for top platform, bottom edge (row 5 bottom) gets a 3px #8D8A85 line... Actually "edges facing the track" — I'll draw 3px rail lines along the entire platform's top and bottom edges in #8D8A85.

Passengers: procedural SVG-ish shapes drawn in canvas: 6×10 body rect, 5px circle head, 8px case. Colors from a small palette (ink? or livery colors). Passengers should be distinct: use cream/white body with colored coat? Let's draw: coat rect in a passenger color (from a small set of muted colors), head circle #E9C9A3-ish skin, case ink. Fine. Simple.

Luggage cart: 12×8 marigold rect with ink outline + 2 wheels.

Train rendering:
- locomotive: rounded rect body with orange gradient? I'll keep to no gradients except the headlight cone. So flat fill #FF6A13 with ink outline 2px, cab block, boiler cylinder, chimney, dome, 3 wheels, cowcatcher. Drawn at cell size 24px → locomotive ~22px wide. Small but ok. Actually draw train pieces at cell scale with some padding: locomotive occupies the cell (24×24) drawn slightly larger (26×22). Since it rotates with direction, I'll draw in a rotated context.

Implementation: draw each segment at (cx, cy), rotate by angle of its heading, draw a locomotive (for head) or wagon (for body) drawn facing right at origin.

Locomotive drawing at 24-unit scale facing right:
- cowcatcher: triangle at x=+11
- boiler: rect from -4..+8, y -5..+5, orange, ink outline
- cab: rect -11..-2, y -8..+7, darker orange #E0500B
- chimney: rect +5..+8, y -11..-5
- dome: small circle at x=0,y=-6
- wheels: 3 circles radius 3 at x=-7,-1,+6, y=+7
- headlight: small cream circle at x=+8,y=-4

Wagon: rounded rect 20×14 with 2px ink outline, livery fill, roof line, 2 wheels, plus 2-3 passenger silhouettes (dark ink dots/shapes) inside. Coupling: 4px ink bar between segments — draw a short line from previous segment.

Since segments follow a path grid, spacing is exactly one cell (24px) so wagons are adjacent. That's fine — draw wagons 22px wide so there are small gaps.

Headlight cone: 90px orange triangle at 35% opacity from the smokestack, rotating with direction. Drawn before/after train — draw before train so it's under it. Use globalAlpha.

Grain: CSS noise via SVG feTurbulence as a data URI background on an overlay div at 4% opacity. That's inline SVG in CSS — allowed (self-contained).

Sound: skip? "SFX tick / steam puff / coupler clank" — Web Audio requires user gesture; I can include a small optional audio toggle. To keep it safe and error-free, I'll implement a tiny WebAudio synth created on first user interaction, with a SOUND ON/OFF button. Must not throw if blocked. Use try/catch and lazy AudioContext. Default: sound ON but context created only on first gesture. Fine.

Actually to minimize risk, I'll include it — it's nice. Keep it simple: oscillator beeps.

Game loop: use setTimeout/rAF with accumulator. Tick 140ms, ramping.

Game states: 'demo' (auto-pilot), 'playing', 'paused', 'over'.

Demo AI: simple greedy — move toward nearest passenger, avoid walls and self... but self-collision isn't a death condition here (no self-collision at all). Only wall collision. So AI: pick direction toward target, avoid reversing, avoid wall. If next step hits wall, turn along the wall. Good enough.

Actually with no self-collision, the game is fairly easy. That's OK — it's a friendly game. Maybe add self-collision as an option? I'll stick to no self-collision: wagons may pass through themselves; the platform handles the traffic. Since the game is easy, ramp speed.

Death: wall collision → red flash, DRAW signal, game over panel with RESTART, score to DEPARTURES.

Score: fare 50 each; consecutive 5 → luggage cart bonus 250 and length +2.

Delays: on death.

Top speed: computed from ticks? Just show km/h = round(600/tickInterval)? Show current speed km/h e.g. 42. I'll show `Math.round(3600/tickMs*2)`... whatever, display something plausible: speed = Math.round(1/tickMs*1000/24*3.6*... ). Let's do: kmh = Math.round((1000/tickMs) * 24 / 3.6) → at 140ms: 7.14 cells/s * 24px = 171 px/s... hmm. Just: kmh = Math.round(6000/tickMs) → 42.8 at 140ms. 

Layout implementation:
- html/body: background #EDE3CE, height 100%, overflow hidden? "Page: 100vh, no page scroll." But on small screens (375px wide), a full three-zone cabinet won't fit in 800px height with a 38×24 board... At 375 wide, board width ~ 375-32 = 343, aspect 38:24 → height 217. Then header 48, footer, HUD panels stacked... total might exceed 800. The requirement: "The first playable frame must never be below the fold" and "no page scroll" for desktop. I'll allow scrolling on small screens via overflow auto on body at <768px, while the board is at top. Actually "no horizontal scrollbar" is the hard rule. Vertical scroll on mobile is acceptable. But better: make it fit by compressing. I'll use `min-height:100vh` and allow vertical scroll on narrow screens.

Let me structure with CSS grid:

```
.cabinet { padding: 32px; height: 100vh; display:flex; flex-direction:column; gap:32px? }
```
Zones: header 88px, main flex:1, footer 64px, with gutters 32px.

Main stage: `display:grid; grid-template-columns: 240px 1fr 220px; gap:32px;` and the board centered.

Board sizing: needs to fit within available height too. Board aspect 38:24 = 1.583. Available height = 100vh - 32*2 - 88 - 64 - gaps(32*2) = 800-64-88-64-64 = 520. Board width at 1280: 1280-64-240-220-64 = 692 → height 437. OK fits under 520. Good. I'll use `width:min(100%, calc((100vh - 320px) * 1.5833))` — messy. Better: board container `flex:1; display:flex; align-items:center; justify-content:center; min-height:0;` and board element `aspect-ratio: 38/24; width:100%; max-height:100%;` with `object-fit`-ish: use `height:100%; aspect-ratio:38/24; max-width:100%;` Actually with aspect-ratio and both max-width/max-height, set:
```
.board-frame { aspect-ratio: 38/24; width:100%; max-height:100%; }
```
If max-height constrains, width won't shrink automatically with aspect-ratio... Actually with `aspect-ratio` and `max-height`, the box does shrink width to preserve ratio when height is constrained? In CSS, `aspect-ratio` with `width:100%` and `max-height` — the max-height clamps height, and since width is definite (100%), aspect-ratio is ignored for width. So the canvas would be squashed. Solution: use `height: 100%; width: auto; aspect-ratio: 38/24; max-width: 100%;` — then height is definite... also problematic.

Robust: use a wrapper with `container` and set board size via JS? Or use `object-fit` on the canvas: canvas has intrinsic dimensions (its width/height attrs) and CSS `width:100%; height:auto; max-height:100%; object-fit:contain;` — canvas behaves like a replaced element, so `object-fit: contain` works and preserves aspect. Yes! Canvas has intrinsic ratio; `width:100%; height:100%; object-fit:contain;` centers it. But then the frame around it wouldn't match. Hmm.

Alternative: JS sizing. Simple and reliable: measure the stage column, compute cell size = floor(min(w/38, h/24)), set board frame width/height = cell*38 / cell*24, canvas backing = that * dpr. On resize, recompute. That's clean and gives crisp pixels. I'll do that.

But then the "board occupies ~70% of stage width" — fine.

Let me just implement `resizeBoard()` in JS with ResizeObserver on the board stage.

Layout at 1280: stage columns 240 / 1fr / 220 with 32 gaps. 1fr = 1280-64(padding)-240-220-64 = 692. Available stage height = 800 - 64 - 88 - 64 - 64 = 520. min(692/38=18.2, 520/24=21.7) → cell 18, board 684×432. Good.

At 375 width: cabinet padding 16px, stage single column, board width 343 → cell 9 → 343×216. That's small but playable-ish. Fine.

Contact rail: 2px ink lines with 6px wheel dots, running the full width under everything, crossing under panels. I'll place an absolutely positioned element in the cabinet at a fixed y (e.g., top: calc(100vh - 110px)) with 3 lines, behind content (z-index 0), content above with z-index 1. Simpler: put `.contact-rail` as a full-width absolute div at bottom area with pointer-events none. Lines at 2px, spaced 10px, with repeating wheel dots via repeating-linear-gradient circles... I'll use repeating-linear-gradient for the wheels: a 6px dot every 48px. Use radial-gradient? A repeating linear gradient can make squares. Use `background-image: radial-gradient(circle, ink 3px, transparent 3px)` with background-size 48px 22px. That gives dots. Good.

Shed plate: 88px circle, brass #C9A227, 2px ink ring, text on a circular path (SVG textPath) `SHED No. 7 · 001`. Positioned overlapping board top-left corner by half its diameter → absolute at left:-44px; top:-44px relative to board frame. But that would overlap the HUD column... That's the "grid break" intent. OK.

Header: wordmark PLATFORM 7 (88px display — that's huge; with 88px header height, line-height 0.92 → 81px, fits). Kicker mono under it? Header is 88px total; wordmark 88px + kicker won't fit. Display is 88px. Hmm — header rail 88px tall with wordmark at 88px font. Tight. I'll set the header height to 88px and wordmark font-size 72px... At 1280 viewport it's fine to have header ~100px. Let me use font-size: clamp(44px, 5vw, 76px) and header height auto with the three groups centered. Actually keeping 88px display for the wordmark makes a bold statement; but "PLATFORM 7" at 88px Oswald 700 is ~ 460px wide. Fine.

I'll make header a flex row, height 88px, wordmark 76px + kicker 11px stacked, which needs 76*0.92+4+11 = 85. Tight but OK. Let's set wordmark to 68px and give header 88px with the kicker below at 10px. Fine — I'll use 68px, close enough, and the display scale 88px is used in the game-over title? Let me use 88px for the wordmark at ≥1200 and shrink via clamp. Header height then ~104. I'll just not fix header height at 88 rigidly; use min-height 88px.

Hmm, "Build exactly to your spec" — I should honor 88px display. Let me set header min-height:88px, wordmark font-size:clamp(46px, 6vw, 88px), line-height .92, and put the kicker inline after the wordmark baseline area (to the right, mono) rather than below. Actually: wordmark "PLATFORM 7" with the kicker "MODEL RAILWAY DISPATCH BOARD · EST. 1998" placed below-left. Let me measure: 88px * 0.92 = 81px + kicker 11*1.2=13 + 4 = 98px header. Fine — I'll let the header be 100px and reduce footer to 64. Total: 64 padding + 100 + 32 + stage + 32 + 64 = 292 + stage. At 800 → stage 508. Board height 508 → cell 21, width 798. And 1fr column at 1280 is 692 → cell 18. Fine.

Let me now write the code.

HTML structure:

```html
<div class="cabinet">
  <div class="contact-rail"></div>
  <header class="header-rail">
    <div class="brand"><h1>PLATFORM 7</h1><p class="kicker">MODEL RAILWAY DISPATCH BOARD · EST. 1998</p></div>
    <div class="header-mid"><span class="eyebrow">NOW DEPARTING</span><span class="ticker-text">…</span></div>
    <div class="header-right">
      <div class="chip"><span class="chip-label">TOP SPEED</span><span class="chip-val">42 KM/H</span></div>
      <div class="controls">3 buttons</div>
    </div>
  </header>
  <main class="stage">
    <aside class="hud">…</aside>
    <section class="board-stage">
       <div class="shed-plate">svg</div>
       <div class="board-frame"><div class="board-chassis"><canvas id="board"></canvas><div class="grain"></div><div class="gameover">…</div></div></div>
    </section>
    <aside class="right-rail">…</aside>
  </main>
  <footer class="footer-ribbon">…</footer>
</div>
<div class="touch-controls">…</div>
```

Mini-map: 38×24 grid of divs = 912 divs. That's fine but heavy-ish for updates. Instead render the mini-map on a second small canvas? A CSS grid of divs is fine and cheap enough. 912 divs updating every tick (only 4 changed cells) — I'll only update changed cells. Good.

Actually simpler: draw mini-map on a second canvas. But a CSS grid of divs is cleaner and simpler here. Let's do divs with efficient updates: keep array of cell divs, and only update the previous head/tail cells and new head. But at 24 ticks/s, updating 912 background colors each tick = 22k ops/s — trivial. Just do full redraw of changed set. I'll do targeted updates.

Legend chips: dynamically created wagons.

Departures board: 6 rows.

Station list: 3 rows with waiting counts.

Footer: 3 lowercase lines, ticker, barcode (4 colored blocks).

Game over panel: absolutely centered over board.

Now the JS game.

```js
const COLS=38, ROWS=24, CELL=24;
let cellSize = 24;
const platforms = [
  {name:'PLAT. 1', x1:2,y1:2,x2:15,y2:5},
  {name:'PLAT. 2', x1:22,y1:18,x2:35,y2:21}
];
```
Hmm, maybe also give more spawn area. Passengers spawn only on platform tiles.

State:
```js
snake = [{x,y}...] // head first
dir = {x:1,y:0}
queue = []
passengers = [] // {x,y,type:'p'|'luggage',color, bobPhase}
```
Pickup: if passenger at head cell → collect.

Spawn passenger: random platform tile not occupied by snake or existing passenger. Max 6 waiting.

Tick:
- apply next direction from queue
- new head
- if out of bounds → crash
- push head
- if collected → don't pop tail, add wagon count (the visual length grows by 1 automatically since we don't pop), score += fare, etc. For luggage: don't pop twice (i.e., grow by 2).
- else pop tail.

Note: since body segments are just positions, growing happens naturally.

Wagon colors: assign per segment index based on a livery cycle. When growing, new wagons get colors in rotation.

Legend chips: one per wagon (body segments after head), color, and passenger dots (accumulate collected passengers on that wagon — just show 0-3 dots).

Speed ramp: tickMs = 140 for wagons<20, 115 for 20-34, 95 for >=35. Use lerp maybe. Keep simple.

Tilt on pickup: set `boardFrame.style.transform = rotateX(...)`. Use CSS class with transition. Implement: `el.style.transform = 'perspective(900px) rotateX(3deg)'` then after 90ms set to `-1.5deg` then to 0 with transitions. Simple approach with transitions defined in CSS: `.tilt-up { transform: perspective(900px) rotateX(3deg); transition: transform 90ms ease-out; }` then remove class → transitions back with 220ms cubic-bezier overshoot. I'll do with JS and explicit transition values.

Shake: translate on the inner board chassis with a keyframe animation class toggled (need to restart animation: remove class, force reflow, add).

Sparks: draw on canvas as particles (array with life) — easier than DOM. Draw 4-6 short red/cream lines from head, 80ms.

Sound: WebAudio.
- tick: short square blip every tick? Might be annoying. Only on pickup/direction. I'll implement: pickup → two-note clank; crash → descending; button → click. Plus a steam puff noise burst on pickup. Keep gain low.

Demo autopilot:
```js
function aiChoose(){
  const head = snake[0];
  // target = nearest passenger
  let target = nearest passenger;
  // candidate dirs excluding reverse
  // prefer dir that reduces distance, avoid next-step wall
  // if would hit wall, turn along
}
```
Since no self-collision, AI is easy: pick direction minimizing distance to target among allowed moves that don't hit a wall next tick. If none, any safe move.

Also demo should never die easily... it might get stuck in a corner. With wall avoidance for 1 step only it could still die. Add: simulate 3 steps ahead — pick moves that keep safe for 2 steps. Simple: score = -dist + penalty if next cell is a wall. Since it can always choose a safe direction along the border... risk of trapping in a corner? With 1-step lookahead it can always move along the wall; when both perpendicular directions are walls and forward is wall, it dies. To reduce, add 2-step safety: if the best move leads to a cell where all next moves are walls, avoid. Keep it simple, demo resets on death anyway with "DRAW" then restarts after a delay.

Actually a death in demo mode would be fine — it shows the crash state. But then it must restart automatically. I'll restart after 1.6s.

Game-over overlay: shows when state==='over'. In demo mode, show a smaller message? For the demo mode overlay, I'll show a centered slab at the bottom of the board: "CLICK OR PRESS A KEY TO START", and on death in demo show "DRAW · RESTARTING" briefly. Keep the demo running.

Keyboard: arrows/WASD → if state is demo, clicking/keypress starts. The game must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, and the iframe only receives keyboard events after the user clicks it. So: the overlay message is visible; clicking the board starts the game; keys also start (if focus). Handle keydown on window: if demo/over → start (except arrow keys should also start and set direction). I'll treat any arrow/WASD/space/enter as "start".

Pause: Space toggles pause during play. But space also starts. Handle: if playing → space toggles pause; else → start.

Buttons: PAUSE (toggle), SOUND (toggle), RESET (restart).

Let me write the CSS with variables.

```css
:root{
  --cream:#EDE3CE; --sand:#E2D4B7; --board:#241E1B; --ballast:#171310;
  --ink:#1B1714; --ink-muted:#7A6B58; --board-cream:#F3EADA;
  --green:#1F7A4D; --red:#C2352B; --orange:#FF6A13; --brass:#C9A227;
}
```

Fonts: Google Fonts — but "no network requests except CDN URLs explicitly allowed". The task says everything inline, no external files... "except the CDN URLs explicitly allowed by the task". No CDN URLs are explicitly allowed here. Hmm. Using Google Fonts link is an external request — risky under "Everything should be inline". But typography spec names Oswald/JetBrains Mono/Inter. I'll include a Google Fonts <link> (commonly accepted) OR use system fonts. To be safe and self-contained, I'll use font stacks: `Oswald, 'Arial Narrow', Impact, sans-serif` etc. Hmm, without the webfont the design loses a lot. 

I want this self-contained with no network requests. Safer: no external font links, use strong system stacks:
- display/board: `"Oswald", "Arial Narrow", "Haettenschweiler", "Impact", sans-serif` — Arial Narrow is available on many systems; Chrome on Linux sandbox may not have it. Fallback to a condensed-ish stack with `font-stretch: condensed` maybe.
- mono: `ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace` — reliable.
- body: `system-ui, -apple-system, "Segoe UI", Roboto, sans-serif` — reliable.

I'll include the Google Fonts link too? The instruction says no external files... but fonts via CDN are commonly what "CDN URLs explicitly allowed" refers to, and none were allowed. I'll skip external fonts and use system stacks with `font-stretch:condensed` + letter-spacing to approximate. Hmm, the display font matters a lot for the look. 

Compromise: use `Impact` fallback for display? Impact is heavy-condensed and available on Windows/macOS, not Linux. In a Linux headless Chrome, likely DejaVu Sans only. Rendering will look plain but fine.

Actually — I could embed a font as base64... too large. I'll use the system stack and lean on uppercase + tight letter-spacing to get the signage feel. I'll declare `font-family: 'Oswald','Arial Narrow',...` first so if the environment has Oswald it uses it.

Hmm, actually many of these sandboxed renderers do have network access for CDNs and the instruction "Everything should be inline ... No external files ... except the CDN URLs explicitly allowed by the task" is strict. I'll go without external requests. Decision made.

Now let me write the whole thing carefully.

Canvas rendering details:

```js
function draw(){
  const ctx = boardCtx;
  ctx.setTransform(dpr,0,0,dpr,0,0);
  ctx.clearRect(0,0,W,H);
  // ballast
  ctx.fillStyle='#241E1B'; ctx.fillRect(0,0,W,H);
  // sleepers: every 2 cells horizontal band
  ctx.fillStyle='rgba(74,66,59,0.18)';
  for(let y=0;y<ROWS;y+=2){ ctx.fillRect(0, y*cell+cell/2-1.5, W, 3); }
  // grid rails
  ctx.strokeStyle='rgba(141,138,133,0.12)'; ctx.lineWidth=1;
  for(let x=0;x<=COLS;x++){...}
  for(let y=0;y<=ROWS;y++){...}
  // platforms
  drawPlatforms();
  // passengers
  // headlight cone
  // train
  // sparks
}
```

Wait, sleeper lines every 2 cells horizontally across the whole board look like horizontal stripes. Combined with the vertical grid it's ok.

Hmm, actually let me make the track read better: draw vertical "rail pairs" on columns? No. Moving on — the grid + sleepers is a subtle texture, good.

Platform drawing:
```js
function drawPlatform(p){
  const x=p.x1*cell, y=p.y1*cell, w=(p.x2-p.x1+1)*cell, h=(p.y2-p.y1+1)*cell;
  ctx.fillStyle='#F3EADA'; ctx.fillRect(x,y,w,h);
  // hatch top & bottom 6px
  ctx.save(); ctx.beginPath(); ctx.rect(x,y,w,6); ctx.clip(); hatch...
  // border
  ctx.strokeStyle='#1B1714'; ctx.lineWidth=2; ctx.strokeRect(x+1,y+1,w-2,h-2);
  // rails facing track: 3px lines at top and bottom edges
  ctx.fillStyle='#8D8A85'; ctx.fillRect(x,y,w,3); ctx.fillRect(x,y+h-3,w,3);
  // label
  ctx.font=`700 ${Math.round(cell*0.62)}px ${DISPLAY}`;
  ctx.fillStyle='#1B1714'; ctx.textAlign='left'; ctx.textBaseline='middle';
  ctx.fillText(p.label, x+10, y+h/2);
}
```
Label in the middle of the platform might collide with passengers. Put label at left part of the platform, and constrain passenger spawn to x >= x1+5 for the top platform. Simpler: draw the label at the platform's center and allow passengers anywhere; slight overlap is ok-ish. Better: draw label in an ink chip in the corner of the platform: `ctx.fillStyle='#1B1714'; fillRect(x+6,y+6, 62, 20);` then cream text. Hmm, that's a chip with 0 radius — fine and looks like signage. But ink text directly on the platform is cleaner. I'll draw ink text on the platform at bottom-left with 8px inset, and reserve those tiles for no spawns (skip tiles where the label is). I'll just compute label rect and skip spawn tiles inside it. Meh — I'll draw the label vertically centered at the left edge and exclude the first 5 columns of each platform from spawning. Good.

Wait, platform 1 is x=2..15 (14 wide), excluding first 5 leaves 9 columns × 4 rows = 36 tiles. Fine.

Passenger drawing at cell center:
```js
function drawPassenger(px,py,c){
  // body
  ctx.fillStyle=c; ctx.fillRect(px-3, py-2, 6, 9);
  // head
  ctx.fillStyle='#E8C9A4'; ctx.beginPath(); ctx.arc(px, py-5, 2.6,0,TAU); ctx.fill();
  // legs
  ctx.fillStyle='#1B1714'; ctx.fillRect(px-3,py+7,2.5,3); ctx.fillRect(px+0.5,py+7,2.5,3);
  // case
  ctx.fillStyle='#1B1714'; ctx.fillRect(px+5,py+2,6,5);
}
```
Add bob: py + Math.sin(t/360 + phase)*1.

Train drawing with rotation:

```js
function drawTrain(){
  for(let i=snake.length-1;i>=0;i--){
    const seg = snake[i];
    const next = snake[i-1] || dir; // heading source
    const ang = Math.atan2(next.y-seg.y, next.x-seg.x);
    ...
  }
}
```
Careful: for the head, heading = dir. For segment i, its heading is toward segment i-1.

But when the train wraps around corners, atan2 gives the direction to the previous cell — good.

Bob: wagons offset y by 2px * phase. Applied after rotation in screen space: I'll add bob to the translation before rotate.

Coupling bars: draw between consecutive segments — a 4px ink line at midpoint. Since segments are 24px apart and wagons ~20px wide, a small bar appears in the gap. Draw before wagons.

Locomotive draw (facing +x), scaled with `s = cell/24`:

I'll define using absolute units based on cell size so it scales. Use ctx.scale(s,s) and draw in 24-unit space.

```js
function drawLoco(){
  // cowcatcher
  ctx.fillStyle='#C2352B'? 
```
Hmm — cowcatcher: use ink. Let me define in 24-space centered at 0:
- cowcatcher: path (10,-5) (14,0) (10,5) → fill #C2352B? Use ink for outline, orange for body. I'll use ink fill for cowcatcher and wheels, orange for boiler/cab.

Shapes:
- cab: rect(-11,-9,9,16) fill #E2551A? Use `#FF6A13` for boiler and `#D94E0B` for cab.
- boiler: rect(-3,-6,13,11) rounded? radius 0 rule applies to UI, not the toy (the toy is allowed to be rounded/3D). Use rounded rect r=3.
- chimney: rect(4,-12,4,7) + cap rect(3,-13,6,2.5)
- dome: arc at (0,-6) r=3
- wheels: circles r=3.2 at x=-7,-1,6, y=7.5; plus small r=2 at x=8,y=8.
- headlight: rect(10,-5,2.5,4)? plus circle.
- window: cream rect on cab.
- outline: draw everything with 1.5px ink stroke.

This is a lot of canvas code but fine.

Wagon draw (facing +x):
- body: rounded rect(-10,-7,20,14) r=3, fill livery, stroke ink 1.5
- roof: rect(-10,-7,20,3) darker? add a line.
- windows/passengers: 2-3 ink silhouettes inside: small circles + rects in rgba(27,23,20,.55).
- wheels: 2 circles r=2.6 at x=-5,5, y=8.5
- link: small rect at x=10.

Let's write it.

Headlight cone: from locomotive head position, rotated to dir:
```js
ctx.save(); ctx.translate(hx,hy); ctx.rotate(ang);
const g = ctx.createLinearGradient(0,0,90,0); // allowed? "No gradients" is a UI rule; the cone is explicitly "35% opacity orange triangle". Use flat fill with alpha.
ctx.fillStyle='rgba(255,106,19,0.16)'; // triangle
ctx.beginPath(); moveTo(12,0); lineTo(12+90,-34); lineTo(12+90,34); closePath; fill();
```
Wait cell units: 90px is in screen px. Since we scale by s, use 90/s units... simpler: draw cone in screen px after computing head screen coords and angle. I'll do that (no ctx.scale active).

OK. Let me set up: all drawing in screen px with `s = cellSize/24` scale factor applied via ctx.scale(s,s) around the segment center.

Now the mini-map. 38×24 = 912 cells at 4px + 1px gap = 5px each → 190×120. Right rail 220px wide → fits (with padding maybe 190). Good. Use CSS grid with `grid-template-columns: repeat(38, 4px)` gap 1px.

Update mini-map: rebuild each tick by clearing only previous snake cells... simplest: keep a `miniCells` array; each tick, compute set of snake cells and passengers; diff against previous state map. I'll just store prevColor per cell and update changed ones. Fine.

Now writing the actual code. Let me be careful about the initial demo and the visible "CLICK OR PRESS A KEY TO START" overlay.

Overlay design: a slab across the bottom of the board: orange slab, ink border, mono text `CLICK OR PRESS A KEY TO START`, plus sub-line `DEMO MODE · TRAIN 07 IS RUNNING ITSELF`. Use `pointer-events:none` so clicks reach the canvas, but I'll attach click to the board wrapper.

Also a "PAUSED" overlay.

Time to write. I'll write the full HTML now mentally and carefully.

CSS details:

```css
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--cream);color:var(--ink);font-family:var(--body);}
body{overflow:hidden}
```
On narrow: `@media (max-width:820px){ body{overflow:auto} }`.

Cabinet:
```css
.cabinet{height:100vh;padding:32px;display:flex;flex-direction:column;position:relative;overflow:hidden}
```
With overflow hidden on cabinet, the shed plate overlapping outside board is fine (inside).

Header:
```css
.header-rail{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;border-bottom:2px solid var(--ink);padding-bottom:8px;flex:0 0 auto;position:relative;z-index:2}
```
Hmm the header should have a rule under it. Good.

Wordmark: `font-family:var(--display);font-size:clamp(42px,5.6vw,88px);line-height:.92;letter-spacing:.02em;font-weight:700;text-transform:uppercase`.

Middle: eyebrow + ticker text, max-width 40ch, flex:1.

Right: chip + controls.

Stage:
```css
.stage{flex:1 1 auto;min-height:0;display:grid;grid-template-columns:240px minmax(0,1fr) 220px;gap:32px;padding:24px 0;position:relative;z-index:1}
```
Left HUD panel:
```css
.hud{background:var(--board-cream);border:2px solid var(--ink);padding:16px;display:flex;flex-direction:column;gap:16px;min-height:0;overflow:hidden}
```
Board rows: `.brow{display:flex;justify-content:space-between;align-items:baseline;border-bottom:2px solid var(--ink);padding:6px 0}`.

Legend chips row: `display:flex;flex-wrap:wrap;gap:4px`.

Right rail: mini-map box + station list.

Footer:
```css
.footer-ribbon{flex:0 0 auto;height:64px;display:flex;align-items:center;justify-content:space-between;gap:24px;border-top:2px solid var(--ink);position:relative;z-index:2}
```

Contact rail:
```css
.contact-rail{position:absolute;left:0;right:0;bottom:86px;height:26px;z-index:0;pointer-events:none;opacity:.55}
.contact-rail::before/after + div lines
```
I'll implement with 3 child divs, each 2px ink with wheel dots overlay.

```css
.rail-line{height:2px;background:var(--ink);position:relative;margin-top:8px}
.rail-line::after{content:"";position:absolute;inset:-2px 0;background-image:radial-gradient(circle at 3px 3px, var(--ink) 3px, transparent 3.5px);background-size:46px 8px;opacity:.9}
```
Hmm radial-gradient for wheels. OK.

Shed plate:
```css
.shed-plate{position:absolute;left:-44px;top:-44px;width:88px;height:88px;z-index:3}
```
relative to `.board-frame` (position:relative).

Board frame:
```css
.board-frame{position:relative;border:10px solid var(--ink);box-shadow:0 8px 0 var(--ballast);background:var(--board);width:...}
```
Plus `.board-chassis{border:4px solid var(--sand); position:relative; inset shadow 0 0 0 2px var(--ballast) inside}`. I'll make: outer frame 10px ink, inner 4px sand, then chassis with inset shadow.

Actually: `10px ink frame, 4px sand rule, dark chassis inside, offset 0 8px 0 #171310`. So: `.board-frame{border:10px solid ink; padding:4px; background:sand; box-shadow:0 8px 0 #171310}` then `.board-chassis{background:board; box-shadow: inset 0 0 0 2px #171310}`.

JS sizes the frame: I'll size `.board-frame` content box = canvas size. Use `box-sizing:content-box` on frame.

Let me write the resize function:
```js
function layoutBoard(){
  const stage = boardStage.getBoundingClientRect();
  const pad = 28; // frame total
  const availW = stage.width - pad, availH = stage.height - pad;
  const cell = Math.max(6, Math.floor(Math.min(availW/COLS, availH/ROWS)));
  cellSize = cell;
  const w = cell*COLS, h = cell*ROWS;
  canvas.style.width=w+'px'; canvas.style.height=h+'px';
  canvas.width = w*dpr; canvas.height=h*dpr;
  frame.style.width=w+'px'; frame.style.height=h+'px';
  draw();
}
```
With box-sizing:content-box and border 10 + padding 4 each side → total = w+28.

boardStage needs `display:flex;align-items:center;justify-content:center;min-height:0;min-width:0`.

Canvas dpr scaling: `ctx.setTransform(dpr,0,0,dpr,0,0)` each draw.

Now, the game loop with rAF:
```js
let last=0, acc=0;
function frame(t){ 
  if(!last) last=t;
  const dt=Math.min(100, t-last); last=t;
  if(state==='playing'||state==='demo'){ acc+=dt; while(acc>=tickMs){ acc-=tickMs; step(); } }
  draw();
  requestAnimationFrame(frame);
}
```
Drawing every frame is fine (canvas 684×432). Plus bob/spark animation needs per-frame draw anyway.

Reduced motion: check matchMedia and disable bob/shake/tilt.

Now the step function:

```js
function step(){
  if(dirQueue.length){ const d=dirQueue.shift(); if(d.x!==-dir.x||d.y!==-dir.y) dir=d; }
  const head={x:snake[0].x+dir.x, y:snake[0].y+dir.y};
  if(head.x<0||head.x>=COLS||head.y<0||head.y>=ROWS){ crash(); return; }
  snake.unshift(head);
  const idx = passengers.findIndex(p=>p.x===head.x&&p.y===head.y);
  if(idx>=0){
    const p=passengers.splice(idx,1)[0];
    pickup(p);
    if(p.type==='luggage'){ /* grow 2: skip popping twice */ 
      // push a dummy tail keep: don't pop, and after that also keep one more
      growExtra = 1; // handled below
    }
  } else {
    snake.pop();
    if(growExtra>0){ growExtra--; /* don't pop again next tick */ }
  }
```
Hmm, simpler: maintain `pendingGrowth` counter. Each step: snake.unshift(head); if pendingGrowth>0 { pendingGrowth--; } else { snake.pop(); }. And on pickup of passenger: pendingGrowth += 1 (so total length grows by 1 — wait, if we don't pop this tick we already grow 1). Let's define: on normal move, unshift + pop (length constant). On pickup: unshift, no pop → +1. For luggage: unshift, no pop this tick AND set pendingGrowth=1 → +2 total. So:

```js
snake.unshift(head);
const got = pickupAt(head);
if(got){ if(got.type==='luggage') pendingGrowth += 1; }
if(pendingGrowth>0) pendingGrowth--; else snake.pop();
```
Wait order: if got luggage → pendingGrowth=1; then `if(pendingGrowth>0){pendingGrowth--} else pop()` → doesn't pop this tick, pendingGrowth=0... that gives only +1. Need: don't pop this tick and next tick. So set pendingGrowth=2 for luggage and use:
```js
if(pendingGrowth>0){ pendingGrowth--; } else { snake.pop(); }
```
With pendingGrowth=2: tick1 no pop (becomes 1), tick2 no pop (becomes 0) → +2. Correct. For regular passenger set pendingGrowth=1 → +1. Actually simpler: on pickup just don't pop (handled by setting pendingGrowth=1 for normal, 2 for luggage). Yes as above.

Score: fare 50; luggage 250.

Consecutive counter: `streak++`; if streak%5===0 → spawn a luggage cart next (set flag). Fine.

Speed: tickMs based on snake length.

Spawn passengers periodically: maintain count up to 5; every N ticks spawn one if under max.

Crash: state='over', shake, red flash, record delay in departures, update board. In demo: auto-restart after 1.6s.

Departures board rows update: I'll build rows array of recent runs: `19:42 · TRAIN 07 · 6 WGN · 12 PAX · ON TIME`. Format time as a fake clock starting 19:42 and incrementing.

Now the HUD stats: FARES COLLECTED, PAX ABOARD, WAGONS, TOP SPEED, DELAYS.

"PAX ABOARD" = total collected.

Legend: wagons chips with dots for passengers aboard that wagon (just distribute: each pickup adds a dot to the last wagon).

I'll keep a `wagonInfo` array of {color, pax}. When growing, push new entries. When the snake length changes... snake length = 1 + wagons. Since tail pop happens each step, the wagon count stays constant except growth. But `wagonInfo` order maps to snake indices 1..n. On pop, the last wagon info should be removed. Hmm — conceptually each pickup adds a wagon and passengers accumulate. But the tail pop removes the oldest wagon. So wagonInfo: on pop, shift() (remove oldest = last element since head is index 0... let's define wagonInfo[i] corresponds to snake[i+1], with wagonInfo[0] = newest wagon right behind the loco). On pop → wagonInfo.pop(). On pickup → wagonInfo.unshift({color: nextColor(), pax:1}) and add pax to... hmm.

Simplify: on pickup, add pax to the newest wagon (wagonInfo[0]) and if we grew, unshift a new wagon with pax 0. Actually passengers board the nearest wagon. Fine:
- pickup: `wagonInfo[0].pax++` (if exists) and if growth → `wagonInfo.unshift({color, pax:0})`. Hmm, then the new empty wagon is in front. Whatever — the legend is decorative. Let me do: on growth, unshift new wagon with pax:1; on non-growth pickup... but every passenger pickup grows the train by 1. So each pickup = new wagon with pax 1. Luggage = 2 new wagons? Or 1 wagon with the cart. Let's do: passenger → unshift wagon {pax:1}; luggage → unshift wagon {pax:0, luggage:true} and another {pax:0}. Good enough.

But we must keep wagonInfo length == snake.length-1. On each step that pops the tail, wagonInfo.pop(). Since pickups always grow, lengths stay in sync if I push on growth. Careful with the +2 growth over two ticks: push both at pickup time, and pop only when the tail actually pops. Then wagonInfo.length could exceed snake.length-1 temporarily. Since it's decorative, I'll clamp for display: `wagonInfo.slice(0, snake.length-1)`. Good — safe.

Hmm, but colors: I want each wagon's color to correspond to the drawn segment color. I'll draw segment i color = wagonColor(i) from a function of index? No — if colors were index-based they'd shift every tick as the tail pops. Better: store color on the snake segment itself! `snake[i] = {x,y,color,pax}`. Head has no color (locomotive). On growth, new tail... wait, growth keeps the old tail; new wagons appear at the tail end, not the front. Hmm — physically, when the train grows, a new wagon is coupled at the rear. So the new wagon is at the tail end.

With `unshift(head)` and no pop, the *tail* stays the same and the length grows — the new segment is at the front (behind the loco). Visually indistinguishable in snake terms but semantically the new wagon should appear at the rear. Alternative: on pickup, keep the head moving but add an extra copy of the tail at the end: `snake.push({...snake[snake.length-1]})` and always pop... Let's do: on pickup, `snake.unshift(head); snake.pop(); snake.push(tailClone)` → length +1 and the new segment is at the rear. That's nicer. Implement:

```js
snake.unshift(head);
if(got){ 
   const tail = snake[snake.length-1];
   snake.push({x:tail.x,y:tail.y,color:nextColor(),pax:...});
   if(luggage) snake.push(clone);
} else snake.pop();
```
Hmm but then the new wagon appears at the exact same position as the old tail and will slide away naturally as the train moves — it will "grow" smoothly from the rear. But the tail clone duplicates a position → the trailing segment stays glued for one tick then separates. Since the path-following is exact grid positions, a duplicate tail position would cause two segments at the same cell for one tick and then they'd separate correctly (since each tick moves the head and pops one). Actually with duplicates: positions [h,...,t,t]. Next tick: [h2,h,...,t,t] pop → [h2,h,...,t]. So the duplicate resolves correctly and the train appears to grow from the rear. 

Colors: give the new tail clone a color. Good. And pax count per wagon stored on segment.

So `snake` entries: `{x,y,color,pax}` for i>=1, head `{x,y}`.

Legend chips = snake.slice(1) mapped. 

Now let me handle `wagonInfo` — no longer needed; use snake segments. 

Let me now write everything concretely.

Colors array: `['#2E86C1','#F2B21C','#7B4FA8','#2FA07A','#D6456B','#F26B21']`.

nextColor = liveryColors[colorIdx++ % 6].

Initial snake: head at (8,12), wagons at (7,12),(6,12),(5,12) with colors [0,1,2] and pax 0.

Wait — direction +x means the head is at the rightmost. snake[0] should be the front. If moving right, head at x=8, tail at x=5. Yes.

Initial passengers: 4-5 random on platforms.

Now the mini-map also shows platforms as cream cells. I'll pre-fill platform cells with cream at init.

Station list: `PLAT. 1 · 2 WAITING`, `PLAT. 2 · 1 WAITING`, `SHED · —`.

OK. Also the header ticker message rotates every 8s through the message list.

Time to write the final code. Let me be thorough but efficient.

One more: `PAUSE` button label toggles to `RESUME`; `SOUND ON/OFF`; `RESET`.

Sound implementation:

```js
let actx=null, soundOn=true;
function ensureAudio(){ if(!actx){ try{ actx=new (window.AudioContext||window.webkitAudioContext)(); }catch(e){ actx=null; } } if(actx&&actx.state==='suspended') actx.resume().catch(()=>{}); }
function blip(freq, dur, type='square', gain=0.05, slide=0){ if(!soundOn||!actx) return; ... }
```
Called only after user gesture (click/keydown) → ensureAudio().

Noise burst for steam: create buffer.

OK, writing now.

For the game-over panel: absolutely positioned centered over the board:
```html
<div class="overlay gameover" id="gameover">
  <div class="slab-title">DRAW</div>
  <div class="go-lines">...</div>
  <button class="btn-slab">RESTART</button>
</div>
```
Style: background rgba(23,19,16,.86), full board area, flex center.

Also the "CLICK OR PRESS A KEY TO START" banner: bottom-centered slab.

Now responsive CSS:

```css
@media (max-width:1100px){
  .stage{grid-template-columns:1fr; grid-template-rows:auto 1fr auto;}
  ...
}
```
At <1100, I'll drop the HUD to a horizontal strip above the board and the mini-map below. That means the board stage gets smaller vertically. Let me implement:
- 1100-1400: three columns but narrower (220/1fr/200).
- 768-1099: single column: hud as horizontal strip (flex row, 6 rows in a row), board, right rail as row.
- <768: mobile with touch controls.

Given 1280×800 is the primary target, I'll make sure that's perfect and keep others sane.

For ≤1099: `body{overflow:auto}` and `.cabinet{height:auto;min-height:100vh}`.

Mobile touch D-pad: shown at ≤768px, 160×160 with 4 arrow buttons + PAUSE.

Let me write the code now for real.

Regarding `dirQueue`: max 2 entries.

Edge case: pressing opposite direction at start — guard.

AI function:

```js
function aiStep(){
  const head=snake[0];
  const targets=passengers;
  let best=null,bestScore=-1e9;
  const opts=[{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}].filter(d=>!(d.x===-dir.x&&d.y===-dir.y));
  for(const d of opts){
    const nx=head.x+d.x, ny=head.y+d.y;
    if(nx<0||nx>=COLS||ny<0||ny>=ROWS) continue;
    let score=0;
    // safety: count free neighbors 2 steps
    let free=0;
    for(const e of opts){ const ex=nx+e.x,ey=ny+e.y; if(ex>=0&&ex<COLS&&ey>=0&&ey<ROWS) free++; }
    score += free*2;
    if(targets.length){
      let md=1e9;
      for(const p of targets) md=Math.min(md,Math.abs(p.x-nx)+Math.abs(p.y-ny));
      score -= md*3;
      // strongly prefer moving into a target
      if(targets.some(p=>p.x===nx&&p.y===ny)) score += 100;
    }
    if(d.x===dir.x&&d.y===dir.y) score+=1.5;
    score += Math.random()*0.5;
    if(score>bestScore){bestScore=score;best=d;}
  }
  if(best) dirQueue.push(best);
}
```
Called each tick in demo mode (recomputing each tick is fine).

Hmm: AI targets the nearest passenger; on platforms at corners. Should be OK.

But careful: the AI could oscillate. The `md*3` greedy + tie-break should be fine.

Let me make sure the demo doesn't die constantly — with `free*2` safety and greedy, deaths should be rare. If it dies, restart after 1.6s.

Now, the `crash()` in demo should show a small "DRAW" then restart.

Also note: the game-over overlay shouldn't appear in demo mode (or should show a lighter version). I'll show the overlay in demo too but with a "RESTARTING" auto label? Simpler: in demo, just show a brief red flash and the header ticker says "DRAW · RESETTING SIGNALS", and restart. Keep the overlay for user games.

Alright. Also `updateHUD()` on every change.

Let me write out the HTML/CSS/JS fully now.

For the ticker text in the header, `id="msg"`.

Barcode: 4 colored blocks, 20px wide, heights varying — 24×48px block of 4 livery colors.

Footer left: three lines of lowercase copy — but footer is 64px tall; 3 lines at 13px = ~54px. Tight but ok. Use 12px/1.35.

Hmm, footer has left (3 lines), center ticker, right barcode. At 1280 width that's crowded. Let me put the 3 lines in one block with `font-size:12px;line-height:1.4`, width ~46ch. Center ticker mono 11px. OK.

Actually 64px height with 3 lines of 12px = 50px + padding. Fine.

Let me now consider the header "NOW DEPARTING" center block — ticker message at 20px mono signature. Max width 42ch.

Writing the final answer now.

I need to double check the canvas text font strings — use `700 13px "Arial Narrow", sans-serif`.

Let me write.

```js
const DISPLAY = '"Oswald","Arial Narrow",Impact,sans-serif';
const MONO = '"JetBrains Mono",ui-monospace,Menlo,Consolas,monospace';
```

Final code writing. I'll be careful with syntax.

One thing: `ctx.roundRect` is supported in latest Chrome. I'll use it with a fallback? It's fine in latest Chrome. But to be safe I'll write a helper `rr(ctx,x,y,w,h,r)` using arcTo — safer.

Let me write the draw functions with a helper.

Now write out.

Also: `requestAnimationFrame` loop always drawing — fine.

Potential issue: `layoutBoard` before first draw when the stage has 0 height (fonts loading). Use `window.addEventListener('load', layoutBoard)` plus ResizeObserver.

Let me write the code.

For the grain overlay:
```css
.grain{position:absolute;inset:0;pointer-events:none;opacity:.04;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E");mix-blend-mode:overlay}
```
Good, inline SVG data URI — self-contained.

Now, final write-up.

Regarding the overlay for demo: place it near the bottom of the board:
```css
.start-banner{position:absolute;left:50%;bottom:18px;transform:translateX(-50%);background:var(--orange);border:2px solid var(--ink);box-shadow:4px 4px 0 var(--ink);padding:10px 18px;white-space:nowrap}
```

Alright, writing the answer.

Let me now actually compose the code text carefully and completely.

I'll write the JS in one script at the end of body.

Key DOM ids:
- #board (canvas)
- #boardFrame, #boardStage
- #miniMap
- #legend
- #statFares, #statPax, #statWagons, #statSpeed, #statDelays
- #msg, #btnPause, #btnSound, #btnReset
- #startBanner, #gameover, #goTitle, #goLines
- #departures (tbody-ish)
- #plat1Count, #plat2Count
- #footTicker

Let me write.

Departures rows: array `departures = [{time,train,wgn,pax,status}]`, render 6 rows.

Initial seeded rows so the board isn't empty:
```
19:42 · TRAIN 07 · 6 WGN · 12 PAX · ON TIME  (this is the live one)
```
I'll seed 5 past rows with plausible data.

Time clock: start at 19*60+42 minutes, increment by 1-3 min per departure.

OK writing now. Also `topSpeed` = max speed observed.

Speed display: `Math.round(6000/tickMs)` → 140→43, 115→52, 95→63. Good ("42 km/h" close enough).

Let me write the final code.

function updateHUD():
```js
faresEl.textContent = String(score).padStart(4,'0');
paxEl.textContent = String(pax).padStart(3,'0');
wagonsEl.textContent = String(snake.length-1).padStart(2,'0');
speedEl.textContent = speed+' KM/H';
delaysEl.textContent = String(delays).padStart(2,'0');
```

Legend render: rebuild on change (pickup/crash) only.

Mini-map update each tick.

OK. Writing now for real. I'll produce the final HTML.

One more consideration on `body{overflow:hidden}` at 1280×800 — the cabinet is exactly 100vh so fine.

Also make sure no horizontal scrollbar: `overflow-x:hidden` on body.

Final code:

I'll now carefully produce it.

For the platform label exclusion in spawn: define for each platform `labelCols: 5` meaning skip x1..x1+4.

Also the passenger spawn must avoid the train.

Let me write `spawnPassenger()`:
```js
function freeTile(){
  for(let tries=0;tries<200;tries++){
    const p = platforms[Math.floor(Math.random()*platforms.length)];
    const x = p.x1 + p.labelCols + Math.floor(Math.random()*(p.x2-p.x1+1-p.labelCols));
    const y = p.y1 + Math.floor(Math.random()*(p.y2-p.y1+1));
    if(passengers.some(q=>q.x===x&&q.y===y)) continue;
    if(snake.some(s=>s.x===x&&s.y===y)) continue;
    return {x,y};
  }
  return null;
}
```

Good.

Now writing.

I also want the crash flash: a red overlay div `#flash` with animation.

And the shake: `#boardChassis` with class `shake` using keyframes.

Tilt: `#boardFrame` transform.

Since boardFrame has the shed plate positioned relative to it and transform creates a containing block — fine.

Careful: transform on boardFrame with perspective could cause layout weirdness but it's transient.

Writing the code now — final.

Let me count the hud rows: FARES COLLECTED / PAX ABOARD / WAGONS / TOP SPEED / DELAYS = 5. I'll add `TICK RATE` = `140 MS` to make 6. Good.

Now produce.