Models 44
Train snakeOne-line

MiniMax M3.1 Flash

by MiniMaxReasoning1 console error
Open in new window

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

How it went

Total time
3 min 31 s
Cost
$0.024
Output tokens
40,055
Spent thinking
20,460 tokens
Speed
190.3 tok/s
Console errors
1
First token after
644 ms
First visible text after
2 min 14 s
Prompt tokens
523
Code size
58.0 KB
Finish reason
stop
Served by
Together
Model id
minimax/minimax-m3.1-flash
Reasoning sent
model default
Max output
524,288 tokens
Generated
Nov 13, 2026

Console errors

Caught in the first 3 seconds after load, in a sandboxed iframe like the one above.

Cannot read properties of undefined (reading 'y')

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 · 2070 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Railway Snake — The Little Express</title>
<style>
  :root {
    --paper: #f4ecd9;
    --paper-light: #fffaf0;
    --ink: #173f37;
    --ink-soft: #526b62;
    --green: #174a40;
    --green-bright: #2f806c;
    --gold: #f2b544;
    --coral: #e35f43;
    --line: #d8c9ab;
    --shadow: 0 8px 0 rgba(51, 64, 51, .08), 0 14px 28px rgba(36, 55, 45, .12);
  }

  * { box-sizing: border-box; }

  html, body { margin: 0; min-height: 100%; }

  body {
    color: var(--ink);
    font-family: "Trebuchet MS", "Avenir Next", Avenir, system-ui, sans-serif;
    background:
      radial-gradient(circle at 8% 4%, rgba(255,255,255,.88), transparent 28rem),
      radial-gradient(circle at 96% 92%, rgba(233,190,113,.24), transparent 30rem),
      repeating-linear-gradient(0deg, rgba(66,80,62,.018) 0 1px, transparent 1px 5px),
      var(--paper);
    overflow-x: hidden;
  }

  button { font: inherit; }

  .app {
    width: min(1440px, 100%);
    margin: 0 auto;
    padding: 17px 24px 22px;
  }

  .topbar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 22px;
    padding: 0 2px 14px;
    margin-bottom: 16px;
    border-bottom: 1px solid rgba(48, 75, 65, .18);
  }

  .brand {
    display: flex;
    align-items: center;
    gap: 13px;
    min-width: 0;
  }

  .brand-mark {
    width: 53px;
    height: 53px;
    flex: 0 0 53px;
    display: grid;
    place-items: center;
    color: #fff4d4;
    background: var(--green);
    border: 2px solid #10362f;
    border-radius: 16px 16px 19px 19px;
    box-shadow: 0 4px 0 #0c2f29, 0 9px 15px rgba(17, 62, 53, .18);
    transform: rotate(-1.5deg);
  }

  .brand-mark svg { width: 37px; height: 37px; }

  .eyebrow {
    margin: 0 0 2px;
    color: #a76536;
    font-size: 10px;
    font-weight: 900;
    letter-spacing: .2em;
    text-transform: uppercase;
  }

  h1 {
    margin: 0;
    font-family: Georgia, "Times New Roman", serif;
    font-size: clamp(24px, 2.4vw, 34px);
    line-height: 1;
    letter-spacing: -.025em;
  }

  .brand-sub {
    margin: 4px 0 0;
    color: var(--ink-soft);
    font-size: 12px;
    font-weight: 700;
  }

  .stats {
    display: flex;
    gap: 10px;
    flex: 0 0 auto;
  }

  .stat {
    min-width: 92px;
    padding: 8px 13px 7px;
    background: rgba(255, 250, 240, .88);
    border: 1px solid var(--line);
    border-radius: 13px;
    box-shadow: 0 3px 0 rgba(63, 73, 56, .06);
  }

  .stat-label {
    display: block;
    color: #7c806f;
    font-size: 9px;
    font-weight: 900;
    letter-spacing: .14em;
    text-transform: uppercase;
  }

  .stat-value {
    display: block;
    margin-top: 1px;
    font-family: Georgia, serif;
    font-size: 21px;
    font-weight: 700;
    font-variant-numeric: tabular-nums;
  }

  .stat.score .stat-value { color: #bc4e34; }
  .stat.pickups .stat-value { color: var(--green-bright); }
  .stat.best .stat-value { color: #9b6a15; }

  .layout {
    display: grid;
    grid-template-columns: minmax(0, 1fr) 292px;
    align-items: start;
    gap: 18px;
  }

  .board-column { min-width: 0; }

  .board-frame {
    overflow: hidden;
    background: #133b34;
    border: 3px solid #0d302a;
    border-radius: 19px;
    box-shadow: 0 13px 0 rgba(12, 47, 41, .12), 0 22px 38px rgba(25, 53, 45, .19);
  }

  .board-bar {
    height: 38px;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    padding: 0 13px;
    color: #d7e7dc;
    background: linear-gradient(#214f46, #163f38);
    border-bottom: 1px solid rgba(255,255,255,.08);
    font-size: 10px;
    font-weight: 800;
    letter-spacing: .09em;
    text-transform: uppercase;
  }

  .board-id, .board-actions {
    display: flex;
    align-items: center;
    gap: 9px;
    min-width: 0;
  }

  .board-location {
    color: #9db8ab;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  .signal {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: #65d29b;
    box-shadow: 0 0 0 4px rgba(101,210,155,.12), 0 0 12px rgba(101,210,155,.6);
    animation: signalPulse 2s ease-in-out infinite;
  }

  @keyframes signalPulse {
    50% { opacity: .55; box-shadow: 0 0 0 2px rgba(101,210,155,.09); }
  }

  .demo-chip {
    padding: 4px 8px;
    color: #ffe6a0;
    background: rgba(242,181,68,.12);
    border: 1px solid rgba(242,181,68,.3);
    border-radius: 999px;
    font-size: 9px;
    white-space: nowrap;
  }

  .icon-button {
    width: 27px;
    height: 25px;
    display: grid;
    place-items: center;
    padding: 0;
    color: #dbe9e0;
    background: rgba(255,255,255,.06);
    border: 1px solid rgba(255,255,255,.12);
    border-radius: 7px;
    cursor: pointer;
  }

  .icon-button:hover { background: rgba(255,255,255,.13); }
  .icon-button svg { width: 13px; height: 13px; }

  .canvas-wrap {
    position: relative;
    width: 100%;
    overflow: hidden;
    background: #cfdfbf;
    isolation: isolate;
  }

  canvas {
    display: block;
    width: 100%;
    cursor: crosshair;
  }

  .start-hint {
    position: absolute;
    z-index: 5;
    top: 13px;
    left: 50%;
    transform: translateX(-50%);
    display: flex;
    align-items: center;
    gap: 10px;
    width: max-content;
    max-width: calc(100% - 22px);
    padding: 9px 14px 9px 10px;
    color: #fff8e9;
    background: rgba(14, 48, 42, .93);
    border: 1px solid rgba(248,203,104,.58);
    border-radius: 14px;
    box-shadow: 0 8px 22px rgba(10,35,30,.26);
    pointer-events: none;
    animation: hintFloat 2.8s ease-in-out infinite;
  }

  @keyframes hintFloat {
    0%, 100% { transform: translateX(-50%) translateY(0); }
    50% { transform: translateX(-50%) translateY(-2px); }
  }

  .hint-icon {
    width: 34px;
    height: 34px;
    flex: 0 0 34px;
    display: grid;
    place-items: center;
    color: #f5c65b;
    background: rgba(255,255,255,.08);
    border-radius: 9px;
  }

  .hint-icon svg { width: 25px; height: 25px; }
  .start-hint strong { display: block; font-size: 13px; letter-spacing: .01em; }
  .start-hint small { display: block; margin-top: 2px; color: #b8cec4; font-size: 10px; }

  .toast {
    position: absolute;
    z-index: 6;
    left: 50%;
    bottom: 15px;
    transform: translate(-50%, 12px);
    max-width: calc(100% - 24px);
    padding: 8px 14px;
    color: #fff8e8;
    background: rgba(19,61,53,.93);
    border: 1px solid rgba(255,255,255,.16);
    border-radius: 999px;
    box-shadow: 0 6px 18px rgba(15,48,40,.22);
    font-size: 11px;
    font-weight: 800;
    text-align: center;
    opacity: 0;
    pointer-events: none;
    transition: opacity .2s ease, transform .2s ease;
  }

  .toast.visible {
    opacity: 1;
    transform: translate(-50%, 0);
  }

  .game-over {
    position: absolute;
    z-index: 8;
    inset: 0;
    display: grid;
    place-items: center;
    padding: 20px;
    background: rgba(12, 44, 38, .35);
    backdrop-filter: blur(2px);
  }

  .over-card {
    width: min(350px, 100%);
    padding: 25px;
    text-align: center;
    background: var(--paper-light);
    border: 2px solid #173f37;
    border-radius: 19px;
    box-shadow: 0 13px 30px rgba(10,35,30,.25);
  }

  .over-card .over-icon {
    width: 52px;
    height: 52px;
    display: grid;
    place-items: center;
    margin: 0 auto 10px;
    color: #fff;
    background: var(--coral);
    border-radius: 50%;
    font-size: 25px;
  }

  .over-card h2 {
    margin: 0 0 5px;
    font-family: Georgia, serif;
    font-size: 25px;
  }

  .over-card p { margin: 0 0 17px; color: var(--ink-soft); font-size: 12px; line-height: 1.5; }

  .primary-button {
    padding: 10px 16px;
    color: #fff8e7;
    background: var(--green);
    border: 0;
    border-bottom: 4px solid #0c312a;
    border-radius: 10px;
    box-shadow: 0 4px 10px rgba(14,55,46,.15);
    font-size: 12px;
    font-weight: 900;
    cursor: pointer;
  }

  .primary-button:hover { background: #215d50; }
  .primary-button:active { transform: translateY(2px); border-bottom-width: 2px; }

  .control-note {
    display: flex;
    align-items: center;
    justify-content: center;
    flex-wrap: wrap;
    gap: 9px 18px;
    padding: 12px 8px 0;
    color: #667a70;
    font-size: 11px;
    font-weight: 700;
  }

  .key-hint { display: inline-flex; align-items: center; gap: 4px; }

  kbd {
    min-width: 21px;
    height: 21px;
    display: inline-grid;
    place-items: center;
    padding: 0 4px;
    color: #315148;
    background: #fffaf0;
    border: 1px solid #cfc1a5;
    border-bottom-width: 3px;
    border-radius: 5px;
    font: 900 10px "Trebuchet MS", sans-serif;
  }

  .sidebar {
    display: flex;
    flex-direction: column;
    gap: 12px;
  }

  .card {
    overflow: hidden;
    background: rgba(255,250,240,.9);
    border: 1px solid var(--line);
    border-radius: 16px;
    box-shadow: var(--shadow);
  }

  .card-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 10px;
    padding: 10px 14px;
    color: #dfece2;
    background: var(--green);
    font-size: 9px;
    font-weight: 900;
    letter-spacing: .15em;
    text-transform: uppercase;
  }

  .card-head .mode {
    color: #f5cd72;
    font-size: 8px;
    letter-spacing: .1em;
  }

  .passenger-card { padding: 14px; }

  .passenger-main {
    display: flex;
    align-items: center;
    gap: 11px;
  }

  .avatar {
    position: relative;
    width: 49px;
    height: 49px;
    flex: 0 0 49px;
    display: grid;
    place-items: center;
    color: white;
    border: 3px solid #fffaf0;
    border-radius: 50%;
    box-shadow: 0 0 0 1px rgba(38,73,63,.18), 0 4px 9px rgba(32,65,55,.13);
    font-family: Georgia, serif;
    font-size: 20px;
    font-weight: 700;
  }

  .avatar::after {
    content: "";
    position: absolute;
    right: -1px;
    bottom: 0;
    width: 11px;
    height: 11px;
    background: #63c68f;
    border: 2px solid var(--paper-light);
    border-radius: 50%;
  }

  .passenger-name {
    margin: 0;
    font-family: Georgia, serif;
    font-size: 18px;
    line-height: 1.05;
  }

  .destination {
    display: flex;
    align-items: center;
    gap: 4px;
    margin-top: 4px;
    color: #61756c;
    font-size: 11px;
    font-weight: 700;
  }

  .destination svg { width: 12px; height: 12px; }

  .passenger-meta {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-top: 12px;
    padding-top: 10px;
    border-top: 1px dashed #d5c6a8;
    color: #788077;
    font-size: 10px;
    font-weight: 800;
  }

  .fare {
    color: #b65437;
    font-size: 12px;
    font-weight: 900;
  }

  .route-list {
    position: relative;
    margin: 0;
    padding: 12px 15px 13px;
    list-style: none;
  }

  .route-list::before {
    content: "";
    position: absolute;
    left: 23px;
    top: 25px;
    bottom: 25px;
    width: 2px;
    background: repeating-linear-gradient(to bottom, #b7c4b4 0 4px, transparent 4px 8px);
  }

  .route-stop {
    position: relative;
    display: flex;
    align-items: center;
    gap: 11px;
    min-height: 33px;
    color: #7e877c;
    font-size: 11px;
    font-weight: 800;
  }

  .route-dot {
    z-index: 1;
    width: 17px;
    height: 17px;
    flex: 0 0 17px;
    display: grid;
    place-items: center;
    background: #f8f1e2;
    border: 2px solid #b7c4b4;
    border-radius: 50%;
  }

  .route-dot::after {
    content: "";
    width: 5px;
    height: 5px;
    background: #b7c4b4;
    border-radius: 50%;
  }

  .route-stop.active { color: var(--ink); }
  .route-stop.active .route-dot {
    background: var(--coral);
    border-color: #b94931;
    box-shadow: 0 0 0 4px rgba(227,95,67,.12);
  }
  .route-stop.active .route-dot::after { background: #fff4d6; }
  .route-stop .stop-time { margin-left: auto; color: #a0a396; font-size: 9px; }

  .log-list {
    margin: 0;
    padding: 7px 14px 10px;
    list-style: none;
  }

  .log-item {
    display: flex;
    align-items: center;
    gap: 9px;
    min-height: 32px;
    border-bottom: 1px dashed #ddd0b4;
    font-size: 10px;
  }

  .log-item:last-child { border-bottom: 0; }

  .log-time {
    color: #a29c8c;
    font-variant-numeric: tabular-nums;
    font-size: 9px;
    font-weight: 800;
  }

  .log-icon {
    width: 20px;
    height: 20px;
    display: grid;
    place-items: center;
    color: #9c6d1f;
    background: #f6df9e;
    border-radius: 6px;
    font-size: 11px;
  }

  .log-text { color: #4d645b; font-weight: 800; }
  .log-points { margin-left: auto; color: #bc4e34; font-weight: 900; }

  .touch-controls {
    display: none;
    grid-template-columns: repeat(3, 52px);
    grid-template-rows: repeat(2, 45px);
    justify-content: center;
    gap: 6px;
    margin-top: 12px;
  }

  .touch-controls button {
    display: grid;
    place-items: center;
    color: #fff9e9;
    background: var(--green);
    border: 0;
    border-bottom: 4px solid #0d332c;
    border-radius: 11px;
    box-shadow: 0 4px 8px rgba(21,67,57,.13);
    cursor: pointer;
    touch-action: manipulation;
  }

  .touch-controls button:active { transform: translateY(2px); border-bottom-width: 2px; }
  .touch-controls button svg { width: 19px; height: 19px; }
  .touch-controls .up { grid-column: 2; grid-row: 1; }
  .touch-controls .left { grid-column: 1; grid-row: 2; }
  .touch-controls .down { grid-column: 2; grid-row: 2; }
  .touch-controls .right { grid-column: 3; grid-row: 2; }

  [hidden] { display: none !important; }

  @media (max-width: 1050px) {
    .layout { grid-template-columns: 1fr; }
    .sidebar { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: start; }
    .board-column { max-width: 940px; width: 100%; margin: 0 auto; }
    .sidebar { max-width: 940px; width: 100%; margin: 0 auto; }
  }

  @media (max-width: 720px) {
    .app { padding: 11px 11px 18px; }
    .topbar { flex-wrap: wrap; gap: 12px; padding-bottom: 12px; margin-bottom: 12px; }
    .brand-mark { width: 46px; height: 46px; flex-basis: 46px; border-radius: 13px; }
    .brand-mark svg { width: 32px; height: 32px; }
    h1 { font-size: 25px; }
    .brand-sub { font-size: 10px; }
    .stats { width: 100%; display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; }
    .stat { min-width: 0; padding: 7px 9px; }
    .stat-value { font-size: 18px; }
    .sidebar { grid-template-columns: 1fr; }
    .board-location { display: none; }
    .board-bar { height: 34px; padding: 0 9px; }
    .start-hint { top: 8px; gap: 7px; padding: 7px 10px 7px 7px; border-radius: 11px; }
    .hint-icon { width: 29px; height: 29px; flex-basis: 29px; }
    .hint-icon svg { width: 21px; height: 21px; }
    .start-hint strong { font-size: 11px; }
    .start-hint small { font-size: 8px; }
    .control-note { display: none; }
    .touch-controls { display: grid; }
    .toast { bottom: 8px; font-size: 10px; }
  }

  @media (max-width: 390px) {
    .stat-label { font-size: 8px; }
    .stat-value { font-size: 17px; }
    .eyebrow { font-size: 8px; }
    h1 { font-size: 22px; }
    .brand { gap: 9px; }
  }
</style>
</head>
<body>
<div class="app">
  <header class="topbar">
    <div class="brand">
      <div class="brand-mark" aria-hidden="true">
        <svg viewBox="0 0 48 48" fill="none">
          <path d="M14 8h20a6 6 0 0 1 6 6v20H8V14a6 6 0 0 1 6-6Z" fill="#f2b544"/>
          <path d="M8 21h32v7H8z" fill="#e35f43"/>
          <path d="M14 13h11v9H14z" fill="#fff4cf"/>
          <circle cx="19.5" cy="17.5" r="2.5" fill="#214f46"/>
          <path d="M12 35h24" stroke="#f7e2a6" stroke-width="3" stroke-linecap="round"/>
          <circle cx="15" cy="38" r="4" fill="#102f29"/>
          <circle cx="33" cy="38" r="4" fill="#102f29"/>
          <circle cx="15" cy="38" r="1.5" fill="#f2b544"/>
          <circle cx="33" cy="38" r="1.5" fill="#f2b544"/>
          <path d="M36 13h5v11h-5z" fill="#102f29"/>
        </svg>
      </div>
      <div>
        <p class="eyebrow">Passenger service · Line 7</p>
        <h1>Railway Snake</h1>
        <p class="brand-sub">The Little Express is ready to roll.</p>
      </div>
    </div>

    <div class="stats" aria-label="Game statistics">
      <div class="stat score">
        <span class="stat-label">Score</span>
        <span class="stat-value" id="scoreValue">0000</span>
      </div>
      <div class="stat pickups">
        <span class="stat-label">Boarded</span>
        <span class="stat-value" id="pickupValue">0</span>
      </div>
      <div class="stat best">
        <span class="stat-label">Best</span>
        <span class="stat-value" id="bestValue">0000</span>
      </div>
    </div>
  </header>

  <main class="layout">
    <section class="board-column">
      <div class="board-frame">
        <div class="board-bar">
          <div class="board-id">
            <span class="signal"></span>
            <strong>Central Yard</strong>
            <span class="board-location">All signals green · <span id="boardClock">00:00</span></span>
          </div>
          <div class="board-actions">
            <span class="demo-chip" id="demoChip">Auto pilot</span>
            <button class="icon-button" id="pauseBtn" aria-label="Pause game" title="Pause game">
              <svg viewBox="0 0 16 16" fill="currentColor"><rect x="3" y="2" width="4" height="12" rx="1"/><rect x="9" y="2" width="4" height="12" rx="1"/></svg>
            </button>
          </div>
        </div>

        <div class="canvas-wrap" id="canvasWrap">
          <canvas id="gameCanvas" aria-label="A train-shaped snake collecting passengers from railway platforms"></canvas>

          <div class="start-hint" id="startHint">
            <span class="hint-icon" aria-hidden="true">
              <svg viewBox="0 0 32 32" fill="none">
                <rect x="7" y="4" width="18" height="18" rx="7" fill="currentColor" opacity=".2"/>
                <path d="M9 12h14v11H9z" fill="currentColor"/>
                <path d="M11 8h10v7H11z" fill="#fff4cf"/>
                <circle cx="13" cy="24" r="3" fill="#173f37"/>
                <circle cx="19" cy="24" r="3" fill="#173f37"/>
                <circle cx="13" cy="24" r="1" fill="#f2b544"/>
                <circle cx="19" cy="24" r="1" fill="#f2b544"/>
              </svg>
            </span>
            <span>
              <strong>Click or press a key to start</strong>
              <small>Auto-driving demo is running — take the wheel!</small>
            </span>
          </div>

          <div class="toast" id="toast" aria-live="polite"></div>

          <div class="game-over" id="gameOver" hidden>
            <div class="over-card">
              <div class="over-icon">!</div>
              <h2>Service suspended</h2>
              <p id="overText">The express met a little trouble on the line.</p>
              <button class="primary-button" id="restartBtn">Run another service</button>
            </div>
          </div>
        </div>
      </div>

      <div class="control-note">
        <span class="key-hint"><kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd> or <kbd>WASD</kbd> to steer</span>
        <span>Collect everyone waiting on the platforms</span>
        <span>Each passenger adds a colorful wagon</span>
      </div>

      <div class="touch-controls" aria-label="Touch controls">
        <button class="up" data-dir="up" aria-label="Move up">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m5 15 7-7 7 7"/></svg>
        </button>
        <button class="left" data-dir="left" aria-label="Move left">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m15 5-7 7 7 7"/></svg>
        </button>
        <button class="down" data-dir="down" aria-label="Move down">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m5 9 7 7 7-7"/></svg>
        </button>
        <button class="right" data-dir="right" aria-label="Move right">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m9 5 7 7-7 7"/></svg>
        </button>
      </div>
    </section>

    <aside class="sidebar">
      <section class="card">
        <div class="card-head">
          <span>Next boarding</span>
          <span class="mode" id="modeLabel">Demo driving</span>
        </div>
        <div class="passenger-card">
          <div class="passenger-main">
            <div class="avatar" id="passengerAvatar">A</div>
            <div>
              <h2 class="passenger-name" id="passengerName">Ada</h2>
              <div class="destination">
                <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 18s6-5.2 6-10a6 6 0 1 0-12 0c0 4.8 6 10 6 10Z"/><circle cx="10" cy="8" r="2"/></svg>
                <span id="passengerDest">Heading to Fernhill</span>
              </div>
            </div>
          </div>
          <div class="passenger-meta">
            <span id="waitTime">Waiting 0s</span>
            <span class="fare" id="fareValue">+100 fare</span>
          </div>
        </div>
      </section>

      <section class="card">
        <div class="card-head"><span>Line 7 timetable</span><span class="mode">Local</span></div>
        <ol class="route-list" id="routeList">
          <li class="route-stop active"><span class="route-dot"></span><span>Central Yard</span><span class="stop-time">Now</span></li>
          <li class="route-stop"><span class="route-dot"></span><span>Fernhill</span><span class="stop-time">08:15</span></li>
          <li class="route-stop"><span class="route-dot"></span><span>Willow Park</span><span class="stop-time">08:27</span></li>
          <li class="route-stop"><span class="route-dot"></span><span>Copper Bridge</span><span class="stop-time">08:41</span></li>
        </ol>
      </section>

      <section class="card">
        <div class="card-head"><span>Conductor’s log</span><span class="mode">Live</span></div>
        <ul class="log-list" id="logList"></ul>
      </section>
    </aside>
  </main>
</div>

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

  const canvas = document.getElementById("gameCanvas");
  const ctx = canvas.getContext("2d");
  const canvasWrap = document.getElementById("canvasWrap");
  const startHint = document.getElementById("startHint");
  const gameOver = document.getElementById("gameOver");
  const toast = document.getElementById("toast");
  const scoreValue = document.getElementById("scoreValue");
  const pickupValue = document.getElementById("pickupValue");
  const bestValue = document.getElementById("bestValue");
  const boardClock = document.getElementById("boardClock");
  const demoChip = document.getElementById("demoChip");
  const modeLabel = document.getElementById("modeLabel");
  const passengerAvatar = document.getElementById("passengerAvatar");
  const passengerName = document.getElementById("passengerName");
  const passengerDest = document.getElementById("passengerDest");
  const waitTime = document.getElementById("waitTime");
  const fareValue = document.getElementById("fareValue");
  const logList = document.getElementById("logList");
  const routeStops = [...document.querySelectorAll(".route-stop")];

  const COLS = 20;
  const ROWS = 13;
  const DIRS = [
    { x: 1, y: 0 },
    { x: 0, y: 1 },
    { x: -1, y: 0 },
    { x: 0, y: -1 }
  ];

  const WAGON_COLORS = [
    "#e45745", "#2a9d8f", "#f2b544", "#8270b8", "#4f9fc4",
    "#df7c9d", "#67a85b", "#ef9146"
  ];

  const PEOPLE = [
    { name: "Ada", dest: "Fernhill", coat: "#df583f", skin: "#eeb78d", hair: "#4b3027" },
    { name: "Milo", dest: "Willow Park", coat: "#2f8e83", skin: "#b97851", hair: "#2d2724" },
    { name: "Clara", dest: "Copper Bridge", coat: "#8064a9", skin: "#f3c6a4", hair: "#8a4e31" },
    { name: "Otis", dest: "Starlight Vale", coat: "#3d8eb2", skin: "#d79b70", hair: "#6c6254" },
    { name: "Nell", dest: "Fernhill", coat: "#cf6f9c", skin: "#f0bd95", hair: "#b45c37" },
    { name: "Hugo", dest: "Willow Park", coat: "#d39a32", skin: "#9e6544", hair: "#332c28" },
    { name: "Bea", dest: "Copper Bridge", coat: "#5d9856", skin: "#e5a779", hair: "#593b2c" },
    { name: "Finn", dest: "Starlight Vale", coat: "#6677ad", skin: "#efc5a2", hair: "#8d673f" }
  ];

  const PROPS = [
    { x: 2, y: 0, type: "station", label: "CENTRAL" },
    { x: 16, y: ROWS - 1, type: "station", label: "FERNHILL" },
    { x: 0, y: 4, type: "kiosk" },
    { x: COLS - 1, y: 9, type: "kiosk" },
    { x: 8, y: 0, type: "tree" },
    { x: 11, y: 0, type: "tree" },
    { x: 4, y: ROWS - 1, type: "tree" },
    { x: 9, y: ROWS - 1, type: "tree" },
    { x: 0, y: 9, type: "tree" },
    { x: COLS - 1, y: 3, type: "tree" }
  ];

  const PROP_KEYS = new Set(PROPS.map(p => `${p.x},${p.y}`));

  const state = {
    snake: [],
    dir: { x: 1, y: 0 },
    queued: [],
    passengers: [],
    particles: [],
    floaters: [],
    logs: [],
    score: 0,
    best: 0,
    collected: 0,
    level: 1,
    demo: true,
    started: false,
    over: false,
    paused: false,
    time: 0,
    last: performance.now(),
    acc: 0,
    tick: 158,
    runId: 0,
    steamTimer: 0,
    spawnCooldown: 0,
    displayScore: 0,
    toastTimer: null,
    passengerSerial: 0
  };

  let cw = 900;
  let ch = 585;
  let cell = 45;

  const GRASS = [];
  let grassSeed = 9371;
  function seeded() {
    grassSeed = (grassSeed * 16807) % 2147483647;
    return (grassSeed - 1) / 2147483646;
  }
  for (let i = 0; i < 330; i++) {
    GRASS.push({
      x: seeded(),
      y: seeded(),
      size: 0.6 + seeded() * 1.4,
      alpha: 0.08 + seeded() * 0.12,
      flip: seeded() > 0.5 ? 1 : -1
    });
  }

  function resize() {
    const width = canvasWrap.clientWidth;
    if (!width) return;
    const height = Math.round(width * ROWS / COLS);
    if (Math.abs(width - cw) < 0.5 && Math.abs(height - ch) < 0.5) return;

    cw = width;
    ch = height;
    cell = cw / COLS;
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    canvas.width = Math.round(cw * dpr);
    canvas.height = Math.round(ch * dpr);
    canvas.style.height = `${ch}px`;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }

  if ("ResizeObserver" in window) {
    new ResizeObserver(resize).observe(canvasWrap);
  }
  window.addEventListener("resize", resize);

  function setText(el, text) {
    text = String(text);
    if (el.textContent !== text) el.textContent = text;
  }

  function keyOf(x, y) { return `${x},${y}`; }

  function formatClock(seconds) {
    const m = Math.floor(seconds / 60).toString().padStart(2, "0");
    const s = Math.floor(seconds % 60).toString().padStart(2, "0");
    return `${m}:${s}`;
  }

  function showToast(message) {
    clearTimeout(state.toastTimer);
    setText(toast, message);
    toast.classList.add("visible");
    state.toastTimer = setTimeout(() => toast.classList.remove("visible"), 2300);
  }

  function makePassenger(x, y) {
    const template = PEOPLE[state.passengerSerial % PEOPLE.length];
    state.passengerSerial++;
    return {
      ...template,
      id: state.passengerSerial,
      x,
      y,
      wait: 0
    };
  }

  function resetGame(demo = true) {
    state.runId++;
    state.snake = [
      { x: 10, y: 6 },
      { x: 9, y: 6 },
      { x: 8, y: 6 },
      { x: 7, y: 6 }
    ];
    state.dir = { x: 1, y: 0 };
    state.queued = [];
    state.passengers = [];
    state.particles = [];
    state.floaters = [];
    state.score = 0;
    state.collected = 0;
    state.level = 1;
    state.demo = demo;
    state.started = !demo;
    state.over = false;
    state.paused = false;
    state.time = 0;
    state.last = performance.now();
    state.acc = 0;
    state.tick = demo ? 158 : 146;
    state.steamTimer = 0;
    state.spawnCooldown = 0;
    state.displayScore = 0;
    state.passengerSerial = 0;

    state.passengers.push(makePassenger(COLS - 1, 6));
    state.passengers.push(makePassenger(17, ROWS - 1));
    state.passengers.push(makePassenger(2, 8));
    state.passengers.push(makePassenger(13, 0));
    state.passengers.push(makePassenger(7, ROWS - 1));

    state.logs = [{
      time: "00:00",
      text: demo ? "Auto-pilot checked in at Central Yard" : "The Little Express departed Central Yard",
      points: ""
    }];

    gameOver.hidden = true;
    startHint.hidden = !demo;
    renderLog();
    updateModeUI();
    updateUI();
  }

  function takeControl() {
    if (state.over) {
      resetGame(false);
      return;
    }
    if (!state.demo) return;
    state.demo = false;
    state.started = true;
    state.queued = [];
    startHint.hidden = true;
    updateModeUI();
    showToast("You're the conductor! Steer with the arrow keys.");
  }

  function togglePause() {
    if (state.over) return;
    state.paused = !state.paused;
    showToast(state.paused ? "Service paused — press P to continue" : "All aboard! Back on the move.");
  }

  function queueDirection(dir) {
    const reference = state.queued.length
      ? state.queued[state.queued.length - 1]
      : state.dir;

    if (dir.x === -reference.x && dir.y === -reference.y) return;
    if (dir.x === reference.x && dir.y === reference.y) return;
    if (state.queued.length < 3) state.queued.push(dir);
  }

  function passengerAt(x, y) {
    return state.passengers.find(p => p.x === x && p.y === y);
  }

  function bfsPath(targets, blocked) {
    const start = state.snake[0];
    const queue = [{ x: start.x, y: start.y }];
    const seen = new Set([keyOf(start.x, start.y)]);
    const cameFrom = new Map();
    let found = null;

    for (let q = 0; q < queue.length; q++) {
      const current = queue[q];
      if (targets.has(keyOf(current.x, current.y))) {
        found = current;
        break;
      }

      for (const dir of DIRS) {
        const nx = current.x + dir.x;
        const ny = current.y + dir.y;
        if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
        const k = keyOf(nx, ny);
        if (seen.has(k) || blocked.has(k)) continue;
        seen.add(k);
        cameFrom.set(k, current);
        queue.push({ x: nx, y: ny });
      }
    }

    if (!found) return null;

    const path = [];
    let current = found;
    while (current.x !== start.x || current.y !== start.y) {
      path.push(current);
      current = cameFrom.get(keyOf(current.x, current.y));
      if (!current) break;
    }
    path.reverse();
    return path;
  }

  function floodCount(start, blocked) {
    const queue = [start];
    const seen = new Set([keyOf(start.x, start.y)]);
    for (let q = 0; q < queue.length; q++) {
      const current = queue[q];
      for (const dir of DIRS) {
        const nx = current.x + dir.x;
        const ny = current.y + dir.y;
        if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
        const k = keyOf(nx, ny);
        if (seen.has(k) || blocked.has(k)) continue;
        seen.add(k);
        queue.push({ x: nx, y: ny });
      }
    }
    return seen.size;
  }

  function aiDirection() {
    const head = state.snake[0];
    const blocked = new Set(
      state.snake.slice(0, Math.max(1, state.snake.length - 1)).map(s => keyOf(s.x, s.y))
    );
    const targets = new Set(state.passengers.map(p => keyOf(p.x, p.y)));

    const path = bfsPath(targets, blocked);
    if (path && path.length) {
      const next = path[0];
      return { x: next.x - head.x, y: next.y - head.y };
    }

    let bestDir = state.dir;
    let bestScore = -Infinity;
    for (const dir of DIRS) {
      if (dir.x === -state.dir.x && dir.y === -state.dir.y) continue;
      const nx = head.x + dir.x;
      const ny = head.y + dir.y;
      if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
      if (blocked.has(keyOf(nx, ny))) continue;

      let distance = 0;
      for (const p of state.passengers) {
        distance += Math.abs(p.x - nx) + Math.abs(p.y - ny);
      }
      const space = floodCount({ x: nx, y: ny }, blocked);
      const score = space * 2 - distance * 0.05;
      if (score > bestScore) {
        bestScore = score;
        bestDir = dir;
      }
    }
    return bestDir;
  }

  function endRun() {
    state.over = true;
    state.queued = [];

    if (state.demo) {
      showToast("The demo took a wrong turn — resetting the line…");
      const runId = state.runId;
      setTimeout(() => {
        if (state.runId === runId && state.demo) resetGame(true);
      }, 1000);
    } else {
      setText(document.getElementById("overText"),
        `You collected ${state.collected} passenger${state.collected === 1 ? "" : "s"} and scored ${state.score} points.`);
      gameOver.hidden = false;
    }
    updateModeUI();
  }

  function pickupPassenger(passenger) {
    const points = 100 + Math.min(50, state.collected * 5);
    state.score += points;
    state.collected++;
    state.level = 1 + Math.floor(state.collected / 4);
    state.tick = Math.max(96, (state.demo ? 158 : 146) - (state.level - 1) * 6);
    state.spawnCooldown = 1.05;
    state.best = Math.max(state.best, state.score);

    const wx = (passenger.x + 0.5) * cell;
    const wy = (passenger.y + 0.5) * cell;
    state.floaters.push({
      x: wx,
      y: wy - cell * 0.35,
      text: `+${points}`,
      life: 1.15,
      max: 1.15,
      color: "#b74c32"
    });

    makeConfetti(wx, wy, passenger.coat);
    state.logs.unshift({
      time: formatClock(state.time),
      text: `${passenger.name} → ${passenger.dest}`,
      points: `+${points}`
    });
    state.logs = state.logs.slice(0, 4);
    renderLog();
    showToast(`${passenger.name} is aboard! +${points} points`);
  }

  function stepGame() {
    if (state.over || state.paused) return;

    let dir;
    if (state.demo) {
      dir = aiDirection();
    } else {
      dir = state.queued.shift() || state.dir;
    }
    if (!dir) return;

    state.dir = dir;
    const head = state.snake[0];
    const nx = head.x + dir.x;
    const ny = head.y + dir.y;
    const hitPassenger = passengerAt(nx, ny);

    if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) {
      endRun();
      return;
    }

    const checkLength = state.snake.length - (hitPassenger ? 0 : 1);
    for (let i = 0; i < checkLength; i++) {
      if (state.snake[i].x === nx && state.snake[i].y === ny) {
        endRun();
        return;
      }
    }

    state.snake.unshift({ x: nx, y: ny });
    if (hitPassenger) {
      state.passengers = state.passengers.filter(p => p.id !== hitPassenger.id);
      pickupPassenger(hitPassenger);
    } else {
      state.snake.pop();
    }
  }

  function spawnPassenger() {
    const occupied = new Set([
      ...PROP_KEYS,
      ...state.snake.map(s => keyOf(s.x, s.y)),
      ...state.passengers.map(p => keyOf(p.x, p.y))
    ]);
    const candidates = [];

    for (let x = 0; x < COLS; x++) {
      for (let y = 0; y < ROWS; y++) {
        if (x !== 0 && x !== COLS - 1 && y !== 0 && y !== ROWS - 1) continue;
        if (!occupied.has(keyOf(x, y))) candidates.push({ x, y });
      }
    }

    if (!candidates.length) return;
    const spot = candidates[Math.floor(Math.random() * candidates.length)];
    state.passengers.push(makePassenger(spot.x, spot.y));
  }

  function makeConfetti(x, y, mainColor) {
    const colors = [mainColor, "#f2b544", "#2a9d8f", "#fff2bc", "#8167ad"];
    for (let i = 0; i < 19; i++) {
      const angle = Math.random() * Math.PI * 2;
      const speed = 30 + Math.random() * 85;
      state.particles.push({
        type: "confetti",
        x: x + (Math.random() - .5) * cell * .3,
        y: y + (Math.random() - .5) * cell * .3,
        vx: Math.cos(angle) * speed,
        vy: Math.sin(angle) * speed - 25,
        size: 2 + Math.random() * 3,
        rotation: Math.random() * Math.PI,
        spin: (Math.random() - .5) * 9,
        color: colors[i % colors.length],
        life: .9 + Math.random() * .45,
        max: 1.35
      });
    }
  }

  function emitSteam(dt) {
    state.steamTimer -= dt;
    if (state.steamTimer > 0 || !state.snake.length) return;
    state.steamTimer = .075;

    const head = state.snake[0];
    const dir = state.dir;
    const perpX = -dir.y;
    const perpY = dir.x;
    const x = (head.x + .5) * cell - dir.x * cell * .38 + perpX * cell * .13;
    const y = (head.y + .5) * cell - dir.y * cell * .38 + perpY * cell * .13;

    state.particles.push({
      type: "steam",
      x,
      y,
      vx: -dir.x * 10 + perpX * (Math.random() - .5) * 8,
      vy: -dir.y * 10 + perpY * (Math.random() - .5) * 8 - 5,
      radius: cell * .085,
      life: 1.05 + Math.random() * .3,
      max: 1.35
    });
  }

  function updateParticles(dt) {
    for (const p of state.particles) {
      p.life -= dt;
      p.x += p.vx * dt;
      p.y += p.vy * dt;
      if (p.type === "steam") {
        p.radius += dt * cell * .32;
        p.vy -= dt * 4;
      } else {
        p.vy += 105 * dt;
        p.vx *= Math.pow(.985, dt * 60);
        p.rotation += p.spin * dt;
      }
    }
    state.particles = state.particles.filter(p => p.life > 0);

    for (const f of state.floaters) f.life -= dt;
    state.floaters = state.floaters.filter(f => f.life > 0);
  }

  function renderLog() {
    logList.innerHTML = state.logs.map(item => `
      <li class="log-item">
        <span class="log-time">${item.time}</span>
        <span class="log-icon">${item.points ? "✓" : "•"}</span>
        <span class="log-text">${item.text}</span>
        <span class="log-points">${item.points || ""}</span>
      </li>
    `).join("");
  }

  function updateModeUI() {
    if (state.demo) {
      setText(demoChip, "Auto pilot");
      setText(modeLabel, "Demo driving");
    } else if (state.over) {
      setText(demoChip, "Service ended");
      setText(modeLabel, "Service ended");
    } else if (state.paused) {
      setText(demoChip, "Paused");
      setText(modeLabel, "Paused");
    } else {
      setText(demoChip, "Manual control");
      setText(modeLabel, "You’re driving");
    }
  }

  function updateUI() {
    state.displayScore += (state.score - state.displayScore) * .18;
    if (Math.abs(state.score - state.displayScore) < .5) state.displayScore = state.score;

    setText(scoreValue, String(Math.round(state.displayScore)).padStart(4, "0"));
    setText(pickupValue, state.collected);
    setText(bestValue, String(state.best).padStart(4, "0"));
    setText(boardClock, formatClock(state.time));

    let next = null;
    let closest = Infinity;
    const head = state.snake[0] || { x: 10, y: 6 };
    for (const p of state.passengers) {
      const distance = Math.abs(p.x - head.x) + Math.abs(p.y - head.y);
      if (distance < closest) {
        closest = distance;
        next = p;
      }
    }

    if (next) {
      setText(passengerAvatar, next.name[0]);
      setText(passengerName, next.name);
      setText(passengerDest, `Heading to ${next.dest}`);
      setText(waitTime, `Waiting ${Math.floor(next.wait)}s`);
      setText(fareValue, `+${100 + Math.min(50, state.collected * 5)} fare`);
      passengerAvatar.style.background = next.coat;
    } else {
      setText(passengerAvatar, "✓");
      setText(passengerName, "All aboard");
      setText(passengerDest, "Looking for the next passenger");
      setText(waitTime, "Waiting for a passenger");
      setText(fareValue, "+100 fare");
      passengerAvatar.style.background = "#2a9d8f";
    }

    const activeStop = state.collected % routeStops.length;
    routeStops.forEach((stop, index) => stop.classList.toggle("active", index === activeStop));
    const activeLabel = routeStops[activeStop].querySelector(".stop-time");
    setText(activeLabel, indexIsCurrent(activeStop) ? "Now" : activeLabel.dataset.time || activeLabel.textContent);
  }

  function indexIsCurrent(index) {
    return index === state.collected % routeStops.length;
  }

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

  function drawBackground() {
    const gradient = ctx.createLinearGradient(0, 0, 0, ch);
    gradient.addColorStop(0, "#dce8c8");
    gradient.addColorStop(.5, "#cedfbc");
    gradient.addColorStop(1, "#d7e3bf");
    ctx.fillStyle = gradient;
    ctx.fillRect(0, 0, cw, ch);

    ctx.save();
    ctx.strokeStyle = "#668c5a";
    ctx.lineWidth = Math.max(.6, cell * .025);
    for (const grass of GRASS) {
      const x = grass.x * cw;
      const y = grass.y * ch;
      ctx.globalAlpha = grass.alpha;
      ctx.beginPath();
      ctx.moveTo(x, y + grass.size);
      ctx.lineTo(x + grass.flip * grass.size * .8, y - grass.size);
      ctx.stroke();
    }
    ctx.restore();

    const soft = ctx.createRadialGradient(cw / 2, ch / 2, cw * .15, cw / 2, ch / 2, cw * .68);
    soft.addColorStop(0, "rgba(255,255,255,0)");
    soft.addColorStop(1, "rgba(46,80,52,.11)");
    ctx.fillStyle = soft;
    ctx.fillRect(0, 0, cw, ch);
  }

  function drawPlatforms() {
    const c = cell;
    ctx.fillStyle = "#e7d5b1";
    ctx.fillRect(0, 0, cw, c);
    ctx.fillRect(0, ch - c, cw, c);
    ctx.fillRect(0, c, c, ch - c * 2);
    ctx.fillRect(cw - c, c, c, ch - c * 2);

    ctx.save();
    ctx.strokeStyle = "rgba(132,110,76,.18)";
    ctx.lineWidth = 1;
    for (let x = 0; x <= cw; x += c) {
      ctx.beginPath();
      ctx.moveTo(x, 0);
      ctx.lineTo(x, c);
      ctx.moveTo(x, ch - c);
      ctx.lineTo(x, ch);
      ctx.stroke();
    }
    for (let y = c; y < ch - c; y += c) {
      ctx.beginPath();
      ctx.moveTo(0, y);
      ctx.lineTo(c, y);
      ctx.moveTo(cw - c, y);
      ctx.lineTo(cw, y);
      ctx.stroke();
    }

    ctx.strokeStyle = "#d9a83d";
    ctx.lineWidth = Math.max(1.2, c * .045);
    ctx.setLineDash([c * .2, c * .13]);
    ctx.beginPath();
    ctx.moveTo(c * .25, c - 2);
    ctx.lineTo(cw - c * .25, c - 2);
    ctx.moveTo(c * .25, ch - c + 2);
    ctx.lineTo(cw - c * .25, ch - c + 2);
    ctx.moveTo(c - 2, c * .25);
    ctx.lineTo(c - 2, ch - c * .25);
    ctx.moveTo(cw - c + 2, c * .25);
    ctx.lineTo(cw - c + 2, ch - c * .25);
    ctx.stroke();
    ctx.restore();

    ctx.fillStyle = "rgba(77,67,44,.12)";
    ctx.fillRect(0, c - 3, cw, 3);
    ctx.fillRect(0, ch - c, cw, 3);
    ctx.fillRect(c - 3, c, 3, ch - c * 2);
    ctx.fillRect(cw - c, c, 3, ch - c * 2);
  }

  function drawTracks() {
    const c = cell;
    const railOffset = Math.max(2.4, c * .085);
    const tieLength = Math.max(4, c * .115);
    const spacing = Math.max(8, c * .3);

    ctx.save();
    ctx.globalAlpha = .12;
    ctx.strokeStyle = "#77664d";
    ctx.lineWidth = Math.max(6, c * .38);
    for (let r = 0; r < ROWS; r++) {
      const y = (r + .5) * c;
      ctx.beginPath();
      ctx.moveTo(c * .5, y);
      ctx.lineTo(cw - c * .5, y);
      ctx.stroke();
    }
    for (let col = 0; col < COLS; col++) {
      const x = (col + .5) * c;
      ctx.beginPath();
      ctx.moveTo(x, c * .5);
      ctx.lineTo(x, ch - c * .5);
      ctx.stroke();
    }
    ctx.restore();

    ctx.save();
    ctx.strokeStyle = "rgba(102,79,54,.64)";
    ctx.lineWidth = Math.max(1, c * .06);
    for (let r = 0; r < ROWS; r++) {
      const y = (r + .5) * c;
      for (let x = c * .5; x <= cw - c * .5; x += spacing) {
        ctx.beginPath();
        ctx.moveTo(x, y - tieLength);
        ctx.lineTo(x, y + tieLength);
        ctx.stroke();
      }
    }
    for (let col = 0; col < COLS; col++) {
      const x = (col + .5) * c;
      for (let y = c * .5; y <= ch - c * .5; y += spacing) {
        ctx.beginPath();
        ctx.moveTo(x - tieLength, y);
        ctx.lineTo(x + tieLength, y);
        ctx.stroke();
      }
    }
    ctx.restore();

    ctx.save();
    ctx.lineCap = "round";
    for (const side of [-1, 1]) {
      ctx.strokeStyle = "rgba(61,72,67,.9)";
      ctx.lineWidth = Math.max(1.25, c * .035);
      for (let r = 0; r < ROWS; r++) {
        const y = (r + .5) * c + side * railOffset;
        ctx.beginPath();
        ctx.moveTo(c * .5, y);
        ctx.lineTo(cw - c * .5, y);
        ctx.stroke();
      }
      for (let col = 0; col < COLS; col++) {
        const x = (col + .5) * c + side * railOffset;
        ctx.beginPath();
        ctx.moveTo(x, c * .5);
        ctx.lineTo(x, ch - c * .5);
        ctx.stroke();
      }
    }
    ctx.strokeStyle = "rgba(244,242,218,.66)";
    ctx.lineWidth = Math.max(.55, c * .014);
    for (const side of [-1, 1]) {
      for (let r = 0; r < ROWS; r++) {
        const y = (r + .5) * c + side * railOffset - .5;
        ctx.beginPath();
        ctx.moveTo(c * .5, y);
        ctx.lineTo(cw - c * .5, y);
        ctx.stroke();
      }
      for (let col = 0; col < COLS; col++) {
        const x = (col + .5) * c + side * railOffset - .5;
        ctx.beginPath();
        ctx.moveTo(x, c * .5);
        ctx.lineTo(x, ch - c * .5);
        ctx.stroke();
      }
    }
    ctx.restore();
  }

  function drawTree(x, y) {
    const cx = (x + .5) * cell;
    const cy = (y + .5) * cell;
    ctx.save();
    ctx.fillStyle = "rgba(42,60,41,.18)";
    ctx.beginPath();
    ctx.ellipse(cx + cell * .05, cy + cell * .24, cell * .22, cell * .08, 0, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = "#8a6545";
    ctx.fillRect(cx - cell * .035, cy, cell * .07, cell * .23);
    ctx.fillStyle = "#4c8150";
    ctx.beginPath();
    ctx.arc(cx, cy - cell * .08, cell * .2, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = "#6d9d58";
    ctx.beginPath();
    ctx.arc(cx - cell * .09, cy - cell * .12, cell * .12, 0, Math.PI * 2);
    ctx.arc(cx + cell * .09, cy - cell * .12, cell * .13, 0, Math.PI * 2);
    ctx.fill();
    ctx.restore();
  }

  function drawStation(prop) {
    const c = cell;
    const cx = (prop.x + .5) * c;
    const cy = (prop.y + .5) * c;

    ctx.save();
    ctx.fillStyle = "rgba(42,53,40,.18)";
    rr(cx - c * .35, cy - c * .21, c * .72, c * .47, c * .05);
    ctx.fill();

    ctx.fillStyle = "#f3dfb2";
    rr(cx - c * .31, cy - c * .17, c * .62, c * .37, c * .04);
    ctx.fill();

    ctx.fillStyle = "#b9523c";
    ctx.beginPath();
    ctx.moveTo(cx - c * .38, cy - c * .16);
    ctx.lineTo(cx + c * .38, cy - c * .16);
    ctx.lineTo(cx + c * .27, cy - c * .34);
    ctx.lineTo(cx - c * .27, cy - c * .34);
    ctx.closePath();
    ctx.fill();

    ctx.fillStyle = "#5e8b83";
    ctx.fillRect(cx - c * .22, cy - c * .08, c * .14, c * .12);
    ctx.fillRect(cx + c * .08, cy - c * .08, c * .14, c * .12);
    ctx.fillStyle = "#7d5b3b";
    ctx.fillRect(cx - c * .055, cy + c * .03, c * .11, c * .17);

    ctx.fillStyle = "#fff4d0";
    rr(cx - c * .25, cy - c * .39, c * .5, c * .13, c * .04);
    ctx.fill();
    ctx.fillStyle = "#244d44";
    ctx.font = `900 ${Math.max(4.5, c * .09)}px "Trebuchet MS", sans-serif`;
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.fillText(prop.label, cx, cy - c * .325);
    ctx.restore();
  }

  function drawKiosk(x, y) {
    const c = cell;
    const cx = (x + .5) * c;
    const cy = (y + .5) * c;
    ctx.save();
    ctx.fillStyle = "rgba(42,53,40,.18)";
    rr(cx - c * .26, cy - c * .21, c * .54, c * .43, c * .05);
    ctx.fill();
    ctx.fillStyle = "#e7b54c";
    rr(cx - c * .25, cy - c * .27, c * .5, c * .43, c * .05);
    ctx.fill();
    ctx.fillStyle = "#f6df99";
    rr(cx - c * .18, cy - c * .2, c * .36, c * .18, c * .03);
    ctx.fill();
    ctx.fillStyle = "#5b4a35";
    ctx.fillRect(cx - c * .17, cy + c * .02, c * .34, c * .045);
    ctx.restore();
  }

  function drawProps() {
    for (const prop of PROPS) {
      if (prop.type === "tree") drawTree(prop.x, prop.y);
      else if (prop.type === "station") drawStation(prop);
      else drawKiosk(prop.x, prop.y);
    }
  }

  function drawPassenger(passenger) {
    const c = cell;
    const cx = (passenger.x + .5) * c;
    const cy = (passenger.y + .5) * c;
    const wave = Math.sin(state.time * 3.2 + passenger.id) * .35;

    ctx.save();
    ctx.strokeStyle = passenger.coat;
    ctx.globalAlpha = .52;
    ctx.lineWidth = Math.max(.8, c * .028);
    ctx.setLineDash([Math.max(2, c * .08), Math.max(2, c * .07)]);
    ctx.lineDashOffset = -state.time * 10;
    ctx.beginPath();
    ctx.ellipse(cx, cy, c * .29, c * .25, 0, 0, Math.PI * 2);
    ctx.stroke();
    ctx.setLineDash([]);
    ctx.globalAlpha = 1;

    ctx.fillStyle = "rgba(42,56,42,.2)";
    ctx.beginPath();
    ctx.ellipse(cx, cy + c * .19, c * .17, c * .055, 0, 0, Math.PI * 2);
    ctx.fill();

    ctx.strokeStyle = passenger.coat;
    ctx.lineWidth = Math.max(1.4, c * .055);
    ctx.lineCap = "round";
    ctx.beginPath();
    ctx.moveTo(cx - c * .07, cy + c * .02);
    ctx.lineTo(cx - c * .16, cy + c * .12);
    ctx.moveTo(cx + c * .07, cy + c * .02);
    ctx.lineTo(cx + c * .15, cy - c * .03 + wave * c * .05);
    ctx.stroke();

    ctx.fillStyle = passenger.coat;
    rr(cx - c * .115, cy - c * .035, c * .23, c * .23, c * .07);
    ctx.fill();

    ctx.fillStyle = passenger.skin;
    ctx.beginPath();
    ctx.arc(cx, cy - c * .16, c * .105, 0, Math.PI * 2);
    ctx.fill();

    ctx.fillStyle = passenger.hair;
    ctx.beginPath();
    ctx.arc(cx, cy - c * .185, c * .108, Math.PI * 1.04, Math.PI * 1.98);
    ctx.fill();

    ctx.fillStyle = "#734b3a";
    ctx.fillRect(cx + c * .12, cy + c * .1, c * .11, c * .13);
    ctx.strokeStyle = "#4f382b";
    ctx.lineWidth = Math.max(.7, c * .022);
    ctx.strokeRect(cx + c * .12, cy + c * .1, c * .11, c * .13);

    ctx.strokeStyle = "#87633f";
    ctx.lineWidth = Math.max(.8, c * .024);
    ctx.beginPath();
    ctx.moveTo(cx + c * .19, cy + c * .1);
    ctx.lineTo(cx + c * .19, cy + c * .045);
    ctx.stroke();

    ctx.fillStyle = passenger.coat;
    ctx.beginPath();
    ctx.moveTo(cx + c * .19, cy + c * .045);
    ctx.lineTo(cx + c * .31, cy + c * .075);
    ctx.lineTo(cx + c * .19, cy + c * .105);
    ctx.closePath();
    ctx.fill();

    ctx.restore();
  }

  function drawWagon(segment, angle, index) {
    const c = cell;
    const x = (segment.x + .5) * c;
    const y = (segment.y + .5) * c;
    const color = WAGON_COLORS[(index - 1) % WAGON_COLORS.length];

    ctx.save();
    ctx.translate(x, y);
    ctx.rotate(angle);

    ctx.fillStyle = "rgba(31,49,41,.22)";
    rr(-c * .35, -c * .19, c * .73, c * .49, c * .1);
    ctx.fill();

    ctx.fillStyle = "#263f39";
    for (const wx of [-c * .23, c * .23]) {
      ctx.beginPath();
      ctx.arc(wx, -c * .23, c * .075, 0, Math.PI * 2);
      ctx.arc(wx, c * .23, c * .075, 0, Math.PI * 2);
      ctx.fill();
    }

    ctx.fillStyle = color;
    rr(-c * .35, -c * .22, c * .7, c * .44, c * .09);
    ctx.fill();

    ctx.fillStyle = "rgba(0,0,0,.14)";
    rr(-c * .33, c * .06, c * .66, c * .14, c * .045);
    ctx.fill();

    ctx.fillStyle = "rgba(255,255,255,.22)";
    rr(-c * .28, -c * .185, c * .56, c * .075, c * .035);
    ctx.fill();

    ctx.fillStyle = "#f8e8ba";
    for (const wx of [-c * .15, c * .03]) {
      rr(wx, -c * .09, c * .13, c * .145, c * .025);
      ctx.fill();
    }

    ctx.fillStyle = "rgba(66,58,43,.35)";
    for (const wx of [-c * .15, c * .03]) {
      ctx.fillRect(wx + c * .058, -c * .09, c * .014, c * .145);
    }

    ctx.fillStyle = "#263f39";
    ctx.fillRect(-c * .41, -c * .045, c * .09, c * .09);
    ctx.restore();
  }

  function drawLocomotive() {
    const c = cell;
    const head = state.snake[0];
    const x = (head.x + .5) * c;
    const y = (head.y + .5) * c;
    const angle = Math.atan2(state.dir.y, state.dir.x);

    ctx.save();
    ctx.translate(x, y);
    ctx.rotate(angle);

    ctx.fillStyle = "rgba(31,49,41,.24)";
    rr(-c * .39, -c * .19, c * .86, c * .5, c * .13);
    ctx.fill();

    ctx.fillStyle = "#243b35";
    for (const wx of [-c * .25, c * .21]) {
      ctx.beginPath();
      ctx.arc(wx, -c * .23, c * .08, 0, Math.PI * 2);
      ctx.arc(wx, c * .23, c * .08, 0, Math.PI * 2);
      ctx.fill();
    }

    ctx.fillStyle = "#24564c";
    rr(-c * .38, -c * .225, c * .29, c * .45, c * .075);
    ctx.fill();

    ctx.fillStyle = "#173f37";
    rr(-c * .43, -c * .25, c * .37, c * .15, c * .045);
    ctx.fill();

    ctx.fillStyle = "#f8e8ba";
    rr(-c * .31, -c * .14, c * .16, c * .19, c * .025);
    ctx.fill();

    const boiler = ctx.createLinearGradient(-c * .12, -c * .18, c * .32, c * .18);
    boiler.addColorStop(0, "#f17a48");
    boiler.addColorStop(1, "#d94b31");
    ctx.fillStyle = boiler;
    rr(-c * .14, -c * .185, c * .47, c * .37, c * .15);
    ctx.fill();

    ctx.fillStyle = "#f3bd52";
    ctx.fillRect(c * .04, -c * .19, c * .045, c * .38);

    ctx.fillStyle = "#263b35";
    rr(c * .04, -c * .28, c * .12, c * .14, c * .035);
    ctx.fill();
    ctx.fillStyle = "#142e29";
    ctx.beginPath();
    ctx.ellipse(c * .1, -c * .28, c * .075, c * .035, 0, 0, Math.PI * 2);
    ctx.fill();

    ctx.fillStyle = "#d94b31";
    ctx.beginPath();
    ctx.arc(c * .29, 0, c * .175, 0, Math.PI * 2);
    ctx.fill();

    ctx.fillStyle = "#fff2c9";
    ctx.beginPath();
    ctx.arc(c * .32, -c * .052, c * .028, 0, Math.PI * 2);
    ctx.arc(c * .32, c * .052, c * .028, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = "#263b35";
    ctx.beginPath();
    ctx.arc(c * .335, -c * .052, c * .013, 0, Math.PI * 2);
    ctx.arc(c * .335, c * .052, c * .013, 0, Math.PI * 2);
    ctx.fill();

    ctx.strokeStyle = "#fff0c0";
    ctx.lineWidth = Math.max(.8, c * .022);
    ctx.beginPath();
    ctx.arc(c * .32, 0, c * .067, .15, Math.PI - .15);
    ctx.stroke();

    const glow = ctx.createRadialGradient(c * .46, 0, 0, c * .46, 0, c * .12);
    glow.addColorStop(0, "rgba(255,231,126,.85)");
    glow.addColorStop(1, "rgba(255,231,126,0)");
    ctx.fillStyle = glow;
    ctx.beginPath();
    ctx.arc(c * .46, 0, c * .12, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = "#ffe59a";
    ctx.beginPath();
    ctx.arc(c * .45, 0, c * .048, 0, Math.PI * 2);
    ctx.fill();

    ctx.strokeStyle = "#d8a744";
    ctx.lineWidth = Math.max(1, c * .028);
    ctx.beginPath();
    ctx.moveTo(c * .42, -c * .11);
    ctx.lineTo(c * .48, -c * .07);
    ctx.moveTo(c * .42, c * .11);
    ctx.lineTo(c * .48, c * .07);
    ctx.stroke();

    ctx.fillStyle = "#263b35";
    ctx.fillRect(-c * .45, -c * .04, c * .09, c * .08);
    ctx.restore();
  }

  function drawTrain() {
    if (!state.snake.length) return;

    ctx.save();
    ctx.strokeStyle = "#263d37";
    ctx.lineWidth = Math.max(1.8, cell * .055);
    ctx.lineCap = "round";
    for (let i = 0; i < state.snake.length - 1; i++) {
      const a = state.snake[i];
      const b = state.snake[i + 1];
      ctx.beginPath();
      ctx.moveTo((a.x + .5) * cell, (a.y + .5) * cell);
      ctx.lineTo((b.x + .5) * cell, (b.y + .5) * cell);
      ctx.stroke();
    }
    ctx.restore();

    for (let i = state.snake.length - 1; i >= 0; i--) {
      const segment = state.snake[i];
      if (i === 0) continue;
      const behind = state.snake[i + 1];
      const angle = Math.atan2(segment.y - behind.y, segment.x - behind.x);
      drawWagon(segment, angle, i);
    }

    drawLocomotive();
  }

  function drawSteam() {
    for (const p of state.particles) {
      if (p.type !== "steam") continue;
      const alpha = Math.max(0, Math.min(.55, (p.life / p.max) * .55));
      ctx.fillStyle = `rgba(255,255,245,${alpha})`;
      ctx.beginPath();
      ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
      ctx.fill();
    }
  }

  function drawConfettiAndFloaters() {
    for (const p of state.particles) {
      if (p.type !== "confetti") continue;
      ctx.save();
      ctx.globalAlpha = Math.max(0, Math.min(1, p.life / .45));
      ctx.translate(p.x, p.y);
      ctx.rotate(p.rotation);
      ctx.fillStyle = p.color;
      ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * .6);
      ctx.restore();
    }

    for (const f of state.floaters) {
      const progress = 1 - f.life / f.max;
      ctx.save();
      ctx.globalAlpha = Math.max(0, Math.min(1, f.life * 2));
      ctx.font = `900 ${Math.max(12, cell * .34)}px Georgia, serif`;
      ctx.textAlign = "center";
      ctx.lineWidth = Math.max(2, cell * .055);
      ctx.strokeStyle = "rgba(255,249,222,.95)";
      ctx.strokeText(f.text, f.x, f.y - progress * cell * .45);
      ctx.fillStyle = f.color;
      ctx.fillText(f.text, f.x, f.y - progress * cell * .45);
      ctx.restore();
    }
  }

  function draw() {
    ctx.clearRect(0, 0, cw, ch);
    drawBackground();
    drawPlatforms();
    drawTracks();
    drawProps();
    for (const passenger of state.passengers) drawPassenger(passenger);
    drawSteam();
    drawTrain();
    drawConfettiAndFloaters();
  }

  function frame(now) {
    const dt = Math.min(.05, Math.max(0, (now - state.last) / 1000));
    state.last = now;

    if (!state.over && !state.paused) {
      state.time += dt;
      for (const passenger of state.passengers) passenger.wait += dt;

      state.spawnCooldown -= dt;
      if (state.spawnCooldown <= 0 && state.passengers.length < 5) {
        spawnPassenger();
        state.spawnCooldown = 1.25;
      }

      state.acc += dt * 1000;
      if (state.acc >= state.tick) {
        state.acc %= state.tick;
        stepGame();
      }

      emitSteam(dt);
      updateParticles(dt);
    }

    draw();
    updateUI();
    requestAnimationFrame(frame);
  }

  document.addEventListener("pointerdown", event => {
    if (event.target.closest("button")) return;
    if (state.demo || state.over) takeControl();
  });

  window.addEventListener("keydown", event => {
    const key = event.key.toLowerCase();
    const keyDirections = {
      arrowup: { x: 0, y: -1 },
      w: { x: 0, y: -1 },
      arrowdown: { x: 0, y: 1 },
      s: { x: 0, y: 1 },
      arrowleft: { x: -1, y: 0 },
      a: { x: -1, y: 0 },
      arrowright: { x: 1, y: 0 },
      d: { x: 1, y: 0 }
    };

    if (keyDirections[key]) {
      event.preventDefault();
      if (state.over) resetGame(false);
      if (state.demo) takeControl();
      queueDirection(keyDirections[key]);
    } else if (key === "p" || key === " ") {
      event.preventDefault();
      if (state.demo) takeControl();
      else if (!state.over) togglePause();
    } else if (key === "enter" && state.over) {
      event.preventDefault();
      resetGame(false);
    } else if (state.demo) {
      takeControl();
    }
  });

  document.querySelectorAll("[data-dir]").forEach(button => {
    button.addEventListener("pointerdown", event => {
      event.preventDefault();
      if (state.over) resetGame(false);
      if (state.demo) takeControl();
      const map = {
        up: { x: 0, y: -1 },
        down: { x: 0, y: 1 },
        left: { x: -1, y: 0 },
        right: { x: 1, y: 0 }
      };
      queueDirection(map[button.dataset.dir]);
    });
  });

  document.getElementById("pauseBtn").addEventListener("click", event => {
    event.stopPropagation();
    if (state.demo) takeControl();
    if (!state.over) togglePause();
  });

  document.getElementById("restartBtn").addEventListener("click", event => {
    event.stopPropagation();
    resetGame(false);
    showToast("A fresh service! Welcome aboard, conductor.");
  });

  resize();
  resetGame(true);
  requestAnimationFrame(frame);
})();
</script>
</body>
</html>
Reasoning trace (20,460 tokens)
We need produce single HTML fenced code block complete file. Need build self-contained snake game train theme locomotive colorful wagons picking passengers on platform. Need visually rich, 1280x800, responsive 375. Self-playing demo mode behind "Click or press a key to start" message, AI plays. Keyboard events only after click but we have clear message. Need no external assets/fonts/network. All inline CSS JS. Must ensure game attractive, visible first paint screenshot at 3 sec. Interesting visible within first seconds. Need think design and implementation.

We can create full screen app with header title, score, best maybe, train status. Main board stylized isometric-ish top-down railway grid with tracks, platforms, passengers waiting, locomotive + colorful wagons as snake body moving continuously. Game mechanics snake train: head locomotive, wagons follow. Move grid-based like snake. Passengers at platform cells appear, train collects by running over passenger cell (head). Platforms are decorative strips along some track rows? Need snake picking passengers waiting on platform. Could design grid: e.g., 19 columns x 13 rows maybe. Tracks as horizontal/vertical? Traditional snake on grid with rails drawn as lines connecting cell centers, but a train on one path with turns. Simpler: board grid of cells, with railway track texture drawn across full grid (both horizontal and vertical lines) and platforms along edges (station platform rows). Passengers stand on platform cells (maybe first/last column and top/bottom rows) waiting. Train moves on grid and picks up passengers when head reaches their cell. Wagons follow.

Visual aim: side-by-side comparison judged aesthetic. Need strong art direction: cozy retro railway game, warm cream background, green/teal board, rails, platform, little passengers, train engine orange/red with steam, colorful wagons. Could use SVG inline for drawing (allowed; code in html). We can generate board with Canvas for gameplay plus HTML overlays. Canvas gives crisp drawing, particles, animated. But SVG DOM could look great too. Canvas at 1280x800: header top ~110, board central ~600 tall and maybe 900 wide, right sidebar maybe 260. Could make layout: top header, main stage grid 2 cols: left board (flex 1), right panel (260) with next passenger, route map, controls, passenger cards. On mobile stack.

Alternatively full-screen centered board with side panels. Need adapt to 375 width. Board aspect ratio maybe 22x15 cells. On mobile cell ~16px => train small but okay. Could reduce board dims on mobile? Use logical grid constant 21x13 but canvas scales; board 375 wide gives ~17px cells. Wagon draw size around 12px, locomotive 16. Passengers tiny. Maybe better mobile use 15x11 grid? But changing grid midgame risky. Could set columns 19 rows 13 and scale canvas; mobile 350/19=18.4. Fine.

Need self-playing AI: game starts in demo mode automatically? Instruction: "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." Interpretation: demo running behind overlay message, AI playing. So overlay translucent/banner "Click or press a key to start" and AI moves train; maybe overlay centered not blocking visual too much. Clicking starts interactive game? Could clicking hides message and takes control. Or game runs AI always until click, then player control. Message persists (maybe small pill at top center of board) so screenshot sees it and action. We'll implement demo mode: train auto moves with simple AI targeting passengers while avoiding collisions; overlay "Click or press a key to start" as a small centered banner with pointer icon that says "Demo running — click or press a key to take the wheel". It doesn't block the view? Better a floating pill at top of board, semi-transparent dark with pulsing. But they asked "behind a 'Click or press a key to start' message" exact phrase. Use overlay over board: full board overlay semi-transparent blur? That would hide visual. Could have a large center card semi-transparent with action; AI visible around. Better: centered compact banner near top: "🚂 Click or press a key to start" plus small "Auto-driving demo in the meantime". Use emoji? Emojis render but can look inconsistent; use SVG icons.

We need implement gameplay robustly:
- grid 20 cols x 13 rows (or 22x14).
- Cell size based canvas dimensions.
- Track network: Draw horizontal/vertical tracks with ties across board. Perhaps full grid lines look like a maze of tracks. Instead draw continuous rail paths crossing, with light gravel bed. Stations/platforms along left and right edges, and maybe top bottom. Passengers wait in platform cells (col 0 or col C-1, or certain row). But AI needs reach them by moving. Need snake-style grid movement. Board can look like a rail yard with tracks every cell. Maybe drawn: cream/green background with subtle grass texture, rails: horizontal lines at each row center, vertical lines at each col center, with sleepers. That is a dense lattice, visually appealing and legible. Platforms: left/right vertical platform strips (stations) at cells c=1 and c=C-2? Passengers on these cells. The train can pick up when its head cell has passenger. We can show station platforms as wide rectangles at left and right board edges, with benches, lamps, signs "CENTRAL", "OLD TOWN". But if grid lines extend into platform cells, passenger stands over track. Maybe passengers at platform cells adjacent to track (col 0? We can treat cells in col 0 and col C-1 as platform cells, train can move into them? Track lines end before platform? Hmm).

Alternative define board grid with 3 platform rows (top row and bottom row) labeled, passengers in those cells; tracks horizontal there too. Train enters platform cell along track and picks. Render platform tiles as concrete cells with yellow edge and passengers standing there, train overlaps. It's a playful abstraction; fine.

Let's do tracks only on interior grid, plus platforms on row 0 and row R-1? Passengers appear along both top/bottom platform rows. AI targets them. Snake can move anywhere in grid but each move follows rails drawn lattice.

Drawing:
- Canvas background: rounded rect board "STAFFORDSHIRE RAILWAY / BOARD 07" etc.
- Use canvas with devicePixelRatio.
- Grid area dimensions computed; draw grass/parkland background deep teal? Palette: parchment UI, emerald board. Let's define:
  body background #f4ecdc warm.
  header ink #18342f.
  board frame dark green #143a33, inner background #d9e6c8? Railway map style with sage green.
  rails #6f756d, ties #8c765a.
  platforms #e6d5b4 with mustard edge.
  locomotive red-orange #e4572e, wagons turquoise #2a9d8f, yellow #f4b942, purple #8e6bb1.
  passenger small figures with varied clothing.
- Add scenery trees, houses, clouds maybe background decorations. Need avoid clutter.

Maybe use canvas for board and decorative scenery, DOM for panels.

Game:
State:
const COLS=20, ROWS=13; snake array of {x,y,dir}? Head first. Body segments (wagons) colored sequence.
Start snake length maybe 4: engine at (10,6), wagons behind at (9,6),(8,6),(7,6). Directions right.
Passengers: create list. Ensure one near path initially and others.
Every tick (e.g., 145ms demo, 125ms player) move:
- compute next direction:
  - Player: input queue, prevent reversing.
  - AI: choose candidate directions (not reverse) that are in bounds and don't collide with snake body, score Manhattan distance to nearest passenger plus avoid dead ends, maybe choose route. Since track lattice, simple greedy can trap itself. Need robust enough self-play for screenshot. AI can plan BFS to target a passenger avoiding snake body. But the snake tail moves; BFS on grid excluding snake body gives path. We can implement simple BFS from head to target avoiding occupied cells (except tail cell maybe) and choose first step. Then fallback avoid collision. AI needs collect passengers. BFS each tick for 20x13 fine.
  - Targets include passenger positions. Pick nearest by BFS distance, maybe.
- Move head; check self/wall collision. If collision -> game over. In demo maybe reset automatically after brief pause to keep action. Player game over overlay with "Press Enter / click to restart". Could auto-restart demo? For screenshot first 3 sec unlikely collision but AI robust.
- When head reaches passenger cell, collect: passengers removed, score += 100 + combo, train adds new wagon? Snake grows by 1. Floating text "All aboard! +100", particles/confetti. Maybe passenger count.
- Passengers respawn after some seconds at platform positions, max 5. New passenger appears with animated marker.
- Need passengers waiting platform. Spawn on platform cells perhaps random from top/bottom rows plus side columns. But if the engine's head needs to reach them, fine. Ensure platform positions available not occupied by snake. Draw platform cells as concrete strip with yellow safety line and station sign at ends.
- Draw train:
  Locomotive oriented by dir. At cell center. Draw shadow. Body as rounded rect length ~0.78 cell, width ~0.5? Since moving in grid, need oriented. Draw using ctx rotate based direction (right=0, down=pi/2, left=pi, up=-pi/2). Locomotive front at +x. Draw boiler cylinder red, cab teal? Let's design miniature top-down-ish side? Classic snake segments but train shape oriented direction. Top-down cute:
    locomotive: main boiler rounded rect red-orange, front round face at front x=0.16c; cab at back x=-0.18c dark teal with roof; chimney black at x=0.05c, y=-0.16c; wheels on sides; headlamp yellow; steam puffs behind. Maybe use ellipse and rounded rect.
  Wagons: each segment cell draw rounded carriage ~0.72c x 0.48c, colored, with roof/darker stripe, windows, coupling. Orientation same.
  Make snake look continuous: coupler line between segments.
  Engine on head, wagons behind, colorful.
  Add bobbing/steam animation using time.
- The train path moves in cardinal directions, turns sharply; wagons follow. Great.
- Passengers:
  Draw waiting people at platform cell: circle head, body coat colored, suitcase, speech bubble with count maybe. Animate wave. Use cell center but stand a bit offset.
  Platform signs "NORTHBOUND", "SOUTHBOUND" near top/bottom.
- Station platforms: top row and bottom row and maybe left/right vertical. To avoid platforms huge, choose row 1 and row R-2? Let ROWS=13, platform rows 1 and 11. Draw those cells as tan tiles with dashed yellow safety line below top row / above bottom. Passengers stand in platform cells at rows 1 and 11. The lattice rails still exist but can underlay. Hmm platform cell train overlapping passenger: train rides on the track at same cell. We'll draw track behind and platform offset? Could model each cell on top/bottom rows as platform but train goes onto platform. That's okay stylized.
  Or use side columns 0 and C-1 as platforms and draw track stopping at cells. Let's use first/last columns plus top/bottom rows as station edges. But then BFS routes to corners. Platform areas are perimeter. Visual nice. Passengers spawn on perimeter cells. Draw platform tiles along perimeter (concrete), rails interior. Train can enter perimeter cell.
  Maybe platform strips inside along columns 1 and C-2? Not.
  Let's make "islands" platforms: top row and bottom row tan; plus left and right columns tan, like a frame. Label four platform areas. Draw fence along edges. Passengers spawn at perimeter. Plenty.
- Scenery on some interior cells: trees, bushes, pond? But snake path is all grid, collision with scenery would be weird. Place decorations only in margins/outside track grid, e.g., corners and between. Could draw houses along top header of canvas.
- Rails: For all interior cells, draw horizontal track at row center from x=0 to W, vertical at col center. That makes dense 20 vertical x 13 horizontal cross grid. Visually like rail yard. Ties every 10 px. Tracks crossing. We can alternate rail colors. Then platform perimeter tan covering rail ends.
- Add route grid dashed? We want visible snake path. Train on rails.

UI layout:
Header:
left logo badge (train icon SVG) + "RAILWAY SNAKE" title, subtitle "The Little Express • Passenger Service"
right stats cards: SCORE, PASSENGERS, BEST, maybe station indicator "NEXT STOP • CENTRAL".
Main:
stage-wrap with board canvas (aspect 20/13 maybe 1.538). Side rail 280:
  card "CONTROL PANEL" with segmented toggle? We can have "DEMO MODE" badge and route info.
  "Next stop" passenger avatar card: name, destination, fare.
  "Service log" list recent pickups.
  "How to play" arrows keys, goal.
Maybe bottom "network" mini route map? Better right panel includes live "Line map" mini canvas/svg showing path and passenger dots? Could use DOM SVG update. Add charming timetable.
Need perhaps include sound toggle? No actual audio necessary. Could implement WebAudio subtle chuff on user interaction, but no need. Maybe no sound UI unless functional.

At 1280x800: header height 90, main 640. Board width about 900 (if side 300, gaps 20, padding 28): 900x585 fits. Right panel 300x585. Great. Canvas dynamic fills container maintaining aspect ratio 20/13 =1.538; if width 900 height 585 exactly. At 1280: available 1224, side 286, gap 18, board 920x598. Main area with padding maybe 24; header 94 + main 600 + bottom? 800. Need height fit. Use body min-height 100vh, header padding 18, main flex, board frame. Could canvas aspect-ratio:20/13. If board width 900 =>585, plus header 90, margins 40 =>715, fits. Good.

At 375: header stack maybe stats row 3 cards; board width 343 =>223 height; side panel below, page scroll vertical allowed (no horizontal). Fine. Game still visible at top. Overlaid "click to start" banner should be clear on board. At 375 board height 223, banner can fit.

Self-playing mode: message visible e.g. fixed overlay in canvas? Canvas text could scale poorly but we can DOM overlay in .board-overlay with .start-hint. It sits at top center, pointer-events:none? We need click anywhere to start; attach click on document/board; keyboard focus. Hint text exact: "Click or press a key to start" with subtext "The little express is driving itself for now." A small pill.
On first interaction (click/key), switch to player mode, hide hint, show toast "You're the conductor! Use arrow keys." Could still allow click to start and steering via swipe/buttons. On mobile add D-pad controls below board. Since instruction says keyboard events only after click; click focus and we listen document keydown. Need display clear message. Also D-pad buttons for touch always available.

Keyboard: Arrow keys/WASD. Space maybe pause. Enter restart. In demo, any key switches to player and applies direction. On keydown prevent default arrows.

Game loop:
Use requestAnimationFrame, accumulate time; tick every ~150ms. Demo initial maybe 165, player 140. Screenshot at 3s -> 20 moves, AI collects maybe passenger. Good.
Initialize passengers so one in path nearby. Start head (10,6) direction right. Passenger at (13,6) maybe? But platform passenger should be on platform. Maybe passenger at (19,6) right platform, AI BFS heads there; 9 ticks. Another at (4,1), etc. Initial path right to right edge collect after 9 ticks (1.4s), visible score. Good. But the first passenger at right perimeter. At 3 sec, likely several pickups.
AI BFS:
function aiDirection(head, snake, passengers):
 target nearest reachable passenger using BFS from head; blocked set snake positions indices 0..len-1 (include head start but allow start). We need find path avoiding body. Exclude all body except tail maybe. BFS start head; neighbors cannot be body except tail. Find first step of shortest path to any passenger. If no path, choose neighbor maximizing free space and Manhattan to nearest passenger.
Potential issue passenger target cell occupied by snake body considered blocked, but likely not.
For safety, do lookahead 2-3 steps to avoid dead ends? BFS path naturally picks route but the body shifts. We can score next moves by BFS path length.
Maybe use BFS distance map to all cells and choose direction that maximizes distance to nearest passenger minus penalties. The body movement shifts, so okay.
Implement BFS from target to head? Simpler BFS from head with blocked body; reconstruct.
Function bfsPath(start, targets, blocked):
 queue; came; find target; returns path array excluding start.
Then ai picks path[0]. But moving into current tail allowed; body collision check later uses tail will move unless eating? We move: tail popped unless grow. If head next equals tail current, after pop it's okay, but collision check typical compares body before pop excluding tail. Use check against snake.slice(0, len - (growing?1:0)).
BFS blocked excludes tail if not growing.
If no target path, choose safe direction based free cells.

Passengers:
array with id,x,y,name,dest,color,wait,phase.
Names: Ada, Milo, Clara, Otis, Nell, Hugo, Bea, Fin.
Destinations: Fernhill, Willow Park, Copper Bridge, Starlight, Marigold.
Each pickup log entry.
Spawn passengers:
platformCells: cells where x===0||x===C-1||y===0||y===R-1; avoid occupied and edge? We can pick random. Ensure max 5. Spawn with delay after pickup 0.7 sec, initial 4. Passenger marker on canvas: small flag/pin, waving person. Score.
Train length initial maybe 4; on pickup grow by 1 up to maybe 12 then maybe keep? Snake grows indefinitely until collision. Color wagons cyclical.

Score and stats in DOM update.
Combo: consecutive pickups within 6 seconds? Keep simple multiplier based on current passengers count? "Perfect timing +50" if train passes platform? Could add star if pickup within 1.5 sec of last.
Score: 100 + min(passengers.length*10). Best persistent? localStorage banned, so best in memory only. Fine.
Game over:
state.over true; overlay centered inside board with "Service suspended" + score + button "Run another service". Since no alert. Clicking button restarts player. Demo auto resets after 2s perhaps.
But demo should not accidentally over quickly. AI BFS robust. We can on demo collision call resetDemo after 1.2 sec with toast.
Maybe don't have full overlay initial because need board visible. start-hint only.

Could add particles: steam puffs behind locomotive continuously, confetti at pickup, ticket paper flying. Use canvas. Implement particles array with x,y,vx,vy,life,type. For steam use gray circles emitted behind. At 3 sec visible.

Canvas drawing detailed:
We'll set canvas CSS width 100%, aspect-ratio. Use ResizeObserver and DPR.
Coordinate: canvas CSS size cw,ch; draw in CSS px by ctx.setTransform(dpr,...). cell = cw/COLS. Grid origin maybe padding? We can make board canvas with internal margin 18? Use board frame. Canvas itself inside board border. Define pad=16, gridW=cw-2*pad, gridH=ch-2*pad, cell=min(gridW/COLS, gridH/ROWS) but aspect ratio board equals grid ratio to avoid unused. Set .board-canvas aspect-ratio: 20/13 and grid dimensions = cw etc; draw grid from 0 to width, cell=cw/COLS. Draw margin? Rail lines from 0..width. Better entire canvas landscape. Board frame adds border and rounded corners via CSS overflow hidden.
Set canvas width = cw*dpr height=ch*dpr. ctx scale dpr.
Rows=13 => if 900 width, cell45; height585 => row45. Good. Mobile cw343 cell17.15, ch223. row17.15.
Draw:
1. background base: linear gradient greenish.
2. subtle grass dots using deterministic random pattern (seed) outside tracks maybe.
3. platform perimeter: fill top/bottom rows and left/right columns with tan tiles. For top row y in [0,cell], bottom row etc. Use rounded rect? Fill rectangles. Add yellow dashed safety line.
4. track lattice: for each row line center, horizontal from x=cell/2 to width-cell/2 maybe; each col vertical from cell/2 to height-cell/2. Draw ties (short perpendicular lines every 13px) in brown, then rails two parallel lines offset ±4 (for horizontal) and ±4 vertical, steel color. But crossing tracks will be messy. At every row center, two horizontal lines separated by 7px. At every col center, two vertical. At intersections, lots. Could use one pair of rails per line. Traditional railroad visual nice.
Alternative route network only on cells snake could use, so all. The train draws on one line of pair? If cell center is between two rails, train sits above. Fine.
Ties every 16px length 14 crossing.
Track shadow/bed: draw wide line rgba.
5. Station labels in perimeter: small sign "NORTH PLATFORM" at top-left and "SOUTH PLATFORM" bottom-right, rotated maybe. Canvas text 9px. At mobile too small but okay.
6. Decorations: houses in platform strips? Trees at corners over perimeter could conflict passengers. Maybe draw little station building in header area of board? Canvas edges. Could reserve top-left cell for station building? But passenger may spawn there. Let's create decorations inside platform area cells as fixed props and don't spawn there. For example top row cells 3-5 station hut, left col row4 kiosk; bottom row cells 14-16 station hut. Draw simple building. Add lamps along platform.
7. Passengers.
8. Train (wagons first then head).
9. particles.
10. overlay? DOM.

Need passengers visually clear: at cell, person circle radius cell*0.13, body rounded rect cell*0.24 x cell*0.26, suitcase small. At 45px cell, person ~18px. Add circular colored halo / dotted ring and little speech bubble "BUS" maybe. Use names? label in small pill above head maybe first letter or station code. To show waiting, draw a speech bubble with tiny "!" or destination icon. Maybe ring with pulsating dashed circle and flag.
At cell 17px mobile, passenger ~7px; still colorful dots.

Train drawing detailed:
At head cell (x+0.5)*cell etc. Use ctx.save translate rotate.
Shadow ellipse along.
Wagon:
- for each wagon index body[1..]:
  rect at center oriented: length 0.72c, width 0.46c, radius 0.1c.
  fill wagonColor; darker bottom; roof panel maybe pale.
  windows two cream squares along x? If moving horizontal, train long axis x; 3 windows maybe.
  wheels black circles at x ±0.22c, y ±0.24c.
  coupling dark line at back.
  highlight.
Locomotive:
  wheels at x ±0.18c, y ±0.23c.
  boiler: rounded rect from -0.12c to 0.32c width 0.44c, height 0.38c, red.
  front face circle at x0.30c radius .19c maybe darker.
  cab: rounded rect x=-0.34c..-0.06c, width .28c height .42c teal? Color #234e4a, roof dark, window cream.
  chimney at x=0.10c,y=-0.16c (perp), black small rect.
  headlamp yellow circle x=.42c.
  steam emitted from behind.
  draw direction orientation right. If direction down, rotate +90deg; if left pi; up -90.
- Wagon coupling may be hidden due segment spacing. Fine.
Colors sequence: coral #ef6f5e, teal #3caea3, mustard #f2c14e, violet #8f7ac5, sky #5aa9e6. Each new wagon color from sequence.

To make it feel "little train" maybe draw a face on locomotive front? A friendly face on boiler front: two cream eyes? Or headlamp and dark face. Could add eyes on cab front? Top-down. Add small eyes on front face.

Game status panel:
- Score (animated count)
- Stops/collected
- Best
- Speed level? Level increases every 5 passengers. "Line speed 1".
- Route strip at side: dot train position and passengers? Could use mini map canvas? To avoid complexity, SVG line map with 5 stops horizontal and active? We can update.
- Next passenger card: portrait (CSS generated circle with emoji? Avoid emoji, use SVG/inline). We can build DOM avatar using inline SVG with simple face. But inline icons in HTML easier. Use simple CSS circle with initials and colored background.
- Service log with three rows showing last pickup; default rows: "05:42 • Ada → Fernhill".
- Controls card.
- D-pad mobile.

Styling:
Use CSS variables. Font stack "Trebuchet MS", "Avenir Next", system-ui. For title use Georgia? Retro serif: Georgia, 'Times New Roman' for title, letter spacing. Warm paper.
Background: radial gradients, subtle stripes; no external fonts. Header logo in dark green square with inline SVG train icon.
Cards cream with border #d8cbb2, shadow.
Board frame: dark #173d36 border, inner shadow; top maybe status bar like ticket:
 .board-toolbar above canvas? The board frame could include a dark top strip with labels "TRACK 07 / LIVE SERVICE" and small lights. But then board smaller. Nice. We'll create .board-frame with .board-bar (height 36) and canvas. Board header shows signal dots and "CENTRAL YARD • LIVE". At mobile, compact.
Main grid .layout {display:grid; grid-template-columns:minmax(0,1fr) 296px; gap:20px; max-width:1400; margin:auto}
Board aspect ratio ratio 20/13 + bar. If board area width 920, canvas height598. Total 634. Header 86 + margins = 748. Fits 800.
At 1280 width with padding 28 =>1224; side 296 gap18 =>910 board canvas 910/1.538=592 plus bar 40 =>632. header maybe 86 + main 632 + bottom 24 =>742. good.
At 800 height, okay. body overflow-y auto not hidden. No horizontal.
At 900 width: side moves below or hidden? media 960: grid 1fr; sidebar grid 2 cols. board full width 850 height553+; page taller but okay.
At 600: header stack; stats 3 across; board; controls.
At 375: canvas 343 width; board bar labels small; sidebar one col; dpad visible.
Need screenshot at 1280x800 initial: Board + sidebar visible. Great.

"Everything interesting visible within first seconds" initial demo action. Passengers initial visible. score maybe pickup at 1.4 sec. Great.

Could include animated small route indicator and "Next stop" panel updates.

Implementation details and code structure:

HTML:
<!doctype html>
<html lang=en>
<head> meta viewport
<style>...</style>
</head>
<body>
<div class="app">
<header class="topbar">...</header>
<main class="layout">
<section class="board-column">
  <div class="board-frame" id="boardFrame">
    <div class="board-bar">...</div>
    <div class="canvas-wrap">
      <canvas id="game" aria-label=...></canvas>
      <div class="start-hint" id="startHint">
        <span class="hint-icon"><svg ...></svg></span>
        <span><strong>Click or press a key to start</strong><small>Auto-driving demo is running — take the wheel!</small></span>
      </div>
      <div class="game-over" id="gameOver" hidden>...</div>
      <div class="toast" id="toast"></div>
    </div>
  </div>
  <div class="touch-controls" ...> four buttons </div>
  <div class="legend-strip maybe">
</section>
<aside class=sidebar>
 card control panel: mode badge, score metrics, speed.
 card next passenger
 card service log
 card controls
</aside>
</main>
<footer ... maybe tiny.
</div>
<script>...</script>
</body>
</html>

Maybe duplicate score in header and sidebar? Header stats score/passengers/best. Sidebar has controls + next stop + log. Avoid redundancy.

Header:
logo, title + subtitle
.stats: stat score, picked up, best.
At mobile.

Sidebar:
1 "Passenger Board" next passenger card with avatar, name/dest, fare, wait time.
2 "Today's Route" vertical stop list:
 CENTRAL YARD (live)
 FERNHILL
 WILLOW PARK
 COPPER BRIDGE
 maybe based on passenger dest update active stop? Simpler line map with dots and train icon moving relative.
3 "Conductor's Notes" log.
4 "How to ride" controls.
Could fit 632 height: cards heights 150 + 180 + 130 + 100 =560 + gaps 42 =602. Good.
Maybe combine next passenger and route.

Use CSS to update bars.

JS:
- constants.
- DOM refs.
- state object: snake, dir, pendingDirs, passengers, score, best, collected, level, demo=true, started=false, over=false, tickMs, lastTime, acc, gameTime, nextId, particles, logs, pickupFlash, toastTimer.
- initGame({demo:true}) resets positions, passengers.
- passenger data with palette.
- pick platform cells excluding station hut props and snake occupancy.
Station hut prop cells list [[2,0],[3,0],[4,0],[16,R-1],[17,R-1],[0,5],[0,6],[C-1,6],[C-1,7]] perhaps.
Platform cells perimeter excluding props.
Initial passengers:
  {x:C-1,y:6,name:'Ada',dest:'Fernhill',color...}
  {x:3,y:1?}
Wait row1 isn't platform if only perimeter. Let's define platform rows y=0 and y=R-1 and columns x=0,x=C-1. Initial at (C-1,4), (4,R-1), (0,8), (13,0), plus (C-1,9). Starting head at (10,6) moving right; target right at row6 or row4.
Set first at (C-1,6), direct path. Another (16,R-1), etc. Passengers wait in platform cells.
Need train turns and collects. BFS.

Spawn function:
const candidates = []; for x 0..C-1 y 0..R-1 if perimeter and not props and not snake and not passenger.
Select random.
Could crowd. max 5.
When pickup, remove; set respawns.push({time:state.time+1.0}); Each update, if passengers<5 and time>=nextSpawn and cooldown then spawn. Ensure first right passenger.

Passenger waiting time increases; if >30 maybe leaves and respawn elsewhere? For demo, passengers may wander? Keep static. To give urgency, after 25s they change destination maybe.
UI "wait" timer.
Passenger card shows next passenger: choose one whose BFS distance maybe. We can just choose first. Display count.

Movement:
function step() {
 if (state.over) return;
 let dir = state.demo ? aiDirection() : nextPlayerDirection();
 if (!dir) return;
 const head = state.snake[0];
 const nx=head.x+dir.x...
 const grow = passengerAt(nx,ny)
 const bodyToCheck = state.snake.slice(0, state.snake.length - (grow?0:1)); // if grow tail stays, else tail frees
 if out or collides => endGame
 else update.
 state.dir=dir
 if passengerAt -> pickup; else snake.pop()
 snake.unshift(newHead)
 etc.
}
Collision check: If grow, check all body including tail. If not, check all except last. We'll check `state.snake.some((seg,i)=> i < state.snake.length - (willGrow?0:1) && seg.x==nx...)`.
In demo, if collision, maybe no game over screen? reset after 1 sec. But AI should avoid. We can implement demo end -> show toast "Demo route reset" and resetGame(true) after 900ms. To avoid state loop. Player -> over overlay.
During game over, animation still particles, steam? no.
Player direction:
pending array; key handling pushes direction if not reverse relative to last queued/current. When key pressed first time, switchToPlayer and set direction. If state.demo and key arrow, switch and queue. If key unrelated (Space), start player but no movement? Fine.
Click anywhere: document pointerdown? But clicking D-pad should also start then direction. Avoid buttons causing duplicate. Attach canvas click and document? We can attach `document.addEventListener('pointerdown', e=>{ if (!state.started && !e.target.closest('button')) takeControl();})`. The overlay has pointer-events none. If clicking a button D-pad, handle separately.
"click it" may click board. We'll listen on board canvas and header? document okay. On first click switch. Maybe clicking sidebar links would too, fine.
Keyboard: window.addEventListener('keydown', e => { if ['Arrow...',' ']. includes preventDefault; if e.key==='Enter' and over restart; else takeControl(); queue direction }). The sandbox iframe only gets keyboard after user clicks; message says.
Take control: demo=false; started=true; hide startHint; show toast; mode badge "MANUAL"; direction stays.
Maybe if demo was in motion, manual direction queues relative to current.
D-pad: buttons data-dir; pointerdown prevent default, takeControl, queue; add active class.

Pause:
Space toggles pause? But key also starts. UI maybe pause button. Could include board bar button with pause icon; user asked game, not necessary but nice. A small "pause" button functional. Need don't overdo.
Board bar: left status "LIVE • LINE 7"; right board-lights and button id pauseBtn. If demo, label "DEMO". Use button with inline SVG.

AI:
function aiDirection():
 const h = snake[0];
 const blocked = new Set(snake.slice(0,-1).map key) // tail free.
 const targets = passengers positions.
 BFS from h to any target with blocked. If path found return first dir.
 Need avoid reverse.
 BFS implementation returns array of cells.
 Since start head in blocked? don't include head.
 For each neighbor within and not blocked. If neighbor is target record.
 Reconstruct: came Map next->prev? From start queue. when target found, path = [target], while != start path.unshift(prev). return path.slice(1).
 If target at head? not.
 Fallback evaluate dirs not reverse, inbounds, not body. Score = -manhattan + freeSpace. Use reachable flood count.
 Could choose direction toward center.
 Also ensure not edge trap? Fine.

But BFS path shortest may lead into dead-end corridor because snake body is blocking but static body changes. Since body shifts, path remains generally.
We should account path through tail that moves: allowed.

Speed: tick interval = max(95, 165 - level*5). Demo 160, player 145. At 3s 18 ticks.

Particles:
emitSteam each frame/time. Engine pos world. Determine direction. We need convert to canvas coordinates. `worldPos(seg)`.
emit at head center minus dir*(cell*0.3) plus perpendicular offset; steam puffs grow.
Add random.
Confetti on pickup around passenger cell.

Draw function:
- clear, background.
- Draw decorative grass deterministic: use pseudo-random precomputed array based on 400 points. For each, fill small circle; avoid? fine.
- Draw perimeter platform.
 Need grid cell edges. Draw rects for y=0 row etc:
 for y 0..R-1 for x 0..C-1 if perimeter fill platform color; but many rect edges. Better fill top/bottom strips and side strips.
 topRect [0,0,W,cell], bottom [0,H-cell,W,cell], left [0,cell,cell,H-2cell], right...
 Fill #dfd0ae; add tile lines every cell? Draw horizontal line along row.
 Safety line dashed at y=cell-3 for top, y=H-cell+3 for bottom; vertical.
- Draw tracks interior over platforms? Tracks should extend to platforms, so draw tracks after platforms. Track center lines from cell/2 to W-cell/2 etc, including perimeter. Platform fill then tracks overlay.
Actually platform strip is the area within cell; track center line at y=0.5cell for top, so train in middle. Yellow safety line at near inner edge (y=cell-2). Train overlaps platform. okay.
- Track bed: for each row draw line at y+cell/2 from x+cell/2 to W-cell/2 with stroke rgba(84,65,46,.18) width cell*0.52 maybe broad. Then ties every cell*0.3: draw perpendicular short line width cell*0.46 in #7b6650. Then rails two lines ± cell*0.09 with width 1.6 color #56605b and highlight .6.
 But horizontal bed stripes across all cells creates continuous wide brown band; verticals cross. Looks like rail yard. Maybe good but dense. To improve, background is grass, tracks draw as simple lines with ties, no bed except subtle.
 For each row center: ties every 16 px across from x=cell/2 to W-cell/2, each length 9 perpendicular. Then rails lines. For vertical similarly.
 At intersections rails cross.
- Draw station buildings/props after tracks (on platform). Fixed props list. Draw simple little house with roof, windows, sign.
- Draw trees outside? platform props.
- Draw route indicators? Maybe small decorative bushes at nonperimeter corners but could conflict track. Avoid.
- Platform signs: top label in sign above cell maybe; draw at x=cell*1.4 y=cell*0.55 "CENTRAL" dark. But cells occupied by building. bottom label "WILLOW PARK" at right.
- Draw passengers after props, but avoid props positions.
- Passenger draw: use ctx:
 cx=(x+.5)*cell; cy=(y+.5)*cell; maybe offset y - cell*0.08.
 shadow ellipse.
 waiting ring dashed circle radius .30cell, stroke rgba color.
 body rounded rect width .24c height .25c at cy-0.04c; head circle radius .12c at cy-0.22c; hair; arms wave depending time.
 small luggage rect.
 label bubble above if cell>30? Draw rounded pill with destination initial? Could display "NEXT" maybe. Perhaps draw station code letter.
 For screenshot, visible dots and names might not be legible. A pin with number.
 Use color from palette, coat.
- Draw train:
 Need draw tail-to-head. For each segment reversed:
   drawCouplings maybe line between centers.
   drawWagon.
 Engine after.
 Ensure orientation each segment maybe current direction for all or body direction; all same? Snake segments follow corners; wagons can orient based segment differences. Use orientation from segment to previous? The chain order head first. For wagon at index i, direction from segment i to segment i+1? We can orient along vector from next (behind) to this (front). drawWagon rotate angle of (this - next) if exists else state.dir. Engine rotate state.dir.
 Coupling line between segment centers.
 Colors.
- Draw front engine details.
- Add motion: slight bounce? use Math.sin(time*8)*0.6 px.
- Steam particles drawn maybe before train so behind.
- Draw particles after? Confetti should overlay, steam behind. Draw steam first then train then confetti? particle type.

Canvas background includes a subtle vignette.

Board bar outside canvas.

UI update each animation or when changed. Score count tween:
 displayScore += (score-displayScore)*0.15; format with padStart maybe.
Passenger count.
Best.
Next card: avatar initials and color, name, destination, wait.
Log update only on pickup.
Status "AUTO PILOT" / "YOU'RE DRIVING".
Route map: list of 4 stops; active index based on level? We could display a horizontal line with 4 dots and a train marker translating based on score. Use DOM .route-line and .route-train absolute left percent based on collected%4? More meaningful maybe show "Next destination" from passenger card. The right panel's service board can have "UP NEXT" with card.
Maybe "Route" card shows vertical route:
  1 CENTRAL YARD [current]
  2 FERNHILL
  3 WILLOW PARK
  4 COPPER BRIDGE
Highlight based on collected %4. Train icon in CSS at active line.
Passengers have destinations matching these. The train moves physically, but route progress via pickups.
Service log initial entries maybe fake? Could show "Service 07 • All signals green", "Demo dispatch is handling the line", no fake. Better log entries appear on pickups. Before first, show "The demo crew is ready at Central Yard."

Side cards:
- "NEXT BOARDING" with avatar, name/destination, "Waiting 03s" and fare "+100". update.
- "LINE 7 TIMETABLE" route dots.
- "CONDUCTOR'S LOG" list.
- "HOW TO PLAY" controls.

Potential need score panel. Header stats fine.
At mobile sidebar below board. D-pad perhaps appears under board, always visible on touch or <=600. Could show compact controls also on desktop? Sidebar has keyboard; D-pad hidden desktop to reduce clutter. But user may click on mobile. media (pointer:coarse) show. Also media max-width 700 show.

Interaction hint centered top of canvas might overlap. Position absolute top:14px; left:50%; transform. Background rgba(17,50,45,.92), color cream, border gold, shadow. At 1280 board width ~900, visible. On mobile board height 223; hint height 60, okay.
Maybe also a tiny corner label "DEMO • AI DRIVING" in board bar.

Game Over overlay hidden attribute; CSS `[hidden]{display:none!important}`. Center card.
Pause overlay maybe board bar button; if paused show toast and dim? Could draw. We can keep.
Toast at bottom center of canvas.

Accessibility: buttons labels.

No external files. Inline SVG allowed. No network.
No console errors.

Need ensure hidden attribute override.

Let's plan code carefully.

HTML SVG icons:
logo: simple train front in 32x32 with rounded rect body, wheels, chimney.
Stats icons maybe no.

CSS detailed:
:root {
 --paper:#f6efdf; --paper2:#fffaf0; --ink:#193c36; --muted:#6e786e; --line:#d9cbb1; --green:#174a40; --green2:#256f5f; --gold:#f3b544; --red:#e35d3f; --blue:#58a6c9;
}
* box-sizing
body margin0 font; background:
 radial-gradient(circle at 12% 8%, rgba(255,255,255,.85), transparent 35%), repeating-linear-gradient(... maybe subtle), #efe4cd.
color.
.app max-width:1440; margin auto; padding:18px 24px 22px;
topbar display flex align center justify space-between; border-bottom 1px solid; padding-bottom:14; margin-bottom:16.
.brand flex
.mark 52x52 dark green border radius 16 rotate? shadow.
.brand h1 font-family Georgia; font-size clamp(24,2.4vw,34); margin.
.eyebrow uppercase.
.stats flex; cards min-width 98; background cream; border; border-radius14; box-shadow; .value.
layout.
board-column min-width0.
.board-frame background #143b34; border 3px solid #0e2d28; border-radius 20; overflow hidden; box-shadow 0 14px 30px rgba...
.board-bar height 38 display flex padding 0 14; color #d8e8dc; font-size11; letter spacing. signal.
.canvas-wrap position relative; background.
canvas display:block;width:100%;aspect-ratio:20/13;
Maybe `height:auto` with width and aspect ratio. canvas default 300x150; CSS width:100%; height:auto; aspect-ratio:20/13; but intrinsic ratio may override? CSS aspect-ratio works when one dimension auto; height computed by width / ratio. Set `width:100%; aspect-ratio:20/13; height:auto;` However intrinsic canvas width/height set by JS to pixel dims matching aspect, so fine. Resize function set style? We'll compute wrapper clientWidth and height = width*ROWS/COLS. Set canvas.style.height = `${h}px`; canvas.style.width=`${w}px`; CSS width 100%. On resize, set.
.board-frame maybe aspect not strict.
Start hint.
Sidebar cards.

At 1280x800, compute app padding x24 =>1232. layout side 300 gap18 => board 914. canvas h=594. bar40 + borders =>640. topbar maybe 72 + margin16 =>88. app top18 => total 18+72+16+640+22=768. great.
Header stats card height 60. Brand mark52. topbar height maybe70.
At 1440 etc.

At height 800, sidebar content may exceed 640 and page scroll slightly, okay. We'll compact:
card padding 16; next boarding ~140, route ~180, log ~150, controls ~110, gaps12 = 640+36=676. Too tall. We can do 3 cards: Next boarding 150, timetable 190, log 160, controls included in footer strip under board. Or side panel 300: next 145, route 180, log 160 = 485 + gaps24 =509. fine. Controls as small row inside board column below canvas (keyboard hint). This fits.
Add `.control-note` below board, not on mobile maybe.

Canvas drawing function specifics.

Resize:
let cw=0,ch=0,cell=0;
function resize(){
 const rect=canvas.getBoundingClientRect();
 cw=rect.width; ch=rect.height;
 const dpr=Math.min(window.devicePixelRatio||1,2);
 canvas.width=Math.round(cw*dpr); canvas.height=Math.round(ch*dpr);
 ctx.setTransform(dpr,0,0,dpr,0,0);
 cell=cw/COLS; // ch should = cw*ROWS/COLS
}
new ResizeObserver(resize).observe(canvas.parentElement?) If observe canvas, setting intrinsic may trigger? CSS width fixed, height style set. Observe wrap.
Use `canvasWrap.clientWidth`; desired h=w*ROWS/COLS. set canvas.style.height. Then canvas.width...
Need not re-observe infinite. Observe boardFrame maybe. `new ResizeObserver(resize).observe(canvasWrap)`.
On resize, style height changes canvasWrap height? canvasWrap contains canvas; if observed, canvas height affects wrap -> could loop but same value. Fine. Better observe boardFrame width only; callback if width changed. But height also. Use window resize listener plus ResizeObserver on boardFrame, compute width boardFrame.clientWidth - borders? Canvas wrap width = boardFrame content width. Observe canvasWrap; its width stable independent height. Should be fine.

draw:
ctx.clearRect.
drawBackground(t):
 fill gradient cached? create each frame maybe fine. Canvas 900x600.
 save.
 Fill #cfe0c5? Maybe board should look like illustrated railway map: grass #d6e3c4 with paper texture. Add grid patches.
 Decorative small bushes: deterministic positions only on perimeter? Use pseudo random precomputed; 120 tiny grass strokes at random positions. Might overlay tracks; draw before tracks.
 add subtle contour lines.

Platform:
 top/bottom/left/right fill #e8d7b3. Edge shadow.
 tile grid lines.
 platform label small.

Tracks:
 const center row y=(r+.5)*cell.
 Ties: spacing=Math.max(8,cell*.28). For horizontal from x=cell*.5 to W-cell*.5, x+=spacing, draw line x,y-4 to x,y+4 with stroke rgba(92,72,51,.45), width max(1,cell*.06). Then rails at y±cell*.09 from x+cell*.5 to W-cell*.5. But start should at x=cell/2, end W-cell/2. Good.
 Vertical similarly.
 At intersection ties overlap; okay.
 Rail highlight stroke #f2f0df maybe? Draw dark base width 2.4 then light center width .8 offset? Use two rails each.
 For horizontal two rails at y±cell*.08: dark stroke width max(1.2,cell*.035), then light stroke width max(.5,cell*.015) with color #93a09a.
 vertical similarly.
 This is dense but visually fine.
 Maybe reduce rails opacity to .68 so train pops.
 Background green #dbe6c7, tracks brown/gray.

Platform props:
props list with type:
 {x:2,y:0,type:'station',name:'CENTRAL'},
 {x:16,y:R-1,type:'station',name:'FERNHILL'},
 {x:0,y:4,type:'kiosk'},
 {x:C-1,y:9,type:'kiosk'},
 plus trees on top row x8 etc and bottom row.
 Draw station building:
 base rounded rectangle width .72c height .62c at cell center; cream wall, roof red/brown triangular? Top-down perhaps simple isometric-ish. Since cell ~45, building 32x27. Draw shadow, wall, roof with polygon, windows, door, tiny sign.
 tree: trunk and layered circles green.
 kiosk small yellow hut.
 lamps: tiny pole and lamp glow at some platform cells.
 Avoid props passengers.

Draw labels on canvas: signs maybe tiny.

Passengers draw. We need map colors.
data:
const PEOPLE = [
 {name:'Ada',dest:'Fernhill',coat:'#e4572e',hair:'#4a2f24'},
 ...
]
Passenger object gets color etc.
Wait display.

Draw train:
function drawSegment(seg, angle, isEngine).
We need the train visible centered. Use cell.
Wagon:
 ctx.rotate(angle)
 draw coupling line from -0.42c to -0.30c? Actually train front is +x.
 shadow: roundRect(-.36c,-.23c,.72c,.46c,.1c) fill rgba.
 body roundRect same, fill color.
 lower band maybe roundRect(-.34c,.05c,.68c,.16c,.05c) dark alpha.
 roof roundRect(-.28c,-.18c,.52c,.32c,.08c) color lighten? Use same but lighter.
 windows: 3 little cream squares at x -0.2,0,0.2? With orientation length .72, windows along x. Use fill '#f8e7b0', stroke dark. For 45 cell, windows 5x8.
 wheels: circles at x ±.22c y ±.24c radius .07c.
 coupler at back x -.42c.
 Maybe draw passenger heads inside windows? Tiny colored circles. Nice.
 For each wagon color different, set interior based index.
Locomotive:
 shadow roundrect(-.42c,-.25c,.86c,.5c)
 wheels circles.
 cab at x=-.30c: body roundRect(-.4c,-.22c,.28c,.44c,.08c) dark green; roof roundRect(-.44c,-.25c,.34c,.14c) darker; window cream.
 boiler roundRect(-.16c,-.18c,.48c,.36c,.15c) red-orange gradient? Canvas gradient maybe.
 front circle at .27c radius .18c fill red darker, eyes.
 chimney: at .08c,-.20c? top perpendicular: roundRect(.04c,-.27c,.12c,.13c,.04c) dark; top circle.
 headlamp at .43c,0 radius .055c yellow with glow.
 gold stripe line along boiler.
 cowcatcher front maybe line.
 draw front face eyes at .30c ±.06c, y=-.04? Since front face circle oriented; draw 2 eyes and smile.
 But top-down engine could look like red blob with eyes. Great.
 steam at behind cab x=-.4c.

Coupling lines drawn before wagons between centers:
 for i 0..len-2 line from head? Use centers, but path connecting centers may cut corners diagonally; traditional snake does. Use stroke dark width 3.

Draw engine bounce maybe.

Particles:
state.particles type steam/confetti/ticket.
Update in animation dt:
 p.x += vx*dt etc; p.life-=dt; p.vy...
 Steam grow radius; draw alpha.
 Confetti rectangles rotate.
 Need avoid high count. emit steam every 70ms in demo/player. `state.steamTimer`.
 Since engine moves grid, p world coordinates.

Pickup:
score += 100 + Math.min(50, state.collected*5); state.collected++;
level =1+floor(collected/4)
pickupFlash = .8; banner maybe floating text array with world pos text.
state.floaters.push({x,y,text:`+${points}`,life:1.2,color})
state.particles confetti.
state.logs.unshift({name,dest,time:...}); slice 3.
Maybe passenger all aboard indicator.
Update next card.
Toast "Ada is aboard! +100" but toast bottom can show.
Sound maybe WebAudio? Since first interaction only; but demo no sound due autoplay restrictions. Could add tiny "CHUG" text steam? Not needed.

AI pickup initial direct right:
start head x=10,y=6. Passenger at C-1,y=6. BFS straight right. Snake body to left. Moves right until x=19 picks. Great. Then AI target maybe (16,12). BFS down/right route. It may pass near body; BFS blocked. Fine.
Need spawn enough passengers; initial maybe 5:
(19,6), (16,12), (2,8), (13,0), (7,12). Some might be props? avoid.
When passenger at right target picked, next spawn delay.

BFS path to target:
blocked = set of snake segments excluding tail and head. Start at head. But BFS can traverse target cell if blocked? passenger won't be on snake. okay.
When reconstructing, path first is adjacent. Direction = sign.
At each tick recalculates. BFS shortest may head down at x14 then... okay.
Potential AI loops due target moving no; passengers static.
When snake grows, BFS blocked more; still.

Collision in demo:
Maybe path BFS ignores future body shifts, but could collide. We'll handle reset quickly:
endGame:
 if demo: state.over=true; showToast("A small detour! Restarting the demo…"); setTimeout(()=>resetGame(true), 850)
 else state.over=true; show overlay.
But setTimeout state may reset after user takes control during 850ms. Use token state.runId to check.
Passenger count score resets on demo reset? Could preserve score? For demo maybe reset all. At 3 sec no issue. Best keep.
Actually reset every crash could reset screenshot stats but still action.
Better implement game over overlay only manual; demo reset.

Player takeover during hint:
Click on canvas will set manual, but click might also place train? no.
Hint overlay pointer-events none.

Pause button: state.paused. Toggle. Demo can pause. If paused show toast. Add button id pauseBtn. Board bar text updates. If user clicks board? pointerdown on button shouldn't trigger takeover? Our document pointerdown handler should ignore buttons, but then pause doesn't take control. Could call takeControl when pausing? no need.
D-pad buttons data-dir.
Keyboard Enter when over restart manual.
Keyboard Space pause? If press space first, take control and pause? Maybe space could pause demo. Use P for pause; UI button.

Stats: level maybe under score "Line 7 • speed x".
Header score value starts 000? show 0000.
Use tabular nums.

Service log times: use `state.clock` seconds from start, format mm:ss.
Initial log maybe:
"00:00 • Demo crew took the controls"
Then pickups. Fine.

Next passenger card:
avatar initials e.g. A, colored; status waiting time.
fare 100+.
Need HTML update:
avatar.style.background=...
avatar.textContent=initial
name.textContent
destination.textContent
wait.textContent = `${Math.floor(wait)}s waiting`
Maybe if no passengers "All aboard!"

Timetable active class:
stop elements data-stop index. State collected %4. Route stops names.
train marker maybe CSS .route-track .train-dot with transform? Could position via style left `${(collected%4)/3*100}%` on route line. Each row? easier list items. Mark current.

CSS aesthetic details:
- paper texture using repeating-linear-gradient.
- card border radius 16, box-shadow 0 4px 0 rgba etc.
- stat cards slight top colored accent.
- .mode-badge with pulsing dot.
- avatars.
- button hover transform.
- focus visible.

Potential issue viewport width 1280, app max width 1400 padding; good.

Canvas overlay game over:
<div class="game-over" id="gameOver" hidden>
  <div class="over-card">... <button id=restartBtn>Start a new service</button></div>
</div>
CSS absolute inset 0 display grid place-items center background rgba(10,35,31,.35) backdrop-filter blur(2px). Hidden attr.
We can keep "Click or press a key to start" visible while AI demo runs; yes.

Initial board state rendering before JS: canvas blank until script runs immediate. script at bottom executes.

Need maybe use CSS `backdrop-filter` fine.

Let's write code robustly.

Detailed JS:

(() => {
'use strict';
const canvas=..., ctx=...
const COLS=20, ROWS=13;
const PLATFORM_ROWS = new Set([0,ROWS-1]);
const PROPS = [...]
const STOPS = ['Central Yard','Fernhill','Willow Park','Copper Bridge'];
const WAGON_COLORS = [...];
const PEOPLE = [...];
const state = {
 snake:[], dir:{x:1,y:0}, queued:[], passengers:[], particles:[], floaters:[],
 score:0,best:0,collected:0, level:1, demo:true, started:false, over:false, paused:false,
 time:0, last:performance.now(), acc:0, tick:160, runId:0, steamTimer:0, toastTimer:null, displayScore:0, spawnCooldown:0
};
let cw=900,ch=585,cell=45;
function key(x,y){return x+','+y}
function rnd...
function occupied(x,y, ignoreTail=false)...
function resetGame(demo=true) {
 state.runId++;
 state.snake=[{x:10,y:6},{x:9,y:6},{x:8,y:6},{x:7,y:6}];
 state.dir={x:1,y:0}; state.queued=[]; state.passengers=[]; state.particles=[]...;
 state.score=0; state.collected=0; state.level=1; state.demo=demo; state.started=!demo? true? Hmm manual restart started true.
 state.over=false; state.paused=false; state.time=0; state.acc=0; state.tick=demo?158:142;
 state.spawnCooldown=0;
 create initial passengers manually with helper addPassenger(x,y,index)
 state.logs=[...]
 gameOver.hidden=true;
 startHint.hidden=!demo? If demo show. But if manual restart, hide.
 updateMode(); updateUI(true)
}
We want initial demo state. `state.started=false` means not manual; demo true. For restart manual, state.demo=false started=true.
Passenger creation:
function makePassenger(x,y,idx) {
 const p=PEOPLE[(state.nextPassenger++)%PEOPLE.length]; but need unique names. state.passengerSerial++;
 const template=PEOPLE[(serial)%...];
 return {id:..., x,y,name,dest,coat,skin,hair,wait:0,birth:state.time}
}
Initial explicit positions. Need ensure not props.
add initial:
makePassenger(19,6)
makePassenger(16,12)
makePassenger(2,8)
makePassenger(13,0)
makePassenger(6,12)
Maybe player grows to 9 by 5 pickups; collisions more likely but okay.
At start head right to passenger, picks in 9 ticks 1.42s. Then BFS target maybe 13,0 or 16,12. The train at (19,6), body behind. To go to 13,0 BFS up then left. At x=19, up is okay, left eventually, body behind doesn't block. Good.
AI path can snake around. Body follows and may cut itself if turns right after left? With body length 5, BFS avoids occupied but body shifts. Fine.

Spawn cooldown: after pickup set `state.spawnCooldown=0.9`; each step decrement? Since time updated each frame, state.spawnCooldown-=dt; and when <=0 and passengers.length<5 spawn one.
Need update passengers waits each frame.

Step called based on state.time:
function frame(now){
 dt=Math.min(.05,(now-state.last)/1000); state.last=now;
 if (!state.paused && !state.over) {
   state.time+=dt;
   updatePassengerTimers(dt);
   updateSpawn(dt);
   state.acc+=dt*1000;
   if(state.acc>=state.tick){ state.acc-=state.tick; if(state.acc>=state.tick) state.acc=0; step(); }
   updateParticles(dt);
   emitSteam(dt);
 } 
 draw();
 updateUI();
 requestAnimationFrame(frame);
}
When reset, state.last=performance.now() perhaps.
Tick interval state.level. `state.tick=Math.max(95, (state.demo?160:148)- (level-1)*6)`.
Update when level changes.

AI BFS:
function bfsPath(targets, blocked) {
 const start=state.snake[0];
 const q=[{x:start.x,y:start.y}];
 const seen=new Set([key(start.x,start.y)]);
 const came=new Map();
 let found=null;
 while q.length:
  cur=q.shift();
  if (targets.has(key(cur.x,cur.y))) {found=cur;break;}
  for d of DIRS:
   nx...
   if out continue; k=...
   if seen.has or blocked.has continue;
   seen.add; came.set(k,cur); q.push
 if !found return null;
 path=[]; cur=found;
 while (!(cur.x==start.x&&cur.y==start.y)) { path.push(cur); cur=came.get(key(cur.x,cur.y));}
 path.reverse(); return path;
}
We exclude reversing direction from possible first step? BFS may path go back into body (blocked) so no. Could path first be opposite of current if body has a gap? E.g. current dir right, left goes into where body was maybe body occupies previous? The previous cell (head-dir) is body index1, blocked, so can't. good.
blocked = snake.slice(0,-1) excluding tail. But head itself not blocked; seen handles.
Targets set passengers.
Pick nearest passenger path maybe BFS finds any. Could choose target with shortest. BFS stops at first target by distance.
Fallback:
function floodScore(nx,ny) BFS from candidate with body blocked to count free cells.
score = free*2 - dist*0.1. choose max. exclude reverse.
Also don't choose current dir? okay.
Return candidate dir.

Queue direction:
function queueDir(d) {
 const last = state.queued.length ? state.queued[state.queued.length-1] : state.dir;
 if (d.x===-last.x&&d.y===-last.y) return;
 if (d.x===last.x&&d.y===last.y) return? allow but no.
 if state.queued.length<3 state.queued.push(d)
}
When step, `state.dir=state.queued.shift()||state.dir`.
If demo, don't consume queue.

Take control:
function takeControl() {
 if (state.over) { resetGame(false); return; }
 if (!state.demo) return;
 state.demo=false; state.started=true; state.queued=[];
 startHint.hidden=true; updateMode();
 showToast("You're the conductor! Steer with the arrow keys.");
}
If clicking after game over on overlay button handled.
Document pointerdown:
if (e.target.closest('button')) return;
if (!state.demo && !state.over) return;
takeControl();
But clicking canvas while demo starts manual. Great.
Could prevent accidental start on scrollbar? fine.

Keyboard:
window.addEventListener('keydown', e=>{
 const key=e.key.toLowerCase();
 const dirs={arrowup,w,...};
 if (dir) { e.preventDefault(); if (state.demo) takeControl(); if(state.over) resetGame(false); queueDir(dir);}
 else if (key===' '||key==='p') {e.preventDefault(); if(!state.demo && !state.over) togglePause();}
 else if(key==='enter' && state.over) { resetGame(false);}
 else { if(state.demo) takeControl(); } // any key starts
});
But pressing 'p' in demo would just take control due else? Handle p separately. fine.
Potential any key including Shift starts manual; okay per message.

Dpad pointerdown:
e.preventDefault; takeControl; queue dir.

Pickup:
function pickup(p) {
 state.score += 100 + Math.min(40,(state.collected)*5);
 state.collected++;
 state.level=1+Math.floor(state.collected/4);
 state.spawnCooldown=1.1;
 state.floaters.push...
 state.logs.unshift({time:formatClock(state.time), text:`${p.name} → ${p.dest}`, points:...});
 state.toast...
 createConfetti(p.x,p.y)
}
World coordinates of passenger. `worldX=(p.x+.5)*cell`.

Collision:
const hitPass = passengerAt(nx,ny);
const limit = state.snake.length - (hitPass?0:1);
for i<limit check.
If collision endRun.
Else if hitPass remove then unshift, else pop then unshift.
Order: if grow, don't pop. Need remove passenger.
state.snake.unshift({x:nx,y:ny});
if(hitPass){remove;pickup} else state.snake.pop();
Good.

Game over manual:
state.over=true; state.paused=false; update gameOver text score; show overlay; updateMode.
Button restart -> resetGame(false). Clicking document pointerdown on button ignored, button click resets.
For demo crash: state.over=true; showToast; const id=state.runId; setTimeout(()=>{if(state.runId===id && state.demo){resetGame(true)}},1000). But resetGame increments runId and state.demo. startHint remains visible. Fine.

However scoreboard score resets when demo crash. okay.

updateMode:
modeDot text etc. DOM:
modeLabel.textContent = state.demo ? 'DEMO DRIVING' : state.over?'SERVICE PAUSED':'MANUAL CONTROL';
modeBadge class.
Board status maybe.
update UI every frame might be heavy but fine. Use state.uiTimer? DOM text update at 10fps. We can set lastUI and update if now-last>80. But score tween needs frame. We'll call updateUI every frame with minimal DOM writes but avoid repeated text if same? Use helper setText if differs.
displayScore lerp.

Next passenger choose: We could choose nearest by Manhattan from head to show. `const next = [...passengers].sort(dist)[0]`.
Wait value p.wait.
Route active: collected%STOPS.length. Set active class.
Logs render innerHTML each pickup only; keep `renderLog()` called on pickup/reset. Avoid XSS (static).
Could use `.replaceChildren` but simple.

Time display on board? "Next departure 08:42". Not needed.
Steam emission:
function emitSteam(dt) {
 state.steamTimer -=dt;
 if <=0 { timer=.08; const h=state.snake[0]; const dir=state.dir; const px=world...; behind = h - dir; perpendicular; position = head center - dir*cell*.38 + perp*cell*.13;
 particles.push({type:'steam',x,y,vx:-dir.x*12 + rand(-4,4),vy:-dir.y*12+...,r:cell*.10,life:1.2,max:1.2,alpha:.5});
 }
}
But if direction changes, steam behind current direction.
Update particles:
for p: p.life-=dt; p.x+=p.vx*dt; p.y...
if steam: p.r += dt*8; vy maybe -8.
confetti: vy+=80*dt; vx*=...
Remove.
Steam drawn before train:
for type steam draw circle fill rgba(255,255,255, alpha).
Confetti after.

Train steam might show white over green.

Canvas draw text:
Use helper roundRect path because ctx.roundRect supported latest Chrome; but to be safe implement own function rr(ctx,x,y,w,h,r). Use ctx.beginPath etc.
function roundedRect(x,y,w,h,r){ctx.beginPath(); ctx.moveTo... quadraticCurveTo...}
Use fill.

Drawing platform props:
PROPS:
{x:2,y:0,type:'station',label:'CENTRAL'}
{x:16,y:ROWS-1,type:'station',label:'FERNHILL'}
{x:0,y:4,type:'kiosk'}
{x:COLS-1,y:9,type:'kiosk'}
{x:8,y:0,type:'tree'}
{x:11,y:0,type:'tree'}
{x:4,y:ROWS-1,type:'tree'}
{x:9,y:ROWS-1,type:'tree'}
{x:0,y:9,type:'tree'}
{x:COLS-1,y:3,type:'tree'}
Could props on platform cells exclude spawn. Props list in key set.

drawStation:
cx,cy; w=.74c,h=.64c. Shadow at y+.08c. wall fill #f1dba9, roof polygon from top? Since top-down, draw building with front-facing slightly: body rounded, roof red trapezoid, windows. We can draw isometric front:
 rect x-.33c,y-.18c,.66c,.38c wall
 roof polygon x-.39c,y-.18c, x+.39c,y-.18c, x+.28c,y-.35c, x-.28c,y-.35c fill red
 windows two squares
 door.
 But cell height 45, top y may clip at row0. y center=22, roof top 6, okay.
 For bottom row, building should maybe upside? still.
 sign text small.
Draw tree: trunk and 3 circles.
kiosk: small yellow hut.

Platform label text might be drawn in prop sign.

Draw waiting passenger ring:
cell 45. person 20 tall. ring radius .3c 13.5. Dashed line.
Maybe draw small destination flag next to them: pole line, triangle colored. The person wave.
Person y center cell+? top row center22; head at maybe 12 fits. bottom row center563 head553 fine.
At mobile 17, head at ~14, top row center8.5, head4 maybe fine.
Scale positions carefully: body height .20c (9px), head r .105c (4.7). cy body center maybe cy+.02c; head cy-.17c. For top row cy8.5, head5.6. good.
Wait ring centered cy.
Draw suitcase at cx+.17c, cy+.12c size .11c.

Draw locomotive at top row may clip; but okay.

Canvas background and route:
Maybe draw dashed line representing preferred AI? no.

Overlay hint styling:
hint-icon animated train small inline svg.
Pulse animation. Exact text.

First paint screenshot after 3 sec: overlay remains and passengers etc.

Potential evaluation wants actual gameplay, not static. We have.

Let's ensure no storage usage.

Let's consider whether using setTimeout is allowed (yes). no alert etc.

No external requests.

Could include a no-JS fallback? irrelevant.

Need maybe update canvas crisp with DPR. resize uses boardFrame width. Let's code:
let lastW=0;
function resize(){
 const wrap=canvas.parentElement;
 const w=wrap.clientWidth;
 if (!w) return;
 const h=Math.round(w*ROWS/COLS);
 if (Math.abs(w-lastW)<1) maybe still update.
 canvas.style.width='100%'; canvas.style.height=h+'px';
 const dpr=Math.min(2,window.devicePixelRatio||1);
 canvas.width=Math.round(w*dpr); canvas.height=Math.round(h*dpr);
 ctx.setTransform(dpr,0,0,dpr,0,0);
 cw=w; ch=h; cell=w/COLS;
 lastW=w;
}
ResizeObserver(()=>resize()).observe(canvas.parentElement);
Potential callback loop due canvas height influencing parent height but not width; no infinite.
At initial, parent .canvas-wrap width determined board frame. good.

Canvas CSS `display:block; width:100%;` and height set inline. Great.

Canvas aria.

Game frame uses dt cap .05. When tab inactive no issue.
Tick accumulator after reset.
When paused, particles freeze? We update only not paused, yes. draw still.
state.time resets each demo.

Overlay startHint hidden when manual; if demo reset show. `startHint.hidden = !state.demo;`
state.demo true means hint visible. If demo paused? okay.

Initial resetGame(true) calls state.displayScore=0.
Best update max state.best.

Need set board status light maybe.
HTML board bar:
<div class=board-bar>
 <div class=board-id><span class=signal on></span><strong>LINE 7</strong><span class=board-location>Central Yard → Starlight Vale</span></div>
 <div class=board-actions><span class=demo-chip id=demoChip>AUTO PILOT</span><button id=pauseBtn ...>...</button></div>
</div>
Board bar light.

Maybe stat "SERVICE" instead of best? We'll do SCORE, BOARDED, BEST.

Sidebar route card with 4 stops. Use CSS .route-list li with dot and line.

Log items.

Control note below board:
<div class=control-note>
 <span class=key-group><kbd>↑</kbd>... Steer</span> <span>Collect passengers from the platform</span> <span>Auto-demo hands over control anytime</span>
</div>
At mobile hidden or wrap.

Touch dpad below board on mobile:
<button data-dir="up">▲ svg etc</button>.
Could include only under 760px.

Maybe use inline svg for arrow, but text symbols are fine; fonts support. Use SVG path for polished.

Let's detail CSS media:
@media(max-width:1050px){ .layout{grid-template-columns:1fr} .sidebar{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));align-items:start} .board-column{...} }
At 900 board width 852, h554, okay.
@media(max-width:760px){
 .app{padding:12px}
 .topbar{flex-wrap:wrap;gap:12}
 .brand h1 font-size:24
 .stats{width:100%;display:grid;grid-template-columns:repeat(3,1fr)}
 .stat{padding:8}
 .sidebar{grid-template-columns:1fr}
 .board-bar{height:34;font-size:9}
 .board-location{display:none}
 .start-hint{...}
 .control-note{display:none}
}
At 375 board width = 375-24-? app 12 each=351; board frame border 3 =>345; canvas h224. Good.
Start hint width calc -20, font 12, icon 34, padding 8 12. Height ~50. It may overlay but board visible.
Sidebar below.

At 1280 sidebar 300 width.

App background maybe bottom visible.

Need avoid horizontal scroll due board min width; use min-width:0 and overflow hidden. Stat cards etc.

Let's think of canvas train and passengers aesthetics with CSS pixel. At 900 width, cell 43.5, engine ~37x22. Good.

Let's define colors with high contrast.

Let's start writing final code. Need ensure exactly one fenced code block, no text. We'll produce complete HTML.

Before final, let's mentally test JS syntax and functions.

Detailed functions order:
const refs.
const DIRS = [{x:1,y:0},{x:0,y:1},{x:-1,y:0},{x:0,y:-1}];
state.
resetGame called after definitions? function declarations hoisted. Use people.

makePassenger:
let serial=0;
function addPassenger(x,y,forceIndex){
 const t=PEOPLE[serial % PEOPLE.length]; serial++;
 const p={...t,id:serial,x,y,wait:0};
 state.passengers.push(p); return p;
}
In reset, serial=0 then initial adds.
PEOPLE each has id maybe.

spawnPassenger:
const occupiedKeys = new Set([...props,...snake,...passengers]);
const candidates = [];
for perimeter; random. Use candidates[Math.floor(Math.random()*...)]
if none return.
Use addPassenger(x,y).

Props excluded.

updateSpawn(dt):
state.spawnCooldown-=dt;
if(state.spawnCooldown<=0 && state.passengers.length<5){ spawn; state.spawnCooldown=1.4; }
Initially spawnCooldown maybe 0 and passengers length5 so no spawn.
After pickup length4 cooldown1.1, then spawn.

Wait timers update each frame.

AI:
blocked excludes tail. But snake can be length4. `state.snake.slice(0, Math.max(1,state.snake.length-1))` excludes tail. if len4 indices0..2 blocked (head seen separately). Good.
Target set.
bfs path may return path whose first is occupied by a segment that's tail? tail excluded so allowed.
Fallback compute free count:
function floodCount(start, blocked) { visited; ...}
But blocked should include body excluding tail and candidate? use Set. Evaluate.
Exclude reverse.
If no candidate, use current dir.

AI path can be to passenger and target cell may be in blocked if snake overlaps; passengers picked immediately so not.
`dirFrom(a,b)`.

step:
let dir;
if(state.demo) dir=aiDirection();
else { dir=state.queued.shift()||state.dir; }
If dir same reverse prevented.
head.
hitPass.
const checkLen = state.snake.length - (hitPass ? 0 : 1);
for i=0;i<checkLen;i++.
For len 4 checkLen4 if grow, includes head at index0 but nx != head. fine.
if collides endRun.
unshift then pop/pickup.
state.dir=dir.

Note when grow hitPass, body remains including tail and head inserted -> length+1. Good.
When not grow, we unshift then pop removes old tail. Good.

AI may plan to passenger at cell occupied by body tail; blocked excludes tail but target could be tail? passenger cannot overlap snake because would have been picked when head entered; but if snake body moved over passenger? Only head can enter and picks, so no.

endRun:
if(state.demo){
 state.over=true;
 showToast("The demo took a wrong turn — resetting the line…");
 const id=state.runId;
 setTimeout(()=>{if(state.runId===id && state.demo){resetGame(true);}},900);
} else {...}
Need state.over true means frame stops updates. Reset after. start hint stays.
Manual game over overlay.
`state.demo` remains true but over. takeControl if state.over calls resetGame(false) due function. However document pointerdown on game over background triggers takeControl -> reset manual. Fine.
But endRun demo set over then document? no.

togglePause only manual? Button could pause demo too; maybe fine but instruction self-play; user might. Keep allow. `state.paused=!state.paused;` show toast.

showToast uses textContent and class visible; set timeout. state.toastTimer clear.

updateUI:
setText.
displayScore: state.displayScore += (state.score-state.displayScore)*0.18. If diff<.5 set exact.
format score `String(Math.round(displayScore)).padStart(4,'0')`.
best format.
boarded.
mode chip text.
mode badge side.
next passenger: choose nearest:
let next=null,bestD=Infinity; for p d=abs... choose.
if next: set avatar etc. wait = Math.floor(next.wait). fare. If no: avatar '✓', etc.
We should not update avatar background every frame unnecessarily but fine. helper setStyle maybe.
Log only rendered on pickup/reset.
route active class: `document.querySelectorAll('.route-stop').forEach((el,i)=>el.classList.toggle('active',i===state.collected%STOPS.length));`
Also .route-current text maybe.
Side card class "next passenger".
State.demo chip class toggles.

setText helper.

renderLog:
logEl.innerHTML = state.logs.map(...).join('');
Static names safe.
Log default:
{time:'00:00', text:'Auto-pilot checked in at Central Yard', points:''}
On pickup unshift.
No arbitrary user input.

formatClock(t): minutes padded, seconds.

drawGame:
ctx.save(); background.
drawGrass: precompute GRASS array using seeded function:
const GRASS=[]; let seed=12345; function seeded(){seed=(seed*16807)%2147483647;return (seed-1)/2147483646}
for 260 points {x,y,r,alpha}
But cell scaling changes; store normalized x,y. draw tiny lines.
ctx.globalAlpha...
Tracks:
drawTrackLines:
const railOffset=Math.max(2.2,cell*.085)
const tieLen=Math.max(3.5,cell*.12)
for r 0..R-1:
 y=(r+.5)c
 for x=c*.5; x<=W-c*.5; x+=spacing: draw tie
 draw rails...
For vertical ties draw horizontal short.
At intersections, okay.
Line width use c? if c=17, rail width 1.2, ties 3.5.
Track colors.
Maybe rails every row and column makes many; set alpha .82.

Platform: Fill before tracks.
Draw perimeter fill:
ctx.fillStyle='#e9d9b8';
ctx.fillRect(0,0,W,c); ...
Tile lines: for x from c to W step c, line; etc.
Safety stripes: dashed line yellow near inner edge. Use setLineDash([cell*.22,cell*.12]).
Platform edge shadow line.

Background lawn maybe #cddfbd.
Add patchwork squares? no.

drawProps after tracks. Then passengers.
Draw station signs maybe.

drawPassenger:
ring color coat with alpha. `ctx.setLineDash([3,3]); lineDashOffset=-time*8`.
For mobile line width .8.
Person:
skin fill, hair arc. Draw body as rounded. wave arm angle sin.
Luggage.
A tiny destination flag: line from cx+.22c, cy-.18c to ...; triangle.
But too much. Add small pin circle above head with number? Could display waiting symbol '•'. We'll do little ticket rectangle above right with fill coat and white stripes? Let's keep.

drawTrain:
for i=snake.length-1 down to 0:
 angle:
 if i===0 use state.dir; else vector from snake[i+1] to snake[i] (behind->front): dx=snake[i].x-snake[i+1].x etc. Use atan2.
 At a turn, wagon angle from segment to next.
 draw coupler between current and next before bodies: for i=0..len-2 line from center i to center i+1. But that diagonal line shows path; use width max(1.5,c*.045) dark.
 Draw wagon for i>=1 with color WAGON_COLORS[(i-1)%...].
 Engine at 0.
Wagon orientation and front should be toward head. Our angle for wagon from behind (i+1) to current (i), correct: vector dx=current.x-behind.x. At straight right, behind x smaller, dx1 -> angle0. good.
Engine angle state.dir.

Wagon details:
function drawWagon(seg,angle,index) {
 const c=cell, x=(seg.x+.5)c,y=...
 ctx.translate; rotate(angle)
 // shadow
 rr(-.36c,-.24c,.72c,.48c,.09c) shadow offset .05c
 body
 wheels
 windows
 roof highlight.
}
Need `rr` uses current ctx global.

Wagon body color; `shade` helper maybe hex adjust. We'll hardcode darker map or use globalAlpha overlay black.
After body, fill rgba(0,0,0,.12) lower half.
Windows: positions -0.18,0,0.18 but length .72; width .09c height .12c. For cell17, .09=1.5px, tiny. okay. Use 2 windows at ±.12c.
Add little head circle in one window for even.
Coupler: black line at back -0.42c.

Engine:
shadow ellipse maybe rr.
Wheels.
Cab.
Boiler gradient: createLinearGradient(-.1c,0,.3c,0) with colors '#f37a49','#d8482e'. fill.
Front circle.
Eyes white/black.
Chimney at x=.08c,y=-.24c.
Gold band.
Smoke? particles.
Headlamp.

Coupling line drawn before.

drawParticles steam before train. We'll call drawSteamParticles then train then confetti/floaters.
drawFloaters: ctx.font=`800 ${max(12,c*.34)}px ...`; text at x,y - progress*24; alpha.
Maybe floaters from pickup at passenger cell. Great.
drawConfetti rectangles.

Canvas edge vignette: radial gradient overlay, but drawing overlay last can dim. Use subtle edges only.

Canvas overlay hint DOM.

Board canvas may be scaled high DPR and text crisp.

Initial positions and canvas.

We also might draw a small arrow around train? no.

Let's ensure pick positions relative to props:
PROPS keys:
2,0
16,12
0,4
19,9
8,0
11,0
4,12
9,12
0,9
19,3
Initial passengers:
19,6 not prop.
16,12 (prop is16,12!) conflict. Choose passenger 15,12 or 17,12. Let's set (17,12) but prop at16,12 adjacent; okay.
2,8 not prop.
13,0.
7,12.
Maybe add (3,3)? But perimeter only. Fine.
Right first at19,6.
Properties station at2,0 and16,12; passenger at17,12.
Good.

Platform candidates exclude props.

People:
{name:'Ada',dest:'Fernhill',coat:'#e4572e',skin:'#f1b98d',hair:'#3b2a22'}
Milo, Clara, Otis, Nell, Hugo, Bea, Finn.
Dest among STOPS plus Starlight Vale, Marigold. Route card maybe stops: Central Yard, Fernhill, Willow Park, Copper Bridge. Passenger dest choose from ['Fernhill','Willow Park','Copper Bridge','Starlight Vale'].

Header subtitle "PASSENGER PICKUP • LINE 7".

Could display clock in board bar updates? Add `<span id=boardClock>00:00</span>`. updateUI format state.time.

Paused chip.

Touch controls:
<div class="touch-controls" aria-label...>
 button up, left, down, right arranged CSS grid areas.
</div>
Order: up row1 col2; left row2 col1; down row2 col2; right row2 col3. But typical dpad 3x2 maybe. Use grid 3x3:
up row1 col2, left row2 col1, down row3 col2, right row2 col3. Tall. Could use 3 columns 2 rows with up col2 row1, left col1 row2, down col2 row2, right col3 row2. Fine.
Buttons 46.

Sidebar control notes.

Potential `hidden` property on startHint and CSS; okay.

No use of external fonts.

Let's write final HTML. Need be mindful code size maybe 35k. Fine.

Let's formulate CSS fully.

One concern: board canvas with `width:100%` and inline height set; .canvas-wrap might get width from content but canvas width 100% of wrap, wrap width auto. Fine. Parent board-frame width.

`ResizeObserver` observing canvasWrap: setting canvas height changes canvasWrap height -> observer fires again (width unchanged) but resize resets canvas width causing maybe repeated? ResizeObserver fires when content box height changes; setting same height no change. First callback then stable. We can track if width/height same and skip to avoid. function resize(force): compute h; if abs(cw-w)<0.5 && abs(ch-h)<0.5 return; set. Good.
DPR may change; window resize triggers.

At initial reset, canvas may not resized yet? call resize before reset.

Start hint overlay is a DOM div positioned within wrap. pointer-events none. Clicking board reaches canvas/document.

The demo chip says "AUTO PILOT" maybe animate dot.

Let's ensure CSS keyframes don't error.

Now code.

Potential accessibility and pointer: `document.addEventListener('pointerdown', ...)` will also fire when clicking canvas; takeControl. Then if user then wants to press arrow, fine.
If clicking restart button, ignored by pointerdown (closest button), click resets.
If clicking pause button, ignored, pause toggles.

Keyboard keydown after click: window receives. good.

We should maybe set canvas tabindex=0 and focus on click? We listen window, no need. But can set focus.

AI direction queue not used.

At start, `state.started=false`; mode "DEMO DRIVING". If any key starts, takeControl. Good.

Use `window.addEventListener('blur', ...)` maybe pause? not needed.

Add "Click or press a key to start" exact text.

Let's check the sidebar card "Conductor's log" updates after pickups. In demo after first 1.4 sec log. good.

Potential score pickup initial: head starts x10, passenger x19 -> 9 moves *160ms=1.44 sec. screenshot 3 sec sees log, confetti maybe gone but score visible.

After pickup snake length5 and head x19,y6. AI target (13,0) maybe BFS path up to row0 then left. It can move up from x19, but passenger at (13,0) accessible via top row. It might collect (17,12) etc. Fine.

Could AI hit itself at turns? Let's simulate rough:
after first pickup head (19,6), body (18,6),(17,6),(16,6),(15,6).
AI target (13,0): BFS shortest up to row0 then left. From (19,6) up path to (19,0), then left to13. Body behind at row6 no issue. As it moves up, body follows. Then left at row0, body includes vertical segment down column19; moving left along row0 fine. It could collect passenger at (13,0). Good.
Then target maybe (17,12): BFS from (13,0), body lies along row0/col19. Shortest right along row0 to x17 then down. But body may block row0 to right as it occupies row0 cells; BFS blocked set includes body segments along row0 x13..18? Head at13, body at14..? Actually after turning left, body includes row0 x14..19, so target path right is blocked. BFS chooses down to row12 then right. Good. Fine.
Eventually body might fill but 13 rows x20 enough.

Player collision overlay.

Now let's write.

Need maybe use `ctx.ellipse` supported. yes.
Rounded rect helper:
function rr(x,y,w,h,r){
 r=Math.min(r,w/2,h/2);
 ctx.beginPath();
 ctx.moveTo(x+r,y); ctx.arcTo(x+w,y,x+w,y+h,r); ...
 ctx.closePath();
}
Then fill.

Color hex alpha? We use globalAlpha.

drawTrack ties spacing: for x from c*.5; x &lt;= W-c*.5+0.1; x+=spacing. At cell 45, spacing13.5. vertical ties overlap horizontal rails; okay.
Could reduce intersection clutter by draw all ties then all rails. good.
Draw bed maybe: line with stroke rgba(92,79,55,.09) width c*.38 along center row/col. This gives subtle ballast. But dense. We'll do.

Platform labels:
draw at top after props maybe text at x=cell*5.8, y=cell*.38 "CENTRAL YARD"; but station at2, label can fit across. On mobile tiny.
At bottom "FERNHILL" at x=cell*15.3, y=H-cell*.3.
Use small cream sign rounded.

drawStation label included.

drawWaiting person coat.

Maybe platform safety line dashed appears under train.

Canvas dark border.

Let's produce.

One more: CSS `.start-hint strong` exact text. Use SVG train icon.
Toast hidden via opacity transform pointer-events none.

We should set `aria-live` on toast.

Let's now write the final code carefully.