Models 44
Train snakeOne-line

MiMo-V2.6-Flash

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
12 min 43 s
Cost
$0.015
Output tokens
53,860
Spent thinking
39,036 tokens
Speed
70.6 tok/s
Console errors
0
First token after
633 ms
First visible text after
10 min 40 s
Prompt tokens
366
Code size
47.0 KB
Finish reason
stop
Served by
Cohere
Model id
xiaomi/mimo-v2.6-flash
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 · 979 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Peak Line Express — Pick Up The Passengers</title>
<style>
:root{
  --canvas:#F4EDE2; --paper:#EADFCB; --ink:#12161C; --muted:#6E6A61;
  --volt:#FF5A1F; --brass:#FFC24B;
  --l1:#FF5A1F; --l2:#00B39F; --l3:#FFC24B; --l4:#3D5AFE; --l5:#E5E1D8;
  --rail:96px;
  --disp:'Archivo Narrow','Oswald','Arial Narrow','Helvetica Neue',Impact,system-ui,sans-serif;
  --mono:'JetBrains Mono','IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,'Liberation Mono',monospace;
}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
body{
  margin:0;background:var(--canvas);color:var(--ink);
  font-family:var(--mono);font-size:13px;line-height:1.5;
  padding-left:var(--rail);overflow-x:hidden;-webkit-font-smoothing:antialiased;
}
img,svg{display:block}
button{font-family:inherit}
.d{font-family:var(--disp);text-transform:uppercase;letter-spacing:.06em;font-weight:700;line-height:.95;margin:0}
.mono{font-family:var(--mono);font-variant-numeric:tabular-nums}
.lab{font-family:var(--mono);font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--muted);line-height:1.4}
.wrap{max-width:1144px;margin:0 auto;padding:0 48px;width:100%}

/* ---------- PLATFORM RAIL ---------- */
.rail{position:fixed;left:0;top:0;bottom:0;width:var(--rail);background:var(--canvas);
  border-right:1px solid var(--ink);z-index:60}
.rail:before{content:"";position:absolute;right:4px;top:0;bottom:0;width:1px;background:var(--ink);opacity:.55}
.rail-ticks{position:absolute;top:132px;left:0;right:0;display:flex;flex-direction:column;gap:30px;align-items:center}
.rtick{display:flex;flex-direction:column;align-items:center;gap:7px;text-decoration:none;color:var(--muted);
  font-family:var(--mono);font-size:11px;letter-spacing:.1em;transition:color .14s ease-out}
.rtick i{display:block;width:64px;height:1px;background:var(--ink);opacity:.3;transition:opacity .14s ease-out}
.rtick:hover,.rtick.on{color:var(--ink)}
.rtick:hover i,.rtick.on i{opacity:1}
.rail-lockup{position:absolute;bottom:40px;left:calc(50% + 7px);transform-origin:0 0;
  transform:rotate(-90deg);white-space:nowrap;font-family:var(--disp);font-weight:700;
  text-transform:uppercase;letter-spacing:.34em;font-size:14px;color:var(--ink)}
.rail-km{position:absolute;bottom:12px;left:0;right:0;text-align:center;font-size:10px;letter-spacing:.08em;color:var(--muted);font-variant-numeric:tabular-nums}

/* ---------- BUTTONS ---------- */
.btn{height:48px;padding:0 28px;border-radius:9999px;border:1px solid var(--ink);background:transparent;
  color:var(--ink);font-family:var(--mono);font-weight:600;font-size:13px;letter-spacing:.14em;
  text-transform:uppercase;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:10px;
  transition:transform .09s ease-out,background .09s ease-out,color .09s ease-out;white-space:nowrap}
.btn:hover{transform:translateY(-2px)}
.btn:active{transform:translateY(0)}
.btn-solid{background:var(--volt);color:var(--canvas);border-color:var(--ink)}
.btn-solid:hover{outline:1px solid var(--ink);outline-offset:3px}
.btn-ghost:hover{background:var(--ink);color:var(--canvas)}
.btn-sm{height:36px;padding:0 16px;font-size:11px;letter-spacing:.12em}

/* ---------- NAMEBOARD ---------- */
.nameboard{position:sticky;top:0;z-index:50;height:88px;background:var(--canvas);
  border-bottom:1px solid var(--ink);display:flex;align-items:center;gap:28px;padding:0 48px}
.nb-left{display:flex;align-items:center;gap:14px;flex:none}
.strip{display:flex;gap:3px}
.strip i{width:10px;height:24px;border:1px solid var(--ink);display:block}
.nb-title{font-family:var(--disp);font-weight:700;text-transform:uppercase;letter-spacing:.05em;
  font-size:26px;line-height:1;white-space:nowrap}
.nb-mid{display:flex;gap:0;margin-left:auto;border-left:1px solid var(--ink);border-right:1px solid var(--ink)}
.nb-cell{padding:2px 18px;border-right:1px solid var(--ink);min-width:118px}
.nb-cell:last-child{border-right:0}
.nb-cell b{display:block;font-size:14px;font-weight:500;letter-spacing:.04em;font-variant-numeric:tabular-nums}
.nb-right{display:flex;gap:10px;flex:none}

/* ---------- DRAWER ---------- */
.drawer{display:none;border-bottom:1px solid var(--ink);background:var(--paper)}
.drawer.open{display:block}
.drawer-in{display:grid;grid-template-columns:1.5fr 1fr auto;gap:48px;padding-top:36px;padding-bottom:36px;align-items:start}
.dr-h{font-size:22px;margin-bottom:18px}
.dr-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:14px}
.dr-list li{display:flex;gap:14px;font-size:13px;line-height:1.55;max-width:52ch}
.dr-list .num{font-size:11px;letter-spacing:.1em;color:var(--volt);padding-top:3px;flex:none}
.sw-row{display:flex;flex-wrap:wrap;gap:14px}
.sw-item{display:flex;flex-direction:column;gap:6px;align-items:flex-start}
.sw-item i{display:block;width:38px;height:38px;border:1px solid var(--ink);background:var(--c)}
.sw-item span{font-size:10px;letter-spacing:.1em;color:var(--muted)}
.key{display:inline-flex;min-width:24px;height:24px;padding:0 6px;align-items:center;justify-content:center;
  border:1px solid var(--ink);background:var(--canvas);font-size:11px;letter-spacing:.06em;color:var(--ink);cursor:pointer;
  transition:background .09s,color .09s}
.key:hover,.key.act{background:var(--ink);color:var(--canvas)}

/* ---------- HERO ---------- */
.hero{position:relative;min-height:clamp(420px,56vh,540px);overflow:hidden;
  display:flex;flex-direction:column;justify-content:center;border-bottom:1px solid var(--ink)}
.hero-svg{position:absolute;inset:0;width:100%;height:100%;opacity:.62;pointer-events:none}
.hero-inner{position:relative;z-index:2;width:100%;max-width:1144px;margin:0 auto;padding:56px 48px 120px}
.hero h1{font-size:clamp(34px,5.6vw,64px);max-width:14ch}
.hero .sub{font-family:var(--disp);font-size:18px;letter-spacing:.04em;line-height:1.35;
  text-transform:none;font-weight:400;color:var(--muted);margin:22px 0 0;max-width:38ch}
.hero .sub b{color:var(--ink);font-weight:700}
.cta{display:flex;gap:14px;margin-top:32px;flex-wrap:wrap}
.scale-rule{position:absolute;left:0;right:0;bottom:0;height:104px;z-index:3;background:var(--canvas);
  border-top:1px solid var(--ink);display:flex;flex-direction:column}
.sr-ticks{height:9px;background:repeating-linear-gradient(90deg,var(--ink) 0 1px,transparent 1px 13px);opacity:.55}
.sr-labels{flex:1;display:flex;align-items:center;justify-content:space-between;padding:0 48px}
.sr-labels span{font-size:10px;letter-spacing:.18em;color:var(--muted);position:relative;padding-left:10px}
.sr-labels span:before{content:"";position:absolute;left:0;top:-14px;width:1px;height:26px;background:var(--ink);opacity:.5}

/* ---------- STATUS RAIL ---------- */
.status-rail{height:108px;display:grid;grid-template-columns:repeat(3,1fr);border-bottom:1px solid var(--ink)}
.sr-cell{border-right:1px solid var(--ink);padding:14px 22px;display:flex;flex-direction:column;justify-content:space-between;min-width:0}
.sr-cell:last-child{border-right:0}
.sr-val{font-family:var(--disp);font-weight:700;font-size:52px;line-height:1;letter-spacing:.02em;font-variant-numeric:tabular-nums}
.sr-val.name{font-size:24px;letter-spacing:.05em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.prog{display:block;width:100%;max-width:220px;height:3px;background:var(--paper);position:relative;overflow:hidden}
.prog i{position:absolute;left:0;top:0;bottom:0;width:0;background:var(--volt);transition:width .18s ease-out}
.prog.slim{max-width:64px}

/* ---------- GAME GRID ---------- */
.game-grid{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:48px;align-items:start}
.game-main{min-width:0}
.stage{position:relative;margin:0 -24px;border:1px solid var(--ink);background:var(--canvas);
  overflow:hidden;touch-action:none;user-select:none;-webkit-user-select:none;cursor:crosshair}
.stage svg.board{width:100%;height:auto}
.reg{position:absolute;width:12px;height:12px;pointer-events:none;z-index:4}
.reg.tl{left:8px;top:8px;border-left:1px solid var(--ink);border-top:1px solid var(--ink)}
.reg.tr{right:8px;top:8px;border-right:1px solid var(--ink);border-top:1px solid var(--ink)}
.reg.bl{left:8px;bottom:8px;border-left:1px solid var(--ink);border-bottom:1px solid var(--ink)}
.reg.br{right:8px;bottom:8px;border-right:1px solid var(--ink);border-bottom:1px solid var(--ink)}
.overlay{position:absolute;inset:0;z-index:6;display:flex;align-items:center;justify-content:center;pointer-events:none}
.ov-card{background:var(--canvas);border:1px solid var(--ink);padding:16px 26px;text-align:center;
  pointer-events:auto;cursor:pointer;max-width:88%}
.ov-k{font-family:var(--disp);font-weight:700;text-transform:uppercase;letter-spacing:.1em;font-size:22px;line-height:1.1}
.ov-t{margin-top:9px;font-size:11px;letter-spacing:.16em;color:var(--volt);text-transform:uppercase}
.ov-s{margin-top:6px;font-size:10px;letter-spacing:.1em;color:var(--muted);text-transform:uppercase}
.toast{position:absolute;left:50%;bottom:16px;transform:translateX(-50%) translateY(8px);z-index:5;
  background:var(--canvas);border:1px solid var(--ink);padding:7px 14px;font-size:11px;letter-spacing:.14em;
  text-transform:uppercase;opacity:0;transition:opacity .18s ease-out,transform .18s ease-out;pointer-events:none}
.toast.on{opacity:1;transform:translateX(-50%) translateY(0)}
@keyframes shudder{0%{transform:translateX(0)}25%{transform:translateX(-4px)}50%{transform:translateX(4px)}75%{transform:translateX(-4px)}100%{transform:translateX(0)}}
.stage.shudder{animation:shudder 120ms steps(1,end) 4}
.stage.invert{filter:invert(1) hue-rotate(180deg)}

/* ---------- LEGEND ---------- */
.legend{height:120px;border-top:1px solid var(--ink);display:flex;align-items:center;justify-content:space-between;gap:24px}
.lg-block{display:flex;flex-direction:column;gap:9px;min-width:0}
.lg-keys{flex-direction:row;align-items:center;gap:6px;flex-wrap:wrap}
.lg-note{font-size:10px;letter-spacing:.1em;color:var(--muted);text-transform:uppercase;margin-left:8px}
.lg-name{font-family:var(--disp);font-weight:700;text-transform:uppercase;letter-spacing:.05em;font-size:20px;
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.lg-right{align-items:flex-end;text-align:right}
.lg-count{font-family:var(--disp);font-weight:700;font-size:34px;line-height:1;font-variant-numeric:tabular-nums}

/* ---------- STAFF ---------- */
.staff{position:sticky;top:104px;border-top:1px solid var(--ink)}
.staff-head{display:flex;align-items:baseline;justify-content:space-between;padding:14px 0 14px;
  border-bottom:1px solid var(--ink)}
.staff-head .t{font-family:var(--disp);font-weight:700;text-transform:uppercase;letter-spacing:.08em;font-size:20px}
.staff-head .n{font-family:var(--disp);font-weight:700;font-size:34px;line-height:1;font-variant-numeric:tabular-nums}
.plates{display:flex;flex-direction:column}
.plate{display:flex;align-items:center;gap:12px;height:52px;padding:0 14px;border-bottom:1px solid var(--ink);
  background:var(--canvas);overflow:hidden}
.plate .sw{width:14px;height:14px;flex:none;background:var(--c);border:1px solid var(--ink)}
.plate .pn{flex:1;font-size:11px;letter-spacing:.1em;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.plate .pc{width:36px;height:28px;flex:none;background:var(--c);border:1px solid var(--ink);position:relative}
.plate .pc:after{content:"";position:absolute;left:3px;right:3px;top:5px;height:7px;background:var(--canvas);opacity:.9}
.plate.empty{color:var(--muted)}
.plate.empty .plus{width:36px;height:28px;border:1px solid var(--ink);display:flex;align-items:center;justify-content:center;font-size:14px}
@keyframes plateIn{from{transform:translateX(48px);opacity:0}to{transform:translateX(0);opacity:1}}
.plate.new{animation:plateIn 520ms cubic-bezier(.2,.9,.2,1) both}

/* ---------- ODOMETER ---------- */
.odo{position:relative;display:inline-block;height:1.1em;line-height:1.1em;overflow:hidden;vertical-align:-.1em}
.odo>span{display:block;height:1.1em;line-height:1.1em}
.odo .r-out{position:absolute;left:0;top:0;animation:rOut 180ms ease-out forwards}
.odo .r-in{animation:rIn 180ms ease-out forwards}
@keyframes rOut{to{transform:translateY(-100%);opacity:0}}
@keyframes rIn{from{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}

/* ---------- FOOTER ---------- */
.ticket{background:var(--ink);color:var(--canvas);min-height:56px;display:flex;align-items:center;
  justify-content:space-between;gap:20px;padding:12px 48px;flex-wrap:wrap;
  font-size:10px;letter-spacing:.14em;text-transform:uppercase;margin-top:96px}
.tk-keys{display:flex;gap:6px;align-items:center}
.tk-keys b{display:inline-flex;width:22px;height:22px;border:1px solid var(--canvas);align-items:center;
  justify-content:center;font-size:10px;font-weight:400}
.ticket .dim{color:#9a958b}

/* ---------- RESPONSIVE ---------- */
@media(max-width:1080px){
  :root{--rail:56px}
  .rail-lockup{display:none}
  .rail-ticks{top:96px;gap:24px}
  .rtick i{width:32px}
  .nameboard{padding:0 20px;gap:16px;height:76px}
  .nb-mid{display:none}
  .nb-title{font-size:19px;letter-spacing:.04em}
  .nb-left .strip i{width:7px;height:19px}
  .wrap{padding:0 20px}
  .hero-inner{padding:40px 20px 110px}
  .sr-labels{padding:0 20px}
  .sr-labels span{font-size:9px;letter-spacing:.1em;padding-left:7px}
  .game-grid{grid-template-columns:minmax(0,1fr);gap:24px}
  .staff{position:static}
  .plates{flex-direction:row;overflow-x:auto;gap:0}
  .plate{min-width:210px;border-right:1px solid var(--ink);border-bottom:1px solid var(--ink)}
  .drawer-in{grid-template-columns:1fr;gap:28px}
  .stage{margin:0}
  .ticket{padding:14px 20px;margin-top:56px}
}
@media(max-width:640px){
  .status-rail{height:96px}
  .sr-cell{padding:10px 10px}
  .sr-val{font-size:34px}
  .sr-val.name{font-size:15px}
  .lab{font-size:9px;letter-spacing:.1em}
  .legend{height:auto;padding:16px 0;flex-direction:column;align-items:flex-start;gap:18px}
  .lg-right{align-items:flex-start;text-align:left}
  .hero .sub{font-size:16px}
  .hero-svg{opacity:.4}
  .btn{height:44px;padding:0 20px;font-size:11px}
  .nb-right .btn{padding:0 12px;letter-spacing:.08em}
  .ov-k{font-size:17px}
}
</style>
</head>
<body>

<!-- ============ PLATFORM RAIL ============ -->
<div class="rail">
  <nav class="rail-ticks">
    <a class="rtick on" href="#hero" data-t="hero"><span>01</span><i></i></a>
    <a class="rtick" href="#drawer" data-t="drawer"><span>02</span><i></i></a>
    <a class="rtick" href="#game" data-t="game"><span>03</span><i></i></a>
    <a class="rtick" href="#ticket" data-t="ticket"><span>04</span><i></i></a>
  </nav>
  <div class="rail-lockup">Peak Line Express</div>
  <div class="rail-km">KM <span id="kmv">0.00</span></div>
</div>

<!-- ============ NAMEBOARD ============ -->
<header class="nameboard">
  <div class="nb-left">
    <div class="strip">
      <i style="background:var(--l1)"></i><i style="background:var(--l2)"></i>
      <i style="background:var(--l3)"></i><i style="background:var(--l4)"></i>
      <i style="background:var(--l5)"></i>
    </div>
    <div class="nb-title">Peak Line Express</div>
  </div>
  <div class="nb-mid">
    <div class="nb-cell"><span class="lab">Cars run</span><b id="nbCars">00</b></div>
    <div class="nb-cell"><span class="lab">Longest train</span><b id="nbBest">01</b></div>
    <div class="nb-cell"><span class="lab">Avg wait</span><b>0:04</b></div>
    <div class="nb-cell"><span class="lab">Service</span><b>24H</b></div>
  </div>
  <div class="nb-right">
    <button class="btn btn-ghost btn-sm" id="soundBtn">Sound off</button>
    <button class="btn btn-ghost btn-sm" id="howBtn">How to play</button>
  </div>
</header>

<!-- ============ HOW TO PLAY DRAWER ============ -->
<section class="drawer" id="drawer">
  <div class="wrap drawer-in">
    <div>
      <h2 class="d dr-h">How to play</h2>
      <ol class="dr-list">
        <li><span class="num">01</span><span>Drive the locomotive with <span class="key">W</span> <span class="key">A</span> <span class="key">S</span> <span class="key">D</span> or the arrow keys. On a touch board, swipe — or tap a cell ahead of the engine.</span></li>
        <li><span class="num">02</span><span>Roll onto a waiting passenger to board them. Every boarding adds one carriage to the train and one enamel name plate to the roster.</span></li>
        <li><span class="num">03</span><span>Don't back into your own train, and don't run off the edge of the network. That is a derailment, and the service resets.</span></li>
      </ol>
    </div>
    <div>
      <h2 class="d dr-h">Livery</h2>
      <div class="sw-row">
        <div class="sw-item"><i style="--c:var(--l1)"></i><span>LINE 01</span></div>
        <div class="sw-item"><i style="--c:var(--l2)"></i><span>LINE 02</span></div>
        <div class="sw-item"><i style="--c:var(--l3)"></i><span>LINE 03</span></div>
        <div class="sw-item"><i style="--c:var(--l4)"></i><span>LINE 04</span></div>
        <div class="sw-item"><i style="--c:var(--l5)"></i><span>LINE 05</span></div>
      </div>
    </div>
    <div><button class="btn btn-ghost" id="closeHow">Return to platform</button></div>
  </div>
</section>

<!-- ============ HERO ============ -->
<section class="hero" id="hero">
  <svg class="hero-svg" id="heroSvg" viewBox="0 0 1240 600" preserveAspectRatio="xMidYMid slice" aria-hidden="true"></svg>
  <div class="hero-inner">
    <h1 class="d">Pick up the<br>passengers.<br>Don't backtrack.</h1>
    <p class="sub">Een trein die mensen ophaalt. <b>Stop, board, and keep rolling</b> — every passenger you collect adds a carriage to your train.</p>
    <div class="cta">
      <button class="btn btn-solid" id="boardCta">Board the train</button>
      <button class="btn btn-ghost" id="heroHow">How to play ↓</button>
    </div>
  </div>
  <div class="scale-rule">
    <div class="sr-ticks"></div>
    <div class="sr-labels">
      <span>MOTION</span><span>SOUND</span><span>TYPE</span><span>COLOR</span><span>CREW</span>
    </div>
  </div>
</section>

<!-- ============ GAME ============ -->
<section id="game">
  <div class="wrap">
    <div class="status-rail">
      <div class="sr-cell">
        <span class="lab">Passengers</span>
        <span class="sr-val"><span class="odo" id="score">00</span></span>
      </div>
      <div class="sr-cell">
        <span class="lab">Train length</span>
        <span class="sr-val"><span class="odo" id="len">01</span></span>
      </div>
      <div class="sr-cell">
        <span class="lab">Next pickup</span>
        <span class="sr-val name" id="next">HARBOUR FRONT</span>
        <span class="prog" id="prog1"><i></i></span>
      </div>
    </div>

    <div class="game-grid">
      <div class="game-main">
        <div class="stage" id="stage">
          <svg class="board" id="board" viewBox="0 0 480 350" aria-label="Train board"></svg>
          <div class="reg tl"></div><div class="reg tr"></div><div class="reg bl"></div><div class="reg br"></div>
          <div class="overlay" id="overlay">
            <div class="ov-card" id="ovCard">
              <div class="ov-k" id="ovK">Ready for departure</div>
              <div class="ov-t" id="ovT">Click or press a key to start</div>
              <div class="ov-s" id="ovS">Demo mode · AI conductor at the controls</div>
            </div>
          </div>
          <div class="toast" id="toast">Car added</div>
        </div>

        <div class="legend">
          <div class="lg-block lg-keys">
            <button class="key" data-dir="up">W</button>
            <button class="key" data-dir="left">A</button>
            <button class="key" data-dir="down">S</button>
            <button class="key" data-dir="right">D</button>
            <span class="lg-note">Arrows · swipe · tap ahead</span>
          </div>
          <div class="lg-block">
            <span class="lab">Next stop</span>
            <span class="lg-name" id="lgNext">Harbour Front</span>
            <span class="prog slim" id="prog2"><i></i></span>
          </div>
          <div class="lg-block lg-right">
            <span class="lab">Passengers</span>
            <span class="lg-count"><span class="odo" id="lgScore">00</span></span>
            <span class="lab">/ 20 this run</span>
          </div>
        </div>
      </div>

      <aside class="staff">
        <div class="staff-head">
          <span class="t">Boardings</span>
          <span class="n"><span class="odo" id="bcount">00</span></span>
        </div>
        <div class="plates" id="plates"></div>
        <div class="plate empty"><span class="plus">+</span><span class="pn">Add a car</span></div>
        <div class="plate empty"><span class="plus">+</span><span class="pn">Add a car</span></div>
        <div class="plate empty"><span class="plus">+</span><span class="pn">Add a car</span></div>
      </aside>
    </div>
  </div>
</section>

<!-- ============ FOOTER ============ -->
<footer class="ticket" id="ticket">
  <div class="tk-keys"><b>W</b><b>A</b><b>S</b><b>D</b> · or the arrow keys</div>
  <div class="dim">No real trains were harmed</div>
  <div class="dim">© Peak Line Transport Authority</div>
</footer>

<script>
(function(){
"use strict";
var $=function(s){return document.querySelector(s)};
var $$=function(s){return Array.prototype.slice.call(document.querySelectorAll(s))};

/* ------------------------------------------------------------------ */
/* DATA                                                                */
/* ------------------------------------------------------------------ */
var COLS=22, ROWS=16;
var LIVERY=['#FF5A1F','#00B39F','#FFC24B','#3D5AFE','#E5E1D8'];
var NAMES=['HARBOUR FRONT','MILLBANK','ASHFORD CROSS','KILN STREET','OLD MARKET','CANAL BASIN',
 'GRANARY WAY','COPPER LANE','NORTH GATE','STADIUM PARK','VIOLET PARK','QUARRY END','LAMPLIGHT',
 'TANNERY ROW','ROYAL EXCHANGE','FERRY POINT','BRICKFIELD','OBSERVATORY','SALT WHARF','ELDER GROVE'];

function pad2(n){return (n<10?'0':'')+n}
function rc(n){return Math.floor(Math.random()*n)}

/* ------------------------------------------------------------------ */
/* SVG PIECES                                                          */
/* ------------------------------------------------------------------ */
var LOCO=
'<g class="lrot">'+
 '<path d="M24,-7 L62,-26 L62,26 L24,7 Z" fill="#FFC24B" opacity=".14"/>'+
 '<rect x="-23" y="-23" width="19" height="34" rx="2" fill="#12161C"/>'+
 '<rect x="-20" y="-20" width="13" height="10" fill="#F4EDE2"/>'+
 '<rect x="-6" y="-13" width="28" height="24" rx="3" fill="#12161C"/>'+
 '<rect x="11" y="-25" width="9" height="13" fill="#12161C"/>'+
 '<rect x="9" y="-29" width="13" height="5" rx="1" fill="#FFC24B"/>'+
 '<rect x="21" y="-9" width="5" height="10" fill="#FFC24B"/>'+
 '<rect x="24" y="1" width="5" height="8" fill="#FFC24B"/>'+
 '<rect x="-5" y="-6" width="15" height="10" fill="#F4EDE2"/>'+
 '<text x="2.5" y="2" font-size="7" text-anchor="middle" fill="#12161C" font-family="monospace">42</text>'+
 '<rect x="-23" y="7" width="52" height="6" fill="#12161C"/>'+
 '<circle cx="-15" cy="14" r="6" fill="#12161C" stroke="#F4EDE2" stroke-width="1"/>'+
 '<circle cx="0" cy="14" r="6" fill="#12161C" stroke="#F4EDE2" stroke-width="1"/>'+
 '<circle cx="15" cy="14" r="6" fill="#12161C" stroke="#F4EDE2" stroke-width="1"/>'+
'</g>';

function wagon(color){
 return '<rect x="-21" y="-16" width="42" height="30" rx="2" fill="'+color+'" stroke="#12161C" stroke-width="1.5"/>'+
 '<rect x="-21" y="-16" width="42" height="6" fill="#F4EDE2" opacity=".88"/>'+
 '<rect x="-15" y="-6" width="8" height="8" fill="#F4EDE2" stroke="#12161C" stroke-width="1"/>'+
 '<rect x="-4" y="-6" width="8" height="8" fill="#F4EDE2" stroke="#12161C" stroke-width="1"/>'+
 '<rect x="7" y="-6" width="8" height="8" fill="#F4EDE2" stroke="#12161C" stroke-width="1"/>'+
 '<rect x="-21" y="7" width="42" height="5" fill="#12161C"/>'+
 '<circle cx="-12" cy="15" r="5" fill="#12161C" stroke="#F4EDE2" stroke-width="1"/>'+
 '<circle cx="12" cy="15" r="5" fill="#12161C" stroke="#F4EDE2" stroke-width="1"/>';
}
function paxChip(color){
 return '<rect x="-17" y="7" width="34" height="9" fill="#EADFCB" stroke="#12161C" stroke-width="1"/>'+
 '<line x1="-17" y1="16" x2="-17" y2="20" stroke="#12161C" stroke-width="1"/>'+
 '<line x1="17" y1="16" x2="17" y2="20" stroke="#12161C" stroke-width="1"/>'+
 '<circle class="ring" r="15" fill="none" stroke="'+color+'" stroke-width="2"/>'+
 '<circle r="12" fill="'+color+'" stroke="#12161C" stroke-width="1.5"/>'+
 '<circle cy="-4.4" r="3.6" fill="#12161C"/>'+
 '<path d="M-6.2,6.4 Q-5,0.4 0,0.4 Q5,0.4 6.2,6.4 Z" fill="#12161C"/>';
}

/* ------------------------------------------------------------------ */
/* HERO DIAGRAM                                                        */
/* ------------------------------------------------------------------ */
var LOOP=[];
(function(){
 var x0=576,y0=96,x1=1152,y1=528,s=48,i;
 for(i=0;i<12;i++) LOOP.push([x0+i*s,y0]);
 for(i=0;i<9;i++)  LOOP.push([x1,y0+i*s]);
 for(i=0;i<12;i++) LOOP.push([x1-i*s,y1]);
 for(i=0;i<9;i++)  LOOP.push([x0,y1-i*s]);
})();

function buildHero(){
 var W=1240,H=600,s='',x,y,i;
 s+='<defs>'+
 '<pattern id="hsleep" width="8" height="8" patternUnits="userSpaceOnUse"><line x1="4" y1="0" x2="4" y2="8" stroke="#12161C" stroke-width="1" opacity=".5"/></pattern>'+
 '<pattern id="hhatch" width="6" height="6" patternUnits="userSpaceOnUse"><line x1="0" y1="0.5" x2="6" y2="0.5" stroke="#12161C" stroke-width="1" opacity=".08"/></pattern>'+
 '</defs>';
 s+='<rect width="'+W+'" height="'+H+'" fill="url(#hhatch)"/>';
 for(x=24;x<W;x+=48) s+='<line x1="'+x+'" y1="0" x2="'+x+'" y2="'+H+'" stroke="#12161C" stroke-width="1" opacity=".15"/>';
 for(y=24;y<H;y+=48) s+='<line x1="0" y1="'+y+'" x2="'+W+'" y2="'+y+'" stroke="#12161C" stroke-width="1" opacity=".15"/>';
 s+='<line x1="40" y1="566" x2="1210" y2="54" stroke="#12161C" stroke-width="1" stroke-dasharray="6 8" opacity=".5"/>';
 s+='<rect x="552" y="72" width="624" height="480" fill="none" stroke="url(#hsleep)" stroke-width="9"/>';
 s+='<rect x="552" y="72" width="624" height="480" fill="none" stroke="#12161C" stroke-width="1" opacity=".6"/>';
 var nodes=[[576,96],[1152,96],[1152,528],[576,528]];
 for(i=0;i<nodes.length;i++){
   s+='<circle cx="'+nodes[i][0]+'" cy="'+nodes[i][1]+'" r="9" fill="#12161C"/>'+
      '<circle cx="'+nodes[i][0]+'" cy="'+nodes[i][1]+'" r="3.6" fill="#F4EDE2"/>';
 }
 s+='<g id="heroTrain"></g>';
 $('#heroSvg').innerHTML=s;
}
var heroUnits=[], heroIdx=0;
function heroStep(){
 var g=document.getElementById('heroTrain'); if(!g) return;
 var n=6;
 while(heroUnits.length<n){
   var el=document.createElementNS('http://www.w3.org/2000/svg','g');
   var inner=document.createElementNS('http://www.w3.org/2000/svg','g');
   inner.setAttribute('class','anim');
   if(heroUnits.length===0){ inner.innerHTML=LOCO; }
   else { inner.innerHTML=wagon(LIVERY[(heroUnits.length-1)%5]); }
   el.appendChild(inner); g.appendChild(el); heroUnits.push(el);
 }
 heroIdx=(heroIdx+1)%LOOP.length;
 var prev=null;
 for(var i=0;i<n;i++){
   var k=(heroIdx-i*1+LOOP.length*4)%LOOP.length;
   var p=LOOP[k];
   heroUnits[i].setAttribute('transform','translate('+p[0]+','+p[1]+')');
   if(i===0){
     var nx=LOOP[(k+1)%LOOP.length];
     var ang=Math.atan2(p[1]-nx[1],p[0]-nx[0])*180/Math.PI;
     heroUnits[i].querySelector('.anim').innerHTML=LOCO;
     var r=heroUnits[i].querySelector('.lrot');
     if(r) r.setAttribute('transform','rotate('+ang.toFixed(0)+')');
   }
   prev=p;
 }
}
buildHero();
setInterval(heroStep,170);

/* ------------------------------------------------------------------ */
/* BOARD                                                               */
/* ------------------------------------------------------------------ */
var board=$('#board'), stage=$('#stage');
var cell=32, W=0, H=0;
var trainG, paxG, fxG, ghostEl;

function buildBoard(){
 W=COLS*cell; H=ROWS*cell;
 board.setAttribute('viewBox','0 0 '+W+' '+H);
 var s='',r,c,x,y;
 s+='<defs>'+
  '<pattern id="pH" width="8" height="8" patternUnits="userSpaceOnUse"><line x1="4" y1="0" x2="4" y2="8" stroke="#12161C" stroke-width="1" opacity=".45"/></pattern>'+
  '<pattern id="pV" width="8" height="8" patternUnits="userSpaceOnUse"><line x1="0" y1="4" x2="8" y2="4" stroke="#12161C" stroke-width="1" opacity=".45"/></pattern>'+
  '<pattern id="hA" width="6" height="6" patternUnits="userSpaceOnUse"><line x1="0" y1="0.5" x2="6" y2="0.5" stroke="#12161C" stroke-width="1" opacity=".07"/></pattern>'+
  '<pattern id="hB" width="6" height="6" patternUnits="userSpaceOnUse"><line x1="0.5" y1="0" x2="0.5" y2="6" stroke="#12161C" stroke-width="1" opacity=".07"/></pattern>'+
 '</defs>';
 s+='<rect width="'+W+'" height="'+H+'" fill="#F4EDE2"/>';
 for(r=0;r<ROWS;r++) for(c=0;c<COLS;c++){
   s+='<rect x="'+(c*cell)+'" y="'+(r*cell)+'" width="'+cell+'" height="'+cell+'" fill="url(#'+(((r+c)%2)?'hA':'hB')+')"/>';
 }
 for(r=0;r<ROWS;r++){ y=(r+0.5)*cell;
   s+='<rect x="0" y="'+(y-4)+'" width="'+W+'" height="8" fill="url(#pH)"/>'+
      '<line x1="0" y1="'+y+'" x2="'+W+'" y2="'+y+'" stroke="#12161C" stroke-width="1" opacity=".3"/>'; }
 for(c=0;c<COLS;c++){ x=(c+0.5)*cell;
   s+='<rect x="'+(x-4)+'" y="0" width="8" height="'+H+'" fill="url(#pV)"/>'+
      '<line x1="'+x+'" y1="0" x2="'+x+'" y2="'+H+'" stroke="#12161C" stroke-width="1" opacity=".3"/>'; }
 s+='<path class="ghost" id="ghost" fill="none" stroke="#12161C" stroke-width="1.5" stroke-dasharray="4 6" opacity=".5"/>';
 s+='<g id="paxG"></g><g id="trainG"></g><g id="fxG"></g>';
 board.innerHTML=s;
 trainG=board.querySelector('#trainG'); paxG=board.querySelector('#paxG');
 fxG=board.querySelector('#fxG'); ghostEl=board.querySelector('#ghost');
 buildTrain();
 if(passenger) placePax();
}

/* board layout css for ghost dashes */
var st=document.createElement('style');
st.textContent='@keyframes dashmove{to{stroke-dashoffset:-20}}.ghost{animation:dashmove 800ms linear infinite}'+
'@keyframes pingring{0%{transform:scale(.35);opacity:.9}100%{transform:scale(1);opacity:0}}'+
'.ring{transform-box:fill-box;transform-origin:center;animation:pingring 900ms ease-out infinite}'+
'@keyframes plateSpring{from{transform:translate(var(--dx),var(--dy)) scale(.2);opacity:.4}to{transform:translate(0,0) scale(1);opacity:1}}'+
'.anim.spring{animation:plateSpring 560ms cubic-bezier(.34,1.56,.64,1) both}';
document.head.appendChild(st);

/* ------------------------------------------------------------------ */
/* STATE                                                               */
/* ------------------------------------------------------------------ */
var body=[], cars=[], dir={x:1,y:0}, queue=[];
var passenger=null, paxSeq=0, plateSeq=0;
var score=0, best=1, speed=120, acc=0, last=performance.now();
var running=false, state='demo', demo=true;

function resetGame(ahead){
 body=[{x:6,y:8}]; cars=[]; dir={x:1,y:0}; queue=[];
 score=0; speed=120; plateSeq=0; paxSeq=0;
 $('#plates').innerHTML='';
 buildTrain(); renderTrain(); spawnPassenger(ahead!==false); updateUI(); updateGhost();
}
function spawnPassenger(ahead){
 var x,y,tries=0;
 if(ahead){ x=13; y=8; }
 else { do{ x=rc(COLS); y=rc(ROWS); tries++; }while(tries<400 && occupied(x,y)); }
 if(ahead && occupied(x,y)) ahead=false;
 if(!ahead){ while(occupied(x,y) && tries<600){ x=rc(COLS); y=rc(ROWS); tries++; } }
 var name=NAMES[paxSeq%NAMES.length];
 var color=LIVERY[paxSeq%LIVERY.length];
 paxSeq++;
 passenger={x:x,y:y,name:name,color:color};
 placePax();
}
function occupied(x,y){
 for(var i=0;i<body.length;i++) if(body[i].x===x&&body[i].y===y) return true;
 return false;
}
function placePax(){
 if(!passenger) return;
 paxG.innerHTML='<g class="paxpos" transform="translate('+((passenger.x+.5)*cell)+','+((passenger.y+.5)*cell)+')">'+
   '<g class="paxin">'+paxChip(passenger.color)+'</g></g>';
}

/* ------------------------------------------------------------------ */
/* TRAIN RENDER                                                        */
/* ------------------------------------------------------------------ */
var units=[];
function buildTrain(){
 trainG.innerHTML=''; units=[];
 for(var i=0;i<body.length;i++) units.push(addUnit(i));
}
function addUnit(i){
 var el=document.createElementNS('http://www.w3.org/2000/svg','g');
 var inner=document.createElementNS('http://www.w3.org/2000/svg','g');
 inner.setAttribute('class','anim');
 if(i===0){ inner.innerHTML=LOCO; }
 else { var c=cars[i-1]?cars[i-1].color:LIVERY[(i-1)%5]; inner.innerHTML=wagon(c); }
 el.appendChild(inner); trainG.appendChild(el); return el;
}
function renderTrain(){
 while(units.length<body.length){
   units.splice(1,0,addUnit(1));
   for(var j=0;j<units.length;j++){
     var ic=cars[j-1]?cars[j-1].color:LIVERY[(j-1)%5];
     if(j>0) units[j].querySelector('.anim').innerHTML=wagon(ic);
   }
 }
 while(units.length>body.length) { var u=units.pop(); if(u&&u.parentNode) u.parentNode.removeChild(u); }
 for(var i=0;i<body.length;i++){
   units[i].setAttribute('transform','translate('+((body[i].x+.5)*cell)+','+((body[i].y+.5)*cell)+')');
 }
 var ang=0;
 if(dir.x===1) ang=0; else if(dir.y===1) ang=90; else if(dir.x===-1) ang=180; else if(dir.y===-1) ang=-90;
 var r=units[0]?units[0].querySelector('.lrot'):null;
 if(r) r.setAttribute('transform','rotate('+ang+')');
}

/* ------------------------------------------------------------------ */
/* GHOST ROUTE                                                         */
/* ------------------------------------------------------------------ */
function updateGhost(){
 if(!ghostEl) return;
 var d='M '+((body[0].x+.5)*cell)+' '+((body[0].y+.5)*cell);
 var cur={x:body[0].x,y:body[0].y};
 for(var i=0;i<4;i++){
   var nx=cur.x+dir.x, ny=cur.y+dir.y;
   if(nx<0||ny<0||nx>=COLS||ny>=ROWS) break;
   if(occupied(nx,ny)) break;
   cur={x:nx,y:ny};
   d+=' L '+((nx+.5)*cell)+' '+((ny+.5)*cell);
 }
 ghostEl.setAttribute('d',d);
}

/* ------------------------------------------------------------------ */
/* UI                                                                  */
/* ------------------------------------------------------------------ */
function roll(el,val){
 if(!el) return;
 if(el.dataset.v===val) return;
 var old=el.dataset.v||'';
 el.dataset.v=val;
 el.innerHTML='<span class="r-out">'+old+'</span><span class="r-in">'+val+'</span>';
}
function updateUI(){
 roll($('#score'),pad2(score)); roll($('#lgScore'),pad2(score));
 roll($('#len'),pad2(body.length)); roll($('#bcount'),pad2(score));
 $('#nbCars').textContent=pad2(score);
 if(score+1>best){ best=score+1; }
 $('#nbBest').textContent=pad2(best);
 if(passenger){
   $('#next').textContent=passenger.name;
   $('#lgNext').textContent=passenger.name;
   var d=Math.abs(passenger.x-body[0].x)+Math.abs(passenger.y-body[0].y);
   var pct=Math.max(2,Math.round((1-d/(COLS+ROWS))*100))+'%';
   $('#prog1').querySelector('i').style.width=pct;
   $('#prog2').querySelector('i').style.width=pct;
 }
}
function addPlate(name,color,animate){
 var el=document.createElement('div');
 el.className='plate'+(animate?' new':'');
 el.style.setProperty('--c',color);
 el.innerHTML='<span class="sw"></span><span class="pn">'+name+'</span><span class="pc"></span>';
 $('#plates').insertBefore(el,$('#plates').firstChild);
 while($('#plates').children.length>16) $('#plates').removeChild($('#plates').lastChild);
}
var toastT=null;
function toast(msg){
 var t=$('#toast'); t.textContent=msg; t.classList.add('on');
 clearTimeout(toastT); toastT=setTimeout(function(){t.classList.remove('on')},1500);
}

/* ------------------------------------------------------------------ */
/* BOARDING ANIMATION                                                  */
/* ------------------------------------------------------------------ */
function boardFX(p,hx,hy){
 var tx=(p.x+.5)*cell, ty=(p.y+.5)*cell;
 /* ping ring */
 var g=document.createElementNS('http://www.w3.org/2000/svg','g');
 g.setAttribute('transform','translate('+tx+','+ty+')');
 g.innerHTML='<circle class="pingc" r="140" fill="none" stroke="#FF5A1F" stroke-width="3" opacity=".9"/>';
 fxG.appendChild(g);
 var c=g.querySelector('.pingc');
 if(c.animate){
   c.animate([{transform:'scale(0.02)',opacity:.95},{transform:'scale(1)',opacity:0}],
     {duration:620,easing:'ease-out'}).onfinish=function(){ if(g.parentNode)g.parentNode.removeChild(g); };
 } else { setTimeout(function(){ if(g.parentNode)g.parentNode.removeChild(g); },620); }
 setTimeout(function(){ if(g.parentNode)g.parentNode.removeChild(g); },900);

 /* chip flies back into the new carriage */
 var fly=paxG.cloneNode(true);
 fxG.appendChild(fly);
 var inner=fly.querySelector('.paxin');
 var dx=-dir.x*cell, dy=-dir.y*cell;
 if(inner && inner.animate){
   inner.animate([
     {transform:'translate(0px,0px) scale(1)',opacity:1},
     {transform:'translate('+(dx*0.5)+'px,'+(dy*0.5-cell*0.5)+'px) scale(.85)',opacity:1,offset:.5},
     {transform:'translate('+dx+'px,'+dy+'px) scale(.35)',opacity:0}
   ],{duration:420,easing:'cubic-bezier(.4,0,.2,1)'}).onfinish=function(){ if(fly.parentNode)fly.parentNode.removeChild(fly); };
 }
 setTimeout(function(){ if(fly.parentNode)fly.parentNode.removeChild(fly); },700);

 /* the carriage springs out of the loco */
 var u=units[1];
 if(u){
   var a=u.querySelector('.anim');
   if(a){
     a.style.setProperty('--dx',(dir.x*cell)+'px');
     a.style.setProperty('--dy',(dir.y*cell)+'px');
     a.classList.remove('spring'); void a.offsetWidth; a.classList.add('spring');
     setTimeout(function(){ a.classList.remove('spring'); },620);
   }
 }
 plateSeq++;
 addPlate(p.name,p.color,true);
 roll($('#bcount'),pad2(score));
 toast('Car added · '+pad2(plateSeq)+' · '+p.name);
 if(score%5===0) toast('Well done! 5 more cars');
 beep(760,0.07,'square');
 setTimeout(function(){beep(1120,0.09,'square')},80);
}

/* ------------------------------------------------------------------ */
/* AI                                                                  */
/* ------------------------------------------------------------------ */
var DIRS=[{x:1,y:0},{x:0,y:1},{x:-1,y:0},{x:0,y:-1}];
function blockedSet(keepTail){
 var s={};
 for(var i=0;i<body.length;i++){ if(keepTail && i===body.length-1) continue; s[body[i].x+','+body[i].y]=1; }
 return s;
}
function bfsFrom(sx,sy,blocked){
 var dist={}, q=[[sx,sy]], head=0; dist[sx+','+sy]=0;
 while(head<q.length){
  var cur=q[head++], d=dist[cur[0]+','+cur[1]];
  for(var i=0;i<4;i++){
   var nx=cur[0]+DIRS[i].x, ny=cur[1]+DIRS[i].y, k=nx+','+ny;
   if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
   if(blocked[k]||dist[k]!==undefined) continue;
   dist[k]=d+1; q.push([nx,ny]);
  }
 }
 return dist;
}
function flood(sx,sy,blocked){
 var seen={}, q=[[sx,sy]], head=0, n=0; seen[sx+','+sy]=1;
 while(head<q.length && n<400){
  var cur=q[head++]; n++;
  for(var i=0;i<4;i++){
   var nx=cur[0]+DIRS[i].x, ny=cur[1]+DIRS[i].y, k=nx+','+ny;
   if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
   if(blocked[k]||seen[k]) continue;
   seen[k]=1; q.push([nx,ny]);
  }
 }
 return n;
}
function aiDir(){
 var head=body[0];
 var blocked=blockedSet(true);
 var distMap=null, target=passenger;
 if(target) distMap=bfsFrom(target.x,target.y,blocked);
 var bestD=null,bestV=-Infinity;
 for(var i=0;i<4;i++){
  var d=DIRS[i];
  if(body.length>1 && d.x===-dir.x && d.y===-dir.y) continue;
  var nx=head.x+d.x, ny=head.y+d.y;
  if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
  if(blocked[nx+','+ny]) continue;
  var v=flood(nx,ny,blocked)*10;
  if(distMap){
    var dd=distMap[nx+','+ny];
    v += (dd===undefined)? -5000 : (2000-dd*10);
  }
  if(d.x===dir.x&&d.y===dir.y) v+=6;
  if(v>bestV){ bestV=v; bestD=d; }
 }
 return bestD||dir;
}

/* ------------------------------------------------------------------ */
/* STEP + LOOP                                                         */
/* ------------------------------------------------------------------ */
function step(){
 if(state==='demo') { var ad=aiDir(); if(ad) dir=ad; queue.length=0; }
 else if(queue.length){
   var nd=queue.shift();
   if(!(nd.x===-dir.x && nd.y===-dir.y)) dir=nd;
 }
 var head=body[0];
 var nx=head.x+dir.x, ny=head.y+dir.y;
 if(nx<0||ny<0||nx>=COLS||ny>=ROWS){ derail(); return; }
 var eat = passenger && passenger.x===nx && passenger.y===ny;
 var chk = eat ? body.length : body.length-1;
 for(var i=0;i<chk;i++){ if(body[i].x===nx&&body[i].y===ny){ derail(); return; } }
 body.unshift({x:nx,y:ny});
 if(eat){
   var p=passenger;
   cars.unshift({name:p.name,color:p.color});
   score++;
   renderTrain();
   boardFX(p,head.x,head.y);
   spawnPassenger(false);
   if(score%5===0 && speed>80) speed=Math.max(80,speed-6);
 } else { body.pop(); }
 renderTrain(); updateGhost(); updateUI();
}
function frame(t){
 var dt=Math.min(120,t-last); last=t;
 if(running){ acc+=dt; var guard=0; while(acc>=speed && guard++<4){ acc-=speed; step(); } }
 requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

/* ------------------------------------------------------------------ */
/* DERAIL / STATE                                                      */
/* ------------------------------------------------------------------ */
function showOverlay(k,t,s){
 $('#ovK').textContent=k; $('#ovT').textContent=t; $('#ovS').textContent=s;
 $('#overlay').style.display='flex';
}
function hideOverlay(){ $('#overlay').style.display='none'; }
function derail(){
 running=false;
 stage.classList.add('shudder','invert');
 beep(80,0.2,'sawtooth');
 setTimeout(function(){ stage.classList.remove('shudder','invert'); },500);
 setTimeout(function(){
   resetGame(true);
   if(demo){ running=true; state='demo'; }
   else { state='derail'; showOverlay('Service interrupted','Tap to resume','Derailment · the platform is waiting'); }
 },1000);
}
function takeOver(){
 if(state==='play') return;
 demo=false; state='play'; resetGame(false); running=true; acc=0; hideOverlay();
}
function resumePlay(){
 demo=false; state='play'; resetGame(false); running=true; acc=0; hideOverlay();
}
function togglePause(){
 if(state==='play'){ running=false; state='pause'; showOverlay('Service paused','Press space or tap to resume',''); }
 else if(state==='pause'){ running=true; state='play'; hideOverlay(); }
}

/* ------------------------------------------------------------------ */
/* INPUT                                                               */
/* ------------------------------------------------------------------ */
var 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}};
window.addEventListener('keydown',function(e){
 var k=(e.key||'').toLowerCase(), d=KEYMAP[k];
 if(d){
   e.preventDefault();
   if(state==='derail'){ resumePlay(); push(d); return; }
   if(state==='pause'){ running=true; state='play'; hideOverlay(); }
   else if(state!=='play'){ takeOver(); }
   push(d);
 } else if(k===' '){ e.preventDefault(); if(state==='demo'){takeOver();return;} togglePause(); }
},false);

function push(d){
 if(queue.length<2) queue.push(d);
 var b=$$('.key');
 for(var i=0;i<b.length;i++) if(b[i].dataset.dir===({0:'x',1:'down'}[0]?'':'')){}
}
/* clickable keycaps */
$$('.key[data-dir]').forEach(function(btn){
 btn.addEventListener('click',function(){
  var m={up:{x:0,y:-1},down:{x:0,y:1},left:{x:-1,y:0},right:{x:1,y:0}};
  if(state==='derail'){ resumePlay(); push(m[btn.dataset.dir]); return; }
  if(state!=='play') takeOver();
  push(m[btn.dataset.dir]);
 });
});

/* pointer: swipe or tap-ahead */
var pDown=null;
stage.addEventListener('pointerdown',function(e){ pDown={x:e.clientX,y:e.clientY}; });
stage.addEventListener('pointerup',function(e){
 if(!pDown) return;
 var dx=e.clientX-pDown.x, dy=e.clientY-pDown.y; pDown=null;
 if(state==='derail'){ resumePlay(); return; }
 if(state==='demo'){ takeOver(); }
 else if(state==='pause'){ running=true; state='play'; hideOverlay(); }
 if(Math.abs(dx)>24||Math.abs(dy)>24){
   push(Math.abs(dx)>Math.abs(dy)?{x:Math.sign(dx),y:0}:{x:0,y:Math.sign(dy)});
 } else {
   var r=board.getBoundingClientRect();
   var cx=Math.floor(((e.clientX-r.left)/r.width)*COLS);
   var cy=Math.floor(((e.clientY-r.top)/r.height)*ROWS);
   var h=body[0], ox=cx-h.x, oy=cy-h.y;
   if(Math.abs(ox)>Math.abs(oy)) push({x:Math.sign(ox)||1,y:0});
   else push({x:0,y:Math.sign(oy)||1});
 }
});
stage.addEventListener('pointerleave',function(){pDown=null});
$('#ovCard').addEventListener('click',function(){
 if(state==='derail') resumePlay(); else takeOver();
});

/* ------------------------------------------------------------------ */
/* SOUND                                                               */
/* ------------------------------------------------------------------ */
var actx=null, sound=false;
function beep(f,dur,type){
 if(!sound) return;
 try{
  if(!actx) actx=new (window.AudioContext||window.webkitAudioContext)();
  var o=actx.createOscillator(), g=actx.createGain();
  o.type=type||'square'; o.frequency.value=f;
  g.gain.setValueAtTime(0.0001,actx.currentTime);
  g.gain.exponentialRampToValueAtTime(0.07,actx.currentTime+0.01);
  g.gain.exponentialRampToValueAtTime(0.0001,actx.currentTime+dur);
  o.connect(g); g.connect(actx.destination); o.start(); o.stop(actx.currentTime+dur+0.02);
 }catch(err){}
}
$('#soundBtn').addEventListener('click',function(){
 sound=!sound; this.textContent=sound?'Sound on':'Sound off';
 if(sound) beep(660,0.07,'square');
});

/* ------------------------------------------------------------------ */
/* DRAWER / CTA                                                        */
/* ------------------------------------------------------------------ */
function openDrawer(){ $('#drawer').classList.add('open'); }
function closeDrawer(){ $('#drawer').classList.remove('open'); }
$('#howBtn').addEventListener('click',function(){ $('#drawer').classList.toggle('open'); });
$('#heroHow').addEventListener('click',function(){ openDrawer(); $('#drawer').scrollIntoView({behavior:'smooth',block:'start'}); });
$('#closeHow').addEventListener('click',function(){ closeDrawer(); $('#game').scrollIntoView({behavior:'smooth',block:'start'}); });
$('#boardCta').addEventListener('click',function(){ closeDrawer(); takeOver(); $('#game').scrollIntoView({behavior:'smooth',block:'start'}); });

/* ------------------------------------------------------------------ */
/* RAIL ODOMETER + TICKS                                               */
/* ------------------------------------------------------------------ */
var ticking=false;
function onScroll(){
 if(ticking) return; ticking=true;
 requestAnimationFrame(function(){
  var max=Math.max(1,document.body.scrollHeight-window.innerHeight);
  var p=Math.min(1,window.scrollY/max);
  $('#kmv').textContent=(p*12.4).toFixed(2);
  ticking=false;
 });
}
window.addEventListener('scroll',onScroll,{passive:true});
if('IntersectionObserver' in window){
 var obs=new IntersectionObserver(function(es){
  es.forEach(function(en){
   if(en.isIntersecting){
    $$('.rtick').forEach(function(t){ t.classList.toggle('on', t.dataset.t===en.target.id); });
   }
  });
 },{rootMargin:'-45% 0px -45% 0px'});
 ['hero','drawer','game','ticket'].forEach(function(id){ var el=document.getElementById(id); if(el) obs.observe(el); });
}

/* ------------------------------------------------------------------ */
/* RESIZE + BOOT                                                       */
/* ------------------------------------------------------------------ */
var rT=null;
function resize(){
 var w=stage.clientWidth-2;
 if(w<80) return;
 var nc=Math.max(20,Math.min(46,Math.floor(w/COLS)));
 if(nc!==cell || !trainG){ cell=nc; buildBoard(); renderTrain(); updateGhost(); placePax(); }
}
window.addEventListener('resize',function(){ clearTimeout(rT); rT=setTimeout(resize,120); });

buildBoard();
resetGame(true);
resize();
running=true; state='demo';
showOverlay('Ready for departure','Click or press a key to start','Demo mode · AI conductor at the controls');
onScroll();
})();
</script>
</body>
</html>
Reasoning trace (39,036 tokens)
Let me design and build a snake game where the snake is a passenger train traveling a rail network, picking up waiting passengers from platforms.

**Who this is for and what it has to feel like.** The brief is a game brief, but the thing I'm making is a *board* — a printed subway diagram that happens to move. So my starting instinct is not "arcade game chrome", it's a station poster: warm enamel paper, a strict color system, circles and 90° elbows, hairlines instead of shadows. If I get that right, the train reading as a snake becomes almost incidental — it's just the rolling stock that happens to obey snake rules. The play grid *is* the network diagram, so track, UI and layout all share one grid, and nothing on the page exists that a real transit authority wouldn't print.

**Palette.** I'm building on warm enamel paper `#F4EDE2` rather than white — white would read as a generic game HUD, and the cream gives the printed-ink feeling immediately. Recessed panels and every alternating rail bed go one step warmer and darker to `#EADFCB`. All ink is `#12161C` — not black, a slightly blue-warm ink — used for type, hairlines, the loco body and the status strip. Every structural line in the document is exactly `1px solid #12161C`; no shadows anywhere, that's a lock.

The voltage color is signal orange `#FF5A1F`, and I'm rationing it hard: primary buttons, the next-pickup ring, the growing segment of the progress line, the level-plate wipe. It never becomes a background field and never touches body copy. The loco gets a second "hot" accent — brass `#FFC24B` on the chimney cap, headlight and buffer beam — but brass stays *inside* the loco illustration and never becomes UI.

The five wagon colors are the palette's real payload: `#FF5A1F` line 1, `#00B39F` line 2, `#FFC24B` line 3, `#3D5AFE` line 4, `#E5E1D8` line 5. Critically these only appear in three places: passenger chips, carriage livery swatches, and the enamel carriage plates that ride behind the train. That means the train physically paints the scoreboard — as it grows, the page's accent color story grows with it, which is a much better reward than a number going up. Staff and perfboard type use `#6E6A61`.

**Typography.** Two voices, extreme scale contrast. Signage is a heavy condensed grotesque — I'm shipping the stack as `'Archivo Narrow', 'Oswald', 'Arial Narrow', Impact, system-ui` so it degrades honestly if the webfont doesn't arrive; everything set uppercase at +0.06em, the way enamel signage is set. Data is a tight mono: `'JetBrains Mono', ui-monospace, Menlo, Consolas` at 11px for platform metadata and 13px for counters, tabular figures throughout so numbers never reflow.

The scale jumps deliberately: 200px numeral → 64px display → 17px arcade headline → 14px mono → 11px mono → 14px tabular figure. There is nothing between 64 and 17, and nothing between 11 and 14. I don't want a gentle ramp; poster type never has one. Every label, button and sign is uppercase; sentence case exists only in the two or three places a human is being spoken to directly ("TAP TO BOARD").

**Layout system.** Three vertical territories: a fixed 96px platform rail hugging the left edge of the document, a fluid center column, and a 320px staff column. The 96px rail is the spine — it carries the rotated `PEAK LINE EXPRESS` lockup, a double hairline running the full page height, an interchange dot that re-syncs to each section as you scroll, the four numbered ticks, and a `KM 0.00` odometer. It's persistent on every screen, including the game, because it's the reason this reads as a document and not a page.

All game chrome aligns to a 12-column grid with 24px gutters inside 1048px max width. The diagram box deliberately ignores it and bleeds 24px past both column edges — so the rail bed is physically wider than the type, the way a printed map is wider than its legend.

On the game screen specifically I'm restructuring: a 96px left rail, a fluid center, a 260px right staff column, on a fixed 880px body height so there's no page scroll during play. Header 72, a status rail of three cells, then the diagram box taking the remaining height with the legend strip at its floor. That gives me a square-ish diagram area at any viewport without horizontal scroll at 375px.

**The grid itself.** 48px cells, cols 1–22 / rows 1–16 for the diagram. Track is drawn on *every cell edge* in the network with a 1px ink path plus a dashed `#EADFCB` sleeper run on top (3/7 dash, 3px stroke) — so the bed reads recessed and the ink line reads as a hairline through it. Hatch is per cell: horizontal lines every 6px at 12% opacity, alternating direction cell to cell, making a diagonal moiré that is both vintage and functionally useful — it tells you your train is moving.

Geometry rule for the whole document: `0px` radius on everything structural, `9999px` reserved exclusively for buttons and station dots. Exactly two circles exist — station nodes and passenger chips. Plus a third circle I'm allowing: the enamel rollerboard on the carriage plates, because it's a genuine railway object and it's the only ornament in the set.

**Motion.** Three verbs only: **ROLL** (hard easing, no bounce), **SWITCH** (color snaps), **PING** (one expanding ring, once). The train is never eased — it advances one cell every 120ms and if a frame is dropped it skips to the next cell rather than sliding, because a slid snake stops reading as rolling stock. The head rotation cuts between four discrete angles, never interpolates.

Boarding is the signature moment: passenger chip arcs onto the arriving carriage over 420ms while a `#FF5A1F` ring pings out; the carriage springs out of the loco over 560ms on `cubic-bezier(.34,1.56,.64,1)` with a 6px overshoot; simultaneously an enamel name plate slides onto the staff column from +48px. Score swaps with a two-frame odometer roll. The headlight cone sweeps twice when the app opens. And when the train collapses or derails, the whole stage shudders twice at ±4px — a hard cut, no fade.

Ambient loops only: sleepers drifting, a dashed ghost route advancing ahead of the head every 800ms, the next passenger's ring pulsing. Nothing else moves, ever.

**Section by section.**

*Platform rail* — fixed, full document height, double hairline, rotated lockup bottom-anchored 40px up, four numbered ticks with 64px rules, odometer at the floor.

*Station nameboard* — 88px tall, bounded top and bottom by hairlines, three zones split by 1px verticals: five enamel squares + `PEAK LINE EXPRESS`; the day's service data in mono (cars run, longest train, wait time); `SOUND ON/OFF` and `HOW TO PLAY` at right. It reads like the header of a printed timetable.

*Hero* — full-bleed diagram behind everything: 1px tracks on a 48px orthogonal grid with four 16px station nodes, one dashed diagonal. Left 7 columns carry the H1 at 64px in three lines — `PICK UP THE / PASSENGERS. / DON'T BACKTRACK.` — then the Dutch subline, then `BOARD THE TRAIN` as the orange pill and `HOW TO PLAY ↓` as a ghost pill. The train runs autonomously behind the type, ignoring the grid entirely. Bottom 104px: a scale-rule ruler, 1px with 6px ticks, labelled `MOTION · SOUND · TYPE · COLOR · CREW`.

*Game screen* — status rail of three hairline cells (passengers / train length / next pickup with a 64×3px progress line), the diagram box with corner registration ticks and a bottom legend strip of the same height as the status rail, and a right staff column that is the emotional payoff: header `BOARDINGS` with a right-aligned count, then the enamel carriage plates and empty dashed `+ ADD A CAR` slots. Under 1100px the staff column drops below the diagram as a horizontal strip; under 640px the legend hides, the rail narrows to 56px and buttons grow to 44px.

*Footer* — a full-width ink strip, cream mono: three key glyphs, `NO REAL TRAINS WERE HARMED`, `© PEAK LINE TRANSPORT AUTHORITY`. No illustration, it's the back of the ticket.

**Imagery.** 100% procedural inline SVG, no photography — justified because the imagery *is* the gameplay, and flat vector livery colors match the chips exactly, so train and timetable are visibly the same system. Loco is drawn at a 48-unit scale: ink body, brass chimney cap, orange headlight, 12px-wheels-at-48 scale so they read, cream windows with a cream "42". Carriages are 44×36 rounded-2px in livery color with ink border, three cream windows and a cream roof band. Passengers are 24px ink circles with a cream face and a 4px livery color bar. Icons are inline SVG paths at 1.5px stroke, no emoji, no icon fonts.

Let me plan the build carefully.

Structure:
- Fixed platform rail 96px left (with rotated lockup, ticks, odometer)
- Station nameboard header (fixed? it's a top header) — 88px
- Hero section with diagram background + train
- Game screen (the actual playable snake-as-train)
- Carriage plate staff column
- Footer strip

Wait — I should build the main focus: the game, plus hero and footer sections. Let's do: nameboard header (sticky), hero (full-bleed diagram with train running behind type), game screen (diagram box + status rail + staff column), legend strip, how-to-play inline drawer, footer.

Actually, to keep it manageable but rich: sections 1-8 as described. The game screen is the core.

Let me think about implementation.

Game: 22×16 grid of 48px cells → 1056×768. That's big. Diagram box width 1048 max. So grid ~21.83. Let's use cell size computed from container width: cell = floor(width/22). At 1048 → 47.6 → 47. Then box height = 16*47 = 752. That's tall for 800px viewport. Hmm. Maybe reduce grid to 22×14 or scale down. Let's make cell size adaptive: fit within available. I'll compute cell = min(floor(w/22), floor(maxH/16)). With maxH around 560, cell = 35. Then grid = 22*35=770 wide, centered in 1048 box. Hmm, that leaves margins.

Alternative: make the diagram box height driven by cell size, and cell = floor(w/22) but cap at 40 → box height 640. Total game screen: nameboard 88 + status rail ~72 + diagram 640+120 legend = ~920 > 800. So page scrolls. That's acceptable — the game is centered and the page can scroll a bit. But screenshot at 3 seconds shows first 800px. Hero is first. Hmm — "Everything that makes the demo interesting must be visible within the first seconds" and screenshot ~3s after load. The first paint shows hero with train running behind type — that's good and interesting.

But better: maybe the game should be prominent. Let me structure: nameboard (88) + hero (~ 640) so that the top of the game section peeks? Or make hero shorter so the game is partly visible. Actually a good approach: hero occupies roughly 560px after 88 header, then the game status rail + diagram start right below, visible in the 800px viewport partially. And the train running in hero is already interesting.

Hmm, alternatively make the hero itself smaller and put the game as the main event right at the top... but I'll stick to my layout: header, hero, game. I'll make hero min-height ~ calc(100vh - 88px - 220px) so that ~220px of game section peeks at bottom. Let's do hero height 560px fixed-ish (min 560). At 800 viewport: 88 + 560 = 648, leaving 152px showing status rail top. Good enough — and the hero train is playing.

Actually the screenshot is of the full page? "screenshot taken about 3 seconds after load" — likely viewport screenshot. I'll ensure hero is compelling AND the game's first rows show.

Let me reconsider: make hero 520px tall on desktop. 88+520 = 608, then status rail 88, then diagram top ~696 → 104px of the diagram visible with the train running. Good.

Now the game engine.

Grid: I'll use cols=21, rows=15 to fit better? With 22×16 at 48px cells in 1048×768 box: 22*48 = 1056 which exceeds 1048. Let's just use CSS to size the box to the grid: cell computed = Math.floor((containerWidth)/22), clamp 30..44. On desktop with 1048 width → 44 → box 968×704. Hmm 704 tall.

Let's think about vertical budget for game section: status rail 88 + diagram box (704) + legend 120 = 912. Page scrolls. Fine.

Maybe reduce rows to 13 and cell 44 → 572 tall. Total 88+572+120 = 780. Plus nameboard 88 + hero 520 = 1388. Still scrolls but the game section fits in one screen if you scroll to it. I'd rather keep 22×16 for a spacious board. Compromise: cols 22, rows 14, cell = clamp(28, floor(w/22), 46). At 1048 → 46 → 1012×644. Status 88 + 644 + 120 = 852. Slightly over 800 but ok.

Hmm, I'll keep 22×16 with cell size derived: cell = Math.min(44, Math.floor(w/22)). 22*44=968, 16*44=704. Total game block 88+704+120=912. It's fine; scroll.

Actually — a cleaner solution: keep rows=16 but let cell shrink so whole game screen fits ~ viewport. Let's target game block ≈ 640: cell such that 88 + 16c + 120 = 640 → c = 27. Too small.

I'll just accept scrolling. The page is a document with a rail; scrolling is natural.

Rendering: SVG for the diagram. viewBox = 0 0 (cols*cell) (rows*cell). Layers: bed hatch pattern, track lines, ghost route, passengers, train.

Train rendering: I'll render the train as SVG groups positioned per cell. Head = locomotive SVG (I'll define a <g> with paths, scaled to cell). Wagons = rect with livery color.

Movement: setInterval-ish with requestAnimationFrame timing; step every 120ms.

Snake logic: array of {x,y}. Direction queue. Wrap? No — hitting wall or self = derail. Level 1 has open track; maybe wrap is nicer but derail is clearer. I'll do walls = derail (bounce animation).

Passengers: spawn on a "platform" cell. Platforms: fixed set of platform cells with signage? Simpler: passengers spawn randomly on empty cells. I could put platform signage in columns 0,1,20,21 (side platforms), but simpler and more readable: each passenger sits on a cell with a small platform mark (a 1px platform edge line under the chip) — I'll draw a short hairline under the chip as the platform edge plus the chip circle. Good.

Pickup: when head enters passenger cell → board. Add carriage (snake grows by 1), score++, passenger respawns after 600ms at a new empty cell.

Also I could add a "well done" bonus passenger occasionally — keep it optional; maybe skip for simplicity, but it's a nice touch. I'll add: every 5th passenger is a "well done" bonus worth extra. Eh, keep simple: normal passengers only, but show toast on streaks. Actually let's include a simple bonus: after 5 consecutive without hitting, spawn a conductor? Skip — scope control. I'll implement plain passengers plus the ghost route and all motion.

Sound: WebAudio, default OFF.

Controls: WASD/arrows, swipe, on-screen d-pad in legend.

States: READY (overlay "Click or press a key to start"), RUNNING, PAUSED, DERAIL.

Self-playing demo: it should start in a self-playing demo mode behind a "Click or press a key to start" message. So: the game runs an AI (greedy path to nearest passenger, avoiding self) automatically. Before user interacts, show overlay message "CLICK OR PRESS A KEY TO START". Hmm — but if demo plays behind the message, the message overlay would hide it. Standard approach: overlay message is semi-transparent / small, and the AI plays behind it. I'll show a centered pill banner "CLICK OR PRESS A KEY TO START" that's not covering everything (a bar across the middle-ish but the train visible around/behind, or positioned at bottom of the diagram). Let's make it a compact centered chip with backdrop, and the game plays behind. On any click/key, chip disappears, demo mode stops, real control begins.

Actually better: keep demo AI running always when user isn't playing. When user clicks/keys → take over control. If idle after derailing in demo... the AI plays; if AI derails, restart automatically.

I'll implement: `demo = true`. AI computes direction each step toward nearest passenger with simple safety (avoid cells occupied by body, prefer not to trap). On pointer/key input → demo=false, reset game to READY→RUNNING with a fresh board.

Let me write the AI: greedy with a fallback. Simple BFS from head to nearest passenger over free cells (body except tail), get first direction. If no path, choose direction that maximizes free-space (flood fill) — that's decent. Flood fill over free cells up to limit. I'll do BFS distance map from head; pick neighbor minimizing distance to target with tie-break on free space. Good enough.

Let's now write AI carefully:

```
function aiDir(){
  const head = body[0];
  // BFS from head to nearest passenger
  // build blocked set from body (exclude tail cell since it moves)
  ...
}
```
Simpler: compute for each of the 3 candidate dirs (not reverse): the cell; if blocked by wall or body (excluding tail), score = -inf. Else compute BFS dist from that cell to nearest passenger through free cells. Pick max score with tie-break preferring straight. Also add flood-fill size as secondary.

BFS over 22*16=352 cells is trivial.

Passenger "reachability": BFS to nearest passenger; if none reachable, use flood fill size.

OK.

Odometer roll for score: implement number swapping with translateY keyframes.

Carriage plates in staff column: each boarding adds a plate. Cap displayed plates to ~8 with "+ ADD A CAR" placeholders.

Name board plates: passenger has a name from a list of 20 destinations (station names). Passenger chip color = livery color of line. Boarding → carriage plate: color swatch of that livery, station name, number.

Status rail: PASSENGERS, TRAIN LENGTH, NEXT PICKUP (station name + progress line filling as the train approaches? "64×3px progress line"). I'll make it fill based on distance to the next passenger: progress = 1 - dist/maxdist. Or fill as time since spawn. I'll use distance-based — meaningful.

Hero diagram: separate SVG with tracks, station nodes, dashed diagonal, and a train running along a path behind the type. I'll animate a train along a polyline using JS (position along a route path, discrete steps every 120ms). That's a mini ambient thing. Simpler: define a rectangular loop route in hero coordinates, and step a head + 5 wagons along it every 140ms. Reuse the same render helpers. Let's do a fixed loop path (orthogonal) and place train cars at path indices. That's doable: precompute array of cells forming a closed loop; train positions = loop[(i - k) mod n].

Nice — hero ambient train runs the loop continuously, subtly. Let's do that.

Hero grid: I'll generate SVG with lines every 48px, station nodes at some intersections, dashed diagonal.

Let me now think about layout CSS.

```
body { padding-left: 96px }  /* platform rail fixed */
.platform-rail { position: fixed; left:0; top:0; bottom:0; width:96px; border-right:1px solid ink; }
```
Actually double hairline: use border-right 1px + an inner ::after line at 4px offset.

Content wrapper: max-width 1144px, margin 0 auto, padding 0 48px. Diagram bleeds 24px beyond → margin: 0 -24px.

Nameboard header: sticky top 0, height 88, border-bottom 1px, background canvas (needs bg to cover scrolling content).

Sections: hero (relative, overflow hidden), game, legend, footer.

Responsive: <1080px rail 56px, body padding-left 56, rotated lockup hidden, content padding 20px. <640 hide legend labels etc.

Game screen layout: grid with `1fr 320px` gap 48px at ≥1080; below that stacked.

Let's write the code.

HTML skeleton:

```html
<div class="rail"> ... </div>
<div class="page">
  <header class="nameboard">...</header>
  <section class="hero">...</section>
  <section class="game">...</section>
  <section class="legend">...</section>
  <footer class="ticket">...</footer>
</div>
```

Odometer in rail updates with scroll: "KM 0.00" → compute scroll progress *  something. And ticks highlight per section via IntersectionObserver.

Now, SVG icons: arrow keys glyphs in footer — I'll draw simple SVG arrow in keycaps. Train icon in HOW TO PLAY button. Sound on/off.

Let me write the loco SVG (48×48):

```svg
<g class="loco">
  <!-- body -->
  <rect x="4" y="12" width="40" height="24" rx="2" fill="#12161C"/>
  <!-- cab -->
  <rect x="28" y="4" width="16" height="10" rx="2" fill="#12161C"/>
  <!-- chimney -->
  <rect x="8" y="2" width="8" height="10" fill="#12161C"/>
  <rect x="6" y="0" width="12" height="4" rx="1" fill="#FFC24B"/>
  <!-- headlight -->
  <rect x="0" y="16" width="6" height="8" fill="#FFC24B"/>
  <!-- windows -->
  <rect x="31" y="6" width="10" height="6" fill="#F4EDE2"/>
  <rect x="14" y="15" width="6" height="6" fill="#F4EDE2"/> x3
  <!-- wheels -->
  <circle cx="14" cy="38" r="5" fill="#12161C"/> etc
</g>
```
With rotation applied around center (24,24). Plus headlight cone when moving right — the cone must rotate with the group, so include in the group.

Head rotation discrete: I'll set transform rotate(angle) on the head group, instant (CSS transition none or step-end). Use `transition: transform 0s`.

Wagon: 44×36 rect with livery fill, cream windows, ink border, wheels.

The train cells: each is a <g> with transform translate(x*c, y*c) and inner content scaled. For wagons connecting, they should visually connect — each cell is 48 wide and wagon is 44 wide centered → 2px gap, reads as joints. Good.

Rendering: I'll create DOM SVG elements once and update transforms each step.

Now passenger chip: g with circle r=12 at cell center, ink fill, face features cream, color bar 4px in livery color at bottom of circle. Plus platform edge hairline.

Let's simplify the chip: circle r=11 fill ink, a small arc face in canvas color, and a 5px tall livery rect at the bottom clipped... Simpler: circle ink + a livery-colored rect band at y = cy+4..cy+10 clipped by circle. I'll just do: outer circle r=11 fill livery color, inner circle r=8 fill ink? Hmm. Design: "24px filled ink circle with a cream face and a 4px livery color bar". I'll do: circle r=11 fill #12161C; rect x=-11,y=6,w=22,h=4 fill livery (band across lower part) — but it'd stick outside circle. Use clipPath per color... simpler: draw circle r=11 ink; then path arc bottom filled livery: `<path d="M -10 4 A 11 11 0 0 0 10 4 Z" fill=livery>` — approximate arc across bottom. Then face: small cream dot for head. Actually let's do: face = cream circle r=4 at (0,-3), and livery band = path bottom arc. Good enough and reads at small size.

I'll use `<circle r="11" fill="#12161C"/><path d="M-10.5 5 A 11 11 0 0 0 10.5 5 Z" fill="{livery}"/><circle cx="0" cy="-3" r="4.5" fill="#F4EDE2"/>` — hmm the arc path from (-10.5,5) to (10.5,5) sweeping the bottom. With sweep-flag 0 vs 1 need care. Let's use: `M -10.5 5 A 11 11 0 0 0 10.5 5 Z`. Start (-10.5,5), end (10.5,5), radius 11, large-arc=0, sweep=0 → goes counterclockwise... In SVG y-down, sweep=0 means counter-clockwise in screen terms which from left point to right point would arc through the top. sweep=1 arcs through bottom? Let's reason: center of circle for chord... Let me just use a different approach: `<circle cy="6" r="11" fill=livery clip-path>` too complex.

Easiest: draw livery circle r=11, then ink circle r=11 offset up? No.

Alternative simple design: chip = circle r=11 fill livery color with ink stroke 1px, and a small cream "person" glyph inside: head circle r=3.5 cream + shoulders path cream. And the livery color bar is the whole chip. That's cleaner: "Passenger chip: circle, livery fill, ink stroke, cream passenger glyph". The chip = ink circle with cream face, and a 4px livery ring/arc. I'll do: circle r=11 fill ink; circle r=7 fill canvas; then a 3px livery ring: circle r=9.5 fill none stroke livery stroke-width=3. Wait order: ink disc r=11, then livery stroke circle r=9 sw=3 → ring inside, then cream r=5.5 core... that's a target look.

Let's do: outer circle r=11 fill = livery color; inner circle r=7.5 fill = #F4EDE2 (face); plus ink stroke 1.5 on outer. Simple, colorful, reads as a person's head on a colored disc (like a station dot). Add a tiny ink "person" inside: circle r=2.6 ink at cy=-1.5 and path shoulders ink. Nice — reads as a passenger marker. I'll do that.

Now — the "platform": draw a 1px ink hairline under the chip: line from cell left+6 to right-6 at y = cell bottom - 5, plus 3 tiny ticks. Good, it reads as a platform edge. Hmm, might clutter. I'll add a subtle dashed platform rect behind chip: `<rect x=cx-16 y=cy+10 w=32 h=6 fill="#EADFCB" stroke="#12161C" stroke-width="1"/>` — a platform slab. Nice, clear "waiting on the platform".

Good.

Boarding animation: chip arcs to carriage then carriage springs. Implementing full SVG arc animation is complex. Simplify: on boarding, chip element animates via transform to the carriage position with a JS/CSS transition along an arc approximated by a translate + a slight scale, using a keyframe with two-step (translate x then y) — I can use CSS `offset-path`? Not reliable. I'll do: animate with Web Animations API: `chip.animate([{transform:...},{transform: mid, offset:.5},{transform: end}], {duration:420, easing:'ease-in-out'})` — mid point gives an arc. That works nicely.

Plus ring ping (circle r 0→140 opacity 1→0, 620ms), carriage spring (new carriage element scale from .2 at loco position to its cell with cubic-bezier overshoot 560ms), plate slide-in, score odometer.

Since the train moves every 120ms during the animation, the carriage position changes. Simplify: append the new carriage immediately to the body (so snake grows), and animate its scale from 0.2 → 1 about its own center with the spring easing, plus a translate from head direction. That's the "springs out of the loco" feel. Good enough.

Also plate name: passenger's station name.

Let's write the destination name list (20):
HARBOUR FRONT, MILLBANK, ASHFORD CROSS, KILN STREET, OLD MARKET, CANAL BASIN, GRANARY WAY, COPPER LANE, NORTH GATE, STADIUM, VIOLET PARK, QUARRY END, LAMPLIGHT, TANNERY, ROYAL EXCHANGE, FERRY POINT, BRICKFIELD, OBSERVATORY, SALT WHARF, ELDER GROVE.

Passenger color cycle through 5 liveries.

Now let's write the code in full.

Key layout numbers:
- rail 96 / 56
- nameboard 88
- hero 520
- game section: status rail 88, diagram box, legend 120

Game box sizing: JS computes cell = Math.max(24, Math.min(46, Math.floor((boxWidth)/22))). boxWidth = diagram container width + 48 (bleed). Let's set diagram container width via CSS (full width of content area + 48px bleed), then cell = floor(w/22).

Total SVG width = 22*cell, height = 16*cell. Center it in the box.

Let's decide cols=22, rows=16 as planned.

Max cell 46 → 1012×736. On 1280 viewport: content width = 1280 - 96 rail = 1184; page max-width 1144 with padding 48 → inner = 1048; +48 bleed = 1096 → cell = 49 → capped 46 → svg 1012 wide, centered. Height 736. Game block = 88 + 736 + 120 = 944 + nameboard 88 + hero 520 = 1552. Page scrolls to game. Acceptable.

Hmm, but I want the game visible in first screenshot. 88+520 = 608; remaining 192 shows status rail (88) + 104px of diagram. The diagram top 104px = first ~2 rows of the train possibly. Fine, and hero has its own train.

Maybe reduce hero to 480. Then 88+480=568, remaining 232: status rail 88 + 144 diagram. OK. I'll use hero height: clamp(440px, 62vh, 560px) — at 800 vh → 496. Good.

Actually careful: 62vh of 800 = 496. 88+496 = 584, +88 = 672, +128 of diagram. Good.

Let's write CSS.

```css
:root{
 --canvas:#F4EDE2; --paper:#EADFCB; --ink:#12161C; --muted:#6E6A61;
 --volt:#FF5A1F; --l1:#FF5A1F; --l2:#00B39F; --l3:#FFC24B; --l4:#3D5AFE; --l5:#E5E1D8;
 --rail:96px;
}
```

Fonts: Google Fonts — but "no external files... except CDN URLs explicitly allowed". Fonts via Google Fonts link is a network request; allowed? It says no external files/images/network requests except CDN URLs explicitly allowed by the task. The task didn't explicitly allow fonts. Safer: use system font stacks only. Impact/Arial Narrow for condensed display, monospace for data. Hmm, quality suffers but it's safest and rules say network requests only when explicitly allowed. I'll use font stacks: `'Archivo Narrow','Oswald','Arial Narrow','Helvetica Neue Condensed',Impact,system-ui` for display, and `ui-monospace,'SFMono-Regular',Menlo,Consolas,'Liberation Mono',monospace` for mono. No external requests at all. Good.

Hmm, but Impact is very heavy/quirks. `font-stretch: condensed` on system-ui might work in Chrome for variable fonts. I'll include 'Arial Narrow' first after webfont names? Order: `'Archivo Narrow','Oswald','Arial Narrow',Impact,'Haettenschweiler',system-ui,sans-serif`. On a bare Chrome Linux sandbox, likely DejaVu Sans / Liberation. Arial Narrow may not exist. It'll fall back to sans-serif. Acceptable.

Actually, I could embed a tiny bit more character with letter-spacing and font-weight 700 + `font-stretch: 75%`. Chrome supports font-stretch synthesis? Not really. Fine.

Let me consider loading Google Fonts anyway... risk: sandbox may block, then fallback. Using `<link>` to fonts.googleapis.com is a network request. "no external ... network requests, except the CDN URLs explicitly allowed by the task" — nothing allowed, so no. Stick to system stacks.

Now writing everything.

Detailed HTML:

```html
<div class="rail" aria-hidden="true">
  <div class="rail-lockup">PEAK LINE EXPRESS</div>
  <div class="rail-ticks">
    <div class="tick" data-sec="hero"><span class="num">01</span><span class="rule"></span></div>
    ...
  </div>
  <div class="rail-km">KM <span id="km">0.00</span></div>
</div>
```

Rail: position fixed, width 96, top0 bottom0, background canvas, border-right 1px ink, z-index 40. Double hairline: ::after absolute right 4px? Actually border-right is at right edge; add ::before at right:4px width1px full height.

Lockup rotated -90 bottom anchored 40px: use writing-mode? Simpler: `position:absolute; bottom:40px; left:50%; transform: translateX(-50%) rotate(-90deg); transform-origin: center; white-space:nowrap;` — rotating around center keeps it in place horizontally but it extends vertically. With bottom:40px and rotate about center, the element's box before rotation has width ~180 height 14; after rotation it's 14 wide 180 tall, centered at the same center point which is at bottom:40+7=47 → occupies 47±90 → from -43 to 137 from bottom. That's off-screen top. Need transform-origin bottom-left style. Use: `bottom:40px; left:50%; transform-origin: 0 0; transform: rotate(-90deg) translate(-100%, -50%)`... Let's do: place element with `left: calc(50% - 7px); bottom: 40px; transform-origin: left bottom; transform: rotate(-90deg);` Wait rotate(-90deg) about left-bottom: element extends upward? A point at (x,0) local rotates to (0,-x)?? rotate(-90deg) maps (x,y)→(x cos+y sin, -x sin + y cos) with θ=-90: cos=0,sin=-1 → (x*0 + y*(-1), -x*(-1)+y*0) = (-y, x). So local (w,0)→(0,w): the element extends upward from origin. Origin at left-bottom of the element positioned at bottom:40px. So the text runs from bottom:40 upward, and its height (14px) extends to the... local (0,h) → (-h, 0): to the left of origin. So text baseline sits to the left. I want it centered in the 96px rail: set left = 50% + 7 (so that -h offsets to center). left: calc(50% + 7px); bottom: 40px; transform-origin: 0 0; transform: rotate(-90deg). Then text occupies x from left-14 to left, i.e., centered-ish. Good: use `left: calc(50% + 7px)`. Approx fine.

Ticks: absolute positioned along rail: use flex column with justify-content space-between? Four numbered ticks along the rail corresponding to sections. I'll place them evenly: a column container centered vertically with gap. Each tick: `01` mono 11px muted + 64px horizontal rule (but rail is 96 wide, rule 64 fits).

Odometer bottom: below lockup? Lockup is bottom 40. Put km at bottom:12px? Conflicts. Let's structure rail as flex column: top: ticks group (centered around 30% ), then spacer, then lockup, then km at very bottom (12px). Hmm, lockup bottom:40 and km bottom:12 — lockup rotated upward from bottom:40, km occupies 12-26. Works: lockup runs upward from 40px, km sits at 12px horizontally. But km text at 11px in 96px width: "KM 0.00" fits. Good.

Ticks: place at top area: `top: 120px` column with gap 40.

Nameboard:

```html
<header class="nameboard">
  <div class="nb-left"><div class="livery-strip">5 squares</div><div class="nb-title">PEAK LINE<br>EXPRESS</div></div>
  <div class="nb-mid"> grid of mono cells: CARS RUN / LONGEST TRAIN / AVG WAIT / SERVICE </div>
  <div class="nb-right"><button id="soundBtn">SOUND OFF</button><button id="howBtn">HOW TO PLAY</button></div>
</header>
```
Title at 64px would be too tall for 88px header in two lines. In header left zone, I'll set the title at ~26px single line "PEAK LINE EXPRESS" next to the strip, and keep the 64px display scale for the H1 in the hero. That's a scale-jump decision.

Mid zone: mono 11px cells with labels above values.

Hero:
```html
<section class="hero" id="hero">
  <svg class="hero-diagram" ...></svg>  (absolute, full)
  <div class="hero-inner">
     <h1>PICK UP THE<br>PASSENGERS.<br>DON'T BACKTRACK.</h1>
     <p class="sub">Een trein die mensen ophaalt. ...</p>
     <div class="cta"><button class="btn-solid">BOARD THE TRAIN</button><button class="btn-ghost">HOW TO PLAY ↓</button></div>
  </div>
  <div class="scale-rule">ticks + labels MOTION SOUND TYPE COLOR CREW</div>
</section>
```

Hero diagram: SVG with preserveAspectRatio="none"? Better: viewBox 0 0 1200 600 with width/height 100%, preserveAspectRatio="xMidYMid slice". Grid lines at 48. Station nodes. Dashed diagonal. Train loop.

Loop path in hero coords: a rectangle route: from (120,120) → (1080,120) → (1080,480) → (120,480) → close. Step cells of 48 along it. Precompute points: top edge y=120 x from 120 to 1080 step 48 → 20 points; right edge x=1080 y 120→480 step 48 → 8; bottom x 1080→120 → 20; left y 480→120 → 8. Total 56 (corners dedup). Train length 6 moves along. Draw loco at head, wagons behind. Also draw a faint track line for the loop (rect stroke ink 1px) — yes, that's the loop track.

Hero also: headline text sits over it. Add a canvas-colored backing? The design says no scrims... but readability: the diagram is hairlines at low contrast, so text over it is fine. I'll use `mix-blend` nothing; just keep diagram opacity ~0.55 and place text with no background.

Hmm, the train in hero behind headline could clash. It's a poster; keep the loop track lower/around. Loop at y=120 top and y=480 bottom; headline occupies left 7 cols roughly y 80-360. The train passes behind. Acceptable and lively — but may hurt legibility. I'll set hero diagram opacity 0.45 and let the train be at full? Can't separate easily... I can put the loop track group at opacity .5 and the train group at .8. Fine.

Actually simpler: make the hero loop occupy the right portion & bottom: rectangle from (600,60) to (1140,540)? The headline is left. But then on mobile it's all behind. Eh — I'll keep a full-width loop but position it lower: top y=430, bottom y=... not enough room. Let's do loop rect x 60..1140, y 380..? height small.

Alternative: the hero train runs along a route that's mostly on the right half: points: (640,90)→(1140,90)→(1140,510)→(640,510)→(640,90). Headline occupies x 0..640. On narrow screens the loop still shows on the right partially. Good. Add left vertical segment at x=640 which is behind... headline is 7 cols of ~1048 → ends at ~660. Slight overlap, ok.

Hmm but on mobile the hero-inner spans full width and loop overlaps. I'll reduce diagram opacity on small screens.

Let's simplify: hero loop rect from (560,70) to (1180,530) in a 1240×600 viewBox with slice. Fine.

Game section HTML:

```html
<section class="game" id="game">
  <div class="status-rail">
     <div class="cell"><div class="lab">PASSENGERS</div><div class="val" id="score">00</div></div>
     <div class="cell"><div class="lab">TRAIN LENGTH</div><div class="val" id="len">04</div></div>
     <div class="cell"><div class="lab">NEXT PICKUP</div><div class="val next" id="next">HARBOUR FRONT</div><div class="prog"><i id="progFill"></i></div></div>
  </div>
  <div class="stage-wrap">  <!-- bleeds -24 -->
    <div class="stage" id="stage">
       <svg id="board">...</svg>
       <div class="reg tl"></div> ... 4 corner registration ticks (CSS)
       <div class="overlay" id="overlay">
          <div class="ov-card">READY FOR DEPARTURE / CLICK OR PRESS A KEY TO START</div>
       </div>
       <div class="toast" id="toast">CAR ADDED · CAB 07</div>
    </div>
  </div>
  <div class="legend"> ... </div>
</section>
```

Staff column: where? Game layout: `grid-template-columns: 1fr 320px` with stage-wrap in col1 and staff in col2? But the status rail spans full width above. Let's do:

```
.game-grid { display:grid; grid-template-columns: minmax(0,1fr) 320px; gap: 48px; align-items:start; }
left: status-rail + stage-wrap + (legend spans full? )
```
Legend should be full width under the stage. Legend is "a strip under the diagram box of identical height". I'll put status rail and stage in left column, legend in left column too, staff in right column spanning rows. Simpler: 

```
.game-grid: [main] [staff]
main: status rail (88), stage, legend (120)
staff: header BOARDINGS + plates list
```
That works and staff column height ≈ 88+stage+120, plenty.

Under 1080: single column, staff becomes horizontal scroll row below legend.

Staff plates: 
```html
<div class="plates" id="plates">
  <div class="plate-empty">+ ADD A CAR</div> x4 initially? 
```
Better: render empty slots count = max(0, 6 - count) always show 6 slots. I'll render actual plates + 3 empty slots.

Plate markup: `<div class="plate" style="--c:#FF5A1F"><span class="swatch"></span><span class="pname">HARBOUR FRONT</span><span class="pnum">07</span></div>` with animation slide-in.

Legend:
```
LEFT/RIGHT/UP/DOWN arrows + WASD | NEXT STOP: <name> + progress | PASSENGERS 07/20
```
Plus a d-pad for touch on small screens.

Footer ticket strip.

How-to-play: inline drawer expanding under the nameboard (full width, hairline bottom). Contains keycaps, 5 livery swatches, and RETURN TO PLATFORM + 3 rules.

Now the game JS. Let me write it.

```js
const COLS=22, ROWS=16;
let cell=44;
const board = document.getElementById('board');
// build SVG content via JS strings for simplicity
```

I'll build SVG innerHTML once with groups:
- defs: pattern hatch
- rect bed (canvas) + hatch rect (paper lines)
- g#track
- g#ghost
- g#passengers
- g#train

Track: draw grid lines? "The track bed: hatch fills the cell, with a 1px ink line drawn on top as the rail." I'll draw rails as a grid of horizontal+vertical lines every cell (like a rail network) in ink at low opacity? That'd be a full grid — combined with hatch it's busy. Instead: draw the track as a grid of lines every 2 cells? Let's do: horizontal lines every row and vertical lines every col, stroke ink opacity .28, width 1. Plus hatch at 12%. Actually the hatch provides the moiré; rails on every cell edge would double up.

Decision: rails drawn as a "network" — vertical rails at every 4th column (cols 0,4,8,12,16,20) and horizontal rails at every 4th row, each rail = 3px paper band with 1px ink edges? Simpler and prettier: draw rail lines (2px paper, 1px ink hairline through) on every 2 cells in both directions forming a network of tracks. Hmm.

Let's go: draw orthogonal track lines on a 48px grid where each track is: a 3px #EADFCB band with 1px #12161C lines on both sides... that's heavy at 1000px width with 22 lines.

Simpler and true to the poster: draw a 1px ink line down the middle of each cell row and column at every 2 cells, plus sleepers as short perpendicular ticks. That's the classic. Sleepers: for horizontal rails, ticks every 8px, 6px tall, ink at 40%.

I'll implement rails on the 2-cell lattice (11 vertical, 8 horizontal) as: band 4px paper, ink 1px edges, sleepers perpendicular 1px every 8px. Hmm, that's real track drawing — could look great. But complexity/time.

Middle ground: rails as simple 1px ink lines at every 2 cells + 3px paper dashed sleeper overlay (stroke-dasharray 3 7, stroke-width 3, stroke paper) on top of the ink line. So: draw ink 1px line, then dashed paper 3px on top → gives sleeper rhythm. That's exactly the effect with two strokes.

So: for each even row: line from x=0..W at y = r*cell + cell/2, ink 1px; then same path stroke #EADFCB width 3 dasharray "3 7". Same for even columns. Result: a track lattice. And the hatch fills everything else.

Good. Cost: 11+8 = 19 lines × 2 = 38 elements. Fine.

Ghost route: dashed path from head ahead 4 cells, ink 1px dasharray 4 6, with stroke-dashoffset animating (CSS animation 800ms linear infinite). Rebuild each step.

Passengers: group elements.

Train: group with loco + wagons.

Now code:

```js
let state='ready', demo=true;
let body=[{x:11,y:8},{x:10,y:8},{x:9,y:8},{x:8,y:8}];
let dir={x:1,y:0}, queue=[];
let passengers=[{x,y,name,color}];
let score=0, plateSeq=0, nextIdx=0;
let speed=120, accumulator=0, lastT=0;
```

Spawn: choose empty cell not on body, not on another passenger. Prefer cells adjacent to nothing special. Random.

Initial: spawn 3 passengers at once? Design: one at a time ("NEXT PICKUP"). Boarding spawns a new one. I'll keep 1 passenger at a time for clarity, maybe 2 early. Keep 1.

Step function:
1. consume dir queue → new dir if not reverse.
2. compute new head; check wall/self → derail.
3. unshift head; if head == passenger → board: score++, don't pop (grow), respawn passenger, else pop tail.
4. update ghost, next-pickup progress, rerender train.

Actually snake grows when eating; without eating it moves same length. So: `if(eat) {grow} else {pop}`.

Render train: for i in body: place element i (create if needed) at cell. Head uses loco group with rotation; wagons use carriage with color = livery[i-1] cycling? Wagons colored in the livery color of the passengers picked up. So each carriage keeps the color of the passenger that created it: array `cars[]` with color + name, aligned with body[1..]. So body index i ≥1 ↔ cars[i-1]. Good.

Number of wagons = cars.length; body length = 1 + cars.length? But snake grows by 1 each board, starting body length 4 → after first board length 5, so wagons = 4 initially but cars = 1. Mismatch.

Fix: initial body length 1 (loco only)? Starting as just a loco seems thin. Alternative: initial body length 4 = loco + 3 wagons, and each boarding adds 1 → cars array should have length body-1. So pre-seed cars with 3 default wagons (colors l5, l4, l3) named "MAIL", "GUARD", "BAGGAGE"? Better: name them EMPTY CAR. Hmm.

Simplest: start with loco only (body length 1) and after 3-4 boards it grows visibly. But "TRAIN LENGTH 04" in status suggests 4 initially.

Compromise: body starts as loco + 3 wagons, cars pre-seeded with 3 plates? No — plates should be passenger pickups.

Alternative: wagons render = number of pickups + 3 base carriages where base ones are cream/mail cars without plates. Hmm, inconsistent.

Cleanest: TRAIN LENGTH counts cells = 1 + cars.length, starting body = [head] + 3 base cars where base cars are part of the train but the staff column shows them as "BASE" ... 

Let me just make it consistent: start with loco + 3 wagons, and pre-fill the staff column with 3 plates labeled `VAN 01/02/03` with color l5? That contradicts "each boarding adds a plate".

Decision: start with loco only, body length 1. Each boarding +1. Status shows TRAIN LENGTH 01 initially, growing to 04 after 3 pickups — within seconds of the demo. The table says initial 04 as an example value. I think a lone loco looks poor initially though. 

Middle: start with loco + 2 wagons (mail van + guard's van), pre-seeded as two plates labeled `MAIL VAN` and `GUARD VAN` in l5/l4 colors — no, plates are "the score"... but they're also just carriage labels. Pre-seeding 2 plates is fine and the header count "BOARDINGS 00" is separate from plate count. So plates list: 2 base + pickups. Empty slots show "+ ADD A CAR".

Hmm, but then plate count ≠ boardings. It's fine visually: plates = the train's cars. Boardings count = score.

I'll do: base cars = ['MAIL VAN'(l5), 'GUARD VAN'(l4)]? Colors: use l4 and l5? l5 is #E5E1D8 near-canvas, needs ink border — fine, carriages have ink border.

Actually simpler and cleaner: base cars = [] and start loco alone... 

Let me settle: start with loco + 3 cars named `MAIL`, `GUARD`, `BRAKE` with colors l4, l5, l3? Too arbitrary.

Final: body starts as 4 cells: loco + 3 cars with colors [l3, l4, l5] and names ['BRAKE VAN','MAIL VAN','GUARD VAN'], and the staff column is initialized with those 3 plates (they're part of the train already, shown as cars). Boardings header count starts 00. Each boarding appends a plate. This is coherent: the plate list = the train's cars; header = boardings. And "+ ADD A CAR" empty slots below.

Hmm, but the design narrative says plates are the reward of boarding. Pre-seeding 3 dilutes it slightly but the list still grows. I'll pre-seed only ONE base car? 

OK: pre-seed 2: 'GUARD VAN' (l5) and 'MAIL VAN' (l4). Body = 3 cells. Status shows 03. After 3 pickups → 06. Good balance. Do it.

Hmm, wait — pre-seeded plates would show "PLATE" with names not from passenger list, fine.

Actually simpler decision: pre-seed 0, body = loco only + immediately the demo picks up within ~1s, showing growth. But if screenshot at 3s... demo plays from load, so by 3s there are 2-3 cars anyway! And TRAIN LENGTH grows. That's actually the best demo: you literally watch it grow.

But before the overlay dismisses, is the game running? Yes — demo mode plays behind the "click to start" chip. So by 3s: 2-3 pickups, plates appearing. 

Decision: start with loco only, body=[head], cars=[]. First pickup quickly. Demo AI must be reliable.

Hmm, a lone loco moving with no wagons reads as "snake head only" which is less charming initially but grows fast. Also derail risk higher with length 1 (no self-collision!). Actually length 1 can't self-collide → safer demo. Then it grows.

I'll start with loco + 2 cars for visual richness: cars = [{name:'GUARD VAN', color:l5},{name:'MAIL VAN', color:l4}]. Body 3. Demo AI with flood-fill safety handles it. Do it.

Wait: cars[i-1] must align with body[i+1]... body[0]=loco, body[1]=cars[0], body[2]=cars[1]. On growth, push car at the end: new cell inserted at body[1]?? No — snake grows at the tail: body.unshift(head); if eat, don't pop → the new cell is at the head, and the tail extends. So the "new carriage" visually appears at the tail. The wagon immediately behind the loco is body[1].

So the newest carriage should be body[body.length-1] (tail). When we grow, the tail cell is the newly added one (since we didn't pop, the previous tail stays and the segment before... wait:

body = [h, a, b]. Step: newH. body = [newH, h, a, b] (no pop) → new cell is newH at index 0, and the tail b remains. The array grew at the front, but the *materialized* new segment: previously cells were h,a,b; now newH,h,a,b. h moved from index0 to index1. So the new segment (newH) is at the head.

So the loco moves forward and the new segment is where the loco just was... meaning the new carriage appears right behind the loco (at body[1]). Hmm no: the loco is at newH; body[1] = old head position, which was occupied by the loco before. So visually a new carriage appears directly behind the loco. That IS "springs out of the loco". 

So cars[i-1] for body[i]: the newest car = cars[0] (body[1]). So on boarding, unshift the new car into cars array: `cars.unshift({name, color})`. And initial cars: body 3 cells → cars = [ {GUARD VAN,l5}?, {MAIL VAN,l4}? ] aligned body[1], body[2]. On growth cars.unshift(new) → length 3, body length 4. ✓.

But wait — if the newest is at body[1], then the plate order should be newest first? The plate list order: newest on top is nicer. I'll insert new plates at the top of the list. Good, and pre-seeded plates then appear at bottom (older). Names in list: bottom = MAIL VAN (oldest)... Actually with unshift, initial cars[0]=body[1] is the one nearest the loco. Whatever — visually it's a train.

Let's just say: plates list renders cars in order (index 0 first = nearest loco = newest boarding on top). Initial cars: [{name:'MAIL VAN', color: l5}, {name:'GUARD VAN', color: l3}]. Rendering: MAIL VAN first (top), GUARD VAN below. Fine. Actually with unshift, after pickup the new plate goes on top. 

Hmm — but the design says plate order matches carriage order along the train; newest nearest the loco = top of list. ✓.

Now derail: on wall/self hit → state 'derail', animate shudder, show overlay with "SERVICE INTERRUPTED — TAP TO RESUME". Restart on input. In demo mode, auto-restart after 1400ms.

Game over condition for score? No lives. Keep it: derail = brief penalty, restart.

Speed: 120ms initially; each 5 passengers reduce by 6ms (min 80). Step timing in rAF.

Input handling: keydown on window (only after click? iframe receives keyboard after click — the overlay says click or press key; I'll attach listeners to window anyway, and also to document). Since iframe may not have focus, clicking gives focus. Fine.

Swipe on the stage.

Direction queue: allow one queued turn.

Ghost route: compute from head: walk in current dir up to 4 cells or until blocked, produce polyline path.

Next pickup progress: Manhattan distance from head to passenger: progress = 1 - d/maxD where maxD = COLS+ROWS.

Toast: "CAR ADDED · CAB 07" on boarding, plus "WELL DONE!" occasionally.

Odometer for score: I'll do a simple swap animation: set element with two spans, animate translateY.

Simplify: `.odo { }` innerHTML `<span class="odo-in">07</span>` with animation `odoRoll 180ms ease-out` (from translateY(-100%) to 0) and old value rolling out. I'll implement a helper that creates a wrapper with old and new and animates. Keep it: 

```js
function rollNumber(el, val){
  const old = el.textContent;
  el.innerHTML = `<span class="r-old">${old}</span><span class="r-new">${val}</span>`;
  ...
}
```
Simpler: apply a class that plays keyframes on the single span: from translateY(-100%) opacity 0 → 0. And the old text is replaced. Two-frame illusion. I'll do: create `<b class="roll-old">old</b><b class="roll-new">new</b>` inside a clipped box. Let's implement with CSS keyframes.

I'll write:
```css
.odo{position:relative;overflow:hidden;display:inline-block;height:1em;vertical-align:bottom}
.odo b{display:block;font-weight:inherit}
.odo .out{animation:rollOut 180ms ease-out forwards}
.odo .in{animation:rollIn 180ms ease-out forwards; position:absolute; top:0; left:0}
```
Hmm height with numbers of same digits. Keep it simple: box height = 1.1em, absolute stacking. Let's do:
```
.odo{display:inline-block;position:relative;overflow:hidden;height:36px;line-height:36px}  /* for big values */
```
Different sizes (64px status vs 14px legend). Use em: height:1em... but line-height for the big value. I'll set `.odo{height:1em;overflow:hidden}` and `.odo b{display:block;height:1em;line-height:1em}`. For 64px value with line-height .9 → set explicit. I'll give the odo a fixed height matching the value line-height via CSS var. Let's just set `.odo{height:1.05em; line-height:1.05em}` and inner b same. Should be OK.

Actually risk of clipping descenders — digits only, fine.

Let me keep the odometer only for the PASSENGERS value and the legend count.

Sound: WebAudio oscillator short blips.

Now — how much total code? Large but let's go. I need to be careful to keep it correct.

Let me write the SVG builders.

```js
function buildBoard(){
  const W=COLS*cell, H=ROWS*cell;
  board.setAttribute('viewBox',`0 0 ${W} ${H}`);
  let s='';
  s+=`<rect width="${W}" height="${H}" fill="#F4EDE2"/>`;
  s+=`<rect width="${W}" height="${H}" fill="url(#hatch)"/>`;
  // rails
  for(let r=0;r<ROWS;r+=2){ const y=r*cell+cell/2; s+=line(0,y,W,y); }
  for(let c=0;c<COLS;c+=2){ const x=c*cell+cell/2; s+=line(x,0,x,H); }
  ...
}
```
hatch pattern:
```svg
<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse">
  <line x1="0" y1="0" x2="6" y2="0" stroke="#EADFCB" stroke-width="1" opacity="0.55"/>
</pattern>
```
Wait hatch: paper-colored 1px lines every 6px at 12% opacity over canvas. #EADFCB at 12% over #F4EDE2 is nearly invisible. At 12% opacity the moiré would be super subtle. I'll use opacity 0.55 of the paper color? Or ink at 12%: `stroke="#12161C" opacity="0.12"` on 1px every 6px — that reads as a hatch. But using paper #EADFCB on #F4EDE2 at 12% is invisible. I'll interpret: hatch lines in a slightly darker tone. Use `#EADFCB` at opacity 1 (they're already subtle as a color difference) — paper vs canvas is a visible but gentle difference. Let's use stroke #EADFCB full opacity, width 1, spacing 6 → that's a visible bed. Then rails in ink on top. That's good and matches "recessed rail bed". But 12%... I'll compromise: `stroke="#12161C" stroke-width="1" opacity="0.10"` — ink at 10% over cream ≈ subtle warm gray line, giving a real hatch. And add a paper tint rect at low opacity? Let's do two: paper rect at opacity .35 + ink hatch at .09. 

I'll go: `<rect fill="#EADFCB" opacity="0.35"/>` + hatch pattern with ink lines opacity .10 spacing 6. Good.

Alternate hatch direction per cell for the moiré — needs per-cell rects: 352 rects, fine. Pattern can't alternate direction easily... I could create two patterns (horizontal lines, vertical lines) and draw per-cell rects alternating. 352 rects is OK for DOM but rebuilt only on resize. Let's do it — it gives the described diagonal moiré.

Actually 352 rects + rails = ~390 elements, fine.

Per-cell: `<rect x y w h fill="url(#hA)"/>` where hA = horizontal lines every 6px, hB = vertical lines. Alternate by (r+c)%2.

Hmm, that's 352 string concat — fine.

Wait: also cells should show a border? No.

Rails: drawn over.

Let me now write track rendering with sleepers:
```
for each even row: 
  <line class="rail-bed" .../>  // stroke #EADFCB width 4? 
  <line stroke="#12161C" stroke-width="1" opacity=".55"/>
  <line stroke="#EADFCB" stroke-width="3" stroke-dasharray="3 7"/>
```
Order: ink line then paper dashes on top → gives sleepers as paper gaps over the ink line. Hmm that makes the rail look dashed paper on ink line — the ink line is 1px, paper dash 3px covers it → result: alternating 3px paper blocks and 7px visible ink line. Reads as sleepers/rail. Good enough. Actually better: ink line 1px continuous + paper dashes perpendicular (sleepers). Perpendicular sleepers need many small lines. Let's do: base line ink 1px opacity .45; then dashes: `<line stroke="#12161C" stroke-width="1" stroke-dasharray="2 8" opacity=".5"/>` — no that's along the line.

Sleepers perpendicular: for a horizontal rail, draw `<line x1=x y1=y-4 x2=x y2=y+4>` every 6px. For W=1012 and 6px spacing → 169 lines per rail × 8 rails = 1352 elements. Too many.

Use stroke-dasharray trick on a perpendicular-clipped... Alternative: draw the sleeper band as a single `<path>` with a "zigzag"? No.

Use pattern! A pattern of a perpendicular tick: define `<pattern id="sleepH" width="8" height="9" patternUnits="userSpaceOnUse"><line x1="0.5" y1="0" x2="0.5" y2="9" stroke="#12161C" stroke-width="1" opacity=".5"/></pattern>` and fill a rect band (height 9, full width) with it → sleepers along a horizontal rail in ONE element. 

So each rail = a filled rect band with a pattern + a continuous ink centerline. For horizontal rail at y: rect x=0,y=y-4.5,w=W,h=9 fill=url(#sleepH) ; line y ink 1px opacity .6. For vertical: pattern sleepV (tick horizontal), rect w=9 h=H.

That's ~19 bands + 19 lines = 38 elements. 

But rails at every 2 cells with 9px sleeper bands over the hatch — good, reads as track. And cells the train travels on aren't necessarily on rails... the snake moves cell-by-cell anywhere, while rails are on a fixed lattice. Slight mismatch: the train should ride the rails. Hmm! Nice touch: make the snake only move along the lattice? No, too restrictive.

Better: draw rails on EVERY cell row and column (48px apart) so any cell path is on rails. With sleeper pattern every 8px along all lines: horizontal lines at every cell center → 16 bands + 16 lines + 22 + 22 = 76 elements. And sleeper bands 9px tall overlapping adjacent? They're 48 apart, no overlap. Good.

But then the grid is dense: a full 48px grid of tracks = looks like a rail yard/map. Combined with hatch it's busy but characteristic. I'll reduce: rails every cell in one direction and every 2 cells in the other? The train moves both ways.

Let's do full grid but make it light: sleeper bands opacity .45, centerline opacity .35, hatch .09. The train (solid ink) sits clearly on top. I think it looks like a proper diagram. Go.

Actually simpler visual: don't hatch every cell AND full grid; that's two overlapping grids (6px hatch + 48px rails). It'll be busy. Let me drop per-cell hatch to only... no, keep hatch (it's signature) but light (ink .07), rails at 48px with sleepers at opacity .5/.4. Should be fine — vintage diagram.

Hmm, alternatively make hatch only 1px lines every 6px = a fine moiré, and rails every 2 cells (96px) — train mostly rides near rails but not exactly. I'll go full 48 grid for correctness (train rides rails) and lighten hatch.

Decision: rails on every cell edge? or cell center? Snake cell (c,r) occupies [c*48,(c+1)*48]. Rails at cell centers means the train (drawn filling the cell) sits on a cross at the center — wheels would align with rails. Actually the train body is horizontal-ish; rails for horizontal travel should run along the cell's horizontal center. Rails at cell centers in both directions = the train always rides a rail through its center. That's exactly right! Every cell center has both a horizontal and vertical rail crossing. 

So: horizontal rails at y = (r+0.5)*cell for r in 0..ROWS-1, vertical at x=(c+0.5)*cell.

Render: sleeper bands (rect h=8 centered) + centerline.

But then adjacent rails 48px apart with 8px bands → fine.

Implementation: 
- `<rect x="0" y="{y-4}" width="{W}" height="8" fill="url(#sleepH)" />` per row (ROWS=16)
- `<line x1=0 y1=y x2=W y2=y stroke=#12161C stroke-width="1" opacity=".38"/>` per row
- same for cols (22).
Total 76 elements. Good.

Pattern sleepH: width 8, height 8, line vertical at x=4? tick: `<line x1="0" y1="0.5" x2="0" y2="7.5" stroke="#12161C" stroke-width="1" opacity=".45"/>` with pattern width 8 → ticks every 8px. Since pattern origin is at 0, and rail rect starts at x=0, ticks align. Fine.

Hmm — but for the vertical rails, pattern sleepV with height 8 → ticks every 8px vertically.

OK good.

Now the train rendering: I'll pre-create group elements for up to N cars (say 60) and toggle visibility.

```html
<g id="train">
  <g id="loco" class="unit"></g>
  <g class="unit wagon"></g> ...
</g>
```
I'll generate them in JS on each length change (rebuild train group if count changed).

Wagon content (48×48 cell, wagon 44×36 centered):
```svg
<g transform="translate(cx,cy)">
 <rect x="-20" y="-16" width="40" height="30" rx="2" fill="{color}" stroke="#12161C" stroke-width="1.5"/>
 <rect x="-20" y="-16" width="40" height="5" fill="#F4EDE2" opacity=".9"/>  <!-- roof band -->
 <rect x="-13" y="-7" width="7" height="7" fill="#F4EDE2"/> x3 windows
 <circle cx="-11" cy="16" r="4" fill="#12161C"/> <circle cx="11" cy="16" r="4"/>
 <line x1="-20" y1="14" x2="20" y2="14" stroke="#12161C" stroke-width="1.5"/> <!-- chassis -->
</g>
```
Wheels below body: body bottom at y=14, wheels center y=16 r=4 → extend to 20, cell half = 24 → fits.

Wait the wagon body 30 tall from -16 to 14, wheels to 20. Total -16..20 = 36, centered-ish. Good.

For l5 color (#E5E1D8) the windows in cream would vanish — use ink windows for light liveries? Simpler: windows fill #F4EDE2 with ink stroke 1 → visible on any color. Do: `stroke="#12161C" stroke-width="1"` on windows. On light cars, cream windows with ink border still read. OK.

Also add the enamel plate on the wagon? The carriage plate is in the staff column; but a small plate on the wagon would be lovely — too small. Skip.

Loco (cell 48, drawn centered at 0,0):
```svg
<g class="loco-unit">
  <g class="loco-rot">  <!-- rotated by dir -->
    <path class="beam" d="M14,-8 L60,-20 L60,20 L14,8 Z" fill="#FFC24B" opacity=".14"/>  <!-- headlight cone -->
    <rect x="-22" y="-14" width="44" height="26" rx="3" fill="#12161C"/>  <!-- boiler -->
    <rect x="-22" y="-22" width="18" height="10" fill="#12161C"/> <!-- cab? -->
    ...
  </g>
</g>
```
Let me design the loco facing right (+x), centered:
- boiler: rect x=-20 y=-10 w=34 h=20 rx=3 ink
- cab: rect x=6 y=-20 w=16 h=30 rx=2 ink (at the rear? For a train facing right, the cab is at the back = left). Hmm, loco at the head facing direction of travel; the rest of the train is behind. If facing right (+x), the train body extends to -x (behind). So cab at -x side (left), boiler at +x, chimney near front (+x), headlight at front (+x), buffer beam at +x.

Redo facing +x:
- cab: rect x=-22 y=-22 w=18 h=32 rx=2 ink (rear-left)
- cab windows: rect x=-19 y=-19 w=12 h=9 fill canvas stroke ink
- boiler: rect x=-6 y=-12 w=26 h=22 rx=3 ink
- chimney: rect x=10 y=-24 w=8 h=12 ink; cap rect x=8 y=-26 w=12 h=5 rx=1 fill #FFC24B
- headlight: rect x=20 y=-8 w=5 h=9 fill #FFC24B
- number plate: rect x=-3 y=-4 w=14 h=9 fill canvas → text "42" ink 7px
- buffer beam: rect x=24 y=2 w=4 h=8 fill #FF5A1F? Voltage should never be body fill; buffer beam in brass? Brass never outside the loco illustration — buffer beam is part of the loco illustration, so brass OK. Use #FFC24B buffer beam.
- wheels: circles at (-14,14) r=6, (0,14) r=6, (14,14) r=6 fill ink, with canvas stroke 1
- chassis line: rect x=-22 y=6 w=46 h=6 fill ink

Beam: cone from x=25 to x=90: path `M25,-6 L95,-34 L95,34 L25,6 Z` fill #FFC24B opacity .12. Rotates with group.

Rotation: transform rotate(deg) around (0,0) on the inner group. Angle: right=0, down=90, left=180, up=270 (or -90).

The loco group is translated to head cell center; inner group rotated.

Also add a subtle "connecting rod"? skip.

Ghost route path: build `M x0 y0 L x1 y1 ...` with class ghost (dasharray 4 6, animation dashmove).

Let's also handle: the train should look connected — wagons have wheels; fine.

Passenger element group per active passenger (1 at a time).

Boarding animation: I'll animate the passenger chip element: get its current center and target (the new head position where the carriage appeared = body[1] cell center). Use element.animate with keyframes.

Ring: append `<circle class="ping" cx cy r=0>` and animate r via WAAPI on attribute? WAAPI can animate CSS `r` property for SVG circle in Chrome (r is a presentation attribute animatable via CSS in Chrome). Yes, Chrome supports `r` as CSS property. I'll use `circle.animate([{r:'0px',opacity:1},{r:'140px',opacity:0}],{duration:620,easing:'ease-out'})`. Hmm keyframes with `r` as CSS property name works in Chrome. Fallback: use transform scale on a group. Safer: use a `<g>` with transform scale — animate transform: `translate(cx,cy) scale(0.1)` → `scale(1)`. Use a circle r=140 and scale from 0.01 to 1. Do that — reliable.

Carriage spring: new wagon group animate `transform: scale(0.2)` → `scale(1)` with overshoot, plus a translate offset toward the loco. Since I set transform attribute translate(cx,cy) on outer group, animate the inner or use CSS transform on the group with transform-box... Let's structure wagon as `<g class="unit" transform="translate(cx,cy)"><g class="inner">...</g></g>` and animate the inner with `transform: scale(.2)` → `scale(1)`. CSS transform on SVG element works in Chrome with transform-origin default (0 0 for SVG) — since inner content is centered at 0,0, scaling about 0,0 works. 

But note: I'd rather rebuild the train group when length changes. Rebuilding kills animations. Approach: maintain wagon elements keyed by index; when length increases, append a new wagon element (with the new car) and animate it; when resetting, rebuild all.

Positions: each frame update `unit.setAttribute('transform', translate(...))`.

OK. Let's also add a slight wheel spin? No — cells jump, fine.

Now, the hero train: separate simple SVG, loop.

```js
const loopPts = buildLoop(560,70,1180,530,48) // returns array of points closed
let li=0; train of 6 positions = loopPts[(li-k+len)%len]
```
Render as SVG groups updated every 140ms.

Let's compute loop points: rectangle corners A(560,70) B(1180,70) C(1180,530) D(560,530), step 48 from A→B→C→D→A. Points along edges inclusive of start exclusive of corner duplicates.

x from 560 to 1180 step 48: 560,608,...,1168? 1180-560=620, 620/48=12.9 → not aligned. Let's pick step 48 and corners at multiples: A(576,96) B(1152,96) C(1152,528) D(576,528). Width 576 = 12*48 ✓, height 432 = 9*48 ✓. Points: top edge 13 points, right edge 10, bottom 13, right... build: 
- for i in 0..11: (576+48i, 96)
- for i in 0..8: (1152, 96+48i)
- for i in 0..11: (1152-48i, 528)
- for i in 0..8: (576, 528-48i)
Total 12+9+12+9 = 42 points, closed loop. ✓

Track: `<rect x=576-24? ...>` Actually rails pass through cell centers; the loop points are centers. Draw the track rect: from (576-24,96-24) to (1152+24,528+24) → x=552,y=72,w=624,h=480 with stroke ink 1, plus sleeper pattern? Just draw the rect with a dashed pattern stroke: `<rect ... stroke="url(#sleepPat)" stroke-width="9"/>` — pattern stroke works. Plus a 1px ink center line rect. Nice.

Hmm the hero viewBox: use `viewBox="0 0 1240 600"` with preserveAspectRatio="xMidYMid slice", width/height 100%.

Also hero decorative: station nodes (4 circles), a dashed diagonal, and a few label texts? Nodes at corners of the loop maybe. I'll add 4 nodes at the loop corners: 16px circles (r=8) ink fill with canvas r=4.

Good.

Now let's write out all the code. I'll be systematic.

CSS plan (long but manageable).

Let me draft the full file.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Peak Line Express — pick up the passengers</title>
<style> ... </style>
</head>
<body>
 rail
 page
 script
</body>
</html>
```

CSS details:

```css
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{background:#F4EDE2;color:#12161C;font-family:var(--mono);padding-left:96px;-webkit-font-smoothing:antialiased;overflow-x:hidden}
```

Fonts:
```
--disp: 'Archivo Narrow','Oswald','Arial Narrow','Helvetica Neue',Impact,system-ui,sans-serif;
--mono: 'JetBrains Mono','IBM Plex Mono',ui-monospace,'SFMono-Regular',Menlo,Consolas,monospace;
```

Display class: font-family var(--disp); font-weight:700; text-transform:uppercase; letter-spacing:.06em; line-height:.95.

Nameboard:
```css
.nameboard{position:sticky;top:0;z-index:30;height:88px;background:var(--canvas);border-bottom:1px solid var(--ink);display:grid;grid-template-columns:1fr auto auto;align-items:center;padding:0 48px;gap:32px}
```
Wait, left lockup with livery strip: 5 squares 10px + title.

Mid: three data cells in a row with vertical rules.

Right: buttons.

Buttons:
```css
.btn{height:44px;padding:0 26px;border-radius:9999px;font:600 13px/1 var(--mono);letter-spacing:.14em;text-transform:uppercase;cursor:pointer;display:inline-flex;align-items:center;gap:10px;border:1px solid var(--ink);transition:transform .09s,background .09s,color .09s}
.btn-ghost{background:transparent;color:var(--ink)}
.btn-ghost:hover{background:var(--ink);color:var(--canvas);transform:translateY(-2px)}
.btn-solid{background:var(--volt);color:var(--canvas)}
.btn-solid:hover{transform:translateY(-2px);box-shadow:...} /* no shadows! */
```
No shadows allowed. Hover: `filter: brightness(1.06)` or a 1px ink outline offset. I'll use `outline: 1px solid var(--ink); outline-offset:3px` on hover for solid. Keep simple: translate + slight bg change.

Layout containers:
```css
.wrap{max-width:1144px;margin:0 auto;padding:0 48px}
```
body has padding-left 96 so content area = viewport-96.

Hero:
```css
.hero{position:relative;min-height:clamp(440px,62vh,560px);border-bottom:1px solid var(--ink);overflow:hidden;display:flex;flex-direction:column;justify-content:center}
.hero-svg{position:absolute;inset:0;width:100%;height:100%;opacity:.5;pointer-events:none}
.hero-inner{position:relative;z-index:2;padding:56px 48px 0;max-width:1144px;margin:0 auto;width:100%}
h1{font-size:clamp(38px,6vw,64px);margin:0}
```

Scale rule at bottom of hero: 
```css
.scale-rule{position:absolute;left:0;right:0;bottom:0;height:104px;border-top:1px solid var(--ink);...}
```
with tick marks generated in JS or repeating-linear-gradient. Use repeating-linear-gradient for 1px ticks every 12px: `repeating-linear-gradient(to right, var(--ink) 0 1px, transparent 1px 12px)` height 6px. Then labels row.

I'll build the scale rule with a div containing an SVG? Simpler: CSS:
```html
<div class="scale-rule">
  <div class="ticks"></div>
  <div class="labels"><span>MOTION</span>...</div>
</div>
```
ticks: height 8px, background: repeating-linear-gradient(90deg,#12161C 0 1px, transparent 1px 12px); plus a top border line.

Labels spread with flex space-between and each with a 1px vertical rule above.

Game section:
```css
.game{padding:0 0 0}  /* header cells have their own borders */
.status-rail{height:88px;display:grid;grid-template-columns:repeat(3,1fr);border-bottom:1px solid var(--ink)}
.status-rail .cell{border-right:1px solid var(--ink);padding:14px 24px;display:flex;flex-direction:column;justify-content:space-between}
```
Full-bleed? The wrap has padding 48. The status rail should align to columns 1-12 → inside wrap. But stage bleeds -24. So:

```html
<div class="wrap">
  <div class="status-rail">…</div>
</div>
<div class="wrap">
  <div class="stage-bleed"> stage </div>
</div>
```
Hmm the stage bleeds -24 each side: `.stage{margin:0 -24px}` inside the wrap → extends into the 48px padding, leaving 24. Good.

But the game grid (main + staff 320) means the stage is only in the main column. The bleed then applies within main column. Fine — "wider than the type" still holds.

Let's structure:
```html
<section class="wrap game">
  <div class="game-grid">
    <div class="game-main">
      <div class="status-rail">3 cells</div>
      <div class="stage" id="stage"> svg + regs + overlay + toast </div>
      <div class="legend">…</div>
    </div>
    <aside class="staff">
      <div class="staff-head">BOARDINGS <span id="boardCount">00</span></div>
      <div class="plates" id="plates"></div>
    </aside>
  </div>
</section>
```
.stage{margin:0 -24px; border:1px solid ink; position:relative; background:canvas}
The svg inside: width 100%, height auto (aspect from viewBox). Set `.stage svg{display:block;width:100%;height:auto}`.

But I want the svg's aspect to match the grid exactly: viewBox 0 0 W H, width 100% → height = width*H/W. Then cell size in CSS px = stageWidth/COLS. Since I compute cell from the same width, consistent. I'll compute cell = Math.floor(stageWidth/COLS) and viewBox = cell*COLS × cell*ROWS, so the rendered height = stageWidth*ROWS/COLS. Stage width includes border (box-sizing border-box) → inner = width-2. Minor rounding; fine.

Actually the stage's inner content width is what matters: measure `stage.clientWidth - 2`.

cell = clamp(26, floor((w-2)/COLS), 46).

Grid ratio 22:16 → height = width*0.727. At main column width ~ (1048-320-48)=680 → cell = floor(678/22)=30 → stage height 680*16/22=494. Reasonable! Total game block = 88+496+120 = 704 + nameboard 88 + hero 496 = 1288. Scrolls. Hmm, main column at 680 wide gives cell 30 — a bit small but the whole board visible.

Maybe better: put the staff column *below* on the right? Or make stage wider by putting staff column beside only the status rail... Let's reconsider: make the game layout: status rail full width (1048), then stage full width (1048+48 bleed = 1096 → cell 49→46, height 736), then legend full width, and the staff column... where? Could overlay the staff column as a right-side panel inside the stage? Design says right staff column 320.

Alternative: staff column on the right of the stage but stage takes remaining → 680 wide. cell 30. Board 660×480. That's a decent play area (22×16 of 30px). The train at 30px cells with a 48-unit loco scaled — will look fine.

I'd prefer bigger. Option: reduce grid to 18×13 with cell 38 → 684×494. Same footprint but chunkier units. 18×13 = 234 cells, still a good board. Hmm, with 22×16, at 680 wide cell=30 and the loco is 30px — ok but small.

Option: put the staff column ABOVE the stage as a horizontal strip? Or between status rail and stage? Or reduce columns... Let's see: grid columns 12 with staff spanning cols 10-12 = 320 of 1048, stage cols 1-9 = 728 minus gutters ≈ 700. So the stage is ~700 wide → cell ~31. The spec itself yields cell ~31 (48px cell × 22 = 1056 doesn't fit the 1048 diagram box anyway — they're inconsistent spec numbers). So cell ≈ 30 is "correct" per the layout spec. Fine — go with 22×16 and auto cell sizing.

But wait, with cell 30 and 16 rows → stage height 440. Game block = 88+442+120 = 650. Hero 496 + header 88 = 584 → total 1234, page scrolls ~430px. Acceptable.

Hmm, actually could I fit everything in 800? 88 + hero 460 + 650 = 1198. No. Fine — the hero is the first screen with its own playing train; scrolling reveals the main board.

Alternatively make the hero shorter (380) so the game status rail + top of stage show at 800. 88+380=468, +88 status =556, stage 442 → 998. Still scrolls.

I'll keep hero at clamp(420,56vh,540) → at 800 = 448. 88+448 = 536 + 88 = 624 → 176px of stage visible at 800. Good, you see the top of the board with the train.

Let's go.

Staff column: `position:sticky; top:104px` maybe. Nice.

Plates list: max-height with overflow auto? Let it grow; 320 wide.

Legend height 120 with border-top 1px. Contents: arrows cluster (left), next stop (center), passengers (right), plus d-pad on touch.

Now, responsive:
- ≤1080: rail 56, body padding-left 56, game-grid → 1 column, staff becomes horizontal scroll row.
- ≤640: nameboard compresses (hide mid data), hero h1 smaller, status rail 3 cells → keep 3 but smaller text (maybe stack to 2 rows?). Keep 3 cells with 11px labels; values 32px. Should fit in 375-56=319 → 3 cells of 106. "PASSENGERS" at 11px letterspacing .12 → ~90px. Tight but ok; I'll reduce label size to 9px on mobile and allow wrap.

Actually at 375: rail 56 → content 319, wrap padding 16 → 287. 3 cells → 95 each. Label "PASSENGERS" at 9px + tracking ≈ 60px. OK. Value 32px.

Legend at mobile: hide arrows cluster text, keep NEXT STOP. Or stack. I'll make legend height auto with flex-wrap on mobile.

Stage bleed -24 at 287 width → 335 > container. On mobile set margin 0. Use `margin-left: max(-24px, ...)`. Simpler: `.stage{margin:0 -24px}` and at ≤640 `margin:0`.

Touch d-pad: show on ≤900 or always? Design legend has arrow keys cluster. I'll show a small d-pad always (4 buttons) in the legend left cell — useful for mouse users too? A d-pad with mouse clicks is good for desktop demo judging. But it takes space. Show on ≤1080 (touch-ish). Also allow click-to-steer on the stage: clicking a cell sets direction based on relative position from head — nice. I'll add: pointerdown on stage → if demo, take over; compute swipe or click direction.

Let's implement pointer: pointerdown records pos, pointerup computes delta; if delta > 24 → swipe direction; else → treat as click: direction from head to clicked cell (dominant axis). Good for mouse users.

How-to-play drawer: a section right after nameboard (before hero), hidden by default (`display:none`), toggled. Contains rules + keycaps + RETURN TO PLATFORM button that closes it. It should be full-width with hairline bottom.

Now sound: 
```js
let actx=null, sound=false;
function beep(freq,dur,type='square',vol=.05){ if(!sound) return; actx ||= new (window.AudioContext||webkitAudioContext)(); ...}
```
Sound button toggles: SOUND ON/OFF.

Now write the AI.

```js
function free(x,y){ return x>=0&&x<COLS&&y>=0&&y<ROWS && !bodySet.has(x+','+y); }
function aiChoose(){
  const head=body[0];
  const dirs=[{x:1,y:0},{x:0,y:1},{x:-1,y:0},{x:0,y:-1}];
  const blocked=new Set();
  body.forEach((s,i)=>{ if(i<body.length-1) blocked.add(s.x+','+s.y); }); // tail frees
  const p=passengers[0];
  let best=null;
  for(const d of dirs){
    if(d.x===-dir.x && d.y===-dir.y && body.length>1) continue;
    const nx=head.x+d.x, ny=head.y+d.y;
    if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
    if(blocked.has(nx+','+ny)) continue;
    const dist = bfsDist(nx,ny,blocked,p);
    const space = floodFill(nx,ny,blocked);
    const scorev = (dist<Infinity? 1000-dist : -1) * 100 + space;
    const straight = (d.x===dir.x&&d.y===dir.y)? 10:0;
    const total = scorev + straight;
    if(!best||total>best.total) best={d,total};
  }
  if(best) return best.d;
  return dir;
}
```
bfsDist: BFS from (nx,ny) to p through free cells (blocked set, but allow target cell even if... target isn't blocked). Cap iterations.

floodFill: BFS counting reachable free cells (cap 400).

Combined this is ~2 BFS per candidate (up to 4) per step at 8/s → 8*8=64 BFS over 352 cells = 22k ops/s. Fine.

Optimization: compute BFS from target once (dist map from passenger over free cells) → gives dist for all candidates in one pass. And flood fill from each candidate. Let's do: distMap = BFS from passenger over free cells (blocked); then for each candidate, dist = distMap[nx,ny]. Plus flood fill per candidate (cheap-ish). Do it.

Now the step loop:

```js
let acc=0, last=performance.now();
function frame(t){ const dt=Math.min(100,t-last); last=t; if(running){ acc+=dt; while(acc>=speed){ acc-=speed; step(); } } requestAnimationFrame(frame); }
```
In demo, running=true always (except during derail pause). Overlay "click to start" shown while demo && !userStarted... Actually keep demo running always until user interacts; after user takes over, running controlled by state.

States: 'demo' (playing automatically, overlay chip visible), 'play' (user), 'pause', 'derail'.

On user input (click/key): if state==='demo' → state='play', hide chip, reset board fresh (clean start), running=true. Also allow from 'derail' → reset & play. From 'pause' → resume.

Keyboard: arrows/WASD → if demo, take over; else set dir. Space → pause/resume. 

In demo, if derail → after 1200ms reset and continue demo.

Now boarding flow:

```js
function step(){
  if(queue.length){ const nd=queue.shift(); if(!(nd.x===-dir.x&&nd.y===-dir.y)) dir=nd; }
  const head=body[0]; const nx=head.x+dir.x, ny=head.y+dir.y;
  if(out of bounds || hits body (excluding tail if not growing)) → derail(); return;
  body.unshift({x:nx,y:ny});
  const p=passengers[0];
  if(p && nx===p.x && ny===p.y){ 
     cars.unshift({name:p.name,color:p.color}); score++; 
     boardAnim(p, nx, ny);
     spawnPassenger();
     if(score%5===0) speed=Math.max(80, speed-6);
  } else body.pop();
  updateTrain(); updateUI(); 
}
```
Careful: self-collision check must consider that tail moves away when not eating. Use `body.slice(0, body.length-1)` as blocked (standard).

train render: ensure units count = body.length.

```js
function renderTrain(){
  while(units.length < body.length) addUnit();
  while(units.length > body.length) removeUnit();
  body.forEach((c,i)=> units[i].setAttribute('transform', `translate(${(c.x+.5)*cell},${(c.y+.5)*cell})`));
  // loco rotation
  rot.setAttribute('transform', `rotate(${deg})`);
}
```
addUnit: index 0 → loco group; else wagon with color cars[i-1].color.

When cars.unshift happens, body grows by 1 and addUnit() appends... but the new segment is at index 1 (behind loco), not at the end. Since units are positional (unit i ↔ body[i]), appending a unit at the end and then assigning positions means: previously units = [loco, w1, w2] at body [h,a,b]; after growth body = [h', h, a, b] and units should be [loco, newW, w1, w2]. Appending gives [loco,w1,w2,newW] and position assignment puts newW at body[3]=b, w1 at body[1]=h, w2 at body[2]=a. Colors would shift: w1 (color of old car) now at position 1 which should be the new car's color. Colors are carried per unit element, so the colors just shift backward along the train — visually the new car's color appears at the end instead of behind the loco. Hmm.

Fix: instead of appending, insert at index 1: `units.splice(1,0,newUnit)`. Then unit0=loco, unit1=new car (with new color), unit2=old w1, etc. Positions: body[1]=old head pos → new car appears right behind the loco ✓, and old cars shift to body[2],body[3] which is where they were ✓ (they moved back one). Wait old w1 was at body[1]=a... let's trace:

Before: body=[h,a,b], units=[loco,w1,w2] → loco@h, w1@a, w2@b.
Growth step: body=[h',h,a,b]. Desired: loco@h', newW@h, w1@a, w2@b. So units should be [loco,newW,w1,w2] → splice(1,0,newW) ✓ and positions auto-assigned: unit1=newW@body[1]=h ✓, unit2=w1@body[2]=a ✓. 

And cars.unshift(new) with cars[i-1] mapping. ✓

For removal (on reset), rebuild all.

Also: the new unit needs a "spring out of the loco" animation: scale from 0.2 with overshoot, plus a translate from the loco direction. It's at body[1] which is where the loco just came from. Animate inner scale .2→1 and translate along dir by -cell*0.5 → 0? Let's do keyframes: transform `translate(${-dir.x*cell*0.6}px, ${-dir.y*cell*0.6}px) scale(0.2)` → `translate(0,0) scale(1)` with cubic-bezier(.34,1.56,.64,1) 560ms. Wait "springs out of the loco" — it comes from behind the loco = from the previous head position. The carriage appears at old head position; it should emerge from the loco which is now one cell ahead, i.e., offset by +dir*cell... The car at body[1] = old head = loco position - dir*cell. So it comes from the loco: start at offset +dir*cell (the loco's position) scaled 0.2, animate to 0,1. Hmm: element position is body[1]; loco is at body[0] = body[1] + dir*cell. So start transform translate(dir*cell) scale(0.2) → translate(0) scale(1). ✓

The inner group gets `transform: translate(Xpx, Ypx) scale(s)` via WAAPI — but note the outer g has attribute transform translate(cx,cy) and the inner's CSS transform composes. ✓

But careful: CSS `transform` on SVG element with px units works in Chrome (uses user units). ✓

Reset (new game): rebuild train group.

Now passenger spawn: choose random empty cell. Prefer cells at least 3 away from head? Not needed.

Also "platform": draw under chip.

Next-pickup ring ping: circle animated r 6→14 opacity .8→0, 900ms infinite with a CSS animation. I'll add a `<g class="pingring">` inside the passenger group with CSS animation scale — but that conflicts with the group's translate. Put ring as sibling: `<circle class="ring" cx cy r="14">` with CSS `animation: ping 900ms ease-out infinite; transform-box: fill-box; transform-origin: center;` and keyframes scale(.4)→scale(1), opacity .9→0. transform-box:fill-box works in Chrome. ✓ Or use transform-origin: cx cy px. I'll use transform-box fill-box.

Ghost route: rebuild path each step.

Toast: show/hide with class.

Derail: add class `.shudder` to stage → CSS animation 240ms steps(2) translateX ±4px. Then overlay shows.

Overlay content by state:
- demo: chip "CLICK OR PRESS A KEY TO START" (small pill, centered) + tiny "DEMO · AI CONDUCTOR" label.
- derail: "SERVICE INTERRUPTED" + "TAP TO RESUME".
- paused: "SERVICE PAUSED".

For demo, the overlay must not hide the game: make `.overlay` pointer-events auto but background transparent, and the chip is a small centered pill with canvas bg + ink border. Clicking anywhere on the stage takes over.

Now UI updates: score, length, next name, progress, boardings count, legend count, plates.

Plates rendering: on new car, create element at top with slide-in animation; cap total DOM plates to 14 (remove bottom overflow).

Empty slots: render 3 "+ ADD A CAR" after the plates. I'll just append a static empty-slot element after the list in the HTML (always present): `<div class="plate empty">+ ADD A CAR</div>` — plus maybe two. Static, always at bottom. Since new plates insert at top, empties stay at bottom. ✓

Score/odometer: for PASSENGERS value.

Let me write the odometer helper:

```js
function roll(el, val){
  const old = el.dataset.v || '';
  if(old === val) return;
  el.dataset.v = val;
  el.innerHTML = `<span class="r-out">${old}</span><span class="r-in">${val}</span>`;
}
```
CSS:
```css
.odo{position:relative;display:inline-block;overflow:hidden;vertical-align:top;height:1em}
.odo span{display:block}
.odo .r-out{position:absolute;left:0;top:0;animation:rOut 180ms ease-out forwards}
.odo .r-in{animation:rIn 180ms ease-out forwards}
@keyframes rOut{to{transform:translateY(-100%);opacity:0}}
@keyframes rIn{from{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}
```
With height:1em and line-height:1em set on the odo. For the big value I'll set font-size 64, line-height 1 → height 64. But '00' at 64px needs height ~64; digits fit in ~0.72em cap height, so 1em box is fine with line-height 1.

Careful: `.r-out` absolute needs the parent to have position relative ✓.

OK. Now let's write everything concretely. I'll write the final HTML now, carefully.

One more: registration marks on stage corners: 4 L-shaped marks via CSS pseudo/absolute divs with borders, 12px, offset -1px outside? "12px L-shaped ink marks at all four corners, offset 8px" — I'll place them just inside the corners with border-left/top etc., color ink, 12px, offset 8px from the corner.

Footer ticket strip: full width of body (excluding rail): `background:#12161C;color:#F4EDE2;height:56px;display:flex;justify-content:space-between;align-items:center;padding:0 48px;font-size:11px;letter-spacing:.1em`.

Key glyphs: small spans with 1px border.

Rail odometer updates on scroll: `window.addEventListener('scroll', ...)` → km = (scrollY / (docHeight - innerHeight)) * 12.4 or distance based on scroll px/100. `KM (scrollY/100).toFixed(2)`? Better: total route 12.40 km mapped to full scroll. I'll do `km = (scrollProgress*12.4).toFixed(2)`.

Tick highlight: use IntersectionObserver on sections #hero,#game,#legend,#crew? Ticks: 01 PLATFORM (hero), 02 BOARDING (game), 03 LEGEND, 04 TICKET (footer). I'll observe hero, game, legend, footer.

Let's finalize section ids: hero, game, legend, ticket.

Now write it.

Also need `#crew`/livery legend — the legend section includes swatches? The scale rule labels MOTION SOUND TYPE COLOR CREW. I'll add a small "COLOR" swatch row in the legend section? The legend strip under the diagram contains controls. The 5 swatches appear in the how-to-play drawer and in the nameboard lockup. Good enough.

Let me write the how-to-play drawer content:
- Left: 3 rules with keycaps.
- Middle: 5 livery swatches with names L1..L5.
- Right: RETURN TO PLATFORM button (closes).

Now writing the code for real.

HTML body:

```html
<div class="rail">
  <div class="rail-ticks">
    <button class="rtick" data-t="hero"><span>01</span><i></i></button>
    ...
  </div>
  <div class="rail-lockup">PEAK LINE EXPRESS</div>
  <div class="rail-km">KM <span id="kmv">0.00</span></div>
</div>
```
Ticks as buttons that scroll to section — nice interaction. Make them `<a href="#hero">`.

Careful: rail is fixed with `z-index:40` and the nameboard sticky z-30. Fine.

Rail ticks positioned: `.rail-ticks{position:absolute;top:140px;left:0;right:0;display:flex;flex-direction:column;gap:34px;align-items:center}`. Each tick: number 11px muted + rule 64px wide 1px ink, stacked? "a 64px horizontal rule" under the number. So tick = column: `01` then a 64px rule. Width 96 → rule 64 centered ✓.

Active tick: number becomes ink and rule becomes... rule is already ink; make active rule width 72 or color? Active: number color ink + rule opacity 1; inactive: rule opacity .3, number muted.

Now, hero SVG content generation in JS or static? Static is fine — I'll write the grid lines via JS for brevity (generate a `<g>` with lines every 48 over 1240×600).

I'll do it in JS: `buildHero()`.

Let me write the code.

```js
// ---------- HERO ----------
const heroSvg=document.getElementById('heroSvg');
function buildHero(){
  const W=1240,H=600;
  let s=`<defs><pattern id="hsleep" width="8" height="8" patternUnits="userSpaceOnUse">
    <line x1="4" y1="0" x2="4" y2="8" stroke="#12161C" stroke-width="1" opacity=".45"/></pattern>
    <pattern id="hhatch" width="6" height="6" patternUnits="userSpaceOnUse">
    <line x1="0" y1="3" x2="6" y2="3" stroke="#12161C" stroke-width="1" opacity=".08"/></pattern>
  </defs>`;
  s+=`<rect width="${W}" height="${H}" fill="url(#hhatch)"/>`;
  // grid lines every 48
  for(let x=24;x<W;x+=48) s+=`<line x1="${x}" y1="0" x2="${x}" y2="${H}" stroke="#12161C" stroke-width="1" opacity=".16"/>`;
  for(let y=24;y<H;y+=48) s+=`<line x1="0" y1="${y}" x2="${W}" y2="${y}" stroke="#12161C" stroke-width="1" opacity=".16"/>`;
  // diagonal dashed
  s+=`<line x1="40" y1="560" x2="1200" y2="60" stroke="#12161C" stroke-width="1" stroke-dasharray="6 7" opacity=".5"/>`;
  // loop track
  s+=`<rect x="552" y="72" width="624" height="480" fill="none" stroke="url(#hsleep)" stroke-width="9" opacity=".9"/>`;
  s+=`<rect x="552" y="72" width="624" height="480" fill="none" stroke="#12161C" stroke-width="1" opacity=".55"/>`;
  // nodes
  [[576,96],[1152,96],[1152,528],[576,528]].forEach(([x,y])=>{ s+=`<circle cx="${x}" cy="${y}" r="8" fill="#12161C"/><circle cx="${x}" cy="${y}" r="3.5" fill="#F4EDE2"/>`;});
  s+=`<g id="heroTrain"></g>`;
  heroSvg.innerHTML=s;
}
```
Grid lines opacity .16 + hatch .08 — light. Good.

heroTrain: build 6 units each step. Instead of rebuilding, pre-create 6 groups once and update.

The hero train is smaller — reuse wagon/loco builders with a scale factor. The unit builders draw at 48 scale; hero cell is 48 too. 

hero loop update every 150ms.

Now the main board builder. Write it.

```js
const COLS=22, ROWS=16;
let cell=32, W=COLS*cell, H=ROWS*cell;
function buildBoard(){
  W=COLS*cell; H=ROWS*cell;
  board.setAttribute('viewBox',`0 0 ${W} ${H}`);
  let s=`<defs>
   <pattern id="pH" width="8" height="8" patternUnits="userSpaceOnUse"><line x1="4" y1="0" x2="4" y2="8" stroke="#12161C" stroke-width="1" opacity=".42"/></pattern>
   <pattern id="pV" width="8" height="8" patternUnits="userSpaceOnUse"><line x1="0" y1="4" x2="8" y2="4" stroke="#12161C" stroke-width="1" opacity=".42"/></pattern>
   <pattern id="hA" width="6" height="6" patternUnits="userSpaceOnUse"><line x1="0" y1="0.5" x2="6" y2="0.5" stroke="#12161C" stroke-width="1" opacity=".10"/></pattern>
   <pattern id="hB" width="6" height="6" patternUnits="userSpaceOnUse"><line x1="0.5" y1="0" x2="0.5" y2="6" stroke="#12161C" stroke-width="1" opacity=".10"/></pattern>
  </defs>`;
  s+=`<rect width="${W}" height="${H}" fill="#F4EDE2"/>`;
  for(let r=0;r<ROWS;r++)for(let c=0;c<COLS;c++){
    s+=`<rect x="${c*cell}" y="${r*cell}" width="${cell}" height="${cell}" fill="url(#${(r+c)%2?'hA':'hB'})"/>`;
  }
  // rails
  for(let r=0;r<ROWS;r++){ const y=(r+.5)*cell; s+=`<rect x="0" y="${y-4}" width="${W}" height="8" fill="url(#pH)"/><line x1="0" y1="${y}" x2="${W}" y2="${y}" stroke="#12161C" stroke-width="1" opacity=".3"/>`;}
  for(let c=0;c<COLS;c++){ const x=(c+.5)*cell; s+=`<rect x="${x-4}" y="0" width="8" height="${H}" fill="url(#pV)"/><line x1="${x}" y1="0" x2="${x}" y2="${H}" stroke="#12161C" stroke-width="1" opacity=".3"/>`;}
  s+=`<g id="ghostG"><path id="ghost" fill="none" stroke="#12161C" stroke-width="1" stroke-dasharray="4 6" opacity=".55"/></g>`;
  s+=`<g id="paxG"></g><g id="trainG"></g><g id="fxG"></g>`;
  board.innerHTML=s;
}
```
352 rects + 76 = ~430 elements innerHTML — acceptable at build time.

Hmm, `.3` opacity rails + `.10` hatch: overall the board is a dense grid. Might be too busy behind the train. Let's reduce rails: sleeper bands opacity via pattern .42 ticks plus a centerline .3. Combined that's a strong grid at 48px. Actually a 48px grid over a 660px board = 14×10 lines — that's normal for a transit diagram. Fine.

But the hatch at 6px + rails = visual noise. I'll lower hatch to .07. Let's use .07.

Now the train builders:

```js
function locoSVG(){ return `<g class="lrot">${BEAM}...</g>`; }
```

I'll write:

```js
const LOCO = `
<g class="lrot">
  <path d="M22,-7 L92,-30 L92,30 L22,7 Z" fill="#FFC24B" opacity=".13"/>
  <rect x="-23" y="-23" width="19" height="34" rx="2" fill="#12161C"/>
  <rect x="-20" y="-20" width="13" height="10" fill="#F4EDE2" stroke="#12161C" stroke-width="1"/>
  <rect x="-6" y="-13" width="28" height="24" rx="3" fill="#12161C"/>
  <rect x="11" y="-25" width="9" height="13" fill="#12161C"/>
  <rect x="9" y="-28" width="13" height="5" rx="1" fill="#FFC24B"/>
  <rect x="21" y="-9" width="5" height="10" fill="#FFC24B"/>
  <rect x="24" y="0" width="5" height="9" fill="#FFC24B"/>
  <rect x="-4" y="-6" width="15" height="10" fill="#F4EDE2"/>
  <text x="3.5" y="2" font-size="7" text-anchor="middle" fill="#12161C" font-family="monospace">42</text>
  <rect x="-23" y="7" width="52" height="6" fill="#12161C"/>
  <circle cx="-15" cy="14" r="6" fill="#12161C" stroke="#F4EDE2" stroke-width="1"/>
  <circle cx="0" cy="14" r="6" fill="#12161C" stroke="#F4EDE2" stroke-width="1"/>
  <circle cx="15" cy="14" r="6" fill="#12161C" stroke="#F4EDE2" stroke-width="1"/>
</g>`;
```
Wheels extend to y=20 < 24 ✓. Beam extends to x=92 — that's outside the cell, will overlap neighbors. That's fine visually (light beam) but it rotates; when facing left it covers the wagons. Reduce beam to x=60 and opacity .13. Also, the beam should only be visible... it's fine.

Hmm, but the beam at 60 extends 36px beyond cell into the next cell, drawn over other train parts? The train group is drawn in order, so the head is unit 0 (drawn first) → wagons drawn after would cover the beam. Actually beam is drawn under wagons — good, wagon over beam is fine (light passes behind? whatever). Better: put beam in a separate group drawn last. Simpler: keep it, opacity low.

Actually the beam would look odd pointing into the train when direction reverses... can't reverse (no 180). Fine.

Wagon:
```js
function wagonSVG(color){ return `
<rect x="-21" y="-16" width="42" height="30" rx="2" fill="${color}" stroke="#12161C" stroke-width="1.5"/>
<rect x="-21" y="-16" width="42" height="6" fill="#F4EDE2" opacity=".85"/>
<rect x="-14" y="-6" width="8" height="8" fill="#F4EDE2" stroke="#12161C" stroke-width="1"/>
<rect x="-4" y="-6" width="8" height="8" fill="#F4EDE2" stroke="#12161C" stroke-width="1"/>
<rect x="6" y="-6" width="8" height="8" fill="#F4EDE2" stroke="#12161C" stroke-width="1"/>
<rect x="-21" y="7" width="42" height="5" fill="#12161C"/>
<circle cx="-12" cy="15" r="5" fill="#12161C" stroke="#F4EDE2" stroke-width="1"/>
<circle cx="12" cy="15" r="5" fill="#12161C" stroke="#F4EDE2" stroke-width="1"/>`;}
```
Body from -16 to 14 (with chassis to 12), wheels to 20. ✓

Livery for l4 #3D5AFE with cream windows and ink border ✓.

Passenger:
```js
function paxSVG(c){ return `
<rect x="-17" y="6" width="34" height="9" fill="#EADFCB" stroke="#12161C" stroke-width="1"/>
<line x1="-17" y1="15" x2="-17" y2="19" stroke="#12161C"/>
<line x1="17" y1="15" x2="17" y2="19" stroke="#12161C"/>
<circle class="ring" r="15" fill="none" stroke="${c}" stroke-width="2"/>
<circle r="12" fill="${c}" stroke="#12161C" stroke-width="1.5"/>
<circle cy="-2.5" r="4" fill="#12161C"/>
<path d="M-6.5,7 a6.5,6.5 0 0 1 13,0 z" fill="#12161C"/>
</g>`}
```
Wait, need `<g>` wrapper. The chip: livery circle r=12 with ink person inside. Path for shoulders: from (-6.5,7) arc to (6.5,7) — need correct sweep. `M-6.5,7 A6.5,6.5 0 0 1 6.5,7 Z` with sweep=1 goes... from left to right with sweep 1 (clockwise in screen coords) → arcs downward? In SVG y-down, sweep-flag=1 means positive angle direction which appears clockwise. Going from (-6.5,7) to (6.5,7) clockwise means passing through the top (y smaller). Hmm: clockwise starting at left point (9 o'clock) → goes up to 12 o'clock → right. So sweep=1 gives the top arc (y<7) — that's the shoulders bulging up, wrong. Use sweep=0 → counterclockwise from 9 o'clock → down to 6 o'clock → to 3 o'clock: passes through y>7 → bottom bulge. Wait we want a "shoulders" shape: a half-disc below the head, flat on top. So the arc should bulge downward: passes through (0, 7+r). That's sweep=0? Let's verify with math: start P1=(-6.5,7), end P2=(6.5,7), r=6.5 → semicircle. Center (0,7). sweep=0 → the arc goes in the direction of decreasing angle... Let's use the standard: for sweep-flag=1, the arc is drawn in the "positive-angle" direction, which in SVG's coordinate system (y down) appears clockwise. Starting at (-6.5,7) which is at angle 180° in standard math but in screen it's left of center. Clockwise on screen from the left point: left → top → right. So it passes through (0, 0.5) (above). sweep=0 → counterclockwise on screen: left → bottom → right, passing (0,13.5). We want below → sweep=0? 

Hmm but I set y=7 for the shoulder line and the circle radius is 12, so bottom of head circle is at y=12. Shoulders at y=7 to y=13.5 would poke outside the circle (r=12 → at x=0 bottom is y=12). So the shoulder path would exceed. Use `A7,7 0 0 0 7,7` with r=7 → bottom at y=14 > 12. Still outside.

Simplify: person = head circle r=4 at cy=-3 + shoulders as a rounded rect clipped... Or just: head circle r=4.5 at cy=-3 filled ink, and a trapezoid: `<path d="M-7,11 L-4.5,3 L4.5,3 L7,11 Z" fill="#12161C"/>` — inside the circle? At y=11, the circle half-width = sqrt(144-121)=4.8 → x=±7 outside. Hmm.

Just make shoulders narrower and higher: `<path d="M-6,6 L-4,1 L4,1 L6,6 Z"` → at y=6 circle half-width = sqrt(144-36)=10.4 ✓. So a small body shape. And head circle r=3.5 at cy=-4.

So: ink chip r=12 livery fill; ink person: head circle (0,-4,r=3.6) + torso path. Reads fine.

Let's use: `<circle cy="-4" r="3.6" fill="#12161C"/><path d="M-6.2,6.5 Q-5,0.5 0,0.5 Q5,0.5 6.2,6.5 Z" fill="#12161C"/>` — a shoulder curve. Approximate; inside circle: at y=6.5 half width sqrt(144-42)=10 ✓; at y=0.5 width 5 ✓. Good.

Actually since the chip fill is livery color, an ink person on it works. But l5 (#E5E1D8) chip with ink person → fine.

Ring ping: `<circle class="ring" r="15" fill="none" stroke=color stroke-width="2"/>` with CSS animation scaling .45→1 opacity .9→0. transform-box:fill-box; transform-origin:center.

Now, board layout of passenger group: `<g id="paxG" transform="translate(cx,cy)"><g>chip</g></g>` — I'll set transform on paxG.

fxG for the boarding ring ping (bigger).

Boarding animation:
```js
function boardAnim(p, hx, hy){
  const tx=(hx+.5)*cell, ty=(hy+.5)*cell; // new carriage position = head? 
```
Wait: after unshift, head is at (nx,ny) and the new car is at old head = (hx,hy) where hx,hy = previous head. The passenger was at (nx,ny)= the new head cell. So the chip is at the head's old location... no: the passenger sits at (nx,ny) and the head moves onto it. The chip element is at (nx,ny) which after the step is the LOCO position (body[0]). The new carriage appears at body[1] = old head = (hx,hy).

So the chip should arc from (nx,ny) → (hx,hy) i.e., one cell backwards along dir. And the carriage springs from loco position (nx,ny) to (hx,hy). Wait the carriage appears at (hx,hy) which is where it is; it springs out of the loco at (nx,ny) → offset = (nx - hx) = dir. So carriage start transform translate(dir*cell) scale(0.2) → 0,1. ✓ (as computed earlier).

Chip animation: from (nx,ny) to (hx,hy) = translate(-dir.x*cell, -dir.y*cell). Chip group is at (nx,ny) so animate a child from translate(0,0) → mid (arced) → (-dir*cell).

But the passenger is removed/respawned immediately; I'll keep the chip element, animate it, then remove at end.

Implementation:
```js
const paxEl = document.getElementById('paxG');
// clone for animation
const fly = paxEl.cloneNode(true); fxG.appendChild(fly);
// remove ring class from fly? keep
fly.animate([{transform:'translate(0px,0px)',opacity:1},
 {transform:`translate(${-d.x*cell*0.5}px, ${-d.y*cell*0.5 - cell*0.55}px)`,opacity:1,offset:.5},
 {transform:`translate(${-d.x*cell}px, ${-d.y*cell}px) scale(.4)`,opacity:0}],
 {duration:420,easing:'cubic-bezier(.4,0,.2,1)'}).onfinish=()=>fly.remove();
```
But fly's own transform attribute is `translate(cx,cy)` — adding CSS transform composes after? For SVG, CSS transform overrides the transform attribute? In Chrome, if both are specified, the CSS `transform` property wins over the presentation attribute (attribute is a presentation attribute = lowest priority). So animating CSS transform would wipe the translate(cx,cy) positioning! Careful.

Solution: structure pax as `<g id="paxG"><g id="paxPos" transform="translate(cx,cy)"><g id="paxInner">chip</g></g></g>` and animate the innermost (which has no transform attribute). Then the CSS transform on paxInner composes with paxPos's attribute ✓.

Same for train units: `<g class="unit"><g class="uInner">content</g></g>` where unit has attribute transform, uInner gets CSS animations. And the loco rotation: the loco content is inside uInner... but rotation must be on a group inside that gets `transform` attribute. If I set the rotate as an attribute on `.lrot` inside uInner, and uInner gets CSS transform — fine, they compose (different elements). ✓

So structure:
- unit `<g class="unit">` (attribute translate)
  - for loco: `<g class="lrot">` (attribute rotate) → content
  - wait, but the spring animation should be on a wrapper. Let's put: `<g class="unit"><g class="anim"> <g class="lrot">content</g> </g></g>` for loco, and `<g class="unit"><g class="anim">content</g></g>` for wagon.

CSS transform on `.anim` (no attribute) ✓, attribute rotate on `.lrot` ✓.

Good.

Now the ring ping big at boarding: in fxG, `<g class="fxping" transform="translate(tx,ty)"><circle class="inner" r="140" fill="none" stroke="#FF5A1F" stroke-width="3"/></g>` and animate `.inner` scale 0.02→1, opacity 1→0 over 620ms. But `.inner` has no transform attribute ✓.

Wait r=140 scaled 0.02 → 2.8. Good.

Now, "staff plate slide-in": new plate element with class `.plate.new` → CSS animation translateX(48px)→0, opacity 0→1, 520ms.

Score odometer.

Now let's handle the stage sizing/resize:
```js
function resize(){
  const w = stage.clientWidth - 2;
  cell = Math.max(24, Math.min(46, Math.floor(w/COLS)));
  buildBoard(); rebuildTrain(); renderAll();
}
```
buildBoard resets groups — must re-query ids. I'll use `board.querySelector('#trainG')` etc. after rebuild. Store references after build.

Debounce resize.

Now: does rebuilding on resize lose the passenger? No, state persists; just re-render.

Let's write renderAll: positions of train, passenger, ghost.

Ghost path: walk from head in dir up to 4 steps while free (not wall, not body):
```js
function ghostPath(){ let p=body[0]; let d=`M ${(p.x+.5)*cell} ${(p.y+.5)*cell}`; let cur={x:p.x,y:p.y};
 for(let i=0;i<4;i++){ const nx=cur.x+dir.x, ny=cur.y+dir.y; if(nx<0||ny<0||nx>=COLS||ny>=ROWS) break; if(body.some(b=>b.x===nx&&b.y===ny)) break; cur={x:nx,y:ny}; d+=` L ${(nx+.5)*cell} ${(ny+.5)*cell}`;}
 return d;}
```
body.some on 4 iterations × length — fine.

Ghost dashoffset animation via CSS: `.ghost{animation: dash 800ms linear infinite}` with `@keyframes dash{to{stroke-dashoffset:-20}}`. But I set stroke-dasharray attribute; the animation on stroke-dashoffset works ✓.

Now UI: next pickup progress based on Manhattan distance: `prog = 1 - d/(COLS+ROWS)`; fill width %.

Let's write the passenger name/color assignment: cycle index.

Names list 20, colors 5. p.name = NAMES[pick], p.color = LIVERY[i%5].

Now, the demo AI runs from load. Let's ensure the first pickup happens fast: spawn the passenger near the head at start.

spawnPassenger(): pick random free cell; if body length small, ensure distance ≥ 4 from head? Let's just random, and for the initial spawn pick a cell a few steps ahead of the head.

Initial body: head at (6,8) facing right, cars behind at (5,8),(4,8).

Initial passenger: at (12,8) (straight ahead) → picked up in ~700ms. 

Now write the code fully. Also the `queue` for turns.

Derail handling:
```js
function derail(){ state = demo? 'demo-derail':'derail'; running=false; stage.classList.add('shudder'); beep(...); setTimeout(()=>{ stage.classList.remove('shudder'); reset(); if(demo){ running=true; } else { showOverlay('derail'); } }, 1400); }
```
Hmm during 'demo-derail' the overlay stays as the start chip. Let's simplify:
- if demo: after shudder, reset and continue (no overlay change).
- if user playing: reset body/passengers but show overlay "SERVICE INTERRUPTED / TAP TO RESUME" and running=false. Then on input → running=true, fresh reset.

Wait, better UX: on derail while playing, show overlay with restart. Input resets & resumes.

Let's define `resetGame()` that reinitializes body, cars, score, speed, plates, passenger.

Plates reset: clear the plate list back to the 2 base plates.

Hmm — pre-seeded base plates vs score. Let's decide finally: NO pre-seeded plates; body = loco + 2 cars with names/colors that come from... 

Ugh. Final call: start with loco ONLY (body length 1, cars []). TRAIN LENGTH shows 01. Plates list shows 3 empty "+ ADD A CAR" slots and "BOARDINGS 00". Demo picks up quickly → by 3 seconds there are 2-3 cars and plates. This is the most coherent and makes the growth visible in the screenshot. And a lone loco with headlight beam looks good.

Do that. But risk: the AI derails quickly? With length 1 it can't self-collide; only walls. AI avoids walls. Then it grows. With flood-fill safety it should survive a while. Add: if length ≥ 3, prefer safe moves.

To be safe for the demo, also add: if the demo AI would hit a wall, it picks a valid dir; if none valid (trapped) → derail → reset. Fine.

Let's also cap cars at ~40 to avoid DOM issues (COLS*ROWS max anyway).

Reset also resets score → plates cleared.

OK, writing now. I also need `state` handling for overlay text.

Overlay markup:
```html
<div class="overlay" id="overlay">
  <div class="ov-card" id="ovCard">
    <div class="ov-k" id="ovK">READY FOR DEPARTURE</div>
    <div class="ov-t" id="ovT">CLICK OR PRESS A KEY TO START</div>
    <div class="ov-s" id="ovS">DEMO MODE · AI CONDUCTOR AT THE CONTROLS</div>
  </div>
</div>
```
`.overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:auto}` — with transparent bg so the game is visible. The card is small (max 420px) with canvas bg, 1px ink border, so it doesn't hide the board. On hide → `.overlay.hide{opacity:0;pointer-events:none}` but then clicking the stage still works via pointer handler on stage (overlay is inside stage; if pointer-events none, the stage gets the event ✓).

Actually keep overlay visible=false with display:none after start.

Let's also handle: clicking the overlay card = take over.

Keyboard listener on window: preventDefault for arrows to avoid page scroll.

Let me write the final code.

One concern: `stage.clientWidth` when stage has margin -24 within wrap padding 48 → fine.

Also the game-grid: 
```css
.game-grid{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:48px;align-items:start}
@media(max-width:1080px){.game-grid{grid-template-columns:minmax(0,1fr);gap:24px}}
```

Status rail inside game-main (col 1) → 680 wide with 3 cells of ~226. Fine.

Hmm, spec: status rail spans cols 1-12 (full 1048). With the grid, it's only 680. I'll put the status rail OUTSIDE the grid, full width, and the grid below with stage+legend in main and staff on right. Then the stage height + legend ≈ staff height. That matches "status rail spans columns 1-12" better. Do that.

```html
<section class="wrap" id="game">
  <div class="status-rail">3 cells</div>
  <div class="game-grid">
    <div class="game-main">
      <div class="stage">…</div>
      <div class="legend">…</div>
    </div>
    <aside class="staff">…</aside>
  </div>
</section>
```

Staff sticky top 100.

Legend inside main → 680 wide with 3 clusters. Might be tight; use flex with gap and smaller text. On ≤1080 it's full width.

Alright, writing final code now.

Legend content:
```html
<div class="legend">
  <div class="lg-keys">
    <span class="keycap">W</span><span class="keycap">A</span><span class="keycap">S</span><span class="keycap">D</span>
    <span class="lg-note">or arrow keys · swipe on the board</span>
  </div>
  <div class="lg-next">
    <span class="lg-lab">NEXT STOP</span>
    <span class="lg-name" id="lgNext">HARBOUR FRONT</span>
    <span class="prog"><i id="prog2"></i></span>
  </div>
  <div class="lg-pax"><span class="lg-lab">PASSENGERS</span><span class="odo" id="lgScore">00</span><span class="lg-note">/ 20</span></div>
</div>
```
Plus a d-pad? The keycaps are clickable buttons — make them buttons that steer. Good: clickable W/A/S/D keycaps double as a d-pad. 

Now write it all.

Also sound toggle in nameboard; how-to-play button.

Footer.

Let me write the CSS carefully with mobile.

I'll now produce the final code. Long but let's go.

Additional detail: `body{overflow-x:hidden}` to prevent horizontal scroll.

Font sizes: display h1 clamp(34px,5.4vw,64px).

Let me write the scale rule labels: MOTION SOUND TYPE COLOR CREW — these could be anchors to sections? They're just labels on a ruler. I'll make them span with a top tick each.

Footer: three key glyphs.

Write now.

For the staff header: `BOARDINGS` + `<span class="odo" id="bcount">00</span>`.

Plates container: `<div class="plates" id="plates">` + static empties after.

I'll render plates fully on reset and append on boarding.

plate number: pad(plateSeq,2).

Let me write JS functions list:
- pad2(n)
- buildHero(), heroLoop tick
- buildBoard(), resize()
- makeUnit(i, car)
- resetTrain(), renderTrain()
- spawnPassenger()
- updateGhost()
- updateUI()
- step()
- aiChoose(), bfsFrom(), flood()
- frame loop
- input handlers
- boardAnim()
- roll(el,val)
- plate add
- sound
- rail scroll

Write it.

For `roll`, initial call sets value.

Careful with odo initial content: `<span class="odo" id="score">00</span>` — roll() sets innerHTML with r-in. First call: old='' → r-out empty span. Fine.

Let's write.

```js
function roll(el,val){
  if(el.dataset.v===val) return;
  const old=el.dataset.v||'';
  el.dataset.v=val;
  el.innerHTML=`<span class="r-out">${old}</span><span class="r-in">${val}</span>`;
}
```

CSS for odo: `.odo{position:relative;display:inline-block;height:1em;line-height:1em;overflow:hidden;vertical-align:baseline}` and children `display:block;height:1em;line-height:1em`.

For the big 64px value, `.val{font-size:64px;line-height:.9}` → odo height 1em = 64px. Hmm line-height .9 on parent but odo sets its own. ok.

Actually `.odo{height:1em}` with font-size 64 → 64px tall box; digits at line-height 1em centered — digits cap height ~46px, ascent ~46. Text baseline in a 64px line box with 1em line-height: content area = font ascent+descent (~1.15em for many fonts) so it may clip slightly. To be safe use `height:1.15em; line-height:1.15em`. Hmm, then vertical alignment shifts. I'll use height:1.1em; line-height:1.1em and `vertical-align:-0.1em`. Let's just test mentally: for a 64px font, typical ascent 0.9em, descent 0.21em = 1.11em content. line-height 1.1em ≈ content height → glyphs fit without clipping. Digits sit on baseline at ascent from top → visible. Good: `height:1.1em;line-height:1.1em`.

Status cell layout: label at top (11px), value at bottom (64px). Cell height 88 with padding 12 → 64 available; value 64px * 1.1 = 70 → overflows. Reduce big value to 48px? Design says 64px. Let's make the status rail 104px tall with padding 12 → 80 available; value line 48*1.1=53 + label 14 = 67 ✓. I'll use 48px for the value on the status rail (deviation) or increase rail height to 116 and use 64px.

Let's set status rail height 116px, padding 14px 24px; value font-size 56px (compromise), line-height 1.05 → 59 + label 13 + gap = 86 ≤ 88 available. Good. Actually just use 64px with rail height 124: 14+14 pad = 28, content 96; label 13 + value 70 = 83 ✓. Rail height 124. Hmm, that eats vertical space. Design said 88px rail with 64px value — inconsistent (64px value + label + padding > 88). I'll go rail height 108, value 52px. Reasonable engineering call.

Let's set: `.status-rail{height:108px}` `.val{font-size:52px;line-height:1}` → 52 + label 13 + gap 8 = 73 ≤ 108-28=80 ✓.

At mobile: rail height 92, val 34.

OK writing now for real. I'll produce the final file.

Also: prevent text selection on the stage, `touch-action:none` on stage for swipes.

Let me write out the whole thing.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Peak Line Express — Pick up the passengers</title>
<style>
:root{
 --canvas:#F4EDE2; --paper:#EADFCB; --ink:#12161C; --muted:#6E6A61;
 --volt:#FF5A1F; --brass:#FFC24B;
 --l1:#FF5A1F; --l2:#00B39F; --l3:#FFC24B; --l4:#3D5AFE; --l5:#E5E1D8;
 --rail:96px;
 --disp:'Archivo Narrow','Oswald','Arial Narrow','Helvetica Neue',Impact,system-ui,sans-serif;
 --mono:'JetBrains Mono','IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
body{margin:0;background:var(--canvas);color:var(--ink);font-family:var(--mono);padding-left:var(--rail);overflow-x:hidden;-webkit-font-smoothing:antialiased}
.d{font-family:var(--disp);text-transform:uppercase;letter-spacing:.06em;font-weight:700;line-height:.95}
...
</style>
```

Note: `scroll-behavior:smooth` might interfere with screenshot? no.

Sticky nameboard top:0 — but the rail is fixed and body has padding-left, so the nameboard starts after the rail ✓.

Let me write all CSS now in the final answer.

I need to double check the `.rail` fixed with `top:0;bottom:0` and body padding-left — rail covers left 96px ✓.

On mobile ≤1080: `--rail:56px`.

Let's write.

For the legend prog bars: `.prog{display:block;width:64px;height:3px;background:var(--paper);position:relative}` `.prog i{position:absolute;left:0;top:0;bottom:0;background:var(--volt);width:0%}`.

Next-stop in status rail: value is a name at smaller size (20px display) since 52px won't fit "ROYAL EXCHANGE". So cell 3 value font-size 22px display uppercase. Good — scale jump still exists.

Let's finalize and write the answer.

For plate animation on add: `.plate{...;animation:plateIn 520ms cubic-bezier(.2,.9,.2,1) both}` — but on reset, re-rendered plates shouldn't animate... they'd all animate; that's fine (nice on reset). Actually on reset the board rebuilds — plates animating in is fine.

Now the "+ ADD A CAR" empty slot is static in HTML after #plates. But #plates is inside a container; put empties as siblings.

Write final.

One more: how-to-play drawer toggling with a `hidden` attribute + CSS transition. Simple: `.drawer{display:none}` `.drawer.open{display:block}`.

"RETURN TO PLATFORM" closes it and scrolls to #game? It should return to the platform (the top/game). I'll close and scroll to #game.

Also the hero CTA "BOARD THE TRAIN" → close drawer, scroll to game, take over (start playing for real)? It should scroll to the board and enter play mode. I'll call `takeOver()` + scroll.

Careful: takeOver resets the board. Good.

Let me write the final JS.

```js
function takeOver(){
  if(state==='play') return;
  state='play'; demo=false; hideOverlay(); resetGame(); running=true; acc=0;
}
```
But if the user presses an arrow key to take over, resetGame puts the head in the middle and their key sets the direction — good.

resetGame: body=[{x:6,y:8}], dir={x:1,y:0}, queue=[], cars=[], score=0, speed=120, plates reset, spawn passenger ahead.

For play start, spawn passenger at a random cell (not ahead necessarily).

For demo start, spawn ahead at (12,8).

resetGame(initialAhead).

Let's write spawnPassenger(prefX,prefY).

Now the AI for demo — with body length 1 at start, the AI drives it to the passenger. ✓

Alright, final code.

Also: after takeOver, if user doesn't press anything the train keeps going right → wall → derail → overlay. Acceptable.

Let me make sure `running` is true in demo from the start and the frame loop always runs.

Edge: when state==='derail-wait' (user), running=false.

Write the frame loop:
```js
function frame(t){
  const dt=Math.min(120,t-last); last=t;
  if(running){ acc+=dt; let guard=0; while(acc>=speed && guard++<4){ acc-=speed; step(); } }
  requestAnimationFrame(frame);
}
```

Now write everything. Also `updateUI()` called after step.

Let me write out NAMES.

NAMES = ['HARBOUR FRONT','MILLBANK','ASHFORD CROSS','KILN STREET','OLD MARKET','CANAL BASIN','GRANARY WAY','COPPER LANE','NORTH GATE','STADIUM','VIOLET PARK','QUARRY END','LAMPLIGHT','TANNERY','ROYAL EXCHANGE','FERRY POINT','BRICKFIELD','OBSERVATORY','SALT WHARF','ELDER GROVE']

Note "DON'T BACKTRACK." has an apostrophe — fine.

Final: write the code.

For the shudder animation:
```css
@keyframes shudder{0%{transform:translateX(0)}25%{transform:translateX(-4px)}50%{transform:translateX(4px)}75%{transform:translateX(-4px)}100%{transform:translateX(0)}}
.stage.shudder{animation:shudder 240ms steps(1,end) 2}
```
steps(1,end) would make each keyframe jump — that's what we want (hard shudder). Actually with explicit keyframes at 0/25/50/75/100 and linear easing it already jumps. Use `animation: shudder 120ms steps(1) 4`. Let's do `shudder 120ms steps(1) 4` → 480ms total alternating ±4. Good.

Also color inversion for 200ms: `.stage.invert{filter:invert(1)}` — inverting the canvas would look dramatic. I'll add `.stage.invert{filter:invert(1) hue-rotate(180deg)}` for 200ms. Hmm invert of cream = dark. It's a nice flash. Include it (200ms).

OK, writing the answer now.

Let me make sure IDs referenced in JS all exist.

HTML ids: kmv, heroSvg, heroTrain, board, stage, overlay, ovK, ovT, ovS, score, len, next, prog1, lgNext, prog2, lgScore, bcount, plates, soundBtn, howBtn, drawer, closeHow, boardCta, heroHow, sfx? 

Write.

Final check on the `.stage` sizing: `.stage{position:relative;margin:0 -24px;border:1px solid var(--ink);background:var(--canvas);overflow:hidden;touch-action:none;user-select:none}` and `.stage svg{display:block;width:100%;height:auto}`.

The svg height = width * ROWS/COLS (since viewBox ratio). ✓

Registration marks inside corners: absolutely positioned divs 12x12 with 2 borders, offset 8px from the stage edges, but they'd be over the board — fine, ink marks.

Let's write them as `.reg{position:absolute;width:12px;height:12px;border-left:1px solid var(--ink);border-top:1px solid var(--ink)}` etc. with modifiers tl/tr/bl/br and positions 8px.

Toast: `.toast{position:absolute;left:50%;bottom:16px;transform:translateX(-50%) translateY(6px);background:var(--canvas);border:1px solid var(--ink);padding:6px 12px;font-size:11px;letter-spacing:.12em;opacity:0;transition:.18s}` `.toast.on{opacity:1;transform:translateX(-50%) translateY(0)}`.

OK. Write the final answer now.

Also need `.staff` plates styles:
```css
.plate{display:flex;align-items:center;gap:10px;height:52px;padding:12px 16px;border-top:1px solid var(--ink);background:var(--canvas)}
.plate:last-of-type{border-bottom:1px solid var(--ink)}
.plate .sw{width:14px;height:14px;flex:none;background:var(--c);border:1px solid var(--ink)}
.plate .pn{flex:1;font-size:11px;letter-spacing:.1em;text-transform:uppercase}
.plate .pc{width:36px;height:28px;background:var(--c);border:1px solid var(--ink)}
```
"enamel carriage plate" — rounded? Plates have 0 radius (only circles allowed are dots/buttons). Keep square.

The plate includes: 14px color swatch, station name, a 36×28 enamel carriage plate. The 36×28 is the plate itself with the color. Let's do: swatch 14×14, name (flex 1), then a 36×28 enamel rect in the livery color with a cream stripe (like a carriage side). Good.

Empty: `.plate.empty{color:var(--muted);border-top:1px dashed?}` — no dashes mentioned; use 1px solid ink with muted text. Actually use `border-top:1px solid var(--ink)` and a `+` glyph.

Staff column also gets a top/bottom border? The head has border-bottom.

Alright — writing.

Regarding `pointerdown` on stage for click-to-steer: also used to take over from demo.

```js
let pDown=null;
stage.addEventListener('pointerdown',e=>{pDown={x:e.clientX,y:e.clientY}; });
stage.addEventListener('pointerup',e=>{
  if(!pDown) return; const dx=e.clientX-pDown.x, dy=e.clientY-pDown.y; pDown=null;
  if(state!=='play'){ takeOver(); if(Math.abs(dx)<20&&Math.abs(dy)<20) return; }
  if(state==='derail'){ resetGame(); running=true; state='play'; hideOverlay(); return; }
  let d=null;
  if(Math.hypot(dx,dy)>24){ d = Math.abs(dx)>Math.abs(dy)?{x:Math.sign(dx),y:0}:{x:0,y:Math.sign(dy)}; }
  else { d = dirFromPoint(e); }
  if(d) pushDir(d);
});
```
dirFromPoint: convert client coords to svg cell using board.getBoundingClientRect and cell size.

Also pointerleave reset.

Keyboard:
```js
window.addEventListener('keydown',e=>{
  const k=e.key.toLowerCase();
  const map={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}};
  if(map[k]){ e.preventDefault();
    if(state==='derail'){ resume(); }
    else if(state!=='play'){ takeOver(); }
    pushDir(map[k]);
  } else if(k===' '){ e.preventDefault(); togglePause(); }
});
```
Note: when state==='play' already, just pushDir.

Careful: takeOver resets and then pushDir sets direction — but if the user presses Left initially (reverse of right), pushDir rejects reverse → stays right. Minor.

togglePause: if state==='play' → running=false, state='pause', show overlay PAUSED; else resume.

Also the iframe "only receives keyboard events after the user clicks it" — our overlay says click or press a key. Fine.

Also clicking the "BOARD THE TRAIN" button → takeOver + scroll.

Let me finally write it. Also `hideOverlay()` sets `overlay.style.display='none'`; `showOverlay(kind)` sets text and display flex.

Write the answer.

For demo, overlay stays visible forever (until takeOver). That's per instructions.

I should double check: `state` values: 'demo','play','pause','derail'. Initially 'demo'.

Derail in demo: reset + continue, overlay unchanged (start chip stays).

Let me write derail():
```js
function derail(){
  running=false;
  stage.classList.add('shudder','invert');
  beep(90,0.18,'sawtooth');
  setTimeout(()=>stage.classList.remove('shudder','invert'),480);
  setTimeout(()=>{
    resetGame();
    if(demo){ running=true; }
    else { state='derail'; showOverlay('derail'); }
  },1000);
}
```
But during the 1000ms, the train is frozen at the crash position — good.

resetGame sets state? No: resetGame should set `state = demo ? 'demo' : 'play'`. Let's have resetGame() not touch state; callers set it.

resume(): resetGame(); state='play'; running=true; hideOverlay().

Good.

Now write final code. I'll be careful about matching HTML ids used in JS.

Length check: this will be ~900 lines. That's fine.

Let me write.

CSS for hero buttons sizes: primary 44 tall (44 tall primary, 52 recommended for touch → I'll use 48).

Write.

Also add `#crew`? Not needed.

Final concern: `preserveAspectRatio` on hero svg: `width:100%;height:100%` with default preserveAspectRatio="xMidYMid meet" would letterbox; use "xMidYMid slice" to fill. Then on a 1184×448 hero with viewBox 1240×600: slice scales to cover → scale = max(1184/1240, 448/600)=max(.955,.747)=.955 → shows full width, height 600*.955=573 > 448 → crops vertically, centered → shows y from 63 to 511. The loop (72..552) mostly visible. OK.

But the hero-inner text overlays. Fine.

On mobile (375-56=319 wide, hero 448 tall): scale = max(319/1240=.257, 448/600=.747)=.747 → width shown = 319/.747=427 units of 1240 → heavily cropped horizontally, showing x from ~406 to 833. That includes part of the loop's left edge at 576. Train visible partially. Acceptable. Maybe set preserveAspectRatio="xMidYMid meet" on mobile? Keep slice.

Hmm, on mobile the crop shows the loop's left segment — train visible ✓.

Write it.

For the hero SVG on mobile I'll reduce opacity to .35 via media query.

Final answer time.

One more: the rail's ticks as anchors `<a href="#hero">` — need `text-decoration:none;color:inherit`.

Also the rail must not block content: it's fixed, width rail, and body has padding-left → no overlap ✓.

Writing now.

For `updateUI`:
```js
function updateUI(){
  roll($('#score'), pad2(score));
  roll($('#lgScore'), pad2(score));
  roll($('#len'), pad2(body.length));
  roll($('#bcount'), pad2(score));
  const p=passengers[0];
  if(p){ $('#next').textContent=p.name; $('#lgNext').textContent=p.name;
    const d=Math.abs(p.x-body[0].x)+Math.abs(p.y-body[0].y);
    const pct=Math.round((1-d/(COLS+ROWS))*100);
    prog1.style.width=pct+'%'; prog2.style.width=pct+'%';
  }
}
```
`#next` is the value cell (22px display). Use textContent — but if I make it an odo... keep plain text.

Note: roll() on #score and #lgScore and #len and #bcount — all should update.

pad2: `(n<10?'0':'')+n`.

Plates: `addPlate(name,color)` prepends.

Initial plates: none. Empty slots static: 3.

On reset: `plates.innerHTML=''` then re-add empties — since empties are static siblings outside #plates, just clear #plates.

Hmm: I want empty slots at the bottom. If they're siblings after #plates in the DOM, yes ✓.

Alright. Writing the final answer.

Let me write the HTML for the drawer:

```html
<section class="drawer" id="drawer">
 <div class="wrap drawer-in">
   <div class="dr-col">
     <div class="dr-h d">HOW TO PLAY</div>
     <ol class="dr-list">
       <li><span class="num">01</span> Drive the locomotive with <b>W A S D</b> or the arrow keys. On touch, swipe the board.</li>
       <li><span class="num">02</span> Roll onto a waiting passenger to add a carriage. Every boarding extends the train by one car.</li>
       <li><span class="num">03</span> Don't back into your own train or the edge of the network — that's a derailment.</li>
     </ol>
   </div>
   <div class="dr-col">
     <div class="dr-h d">LIVERY</div>
     <div class="swatches">5 × <div class="sw"><i style="--c"></i><span>LINE 1</span></div></div>
   </div>
   <div class="dr-col dr-act"><button class="btn btn-ghost" id="closeHow">RETURN TO PLATFORM</button></div>
 </div>
</section>
```

The drawer is placed after the nameboard, before hero, `display:none` unless `.open`.

"RETURN TO PLATFORM" closes and scrolls to #game.

Good. Now write.

Sizing of keycaps: `.key{display:inline-flex;min-width:22px;height:22px;align-items:center;justify-content:center;border:1px solid var(--ink);font-size:11px;padding:0 5px;background:var(--canvas)}`.

Final write-up. Also ensure no horizontal scroll: `.wrap{max-width:1144px;margin:0 auto;padding:0 48px}` with body padding-left 96 → at 1280: available 1184, wrap 1144 + padding inside → fine. Stage margin -24 stays within padding 48 ✓.

At 375: rail 56 → 319 available; wrap padding 16 (media), stage margin 0. ✓

**Locking it down:**
Colors: canvas `#F4EDE2`, paper `#EADFCB`, ink `#12161C`, muted `#6E6A61`, voltage `#FF5A1F`, brass `#FFC24B`, livery 1 `#FF5A1F`, 2 `#00B39F`, 3 `#FFC24B`, 4 `#3D5AFE`, 5 `#E5E1D8`. Type: display stack `'Archivo Narrow','Oswald','Arial Narrow',Impact,system-ui` uppercase +0.06em, 64/40/22/17px; mono stack `'JetBrains Mono',ui-monospace,Menlo,Consolas`, 14/13/11px, tabular. Spacing: base 8, rail 96/56, nameboard 88, status rail 108, legend 120, plate 52, section padding 96/48. Borders `1px solid #12161C`, radius 0 except pills 9999px and dots 50%. Motion: step 120ms linear, boarding 420ms arc + 620ms ping + 560ms `cubic-bezier(.34,1.56,.64,1)` spring + 520ms plate slide, ghost 800ms linear, shudder 120ms×4 steps(1), color 140ms `cubic-bezier(.2,.9,.2,1)`. Grid 22×16, cell = clamp(24, floor(width/22), 46). Controls: WASD/arrows, swipe, click-to-steer, clickable keycaps, Space to pause. No external requests of any kind — every graphic is generated SVG. Delivery: one self-contained HTML file.

Now, let's assemble the complete, self-contained HTML file.