Models 44
Train snakeOne-line

MiniMax M3

by MiniMaxreasoning default1 console error
Open in new window

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

How it went

Total time
9 min 11 s
Cost
$0.055
Output tokens
45,419
Spent thinking
25,490 tokens
Speed
83.7 tok/s
Console errors
1
First token after
8.5 s
First visible text after
6 min 34 s
Prompt tokens
530
Code size
60.1 KB
Finish reason
stop
Served by
Minimax
Model id
minimax/minimax-m3
Reasoning sent
model default
Max output
512,000 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 null (reading 'addEventListener')

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

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Railway Rush — The Passenger Snake</title>
<style>
  :root {
    --ink: #183f43;
    --ink-2: #28585a;
    --cream: #fbf7ec;
    --paper: #fffaf0;
    --gold: #f3b94f;
    --coral: #e76952;
    --teal: #399b91;
    --line: #d9cfba;
    --muted: #75817d;
    --shadow: rgba(24, 63, 67, .16);
  }

  * { box-sizing: border-box; }

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

  body {
    color: var(--ink);
    background:
      radial-gradient(circle at 8% 12%, rgba(243,185,79,.13) 0 3px, transparent 4px),
      radial-gradient(circle at 92% 82%, rgba(57,155,145,.10) 0 4px, transparent 5px),
      linear-gradient(135deg, #f8f3e7 0%, #f1ecdf 100%);
    background-size: 34px 34px, 43px 43px, auto;
    font-family: "Trebuchet MS", "Arial Rounded MT Bold", Arial, sans-serif;
  }

  button { font: inherit; }

  .app {
    width: calc(100% - 36px);
    max-width: 1180px;
    margin: 0 auto;
    padding: 20px 0 24px;
  }

  .topbar {
    display: flex;
    align-items: center;
    gap: 22px;
    margin-bottom: 19px;
    min-height: 66px;
  }

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

  .brand-mark {
    width: 56px;
    height: 56px;
    flex: 0 0 auto;
    border-radius: 17px;
    background: var(--gold);
    border: 2px solid var(--ink);
    box-shadow: 0 4px 0 rgba(24,63,67,.18);
    display: grid;
    place-items: center;
  }

  .brand-mark svg {
    width: 45px;
    height: 45px;
    display: block;
  }

  .brand-copy h1 {
    margin: 0;
    font-size: clamp(22px, 2.2vw, 29px);
    line-height: 1;
    letter-spacing: -.7px;
    font-weight: 900;
  }

  .brand-copy p {
    margin: 6px 0 0;
    color: #72817c;
    font-size: 11px;
    font-weight: 800;
    letter-spacing: 1.7px;
    text-transform: uppercase;
  }

  .stats {
    display: flex;
    gap: 9px;
    margin-left: auto;
  }

  .stat {
    min-width: 92px;
    padding: 8px 13px 7px;
    background: rgba(255,250,240,.86);
    border: 1.5px solid var(--line);
    border-radius: 13px;
    box-shadow: 0 3px 0 rgba(24,63,67,.07);
  }

  .stat span {
    display: block;
    color: #7c8781;
    font-size: 9px;
    font-weight: 900;
    letter-spacing: 1.25px;
    text-transform: uppercase;
    margin-bottom: 2px;
  }

  .stat strong {
    display: block;
    font-size: 22px;
    line-height: 1;
    font-variant-numeric: tabular-nums;
  }

  .stat strong.bump { animation: score-pop .42s ease; }

  @keyframes score-pop {
    0% { transform: scale(1); color: var(--ink); }
    35% { transform: scale(1.26) rotate(-3deg); color: var(--coral); }
    100% { transform: scale(1); color: var(--ink); }
  }

  .mode-toggle {
    height: 44px;
    border: 1.5px solid var(--ink);
    border-radius: 14px;
    padding: 0 11px 0 13px;
    color: var(--ink);
    background: var(--paper);
    display: flex;
    align-items: center;
    gap: 8px;
    cursor: pointer;
    box-shadow: 0 3px 0 rgba(24,63,67,.18);
    font-size: 11px;
    font-weight: 900;
    white-space: nowrap;
  }

  .mode-toggle:active { transform: translateY(2px); box-shadow: 0 1px 0 rgba(24,63,67,.18); }
  .mode-toggle .mode-dot { width: 8px; height: 8px; border-radius: 50%; background: #9aa6a1; }
  .mode-toggle.demo .mode-dot { background: var(--teal); box-shadow: 0 0 0 4px rgba(57,155,145,.15); }
  .mode-toggle.demo { background: #edf7ef; }
  .mode-toggle .switch { width: 25px; height: 15px; border-radius: 20px; background: #d3d9d3; position: relative; }
  .mode-toggle .switch::after { content: ""; position: absolute; width: 11px; height: 11px; border-radius: 50%; background: white; top: 2px; left: 2px; box-shadow: 0 1px 3px #777; }
  .mode-toggle.demo .switch { background: var(--teal); }
  .mode-toggle.demo .switch::after { left: 12px; }

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

  .board-card {
    min-width: 0;
    overflow: hidden;
    border: 3px solid var(--ink);
    border-radius: 21px;
    background: var(--paper);
    box-shadow: 0 8px 0 rgba(24,63,67,.12), 0 22px 45px var(--shadow);
  }

  .board-toolbar {
    min-height: 49px;
    padding: 7px 10px 7px 14px;
    color: #fff9e9;
    background: var(--ink);
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 10px;
  }

  .line-badge { display: flex; align-items: center; gap: 10px; min-width: 0; }
  .line-number {
    width: 33px;
    height: 33px;
    display: grid;
    place-items: center;
    flex: 0 0 auto;
    border-radius: 10px;
    color: var(--ink);
    background: var(--gold);
    font-size: 15px;
    font-weight: 900;
    border: 1px solid rgba(255,255,255,.25);
  }
  .line-name { font-size: 12px; font-weight: 900; letter-spacing: .3px; }
  .line-name small { display: block; color: #aac5c0; font-size: 9px; margin-top: 2px; letter-spacing: .5px; }

  .board-tools { display: flex; align-items: center; gap: 8px; }
  .status-pill {
    display: flex;
    align-items: center;
    gap: 6px;
    height: 28px;
    padding: 0 9px;
    border: 1px solid rgba(255,255,255,.15);
    border-radius: 20px;
    font-size: 8px;
    font-weight: 900;
    letter-spacing: 1px;
    white-space: nowrap;
  }
  .status-pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: #71d0ac; box-shadow: 0 0 0 3px rgba(113,208,172,.13); }
  .status-pill.human::before { background: var(--gold); box-shadow: 0 0 0 3px rgba(243,185,79,.13); }

  .icon-button {
    border: 1px solid rgba(255,255,255,.2);
    background: rgba(255,255,255,.09);
    color: white;
    height: 29px;
    min-width: 31px;
    border-radius: 9px;
    cursor: pointer;
    font-size: 12px;
    font-weight: 900;
  }
  .icon-button:hover { background: rgba(255,255,255,.18); }

  .canvas-wrap {
    position: relative;
    overflow: hidden;
    background: #dbe7cc;
    line-height: 0;
  }

  canvas {
    width: 100%;
    height: auto;
    aspect-ratio: 18 / 10;
    display: block;
    cursor: crosshair;
    outline: none;
  }

  canvas.human { cursor: default; }

  .start-overlay {
    position: absolute;
    inset: 0;
    z-index: 10;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 14px;
    background: rgba(19,53,54,.19);
    backdrop-filter: blur(1.5px);
    transition: opacity .25s ease, visibility .25s ease;
  }

  .start-overlay.is-hidden {
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
  }

  .start-card {
    width: min(372px, 100%);
    padding: 22px 25px 20px;
    text-align: center;
    background: #fffaf0;
    border: 2px solid var(--ink);
    border-radius: 20px;
    box-shadow: 0 7px 0 rgba(24,63,67,.22), 0 18px 35px rgba(24,63,67,.22);
  }

  .engine-badge {
    width: 55px;
    height: 55px;
    margin: -5px auto 10px;
    border-radius: 17px;
    background: #f7df9d;
    border: 2px solid var(--ink);
    display: grid;
    place-items: center;
    box-shadow: 0 3px 0 rgba(24,63,67,.15);
  }
  .engine-badge svg { width: 43px; height: 43px; }

  .eyebrow {
    display: block;
    color: var(--coral);
    font-size: 9px;
    font-weight: 900;
    letter-spacing: 1.8px;
  }

  .start-card h2 {
    margin: 5px 0 7px;
    font-size: 33px;
    line-height: 1;
    letter-spacing: -1px;
  }

  .start-card p {
    margin: 0 auto 13px;
    max-width: 280px;
    color: #6d7c77;
    font-size: 13px;
    line-height: 1.45;
  }

  .key-callout {
    display: inline-flex;
    align-items: center;
    gap: 7px;
    margin-bottom: 12px;
    padding: 7px 11px;
    border-radius: 9px;
    color: var(--ink);
    background: #edf2e5;
    border: 1px dashed #8ba28d;
    font-size: 10px;
    font-weight: 900;
    letter-spacing: .8px;
    text-transform: uppercase;
  }

  .key-callout::before { content: "●"; color: var(--coral); font-size: 10px; }

  .primary-button, .secondary-button {
    border: 2px solid var(--ink);
    border-radius: 11px;
    cursor: pointer;
    font-weight: 900;
  }

  .primary-button {
    width: 100%;
    padding: 11px 15px;
    color: var(--ink);
    background: var(--gold);
    box-shadow: 0 4px 0 var(--ink);
  }
  .primary-button:hover { background: #ffc85e; }
  .primary-button:active { transform: translateY(3px); box-shadow: 0 1px 0 var(--ink); }

  .secondary-button {
    margin-top: 9px;
    padding: 8px 13px;
    color: var(--ink);
    background: transparent;
    border-color: #aebbb2;
    font-size: 11px;
  }

  .start-card .tiny-note { display: block; margin-top: 11px; color: #8b958f; font-size: 9px; }

  .mini-overlay {
    position: absolute;
    inset: 0;
    z-index: 9;
    display: grid;
    place-items: center;
    background: rgba(24,63,67,.3);
    backdrop-filter: blur(2px);
  }
  .mini-overlay.is-hidden { display: none; }
  .pause-card {
    background: var(--paper);
    border: 2px solid var(--ink);
    border-radius: 16px;
    padding: 18px 22px;
    text-align: center;
    box-shadow: 0 6px 0 rgba(24,63,67,.2);
  }
  .pause-card h3 { margin: 0 0 5px; font-size: 22px; }
  .pause-card p { margin: 0 0 12px; color: #78847f; font-size: 11px; }
  .pause-card button { width: auto; }

  .toast {
    position: absolute;
    z-index: 7;
    top: 12px;
    left: 50%;
    transform: translate(-50%, -12px);
    opacity: 0;
    pointer-events: none;
    padding: 8px 15px;
    border: 2px solid var(--ink);
    border-radius: 30px;
    background: #fffaf0;
    box-shadow: 0 4px 0 rgba(24,63,67,.18);
    text-align: center;
    transition: .25s ease;
    white-space: nowrap;
  }
  .toast.show { opacity: 1; transform: translate(-50%, 0); }
  .toast strong { display: block; font-size: 12px; }
  .toast span { display: block; margin-top: 1px; color: var(--coral); font-size: 10px; font-weight: 900; }

  .board-footer {
    min-height: 49px;
    padding: 8px 13px;
    background: #fffaf0;
    border-top: 2px solid var(--ink);
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
  }

  .control-copy { display: flex; align-items: center; gap: 8px; color: #6d7d78; font-size: 10px; font-weight: 800; min-width: 0; }
  .key-set { display: flex; gap: 3px; }
  kbd {
    min-width: 21px;
    height: 22px;
    padding: 0 5px;
    display: grid;
    place-items: center;
    border: 1px solid #aeb9b0;
    border-bottom-width: 3px;
    border-radius: 5px;
    color: var(--ink);
    background: white;
    font: 900 11px "Trebuchet MS", sans-serif;
  }

  .legend { display: flex; align-items: center; gap: 13px; color: #7c8883; font-size: 9px; font-weight: 800; white-space: nowrap; }
  .legend span { display: flex; align-items: center; gap: 5px; }
  .legend-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--gold); box-shadow: 0 0 0 3px rgba(243,185,79,.2); }
  .legend-train { width: 13px; height: 8px; border-radius: 3px; background: var(--teal); border: 1px solid var(--ink); }

  .restart-button {
    border: 1.5px solid #aebbb1;
    background: transparent;
    color: var(--ink);
    border-radius: 9px;
    height: 30px;
    padding: 0 11px;
    font-size: 10px;
    font-weight: 900;
    cursor: pointer;
    white-space: nowrap;
  }
  .restart-button:hover { background: #edf2e8; }

  .touch-controls {
    display: none;
    grid-template-columns: repeat(3, 45px);
    grid-template-rows: repeat(2, 39px);
    justify-content: center;
    gap: 5px;
    padding: 9px;
    border-top: 1px solid #ddd4c3;
    background: #f6f0e3;
    user-select: none;
  }
  .touch-controls button {
    border: 1.5px solid var(--ink);
    border-bottom-width: 3px;
    border-radius: 10px;
    color: var(--ink);
    background: white;
    font-weight: 900;
    cursor: pointer;
    touch-action: none;
  }
  .touch-controls button:active { transform: translateY(2px); background: #ffe6a8; }
  .touch-controls .up { grid-column: 2; }
  .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; }

  .sidebar { display: grid; gap: 13px; }
  .side-card {
    background: rgba(255,250,240,.93);
    border: 1.5px solid var(--line);
    border-radius: 17px;
    box-shadow: 0 4px 0 rgba(24,63,67,.07);
    padding: 16px;
  }

  .side-title {
    margin: 0 0 11px;
    display: flex;
    align-items: center;
    justify-content: space-between;
    color: #7b8882;
    font-size: 9px;
    font-weight: 900;
    letter-spacing: 1.5px;
  }
  .side-title .ticket-icon { color: var(--coral); font-size: 15px; letter-spacing: 0; }

  .next-passenger { display: flex; align-items: center; gap: 11px; }
  .next-avatar {
    --skin: #d99b72;
    --coat: #e76952;
    width: 57px;
    height: 57px;
    flex: 0 0 auto;
    position: relative;
    border-radius: 17px;
    border: 2px solid var(--ink);
    background: #f3dfb4;
    overflow: hidden;
  }
  .next-avatar::after { content: ""; position: absolute; width: 32px; height: 5px; left: 10px; bottom: 0; background: rgba(24,63,67,.12); border-radius: 50%; }
  .avatar-head { position: absolute; width: 13px; height: 13px; border-radius: 50%; background: var(--skin); top: 9px; left: 20px; z-index: 2; box-shadow: inset 0 4px 0 #594235; }
  .avatar-body { position: absolute; width: 24px; height: 24px; border-radius: 10px 10px 4px 4px; background: var(--coat); left: 15px; bottom: -2px; border: 2px solid rgba(24,63,67,.25); }

  .next-copy { min-width: 0; }
  .next-copy strong { display: block; font-size: 17px; line-height: 1.05; }
  .next-copy span { display: block; margin-top: 5px; color: #7c8883; font-size: 10px; font-weight: 800; }
  .platform-badge { display: inline-block !important; margin-top: 7px !important; padding: 3px 6px; border-radius: 5px; color: var(--teal) !important; background: #e7f2e8; font-size: 8px !important; letter-spacing: .7px; }

  .streak-row {
    margin-top: 13px;
    padding-top: 11px;
    border-top: 1px dashed #d4cdbd;
    display: flex;
    align-items: center;
    justify-content: space-between;
    font-size: 10px;
    font-weight: 800;
    color: #74817c;
  }
  .streak-row b { color: var(--coral); font-size: 13px; }

  .waiting-list { display: grid; gap: 5px; }
  .waiting-row {
    min-height: 34px;
    padding: 5px 7px;
    display: grid;
    grid-template-columns: 10px 1fr auto;
    align-items: center;
    gap: 8px;
    border-radius: 9px;
    background: #f7f2e7;
  }
  .person-dot { width: 9px; height: 9px; border-radius: 50%; border: 1.5px solid rgba(24,63,67,.35); }
  .waiting-row strong { display: block; font-size: 11px; }
  .waiting-row small { display: block; color: #8a948f; font-size: 8px; margin-top: 1px; }
  .waiting-row .arrow { color: #97a29d; font-size: 13px; }

  .station-list { margin-top: 12px; padding-top: 10px; border-top: 1px dashed #d4cdbd; display: grid; gap: 8px; }
  .station-row { display: grid; grid-template-columns: 13px 1fr auto; align-items: center; gap: 7px; font-size: 10px; font-weight: 800; }
  .station-node { width: 10px; height: 10px; border-radius: 50%; border: 2px solid #8ea097; background: var(--paper); }
  .station-row.done .station-node { background: var(--teal); border-color: var(--teal); }
  .station-row span { color: #6f7d78; }
  .station-row b { min-width: 19px; height: 19px; padding: 0 5px; display: grid; place-items: center; border-radius: 8px; background: #ece8dc; color: #75817c; font-size: 9px; }
  .station-row.done b { background: #dceee4; color: #287c70; }

  .tip-card { position: relative; overflow: hidden; background: #f6df9d; border-color: #d6b967; }
  .tip-card::after { content: "✦"; position: absolute; right: 12px; top: 6px; color: rgba(24,63,67,.15); font-size: 38px; }
  .tip-card h3 { margin: 0 0 6px; font-size: 13px; }
  .tip-card p { margin: 0; color: #5e6b65; font-size: 10px; line-height: 1.5; }
  .tip-card b { color: var(--ink); }

  @media (max-width: 940px) {
    .topbar { flex-wrap: wrap; }
    .stats { margin-left: auto; }
    .layout { grid-template-columns: 1fr; }
    .sidebar { grid-template-columns: 1.15fr 1fr; }
    .tip-card { grid-column: 1 / -1; }
  }

  @media (max-width: 650px) {
    .app { width: calc(100% - 16px); padding: 9px 0 18px; }
    .topbar { display: grid; grid-template-columns: 1fr auto; gap: 10px; margin-bottom: 11px; }
    .brand-mark { width: 47px; height: 47px; border-radius: 14px; }
    .brand-mark svg { width: 38px; height: 38px; }
    .brand-copy h1 { font-size: 22px; }
    .brand-copy p { display: none; }
    .mode-toggle { height: 39px; padding: 0 8px 0 10px; font-size: 9px; }
    .mode-toggle .switch { display: none; }
    .stats { grid-column: 1 / -1; width: 100%; margin: 0; display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
    .stat { min-width: 0; padding: 7px 10px; }
    .stat span { font-size: 8px; }
    .stat strong { font-size: 20px; }
    .board-toolbar { min-height: 43px; padding-left: 9px; }
    .line-number { width: 29px; height: 29px; }
    .line-name { font-size: 10px; }
    .line-name small { display: none; }
    .status-pill { padding: 0 6px; font-size: 7px; }
    .board-footer { min-height: 46px; padding: 7px 9px; }
    .legend { display: none; }
    .control-copy { font-size: 9px; }
    .touch-controls { display: grid; }
    .sidebar { grid-template-columns: 1fr; }
    .tip-card { grid-column: auto; }
    .start-card { padding: 12px 16px 13px; border-radius: 15px; }
    .engine-badge { display: none; }
    .start-card h2 { font-size: 25px; margin: 3px 0 5px; }
    .start-card p { font-size: 10px; line-height: 1.3; margin-bottom: 7px; max-width: 240px; }
    .eyebrow { font-size: 7px; }
    .key-callout { margin-bottom: 8px; padding: 5px 8px; font-size: 8px; }
    .primary-button { padding: 8px 12px; font-size: 11px; }
    .start-card .tiny-note { display: none; }
    .secondary-button { margin-top: 6px; padding: 5px 10px; font-size: 9px; }
  }

  @media (max-width: 390px) {
    .brand-copy h1 { font-size: 19px; }
    .brand { gap: 8px; }
    .mode-toggle { padding: 0 8px; }
    .mode-toggle #modeToggleText { max-width: 57px; overflow: hidden; text-overflow: ellipsis; }
    .board-tools { gap: 4px; }
    .status-pill { max-width: 78px; overflow: hidden; }
    .control-copy .key-set { display: none; }
  }
</style>
</head>
<body>
<div class="app">
  <header class="topbar">
    <div class="brand">
      <div class="brand-mark" aria-hidden="true">
        <svg viewBox="0 0 64 64">
          <rect x="11" y="25" width="7" height="21" rx="3" fill="#24494b"/>
          <rect x="46" y="25" width="7" height="21" rx="3" fill="#24494b"/>
          <rect x="6" y="48" width="52" height="4" rx="2" fill="#24494b"/>
          <path d="M10 28h35l8 9v10H10z" fill="#e76851" stroke="#183f43" stroke-width="2.5" stroke-linejoin="round"/>
          <rect x="14" y="17" width="22" height="23" rx="4" fill="#f7df9d" stroke="#183f43" stroke-width="2.5"/>
          <rect x="18" y="22" width="7" height="8" rx="1.5" fill="#77b9c5"/>
          <rect x="28" y="22" width="4" height="8" rx="1.5" fill="#77b9c5"/>
          <rect x="34" y="29" width="13" height="13" rx="4" fill="#399b91" stroke="#183f43" stroke-width="2.5"/>
          <circle cx="42" cy="22" r="4" fill="#24494b"/>
          <circle cx="22" cy="12" r="3" fill="#f3b94f"/>
          <circle cx="29" cy="8" r="4" fill="#fff" opacity=".9"/>
          <circle cx="36" cy="5" r="5" fill="#fff" opacity=".65"/>
        </svg>
      </div>
      <div class="brand-copy">
        <h1>Railway Rush</h1>
        <p>The passenger snake</p>
      </div>
    </div>

    <div class="stats" aria-label="Game statistics">
      <div class="stat"><span>Tickets</span><strong id="score">000</strong></div>
      <div class="stat"><span>Passengers</span><strong id="passengerCount">0</strong></div>
      <div class="stat"><span>Train cars</span><strong id="carCount">4</strong></div>
    </div>

    <button class="mode-toggle demo" id="modeToggle" type="button" aria-label="Toggle AI or player control">
      <span class="mode-dot"></span>
      <span id="modeToggleText">AI driving</span>
      <span class="switch"></span>
    </button>
  </header>

  <main class="layout">
    <section class="board-card" aria-label="Railway Rush game">
      <div class="board-toolbar">
        <div class="line-badge">
          <span class="line-number">07</span>
          <div class="line-name">Pine Line<small>Northborough · Scenic Loop</small></div>
        </div>
        <div class="board-tools">
          <div class="status-pill" id="modeStatus">AI CONDUCTOR</div>
          <button class="icon-button" id="pauseButton" type="button" aria-label="Pause game">Ⅱ</button>
        </div>
      </div>

      <div class="canvas-wrap">
        <canvas id="gameCanvas" width="900" height="500" tabindex="0" aria-label="Snake train game. Steer with arrow keys or WASD."></canvas>

        <div class="start-overlay" id="startOverlay">
          <div class="start-card">
            <div class="engine-badge" aria-hidden="true">
              <svg viewBox="0 0 64 64">
                <circle cx="16" cy="43" r="7" fill="#183f43"/>
                <circle cx="45" cy="43" r="7" fill="#183f43"/>
                <path d="M8 31h31l12 9v7H8z" fill="#399b91" stroke="#183f43" stroke-width="3" stroke-linejoin="round"/>
                <path d="M11 20h18v15H11z" fill="#e76851" stroke="#183f43" stroke-width="3" stroke-linejoin="round"/>
                <rect x="15" y="24" width="9" height="7" rx="1" fill="#d9f1ee"/>
                <path d="M44 25l8 7v8H39V30z" fill="#f3b94f" stroke="#183f43" stroke-width="3" stroke-linejoin="round"/>
                <circle cx="42" cy="21" r="5" fill="#183f43"/>
                <circle cx="28" cy="11" r="4" fill="#fff" opacity=".85"/>
                <circle cx="36" cy="7" r="6" fill="#fff" opacity=".6"/>
              </svg>
            </div>
            <span class="eyebrow">NORTHBOROUGH RAILWAY</span>
            <h2>All aboard!</h2>
            <p>Collect every waiting passenger and add a colorful coach to your little train.</p>
            <div class="key-callout">Click or press a key to start</div>
            <button class="primary-button" id="startButton" type="button">Take the controls &nbsp; →</button>
            <span class="tiny-note">The AI conductor is already demonstrating the route.</span>
          </div>
        </div>

        <div class="mini-overlay is-hidden" id="pauseOverlay">
          <div class="pause-card">
            <h3>Station stop</h3>
            <p>Your train is waiting right here.</p>
            <button class="primary-button" id="resumeButton" type="button">Back on board</button>
          </div>
        </div>

        <div class="start-overlay is-hidden" id="gameOverOverlay">
          <div class="start-card">
            <span class="eyebrow">END OF THE LINE</span>
            <h2>One more route?</h2>
            <p id="finalScoreText">You collected a lovely bunch of passengers.</p>
            <button class="primary-button" id="restartButton" type="button">Run another route</button>
            <button class="secondary-button" id="watchDemoButton" type="button">Let the AI drive</button>
          </div>
        </div>

        <div class="toast" id="toast" role="status"></div>
      </div>

      <div class="board-footer">
        <div class="control-copy">
          <span class="key-set"><kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd></span>
          <span id="driveHint">The conductor is on demo duty</span>
        </div>
        <div class="legend">
          <span><i class="legend-dot"></i> Waiting passenger</span>
          <span><i class="legend-train"></i> Your train</span>
        </div>
        <button class="restart-button" id="footerRestart" type="button">↻ Restart</button>
      </div>

      <div class="touch-controls" aria-label="Touch direction controls">
        <button class="up" data-dir="up" type="button" aria-label="Move up">▲</button>
        <button class="left" data-dir="left" type="button" aria-label="Move left">◀</button>
        <button class="down" data-dir="down" type="button" aria-label="Move down">▼</button>
        <button class="right" data-dir="right" type="button" aria-label="Move right">▶</button>
      </div>
    </section>

    <aside class="sidebar">
      <section class="side-card">
        <h2 class="side-title">Next pickup <span class="ticket-icon">▣</span></h2>
        <div class="next-passenger">
          <div class="next-avatar" id="nextAvatar"><span class="avatar-head"></span><span class="avatar-body"></span></div>
          <div class="next-copy">
            <strong id="nextName">Noah is waiting</strong>
            <span id="nextDestination">Sunset Quay</span>
            <span class="platform-badge" id="nextPlatform">NORTH PLATFORM</span>
          </div>
        </div>
        <div class="streak-row"><span>Pickup streak</span><b id="streak">0 stops</b></div>
      </section>

      <section class="side-card">
        <h2 class="side-title">Waiting on the platform <span id="waitingCount">5</span></h2>
        <div class="waiting-list" id="waitingList"></div>
        <div class="station-list">
          <div class="station-row" id="station-pine"><i class="station-node"></i><span>Pine Grove</span><b>0</b></div>
          <div class="station-row" id="station-market"><i class="station-node"></i><span>Market Street</span><b>0</b></div>
          <div class="station-row" id="station-sunset"><i class="station-node"></i><span>Sunset Quay</span><b>0</b></div>
          <div class="station-row" id="station-old"><i class="station-node"></i><span>Old Town</span><b>0</b></div>
        </div>
      </section>

      <section class="side-card tip-card">
        <h3>Conductor's tip</h3>
        <p><b>Follow the golden rings.</b> Each passenger adds a bright new coach. Keep clear of the tracks' ends — and your own wagons!</p>
      </section>
    </aside>
  </main>
</div>

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

  const $ = id => document.getElementById(id);
  const canvas = $("gameCanvas");
  const ctx = canvas.getContext("2d");

  const W = 900;
  const H = 500;
  const COLS = 18;
  const ROWS = 10;
  const CELL = 50;
  const DPR = Math.min(window.devicePixelRatio || 1, 2);
  canvas.width = W * DPR;
  canvas.height = H * DPR;
  ctx.setTransform(DPR, 0, 0, DPR, 0, 0);

  const DIRS = [
    { x: 1, y: 0 },
    { x: -1, y: 0 },
    { x: 0, y: 1 },
    { x: 0, y: -1 }
  ];

  const COACH_COLORS = [
    "#e76851", "#399b91", "#f3b94f", "#8174b5",
    "#4d86ae", "#db78a0", "#6eaa63", "#e38b45"
  ];

  const PROFILES = [
    { name: "Mia",   dest: "Pine Grove",   coat: "#e76851", skin: "#dca178", zone: "pine" },
    { name: "Noah",  dest: "Pine Grove",   coat: "#4d86ae", skin: "#efc49d", zone: "pine" },
    { name: "Lina",  dest: "Sunset Quay",  coat: "#8174b5", skin: "#b97952", zone: "sunset" },
    { name: "Theo",  dest: "Market Street",coat: "#399b91", skin: "#e5ae83", zone: "market" },
    { name: "Ava",   dest: "Old Town",     coat: "#db78a0", skin: "#f0c49b", zone: "old" },
    { name: "Omar",  dest: "Sunset Quay",  coat: "#e38b45", skin: "#9f6445", zone: "sunset" },
    { name: "Zoe",   dest: "Market Street",coat: "#8174b5", skin: "#e9b38d", zone: "market" },
    { name: "Leo",   dest: "Old Town",     coat: "#4d86ae", skin: "#f2c9a2", zone: "old" },
    { name: "Ivy",   dest: "Pine Grove",   coat: "#db78a0", skin: "#c88861", zone: "pine" },
    { name: "Sam",   dest: "Sunset Quay",  coat: "#6eaa63", skin: "#e9b58e", zone: "sunset" },
    { name: "Nina",  dest: "Market Street",coat: "#f0b44d", skin: "#b8734c", zone: "market" },
    { name: "Ben",   dest: "Old Town",     coat: "#e76851", skin: "#f1c5a0", zone: "old" }
  ];

  const ZONES = {
    pine:   { dest: "Pine Grove",    platform: "top",    cells: [1, 2, 3, 4, 5, 6] },
    sunset: { dest: "Sunset Quay",   platform: "top",    cells: [11, 12, 13, 14, 15, 16] },
    market: { dest: "Market Street", platform: "bottom", cells: [1, 2, 3, 4, 5, 6, 7] },
    old:    { dest: "Old Town",      platform: "bottom", cells: [11, 12, 13, 14, 15, 16] }
  };

  let body = [];
  let oldBody = [];
  let direction = { x: 1, y: 0 };
  let directionQueue = [];
  let passengers = [];
  let score = 0;
  let collected = 0;
  let streak = 0;
  let nextProfile = 5;
  let nextPassengerId = 6;
  let mode = "demo";
  let hasStarted = false;
  let paused = false;
  let gameOver = false;
  let demoCrashTimer = 0;
  let pendingSpawn = 0;
  let animationProgress = 0;
  let currentAngles = [];
  let targetAngles = [];
  let effects = [];
  let steam = [];
  let particles = [];
  let floatingTexts = [];
  let flash = 0;
  let steamClock = 0;
  let lastSideSignature = "";
  let toastTimer = 0;

  const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
  const cellKey = (x, y) => y * COLS + x;
  const inBounds = (x, y) => x >= 0 && x < COLS && y >= 0 && y < ROWS;
  const lerp = (a, b, t) => a + (b - a) * t;

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

  function angleDifference(from, to) {
    let d = (to - from + Math.PI) % (Math.PI * 2);
    if (d < 0) d += Math.PI * 2;
    return d - Math.PI;
  }

  function makePassenger(profile, x, platform) {
    return {
      id: nextPassengerId++,
      name: profile.name,
      dest: profile.dest,
      coat: profile.coat,
      skin: profile.skin,
      platform,
      cell: { x, y: platform === "top" ? 0 : ROWS - 1 }
    };
  }

  function resetData() {
    body = [
      { x: 5, y: 5 },
      { x: 4, y: 5 },
      { x: 3, y: 5 },
      { x: 2, y: 5 }
    ];
    oldBody = body.map(p => ({ ...p }));
    direction = { x: 1, y: 0 };
    directionQueue = [];
    score = 0;
    collected = 0;
    streak = 0;
    nextProfile = 5;
    nextPassengerId = 6;
    pendingSpawn = 0;
    animationProgress = 0;
    effects = [];
    steam = [];
    particles = [];
    floatingTexts = [];
    flash = 0;
    steamClock = 0;
    currentAngles = body.map(() => 0);
    targetAngles = body.map(() => 0);

    passengers = [
      makePassenger(PROFILES[0], 2, "top"),
      makePassenger(PROFILES[1], 6, "top"),
      makePassenger(PROFILES[2], 12, "top"),
      makePassenger(PROFILES[3], 1, "bottom"),
      makePassenger(PROFILES[4], 16, "bottom")
    ];

    updateUI();
  }

  function spawnPassenger() {
    let profile = PROFILES[nextProfile % PROFILES.length];
    nextProfile++;
    let zone = ZONES[profile.zone];
    let options = zone.cells.filter(x =>
      !body.some(p => p.x === x && p.y === (zone.platform === "top" ? 0 : ROWS - 1)) &&
      !passengers.some(p => p.cell.x === x && p.cell.y === (zone.platform === "top" ? 0 : ROWS - 1))
    );

    if (!options.length) {
      const zoneNames = Object.keys(ZONES);
      for (const zn of zoneNames) {
        const z = ZONES[zn];
        const alt = z.cells.filter(x =>
          !body.some(p => p.x === x && p.y === (z.platform === "top" ? 0 : ROWS - 1)) &&
          !passengers.some(p => p.cell.x === x && p.cell.y === (z.platform === "top" ? 0 : ROWS - 1))
        );
        if (alt.length) {
          profile = { ...profile, dest: z.dest };
          zone = z;
          options = alt;
          break;
        }
      }
    }

    if (!options.length) return;
    const x = options[Math.floor(Math.random() * options.length)];
    passengers.push(makePassenger(profile, x, zone.platform));
    updateSidePanel();
  }

  function startHuman() {
    hasStarted = true;
    mode = "human";
    paused = false;
    gameOver = false;
    $("startOverlay").classList.add("is-hidden");
    $("gameOverOverlay").classList.add("is-hidden");
    $("pauseOverlay").classList.add("is-hidden");
    canvas.classList.add("human");
    updateModeUI();
    showToast("You're the conductor!", "Steer with arrow keys or WASD");
    canvas.focus({ preventScroll: true });
  }

  function updateModeUI() {
    const isDemo = mode === "demo";
    $("modeToggle").classList.toggle("demo", isDemo);
    $("modeToggleText").textContent = isDemo ? "AI driving" : "You're driving";
    $("modeStatus").textContent = isDemo ? "AI CONDUCTOR" : "YOU ARE DRIVING";
    $("modeStatus").classList.toggle("human", !isDemo);
    $("driveHint").textContent = isDemo
      ? "The conductor is on demo duty"
      : "Collect gold-ring passengers • avoid collisions";
  }

  function toggleMode() {
    if (!hasStarted) {
      startHuman();
      return;
    }
    if (gameOver) {
      resetData();
      $("gameOverOverlay").classList.add("is-hidden");
      gameOver = false;
      mode = "demo";
      hasStarted = true;
      updateModeUI();
      return;
    }
    mode = mode === "demo" ? "human" : "demo";
    directionQueue = [];
    if (mode === "human") {
      canvas.classList.add("human");
      showToast("You have the controls", "Arrow keys or WASD to steer");
    } else {
      canvas.classList.remove("human");
      showToast("The AI conductor is back", "Sit back and enjoy the ride");
    }
    updateModeUI();
  }

  function restartHuman() {
    mode = "human";
    hasStarted = true;
    paused = false;
    gameOver = false;
    demoCrashTimer = 0;
    $("startOverlay").classList.add("is-hidden");
    $("gameOverOverlay").classList.add("is-hidden");
    $("pauseOverlay").classList.add("is-hidden");
    canvas.classList.add("human");
    resetData();
    updateModeUI();
    showToast("A fresh route!", "All aboard");
  }

  function togglePause(forceResume = false) {
    if (!hasStarted) {
      startHuman();
      return;
    }
    if (gameOver) return;
    paused = forceResume ? false : !paused;
    $("pauseOverlay").classList.toggle("is-hidden", !paused);
    $("pauseButton").textContent = paused ? "▶" : "Ⅱ";
    $("pauseButton").setAttribute("aria-label", paused ? "Resume game" : "Pause game");
  }

  function showToast(title, subtitle) {
    const toast = $("toast");
    toast.innerHTML = `<strong>${title}</strong><span>${subtitle}</span>`;
    toast.classList.add("show");
    clearTimeout(toastTimer);
    toastTimer = setTimeout(() => toast.classList.remove("show"), 2200);
  }

  function queueDirection(d) {
    if (!hasStarted) startHuman();
    if (mode !== "human") {
      mode = "human";
      canvas.classList.add("human");
      updateModeUI();
    }
    if (paused) togglePause(true);
    if (directionQueue.length >= 2) return;
    const previous = directionQueue.length ? directionQueue[directionQueue.length - 1] : direction;
    if (d.x === previous.x && d.y === previous.y) return;
    if (d.x === -previous.x && d.y === -previous.y) return;
    directionQueue.push(d);
  }

  function isOpposite(a, b) {
    return a.x === -b.x && a.y === -b.y;
  }

  function neighbors(cell) {
    const result = [];
    for (const d of DIRS) {
      const x = cell.x + d.x;
      const y = cell.y + d.y;
      if (inBounds(x, y)) result.push({ x, y });
    }
    return result;
  }

  function bfsDistances(start, blocked) {
    const dist = new Int16Array(COLS * ROWS);
    dist.fill(-1);
    const queue = new Int32Array(COLS * ROWS);
    let head = 0;
    let tail = 0;
    const startKey = cellKey(start.x, start.y);
    dist[startKey] = 0;
    queue[tail++] = startKey;

    while (head < tail) {
      const current = queue[head++];
      const x = current % COLS;
      const y = Math.floor(current / COLS);
      for (const d of DIRS) {
        const nx = x + d.x;
        const ny = y + d.y;
        if (!inBounds(nx, ny)) continue;
        const nk = cellKey(nx, ny);
        if (blocked.has(nk) || dist[nk] !== -1) continue;
        dist[nk] = dist[current] + 1;
        queue[tail++] = nk;
      }
    }
    return dist;
  }

  function floodSize(start, blocked) {
    const seen = new Set([cellKey(start.x, start.y)]);
    const queue = [start];
    let size = 0;
    while (queue.length) {
      const current = queue.pop();
      size++;
      for (const n of neighbors(current)) {
        const k = cellKey(n.x, n.y);
        if (!seen.has(k) && !blocked.has(k)) {
          seen.add(k);
          queue.push(n);
        }
      }
    }
    return size;
  }

  function chooseAIDirection() {
    const blocked = new Set(body.map(p => cellKey(p.x, p.y)));
    const options = [];

    for (const d of DIRS) {
      const next = { x: body[0].x + d.x, y: body[0].y + d.y };
      if (!inBounds(next.x, next.y)) continue;
      if (blocked.has(cellKey(next.x, next.y))) continue;
      const space = floodSize(next, blocked);
      options.push({ dir: d, next, space });
    }

    if (!options.length) return direction;

    let targetMap = null;
    let bestDistance = Infinity;

    for (const passenger of passengers) {
      const map = bfsDistances(passenger.cell, blocked);
      for (const option of options) {
        const distance = map[cellKey(option.next.x, option.next.y)];
        if (distance >= 0 && distance < bestDistance) {
          bestDistance = distance;
          targetMap = map;
        }
      }
    }

    let best = null;
    let bestMetric = Infinity;

    for (const option of options) {
      let metric;
      if (targetMap) {
        const distance = targetMap[cellKey(option.next.x, option.next.y)];
        if (distance < 0) continue;
        metric = distance * 10 + (option.space < 17 ? 14 : 0);
      } else {
        metric = -option.space;
      }

      if (option.dir.x === direction.x && option.dir.y === direction.y) metric -= 0.05;
      if (metric < bestMetric) {
        bestMetric = metric;
        best = option;
      }
    }

    return best ? best.dir : direction;
  }

  function tick() {
    if (paused || gameOver || demoCrashTimer > 0) return;

    oldBody = body.map(p => ({ ...p }));
    let chosen;
    if (mode === "demo") {
      chosen = chooseAIDirection();
    } else {
      chosen = directionQueue.length ? directionQueue.shift() : direction;
      if (isOpposite(chosen, direction)) chosen = direction;
    }

    const next = { x: body[0].x + chosen.x, y: body[0].y + chosen.y };
    direction = chosen;

    if (!inBounds(next.x, next.y)) {
      crash();
      return;
    }

    const occupied = body.slice(0, -1);
    if (occupied.some(p => p.x === next.x && p.y === next.y)) {
      crash();
      return;
    }

    const pickup = passengers.find(p => p.cell.x === next.x && p.cell.y === next.y);
    body.unshift({ ...next });

    if (pickup) {
      body.push({ ...body[body.length - 1] });
      collectPassenger(pickup, next);
    } else {
      body.pop();
    }

    targetAngles = body.map((p, i) => {
      if (i === 0) return Math.atan2(direction.y, direction.x);
      const dx = body[i - 1].x - p.x;
      const dy = body[i - 1].y - p.y;
      if (dx === 0 && dy === 0) return targetAngles[i - 1] || 0;
      return Math.atan2(dy, dx);
    });

    while (currentAngles.length < body.length) currentAngles.push(targetAngles[currentAngles.length - 1] || 0);
  }

  function collectPassenger(passenger, cell) {
    passengers = passengers.filter(p => p.id !== passenger.id);
    streak++;
    const points = 100 + (streak - 1) * 25;
    score += points;
    collected++;
    pendingSpawn = 0.22;

    const cx = cell.x * CELL + CELL / 2;
    const cy = cell.y * CELL + CELL / 2;

    for (let i = 0; i < 16; i++) {
      const a = Math.random() * Math.PI * 2;
      const speed = 28 + Math.random() * 75;
      particles.push({
        x: cx, y: cy,
        vx: Math.cos(a) * speed,
        vy: Math.sin(a) * speed - 18,
        age: 0,
        life: 0.45 + Math.random() * 0.45,
        size: 2 + Math.random() * 4,
        color: i % 3 === 0 ? "#fff7d5" : passenger.coat
      });
    }

    floatingTexts.push({
      x: cx,
      y: cy - 15,
      age: 0,
      life: 1.2,
      text: `${passenger.name.toUpperCase()}  +${points}`
    });

    showToast(`${passenger.name} is aboard!`, `+${points} tickets · a new coach added`);
    $("score").classList.remove("bump");
    void $("score").offsetWidth;
    $("score").classList.add("bump");
    updateUI();
  }

  function crash() {
    flash = 1;
    for (let i = 0; i < 13; i++) {
      particles.push({
        x: body[0].x * CELL + 25,
        y: body[0].y * CELL + 25,
        vx: (Math.random() - .5) * 130,
        vy: (Math.random() - .5) * 130,
        age: 0,
        life: .45 + Math.random() * .35,
        size: 3 + Math.random() * 4,
        color: i % 2 ? "#e76851" : "#f3b94f"
      });
    }

    if (mode === "demo") {
      demoCrashTimer = 1.15;
      showToast("A small derailment!", "The conductor is choosing a new route");
      $("modeStatus").textContent = "REROUTING";
    } else {
      gameOver = true;
      $("finalScoreText").textContent = `You collected ${collected} passenger${collected === 1 ? "" : "s"} and earned ${score} tickets.`;
      $("gameOverOverlay").classList.remove("is-hidden");
    }
  }

  function updateUI() {
    $("score").textContent = String(score).padStart(3, "0");
    $("passengerCount").textContent = collected;
    $("carCount").textContent = body.length;
    $("streak").textContent = `${streak} stop${streak === 1 ? "" : "s"}`;
    updateSidePanel();
  }

  function updateSidePanel() {
    const sorted = [...passengers].sort((a, b) => {
      const da = Math.abs(a.cell.x - body[0].x) + Math.abs(a.cell.y - body[0].y);
      const db = Math.abs(b.cell.x - body[0].x) + Math.abs(b.cell.y - body[0].y);
      return da - db;
    });

    const next = sorted[0];
    if (next) {
      $("nextName").textContent = `${next.name} is waiting`;
      $("nextDestination").textContent = `Destination: ${next.dest}`;
      $("nextPlatform").textContent = next.platform === "top" ? "NORTH PLATFORM" : "SOUTH PLATFORM";
      $("nextAvatar").style.setProperty("--skin", next.skin);
      $("nextAvatar").style.setProperty("--coat", next.coat);
    }

    $("waitingCount").textContent = passengers.length;
    const signature = sorted.map(p => p.id).join(",") + "|" + (next ? next.id : 0);
    if (signature !== lastSideSignature) {
      lastSideSignature = signature;
      $("waitingList").innerHTML = sorted.map(p => `
        <div class="waiting-row">
          <i class="person-dot" style="background:${p.coat}"></i>
          <div><strong>${p.name}</strong><small>${p.dest}</small></div>
          <span class="arrow">${p.platform === "top" ? "↑" : "↓"}</span>
        </div>
      `).join("");
    }

    const counts = { "Pine Grove": 0, "Market Street": 0, "Sunset Quay": 0, "Old Town": 0 };
    passengers.forEach(p => counts[p.dest]++);
    const map = {
      pine: "Pine Grove", market: "Market Street", sunset: "Sunset Quay", old: "Old Town"
    };
    for (const key of Object.keys(map)) {
      const row = $("station-" + key);
      const total = counts[map[key]];
      row.querySelector("b").textContent = total;
      row.classList.toggle("done", total > 0);
    }
  }

  function spawnSteam(dt) {
    steamClock += dt;
    if (steamClock < 0.12) return;
    steamClock = 0;
    const pos = trainPositions()[0];
    const angle = currentAngles[0] || 0;
    const localX = 10;
    const localY = -13;
    const x = pos.x + Math.cos(angle) * localX - Math.sin(angle) * localY;
    const y = pos.y + Math.sin(angle) * localX + Math.cos(angle) * localY;
    steam.push({
      x, y,
      vx: -Math.cos(angle) * 8,
      vy: -10,
      age: 0,
      life: .65 + Math.random() * .35,
      size: 4 + Math.random() * 3
    });
  }

  function updateEffects(dt) {
    for (let i = steam.length - 1; i >= 0; i--) {
      const p = steam[i];
      p.age += dt;
      p.x += p.vx * dt;
      p.y += p.vy * dt;
      p.size += dt * 8;
      if (p.age >= p.life) steam.splice(i, 1);
    }
    for (let i = particles.length - 1; i >= 0; i--) {
      const p = particles[i];
      p.age += dt;
      p.x += p.vx * dt;
      p.y += p.vy * dt;
      p.vy += 70 * dt;
      if (p.age >= p.life) particles.splice(i, 1);
    }
    for (let i = floatingTexts.length - 1; i >= 0; i--) {
      const t = floatingTexts[i];
      t.age += dt;
      t.y -= 23 * dt;
      if (t.age >= t.life) floatingTexts.splice(i, 1);
    }
    flash = Math.max(0, flash - dt * 1.5);

    if (pendingSpawn > 0) {
      pendingSpawn -= dt;
      if (pendingSpawn <= 0) {
        pendingSpawn = 0;
        spawnPassenger();
      }
    }
  }

  function trainPositions() {
    const t = animationProgress;
    return body.map((p, i) => {
      if (i < oldBody.length) {
        return {
          x: lerp(oldBody[i].x, p.x, t) * CELL + CELL / 2,
          y: lerp(oldBody[i].y, p.y, t) * CELL + CELL / 2
        };
      }
      return { x: p.x * CELL + CELL / 2, y: p.y * CELL + CELL / 2 };
    });
  }

  function updateAngles(dt) {
    while (currentAngles.length < targetAngles.length) currentAngles.push(targetAngles[currentAngles.length - 1] || 0);
    for (let i = 0; i < currentAngles.length; i++) {
      const target = targetAngles[i] ?? currentAngles[i];
      currentAngles[i] += angleDifference(currentAngles[i], target) * Math.min(1, dt * 13);
    }
  }

  function drawBackground() {
    const gradient = ctx.createLinearGradient(0, 0, W, H);
    gradient.addColorStop(0, "#e4edcf");
    gradient.addColorStop(1, "#cbdcbf");
    ctx.fillStyle = gradient;
    ctx.fillRect(0, 0, W, H);

    ctx.save();
    ctx.strokeStyle = "rgba(69,112,81,.18)";
    ctx.lineWidth = 1.4;
    for (let i = 0; i < 120; i++) {
      const x = (i * 137.51) % W;
      const y = (i * 83.73) % H;
      ctx.beginPath();
      ctx.moveTo(x - 2, y);
      ctx.lineTo(x, y - 3);
      ctx.lineTo(x + 2, y);
      ctx.stroke();
    }
    ctx.restore();
  }

  function drawTracks() {
    ctx.save();

    ctx.fillStyle = "rgba(222,215,192,.88)";
    for (let y = 0; y < ROWS; y++) {
      ctx.fillRect(0, y * CELL + 8, W, CELL - 16);
    }
    for (let x = 0; x < COLS; x++) {
      ctx.fillRect(x * CELL + 8, 0, CELL - 16, H);
    }

    ctx.fillStyle = "#aa9878";
    for (let y = 0; y < ROWS; y++) {
      const cy = y * CELL + CELL / 2;
      for (let x = 10; x < W; x += 19) {
        ctx.fillRect(x, cy - 9, 6, 18);
      }
    }
    for (let x = 0; x < COLS; x++) {
      const cx = x * CELL + CELL / 2;
      for (let y = 10; y < H; y += 19) {
        ctx.fillRect(cx - 9, y, 18, 6);
      }
    }

    ctx.strokeStyle = "#536360";
    ctx.lineWidth = 2.2;
    for (let y = 0; y < ROWS; y++) {
      const cy = y * CELL + CELL / 2;
      for (const offset of [-6, 6]) {
        ctx.beginPath();
        ctx.moveTo(0, cy + offset);
        ctx.lineTo(W, cy + offset);
        ctx.stroke();
      }
    }
    for (let x = 0; x < COLS; x++) {
      const cx = x * CELL + CELL / 2;
      for (const offset of [-6, 6]) {
        ctx.beginPath();
        ctx.moveTo(cx + offset, 0);
        ctx.lineTo(cx + offset, H);
        ctx.stroke();
      }
    }

    ctx.strokeStyle = "rgba(255,255,255,.35)";
    ctx.lineWidth = 1;
    for (let y = 0; y < ROWS; y++) {
      const cy = y * CELL + CELL / 2;
      ctx.beginPath();
      ctx.moveTo(0, cy - 7);
      ctx.lineTo(W, cy - 7);
      ctx.stroke();
    }
    for (let x = 0; x < COLS; x++) {
      const cx = x * CELL + CELL / 2;
      ctx.beginPath();
      ctx.moveTo(cx - 7, 0);
      ctx.lineTo(cx - 7, H);
      ctx.stroke();
    }

    ctx.restore();
  }

  function drawAwning(x1, x2, color, y, text, bottom = false) {
    ctx.save();
    const h = 12;
    ctx.fillStyle = color;
    ctx.fillRect(x1, y, x2 - x1, h);
    ctx.fillStyle = "rgba(255,248,222,.72)";
    for (let x = x1; x < x2; x += 34) {
      ctx.fillRect(x + 8, y, 13, h);
    }

    ctx.fillStyle = color;
    const edge = bottom ? y : y + h;
    for (let x = x1 + 4; x < x2; x += 17) {
      ctx.beginPath();
      ctx.arc(x, edge, 5, 0, Math.PI * 2);
      ctx.fill();
    }

    const pw = 103;
    const px = (x1 + x2) / 2 - pw / 2;
    const py = y + 1;
    ctx.fillStyle = "rgba(24,63,67,.18)";
    roundedRect(ctx, px + 1, py + 2, pw, 10, 3);
    ctx.fill();
    ctx.fillStyle = "#fff7dc";
    roundedRect(ctx, px, py, pw, 10, 3);
    ctx.fill();
    ctx.fillStyle = "#345252";
    ctx.font = '800 7px "Trebuchet MS", sans-serif';
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.fillText(text.toUpperCase(), px + pw / 2, py + 5.5);
    ctx.restore();
  }

  function drawStations() {
    drawAwning(0, 350, "#399b91", 0, "Pine Grove");
    drawAwning(550, 900, "#4d86ae", 0, "Sunset Quay");
    drawAwning(50, 400, "#e76851", H - 12, "Market Street", true);
    drawAwning(550, 850, "#8174b5", H - 12, "Old Town", true);

    ctx.save();
    ctx.strokeStyle = "#536360";
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(450, 21);
    ctx.lineTo(450, 39);
    ctx.stroke();
    ctx.fillStyle = "#fff8e3";
    ctx.strokeStyle = "#294a4b";
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.arc(450, 12, 11, 0, Math.PI * 2);
    ctx.fill();
    ctx.stroke();
    ctx.strokeStyle = "#e76851";
    ctx.lineWidth = 1.5;
    ctx.beginPath();
    ctx.moveTo(450, 5);
    ctx.lineTo(450, 12);
    ctx.lineTo(455, 15);
    ctx.stroke();
    ctx.restore();
  }

  function drawPassengers(time) {
    for (const p of passengers) {
      const cx = p.cell.x * CELL + CELL / 2;
      const cy = p.cell.y * CELL + CELL / 2;

      ctx.save();
      ctx.translate(cx, cy);
      ctx.rotate(time * 0.45 + p.id);
      const pulse = 14 + Math.sin(time * 4 + p.id) * 1.3;
      ctx.fillStyle = "rgba(243,185,79,.12)";
      ctx.beginPath();
      ctx.arc(0, 0, pulse + 4, 0, Math.PI * 2);
      ctx.fill();
      ctx.strokeStyle = "#d9952e";
      ctx.lineWidth = 2;
      ctx.setLineDash([4, 4]);
      ctx.beginPath();
      ctx.arc(0, 0, pulse, 0, Math.PI * 2);
      ctx.stroke();
      ctx.setLineDash([]);
      ctx.restore();

      const personX = cx + (p.platform === "top" ? -12 : 12);
      const feet = p.platform === "top" ? 24 : H - 23;

      ctx.save();
      ctx.translate(personX, feet);
      ctx.fillStyle = "rgba(24,63,67,.15)";
      ctx.beginPath();
      ctx.ellipse(0, 1, 7, 2.5, 0, 0, Math.PI * 2);
      ctx.fill();

      ctx.strokeStyle = "#30484a";
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.moveTo(-2, -4);
      ctx.lineTo(-3, 0);
      ctx.moveTo(2, -4);
      ctx.lineTo(3, 0);
      ctx.stroke();

      ctx.fillStyle = p.coat;
      roundedRect(ctx, -5, -13, 10, 10, 3);
      ctx.fill();
      ctx.strokeStyle = "rgba(24,63,67,.4)";
      ctx.lineWidth = 1;
      ctx.stroke();

      ctx.strokeStyle = p.skin;
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.moveTo(-5, -10);
      ctx.lineTo(-8, -6);
      ctx.moveTo(5, -10);
      ctx.lineTo(8, -7);
      ctx.stroke();

      ctx.fillStyle = p.skin;
      ctx.beginPath();
      ctx.arc(0, -17, 4.1, 0, Math.PI * 2);
      ctx.fill();
      ctx.fillStyle = "#554235";
      ctx.beginPath();
      ctx.arc(-1, -19, 4, Math.PI, Math.PI * 2);
      ctx.fill();

      ctx.fillStyle = "#e8b84e";
      roundedRect(ctx, 5, -6, 6, 6, 1.5);
      ctx.fill();
      ctx.strokeStyle = "#785c33";
      ctx.lineWidth = 1;
      ctx.stroke();
      ctx.restore();
    }
  }

  function drawWagon(x, y, angle, color, index) {
    ctx.save();
    ctx.translate(x, y);
    ctx.rotate(angle);

    ctx.fillStyle = "rgba(24,63,67,.22)";
    roundedRect(ctx, -19, -10, 40, 25, 7);
    ctx.fill();

    ctx.fillStyle = "#263d3f";
    for (const wx of [-12, 11]) {
      for (const wy of [-13, 13]) {
        ctx.beginPath();
        ctx.arc(wx, wy, 4.2, 0, Math.PI * 2);
        ctx.fill();
      }
    }

    ctx.strokeStyle = "#30484a";
    ctx.lineWidth = 3;
    ctx.beginPath();
    ctx.moveTo(17, 0);
    ctx.lineTo(23, 0);
    ctx.stroke();

    ctx.fillStyle = "#23494b";
    roundedRect(ctx, -19, -11, 38, 22, 5);
    ctx.fill();

    const gradient = ctx.createLinearGradient(0, -10, 0, 10);
    gradient.addColorStop(0, color);
    gradient.addColorStop(1, color);
    ctx.fillStyle = gradient;
    roundedRect(ctx, -17, -9, 34, 18, 4);
    ctx.fill();

    ctx.fillStyle = "rgba(255,255,255,.22)";
    roundedRect(ctx, -15, -8, 30, 4, 2);
    ctx.fill();

    for (let i = 0; i < 3; i++) {
      const wx = -11 + i * 9;
      ctx.fillStyle = "#315658";
      roundedRect(ctx, wx - 1, -6, 7, 11, 2);
      ctx.fill();
      ctx.fillStyle = "#eaf4df";
      roundedRect(ctx, wx, -5, 5, 8, 1.5);
      ctx.fill();
      if ((index + i) % 3 !== 0) {
        ctx.fillStyle = ["#e76851", "#f3b94f", "#8174b5"][(index + i) % 3];
        ctx.beginPath();
        ctx.arc(wx + 2.5, -2, 1.7, 0, Math.PI * 2);
        ctx.fill();
      }
    }

    ctx.fillStyle = "rgba(30,64,66,.28)";
    ctx.fillRect(-15, 6, 30, 2);
    ctx.restore();
  }

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

    const glow = ctx.createLinearGradient(20, 0, 70, 0);
    glow.addColorStop(0, "rgba(255,220,111,.23)");
    glow.addColorStop(1, "rgba(255,220,111,0)");
    ctx.fillStyle = glow;
    ctx.beginPath();
    ctx.moveTo(20, -4);
    ctx.lineTo(70, -27);
    ctx.lineTo(70, 27);
    ctx.lineTo(20, 4);
    ctx.closePath();
    ctx.fill();

    ctx.fillStyle = "rgba(24,63,67,.25)";
    roundedRect(ctx, -24, -12, 49, 29, 8);
    ctx.fill();

    ctx.fillStyle = "#263d3f";
    for (const wx of [-13, 10]) {
      for (const wy of [-14, 14]) {
        ctx.beginPath();
        ctx.arc(wx, wy, 4.5, 0, Math.PI * 2);
        ctx.fill();
        ctx.fillStyle = "#6f8580";
        ctx.beginPath();
        ctx.arc(wx, wy, 1.6, 0, Math.PI * 2);
        ctx.fill();
        ctx.fillStyle = "#263d3f";
      }
    }

    ctx.strokeStyle = "#30484a";
    ctx.lineWidth = 3;
    ctx.beginPath();
    ctx.moveTo(-21, 0);
    ctx.lineTo(-30, 0);
    ctx.stroke();

    const boiler = ctx.createLinearGradient(0, -11, 0, 11);
    boiler.addColorStop(0, "#48a99e");
    boiler.addColorStop(1, "#236f70");
    ctx.fillStyle = boiler;
    roundedRect(ctx, -8, -11, 31, 22, 7);
    ctx.fill();
    ctx.strokeStyle = "#244b4d";
    ctx.lineWidth = 1.5;
    ctx.stroke();

    ctx.fillStyle = "#f3b94f";
    ctx.fillRect(12, -10, 3, 20);
    ctx.fillStyle = "rgba(255,255,255,.22)";
    roundedRect(ctx, -5, -9, 17, 4, 2);
    ctx.fill();

    ctx.fillStyle = "#285b5c";
    roundedRect(ctx, -24, -12, 19, 24, 4);
    ctx.fill();
    ctx.strokeStyle = "#183f43";
    ctx.lineWidth = 1.5;
    ctx.stroke();

    ctx.fillStyle = "#f7df9d";
    roundedRect(ctx, -26, -14, 22, 28, 4);
    ctx.fill();
    ctx.strokeStyle = "#183f43";
    ctx.stroke();

    ctx.fillStyle = "#8ac5cb";
    roundedRect(ctx, -20, -7, 11, 10, 2);
    ctx.fill();
    ctx.strokeStyle = "#315658";
    ctx.lineWidth = 1;
    ctx.stroke();
    ctx.fillStyle = "rgba(255,255,255,.45)";
    ctx.fillRect(-18, -6, 3, 7);

    ctx.fillStyle = "#315658";
    ctx.beginPath();
    ctx.arc(11, -11, 5, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = "#24484a";
    ctx.beginPath();
    ctx.arc(11, -11, 2.7, 0, Math.PI * 2);
    ctx.fill();

    ctx.fillStyle = "#f3b94f";
    ctx.beginPath();
    ctx.arc(0, -12, 3.2, 0, Math.PI * 2);
    ctx.fill();

    ctx.fillStyle = "#e76851";
    ctx.beginPath();
    ctx.moveTo(20, -8);
    ctx.lineTo(27, 0);
    ctx.lineTo(20, 8);
    ctx.closePath();
    ctx.fill();
    ctx.strokeStyle = "#183f43";
    ctx.lineWidth = 1.4;
    ctx.stroke();

    const lamp = ctx.createRadialGradient(22, 0, 0, 22, 0, 8);
    lamp.addColorStop(0, "#fff4ad");
    lamp.addColorStop(1, "rgba(255,218,90,.2)");
    ctx.fillStyle = lamp;
    ctx.beginPath();
    ctx.arc(22, 0, 8, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = "#fff2a3";
    ctx.beginPath();
    ctx.arc(22, 0, 3.4, 0, Math.PI * 2);
    ctx.fill();

    ctx.restore();
  }

  function drawTrain() {
    const positions = trainPositions();

    ctx.save();
    for (let i = 1; i < positions.length; i++) {
      ctx.strokeStyle = "#294749";
      ctx.lineWidth = 5;
      ctx.lineCap = "round";
      ctx.beginPath();
      ctx.moveTo(positions[i].x, positions[i].y);
      ctx.lineTo(positions[i - 1].x, positions[i - 1].y);
      ctx.stroke();
      ctx.strokeStyle = "#c8a15d";
      ctx.lineWidth = 2;
      ctx.stroke();
    }
    ctx.restore();

    for (let i = body.length - 1; i >= 1; i--) {
      const scale = i < oldBody.length ? 1 : .88 + animationProgress * .12;
      ctx.save();
      ctx.translate(positions[i].x, positions[i].y);
      ctx.scale(scale, scale);
      ctx.translate(-positions[i].x, -positions[i].y);
      drawWagon(positions[i].x, positions[i].y, currentAngles[i] || 0, COACH_COLORS[(i - 1) % COACH_COLORS.length], i);
      ctx.restore();
    }

    drawLocomotive(positions[0].x, positions[0].y, currentAngles[0] || 0);
  }

  function drawEffects() {
    ctx.save();
    for (const p of steam) {
      const alpha = 1 - p.age / p.life;
      ctx.globalAlpha = alpha * .68;
      ctx.fillStyle = "#ffffff";
      ctx.beginPath();
      ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
      ctx.fill();
    }

    for (const p of particles) {
      ctx.globalAlpha = 1 - p.age / p.life;
      ctx.fillStyle = p.color;
      ctx.save();
      ctx.translate(p.x, p.y);
      ctx.rotate(p.age * 8);
      ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size);
      ctx.restore();
    }

    for (const t of floatingTexts) {
      ctx.globalAlpha = clamp(1 - t.age / t.life, 0, 1);
      ctx.font = '900 15px "Trebuchet MS", sans-serif';
      ctx.textAlign = "center";
      ctx.textBaseline = "middle";
      ctx.lineWidth = 4;
      ctx.strokeStyle = "rgba(255,250,232,.9)";
      ctx.strokeText(t.text, t.x, t.y);
      ctx.fillStyle = "#d76a45";
      ctx.fillText(t.text, t.x, t.y);
    }
    ctx.restore();
  }

  function render(time) {
    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
    ctx.clearRect(0, 0, W, H);
    drawBackground();
    drawTracks();
    drawStations();
    drawPassengers(time);
    drawTrain();
    drawEffects();

    if (flash > 0) {
      ctx.fillStyle = `rgba(231,104,81,${flash * .28})`;
      ctx.fillRect(0, 0, W, H);
    }
  }

  $("startButton").addEventListener("click", startHuman);
  $("startOverlay").addEventListener("pointerdown", event => {
    if (event.target === $("startOverlay")) startHuman();
  });
  $("canvas").addEventListener("pointerdown", () => {
    if (!hasStarted) startHuman();
  });
  $("modeToggle").addEventListener("click", toggleMode);
  $("pauseButton").addEventListener("click", () => togglePause());
  $("resumeButton").addEventListener("click", () => togglePause(true));
  $("footerRestart").addEventListener("click", restartHuman);
  $("restartButton").addEventListener("click", restartHuman);
  $("watchDemoButton").addEventListener("click", () => {
    mode = "demo";
    hasStarted = true;
    gameOver = false;
    paused = false;
    $("gameOverOverlay").classList.add("is-hidden");
    canvas.classList.remove("human");
    resetData();
    updateModeUI();
    showToast("The conductor is on board", "Watch the AI collect every passenger");
  });

  document.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();
      queueDirection(keyDirections[key]);
    } else if (key === " " || key === "enter") {
      event.preventDefault();
      if (!hasStarted) startHuman();
      else togglePause();
    } else if (key === "r") {
      event.preventDefault();
      restartHuman();
    } else if (key === "p") {
      event.preventDefault();
      togglePause();
    }
  });

  document.querySelectorAll(".touch-controls button").forEach(button => {
    const dirs = {
      up: { x: 0, y: -1 },
      down: { x: 0, y: 1 },
      left: { x: -1, y: 0 },
      right: { x: 1, y: 0 }
    };
    button.addEventListener("pointerdown", event => {
      event.preventDefault();
      queueDirection(dirs[button.dataset.dir]);
    });
    button.addEventListener("click", event => {
      if (event.detail === 0) queueDirection(dirs[button.dataset.dir]);
    });
  });

  resetData();
  updateModeUI();

  let previousTime = performance.now();
  let accumulator = 0;

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

    if (!paused && !gameOver) {
      accumulator += dt;
      const interval = mode === "demo" ? .16 : .185;
      while (accumulator >= interval) {
        accumulator -= interval;
        tick();
        if (gameOver || demoCrashTimer > 0) {
          accumulator = 0;
          break;
        }
      }
      animationProgress = clamp(accumulator / interval, 0, 1);
      updateAngles(dt);
      spawnSteam(dt);
      updateEffects(dt);
    } else {
      updateAngles(dt * .25);
    }

    if (demoCrashTimer > 0) {
      demoCrashTimer -= dt;
      if (demoCrashTimer <= 0) {
        demoCrashTimer = 0;
        resetData();
        updateModeUI();
      }
    }

    render(now / 1000);
    requestAnimationFrame(frame);
  }

  requestAnimationFrame(frame);
})();
</script>
</body>
</html>
Reasoning trace (25,490 tokens)
We need create self-contained HTML snake game train theme locomotive colorful wagons picking passengers waiting platform. Need output exactly one fenced html code block no text. Need ensure visually excellent 1280x800 adaptive 375. Self-playing demo mode behind "Click or press a key to start". No external images etc. Need design game robust keyboard controls maybe arrows, WASD, touch buttons. Train snake locomotive and colorful wagons. Passengers on platform wait, train picks by traveling through, score perhaps station happiness. Need mechanics meaningful visible first paint screenshot 3s, self playing demo behind overlay "Click or press a key to start" message, likely show board and overlay no high score required. Need build polished graphical train using inline SVG? Can draw canvas high-DPI. Need perhaps HTML layout with header sidebar and canvas board. We can use canvas to render all game: side view railway grid with snake locomotive at head, wagons colored, passengers waiting at platform along top/bottom. Track maybe horizontal winding? Snake game convention grid; can render top-down pseudo-isometric train following grid. Train locomotive front, wagons trailing, passengers on platforms along route. Need make charming, responsive.

Let's plan layout: full page background cream/navy? Theme station. Header top "RAILWAY RUSH" logo icon, tagline "Collect • Deliver • Keep rolling". Score/tickets/distance cards. Main area board framed as illustrated station map, left map or right mission panel. Canvas large 1000x600? Need fit 1280x800. Design responsive: body max-width 1240 margin, padding 24. header row height 70. Stats cards maybe score best train length. Main game layout grid 1fr 280px side. Canvas aspect ratio 16/9 with 16 columns 9 rows? Make board grid 18x10 maybe 900x500. Need train moves on grid smooth. Train locomotive oriented direction (east/north etc), wagons at tail. Passengers waiting on platform could be cells outside track (platform top/bottom and maybe side). Passengers picked when train head enters passenger cell; but snake self collision. Classic snake endless with passengers spawning on track? "waiting on the platform" can render little characters on platform band along edges, and when train passes their platform adjacent collect (head in designated station cell). Nice.

Could design board as a park with looping stylized railway route? But classic snake grid works. Use horizontal/vertical track with decorative sleepers, passengers on top/bottom platforms. Snake train body on tracks. Map constraints wrap or walls? Self collisions wagons. Grid 20 cols 12 rows 48 cell? canvas 960x576. Board header within? Need locomotive longer approx 2 cells (train head 1.25 length, wagons 0.8). Train moves one grid step smoothly every 0.14 sec? Human speed ~0.16-0.2 sec. Self play AI collects nearest passengers plus safe route, travels around with no crashing for long time. Need avoid wall by turning before edge, maybe grid 18 x 10 cell 48 = 864x480. Canvas CSS width 100%; devicePixel ratio high. Main grid board maybe 17 columns x11 rows? Could use COLS=18 ROWS=10, 52 px = 936x520. With top title and canvas frame. On 1280 width content ~1192; grid columns minmax(0,1fr) 280 -> board 884? canvas aspect 18/10 and width=884 => height 491; header 110 bottom 40 total 700 good. On 375 content width 343 -> canvas 191 height too small maybe tiny but responsive. Instead mobile stack stats compact, hide side mission or turn 2 col, canvas min height? Ratio 16/10 -> 343x214 acceptable. Need design good first paint 1280x800. Main board canvas 850x480.

Could use HTML canvas full illustrated scene with gradient grass, track, platforms. Need no external font; use system "Trebuchet MS", "Arial Rounded MT Bold", sans-serif. UI CSS charming vintage railway. Colors: #f5efe1 paper, #173f45 teal, #efb445 gold, #ed6b52 coral. Header logo circle locomotive simple svg inline in html or CSS. Use inline SVG for logo and UI icons. Only one HTML file.

Canvas:
- Background entire station grounds pale sage (#d9e7c7) with grass textures/trees.
- Railway tracks one cell? Each grid cell horizontal and vertical crossing sleepers. We can draw route track across each cell in all directions: horizontal rail bed and vertical rail bed making grid railway. This is aesthetically interesting but visually busy. Classic snake map typically track is grid lines; train travels along track across rows/cols. Draw horizontal rows of track and vertical lines to allow turns. Rails: each cell horizontal at y center with light beige ballast rectangle and dark gray narrow rails. Vertical cells similarly. At crossings combined. Draw "stations/platforms" top and bottom bands. Passengers on platform waiting by edge. Could make passenger pickups cells on actual track at station areas, with platform behind them (top row top side; bottom row bottom side; maybe left/right edge). Train picks up passenger if head enters passenger cell. Passengers positioned as people on platform near track in that cell; render them at top of cell if row 0 etc. In platform cells maybe passenger is on track with small platform. Define platform cells along top row and bottom row plus maybe every few columns; waiting passengers sit in these cells and collect when train travels along platform track. Render platform platform above the track of row 0 or below track of last row. In board background, dedicated "platform" at top row y maybe 0? If cells are track, head passes. Passenger figures stand just inside cell near edge, train overlaps somewhat. We can represent waiting people as small figures standing on platform strip adjacent to rail, with target little golden flag/circle at track cell. Draw passengers at top edge of row 0 (body at y=9?), bottom of row9 (body at y=height-8). Train collects when head at any platform cell that has passenger. Must ensure spawn on "platform cells" (top/bottom rows). If AI seeks these, long paths but fine.

Maybe track map snake. Passengers at station cells top/bottom row. Train is head locomotive, wagons follow path; pickups add wagon (new passenger coach colored). More colorful wagons from collected passenger's assigned color. Locomotive oriented direction with detailed drawing.

Game mechanics:
- Grid 18x10 (or 20x12). Head starts x=3,y=5 facing east; wagons positions x=2,1,0? Ensure board. Demo AI chooses target passenger, uses pathfinding BFS on occupancy (ignore tail maybe), avoids dead ends. We can implement self-driving AI robust.
- Classic snake movement with head new cell. Check wall collision and body collision. If wall crash game over, in demo mode maybe auto restart quietly. Need overlay initial Click or press key start; automated demo after click? Rules require game starts in self-playing demo mode behind message. We can show start overlay with title "All aboard! Click or press a key to start" and game running behind it (AI). On user input overlay fades and enables human controls, maybe retains AI until input. Need keyboard listener after click; overlay says Click or press a key to start, but iframe only receives keyboard after click so correct. Clicking anywhere starts and maybe arrow control. Use overlay with button "Start the journey" and "Watch the demo" maybe two options. But user asks game not instructions; show "Click or press a key to start" clearly. Overlay centered board translucent dark. Behind visible train and passengers. The game can already run in demo mode. Click on canvas starts human mode; arrow keys takes control. The overlay start button. Demo badge appears on header maybe "DEMO ON" and toggle "Take the wheel". Could initial overlay not hide too much board; compact card center-right.

Need implement AI robust and aesthetically. AI BFS to nearest passenger using occupancy obstacles excluding head? We need find safe path. Standard:
- occupancy set from body excluding tail (will move).
- BFS from head neighbors to target cell treating body positions as blocked but target maybe passenger cell.
- choose path shortest to passenger; then snake may hit itself eventually. AI should select best.
- Need avoid dead end after pickup. Grid with no walls can route around body.
- head direction cannot reverse into neck.
- Choose neighbor based BFS distance to target with danger heuristic. If unreachable choose safe neighbor maximizing space using flood fill.
Could compute BFS distance map from target over free cells, and select adjacent neighbor minimizing distance plus penalty. If target at top/bottom passenger cell, path likely. Could choose nearest passengers and path around. Need ensure path actually enters passenger target when path next cell target; passenger cells at row 0 or 9; head could run along row0 collect. BFS allowed target even if passenger cell no body. Fine.
- At each step head at cell; compute BFS from target to all free cells; choose neighbor that reduces distance. If target is adjacent, step onto collect. If no path, choose safe neighbor maximizing reachable area and not reducing something. Could use heuristic.

Collision policy: Walls: in demo, if next wall crash, reset with new train after short delay. But AI should avoid walls. If body collision, game over/restart. Human game over overlay with score and restart button. User interaction start human. Demo can restart automatically after crash so screenshot always lively. Need no alerts.

Train rendering:
Use ctx.save/translate to interpolated cell position. Locomotive rendered as top-down stylized locomotive? If track top-down and platforms side, should render top-down train rather than side? Request little train with locomotive and colorful wagons. Top-down cute locomotives with boiler, cabin, wheels visible side-ish maybe easiest draw 2.5D train pointing east. Use rotation to direction. Locomotive along x axis in local coordinate (front x positive). Draw shadow; chassis dark; wheels small circles at corners; boiler large rounded rectangle blue/teal; cabin at back dark blue with window; smokestack front with steam puffs; cowcatcher triangle at front; headlight; red trim. Locomotive length ~1.6 cells, width ~0.7 cell; wagons behind each 1 cell, draw rounded rectangle, colorful body, windows, tiny wheels. Need head position definition and wagon positions align. Snake body stores head cell plus wagons cells. We render head center at current head interpolated from old to new; wagons at body cell centers (from index1). Need connected; locomotive length maybe 1.3 cell, first wagon cell at tail of head -> distance 1 cell enough. If head at new cell center, body first wagon at previous cell center, with loco extending forward maybe overlaps body. Locomotive occupies cell and front extends 0.55cell; tail extends -0.55, next wagon center at -1 so gap .1; connector. draw couplers. Render each wagon at cell, oriented direction (same? Curve when snake turns should wagons follow path orientation per segment). We can store body with positions and dir; orientation for each segment based current-next. In typical snake body array [head, seg1...] and direction global. At turn, wagon orientation not update until next segment but can interpolate. Fine. Render order tail to head to overlap. Use body index and direction: for segment i orientation toward body[i-1]? local x front should point toward next cell ahead. For head uses dir. For body i, direction vector body[i-1]-body[i] (the segment direction forward). If difference 0 use previous dir.
- Wagons with different colors: start 3 wagons maybe blue, red, yellow. Train initial length 4 (loco+3). Each pickup adds a wagon, color cycle.
- Passenger figures drawn with heads, clothing, luggage, little signal marker. At top platform cell, platform sign e.g. "PINE STATION" near left, "HARBOR" near right; draw platforms as wood/tan strips along top edge and bottom edge. Row0 track y=cell center 26. Platform top from 0 to 14? Passengers need be visible above track. Cell height 52; track center 26; person height ~25 anchored at y=12? overlaps track. Could instead platform cells have track center at row y+? But game cells center. We can draw platform strip along top 0-16 and people small 18px anchored 15 so feet y=15, head y=3; flag marker at track cell. On bottom strip bottom 16px. With cell 52 figure height 20. Main render visual.
- Also station buildings at top edge background behind platform? Board can have station signs at top corners and decorative trees outside track? There is not much space. Could allocate board canvas with internal margin? Grid covers. Maybe draw entire board with tracks on soft green, top/bottom station platforms and little houses at corners.
- Need clear game: snake train train over rails, wagons.

Could have canvas board with station-themed map and no explicit "grid" harsh. Render all horizontal tracks at every row, vertical at every col. That's 18x10 grid of tracks and rails, 28 lines, busy. Train route snake easy to see. Could lighten track rails to white/beige. Use color palette dark teal rails (#8a9385), ties brown. Background green; many rails may look like actual rail yard. We can draw continuous horizontal lines row y and vertical x. Each line two rails separated 7px with sleepers every 18px. That's lot but attractive. At crossings some muddle. Better draw track bed lines width cell*0.64 and rails two strokes with offset. For horizontal: ctx.strokeStyle dark 2, x full, y +/-5. sleepers rectangles along x at intervals 20, width 6, height 18, behind rails. Vertical likewise. At intersections it will form grid. Fine.

Maybe define track path as "route" made by cells all connected. Good.

Side panel:
- Mission card "NEXT STOP" with passenger portrait (canvas? HTML inline SVG) and destination.
- "Today's route" progress ticket with 3 passengers.
- Controls "Arrow keys / WASD to steer", mobile buttons.
- Collected count.
- Header stats: Tickets (score), passengers, best streak, train length.
Could keep canvas dominant.

Layout 1280x800:
body margin 0; background #f6f2e8 with subtle radial patterns.
.app max-width 1240, margin auto, padding 24 28 22.
topbar display flex align center justify between height 76.
brand logo 50x50, title 27.
.stats display flex gap.
main .game-layout display grid grid-template-columns:minmax(0,1fr) 272px; gap 18. board shell border 3px solid #193f43 border-radius 22 overflow hidden box-shadow.
board-top small header "NORTH LOOP • LINE 07" + status chips.
canvas width 100%, aspect-ratio 18/10; height auto, display block.
footer board controls status row maybe small legend.
sidebar card height approx matching board including header? Board total 530+? 1280x800 with header 80, stats 60 etc. Need calculate: app padding top 22; header 70; gap 18; main board header 46+canvas (width 936? layout overall 1224-272-18=934; canvas height 519) +footer 46 => 611; total 22+70+18+611=721 +bottom 24=745. Great. At 1280 app width calc 100%-40=1240; inner content 1224? max 1180 maybe. Let's set max-width 1180; app width calc(100%-40px) =1240? max=1180. main left 1180-268-18=894, canvas height 497; total 690. Sidebar. At 375 max responsive, header stacked; stats wrap. main single column; canvas 343x190; sidebar could be grid of cards; board height. No horiz scroll.

Could incorporate mobile controls overlay below canvas. D-pad visible only on coarse pointer or max-width 640. We can show always small circular buttons in footer perhaps. For desktop footer has controls hints and Pause button, sound toggle. Need functional pause, restart, fullscreen? No window.open. Buttons.

Game states:
- mode='demo' initially, overlay visible; game loop running.
- started? on click/keyboard, overlay hide, mode='human'. But keydown arrow while overlay -> start and set direction. Provide "Demo" toggle maybe top "AUTO PILOT" pill with switch button to return demo. Header button "Let AI drive" maybe.
- Start overlay has "Click or press a key to start" exactly. It can be a central card with train icon, "All aboard!" text, instructions. Because behind it game moving. Use overlay absolute inset 0 with pointer-events auto; background rgba cream 0.3 backdrop-filter blur(2). Card ~360 wide.
- On start button mode human, overlay class hidden. Set toast "You're the conductor! Arrow keys to steer.".
- If pressing keyboard in initial overlay, prevent default, start human, then set direction arrow. But arrow event maybe only after click as requirement; keydown listener works global.
- Enter/space maybe pause? Use keydown.
- Human movement discrete. Need avoid multiple key turns between ticks; allow queue up to 2 directions. Implement directionQueue. Direction can't reverse.
- Demo AI direction each tick. If no passenger target, wander safely.
- Pause toggle button; if paused overlay small "Paused".
- Restart resets; in human mode maybe gameover modal.
- Game over overlay shows "End of the line!" score, button "Run it again"; not localStorage.
- No localStorage.

Scoring:
score = passengers*100 + maybe distance? "TICKETS" count points. Each passenger gives 100 + streak bonus 25. Display animated number.
Passenger goals: each passenger has name, destination, color, target platform cell. At any time 5 waiting. Pickup removes, increments score and train length; spawn next after maybe 0.3 sec. Add floating "+120 • NAME" toast in canvas and HTML.
Initial passengers e.g:
Mia → Pine Hill (teal)
Owen → Harbor (orange)
Lina → Maple (purple)
Kai → Sunrise (blue)
Sofia → Market (pink)
Need platform destinations maybe sign labels. Target cell on top/bottom. Each waiting person visible. Could have station platform along top at x=0..5 named PINE GROVE, x=10..17 "SUNSET QUAY"; bottom x=4..13 "MARKET STREET". Passengers targets within designated platform zones.
Start train positions x=3,y=5 etc. initial head at x=5? head=5 y=5, body 4,3,2. Good.

AI robust:
Represent cells as index y*COLS+x. Body initial [5,5],[4,5],[3,5],[2,5].
Snake update:
const next = head + dir;
if wall => crash.
if body.slice(0, -1).includes(next) => collision. If next==tail allowed because tail vacates. Need update body unshift, pop.
if passenger at next => pickup.
For AI, decide direction avoiding:
  occupied set excluding tail and maybe head.
  target = nearest passenger by Manhattan+path.
  BFS from head? We can compute distance to target:
function bfsDistances(start, blocked) returns Int16Array; blocked includes body except tail; start head.
Then choose neighbor allowed not reverse? Reverse isn't occupied immediate (neck blocked) so naturally. For target cell if blocked? passengers spawn only not body. Could spawn passenger under body? Ensure cells not body and not head and preferably no overlapping existing passenger. Initial. Spawn random at platform zones free.
At each decision:
- compute reachable free map from head over blocked excluding tail. Choose neighbor with min distToTarget. Dist computed BFS from target blocked set. But target may be accessible from either direction.
- Add safety: calculate free space reachable from neighbor with board boundaries/body after moving. Choose candidates among neighbor and maybe path to target. Standard:
   candidate dirs where next valid and not body except tail.
   targetDist = distanceMap[next].
   if targetDist >=0 choose min targetDist.
   if no target reachable, use flood size.
Potential cycles while traveling to target? BFS path ensures shortest no revisits, but snake body can block. It recalcs.
- Danger of choosing target path that encloses itself; BFS still finds path and each step shortest could lead into trap. Evaluate after candidate next, flood fill area; require if targetDist path length>1 maybe area >= path distance+body length? For demo stability, choose candidate with max reachable size among equal/similar dist. We can compute flood size for each candidate considering body after next and tail removed. Penalize low free space.
- target selection choose nearest reachable by Manhattan but ensure route. Compute BFS from head to each passenger? expensive 5*180 fine. Could use path distances from head with occupancy, pick min. Then get path toward target using BFS from target. But body changes. We can compute for each target shortest path distance from head ignoring tail maybe. Score distance + 0.1*manhattan. Choose min.
- If target is top/bottom, snake often goes across row5 then to row0; path may snake vertical. Good.
- AI might get stuck if no passenger reachable; choose max free space.

Implement AI:
function chooseDemoDirection() {
 const current = body[0], blocked = new Set(body.slice(0,-1).map(idx));
 const candidates = dirs.map(d=> next).filter(valid & not blocked);
 if none return current dir.
 Compute distances map from each target? BFS from current with blocked; if current not blocked. For each passenger p target cell, distance BFS where blocked excludes current but includes body; BFS from current; target dist. choose min.
 If bestTarget:
   BFS from current to target avoiding blocked gives firstStep. We can reconstruct path; choose among candidate first steps. But BFS distance enough if multiple shortest could pick trap. reconstruct one.
 else wander.
 Safety: for each candidate, simulate body next = [next,...body.slice(0,-1)], blocked after = body positions excluding new tail? Evaluate space flood from next over simulated occupied excluding tail maybe. Candidate score = -dist*10 + space. If candidate reaches target, best regardless maybe.
 Need avoid target chosen that distance route goes onto own tail etc.
 Use path BFS from current to target blocked; reconstruct first. Simulate and flood area. Compare candidates with path first steps; choose one with highest space and lowest dist. BFS itself picks one first; candidates with path distance equal may alternate.
 Simpler: for each target, BFS distances from target (blocked). Choose candidate dcell with min dist. If targetDist for target reachable from current, compute distMap from target, each candidate's d=distMap[next]. Choose target with min minimum d. Then among candidates within maybe min, max flood space. This ensures route.
 distanceMap BFS from target blocked. At current distMap[current]=distance. Candidate dist+1. Select min. This can have BFS through current? fine.
 Ties choose max space. Compute flood for candidate considering current snake after move.
 If no target, max flood.
 Flood area can be grid BFS ignoring simulated body excluding tail maybe.
 Danger if candidate target causes collision after snake body? blocked includes body except tail, so okay. Next step if next==tail allowed and collision check excludes tail; candidate blocked excludes tail so okay, but body after moves and flood. Fine.
Need ensure AI direction not 180 neck because neck blocked. Good.

Movement smooth:
game update based accumulator stepInterval maybe 0.17s demo, 0.19 human. Use requestAnimationFrame dt max 0.05.
previousHead, new head update and interpolation progress. Body positions update. For smooth movement, for each segment i:
- head draw center lerp(prevHead, newHead, t)
- body segment i draw at body[i]? Since body updated each tick and shifted, a wagon jumps at tick if simply draw body[i]. Need proper interpolation along snake path. We can maintain renderPath positions as continuous polyline with each segment world points. Classic snake smooth rendering could interpolate head only, body shifts discretely causing jerk. Can store trail points and interpolate body along historical head positions spaced cell distance. Better approach:
Maintain pathHistory of head cell centers as snake moves, but when turns corners, body should follow.
Each body segment should follow exact grid cells; interpolation can use tween positions per segment from oldBody[i] to newBody[i] each tick. At tick, oldBody cells and newBody. Each body segment moves from old index to new index (which is old previous segment), so interpolates along center straight from old to new. At turns, segment moves diagonally from old cell center to prior cell center, which cuts corner rather than follows L. Could draw connectors with rounded path and use eased movement along a polyline: each segment moves from old cell to previous cell through corner; but only 0.17 sec tick, maybe minor. Could make locomotive/wagons tween positions t. body positions updated. At turn, new head moves old->new. Segment1 moves old segment1->old head; but both endpoints differ x or y by one, okay segment1 moves one cell along axis; segment at turn shifts into old head, while old head moved toward new cell, leaving coupling. Draw coupler lines between segment centers. Looks okay.
Actually oldBody[i+1] to newBody[i+1]=oldBody[i]. Segment i+1 interpolates toward old head cell, while head moves away. Loco tail at interpolation oldHead->new; gap perhaps but connector.
We can render center positions with easing t (ease linear). Local orientation for wagons use new body direction based differences.
Need train shape size and wagons overlap track.

Canvas scaling:
Set logical W=900,H=500 (COLS=18 =>50; ROWS=10=>50). Use ctx.setTransform(dpr*cssW/W,...). easiest canvas backing store = logical dimensions*dpr? CSS width 100%; set canvas.width= W*dpr, height=H*dpr; ctx.setTransform(dpr,0,0,dpr,0,0), drawing logical W,H; browser scales. But responsive CSS scales to 894 px nearly 1. Need dpr min(2).
Canvas width attribute 900 height 500; script get actual client after layout? fixed logical 900 and 500, aspect ratio 18/10. CSS width 100%; height auto. backing 1800x1000 for dpr2. Good.
- Draw at logical dimensions.
- Cell=50, board W900 H500.
Grid exact.
- Coordinates x*50+25, y*50+25.

Draw background:
ctx.fillStyle '#dce8ce'; fill.
Maybe grid lawns patches with dots and flowers at random deterministic positions but not on track? Draw before tracks.
Top/bottom station area integrated.
Track beds: For each row draw horizontal bed y center width full height 34, fill '#e8dfca' or '#cfc9b5'. For each col vertical bed x center width 34. This makes rectangular grid of beige beds. Then sleepers and rails.
Drawing order:
- background green
- station platform strips: top y=0..14? But horizontal row0 bed center25 extends 8..42, overlap. Platform top y0..12 tan and boardwalk line. bottom y488..500.
- horizontal track beds each row full width 32.
- vertical track beds each col full height 32; overlapping creates grid.
- sleepers for each row: x every 22, 5x18. vertical: y every 22 5x18. Combined at cross.
- rails horizontal y center ±5 from x=0..W. vertical x±5.
At crossings tracks overlap and rails.
Could reduce by not drawing vertical beds across all y? But need obvious rail network. Fine.
- Background grass only visible at edges intersections.

Stations:
Top platform entire width perhaps wood decking y0..13 with thin vertical planks. signs in middle? Passengers on top row but people at y0..14. We can draw boardwalk behind and "PINE GROVE" sign at x70 and "SUNSET QUAY" at x700. Small station buildings would cover track; skip. Maybe wooden signs anchored y0, extending to 35.
Bottom platform y487..500 with station name. Passengers standing there anchored.
- side decorative depot maybe not.

Passengers:
Draw platform people small (height 22) at x center + maybe offset to side, y=13 for top (feet) or y=487 bottom. Head radius4, body colored rect/round, legs. Target circle at track center? A pickup target glowing golden ring around cell maybe use dashed circle around (x center,y center). Person stands at platform edge. Good.
- flags sign with check icon? UI panel current target.
- waiting count.

Train:
draw wagons at body positions. Body length initial 4 total. At cell center. Wagon length 36 (cell50), width 24; loco length 42 width28. Draw shadow ellipse.
Local orientation angle based dx/dy. Local x forward.
Wagon:
 shadow rounded rect x=-19 y=-12 w38 h26.
 wheels black at x=-12,+12 y=-14? since top down but show little wheels at sides (rect/circle).
 chassis dark line.
 body color with rounded rect x=-18 y=-10 width36 height20, gradient maybe fill solid plus light top.
 roof? Coach body color, inner windows 3 pale yellow with dark frames, stripe.
 Coupler lines.
 Colorful sequence cycle ['#e76f51','#3b9c8f','#efb44b','#8d78bd','#4b86b4','#dd7ca2'].
 Could render passengers inside coach windows as tiny dots for fun.
 Locomotive:
 local x forward. length 44, width30.
 wheels at x=-12,10, y ±13 (small).
 chassis black x=-22 y=-12 w44 h24.
 boiler teal x=-7 y=-11 w29 h22 rounded.
 cabin x=-23 y=-12 w18 h24 deep teal; window x=-19 y=-7 w10 h7 pale.
 front nose x=20 maybe cowcatcher triangle.
 smokestack at x=11 y=-13? Because top-down still side y offsets. Draw stack circle at x=13,y=-12 and steam puffs.
 headlight x=23 y=0 yellow circle.
 red bumper.
 stripes.
 Wheels visible.
 Connector between loco tail (-22) and wagon front(+18) but cell distance 50 -> 10 gap.
 Need wagons orientation matching path. If turn, local forward toward body previous. Head angle dir. For segment i forward = body[i-1]-body[i]; draw angle. Body array order head->tail. For wagon index1, forward = body0-body1 (toward ahead). Great.
 Draw connectors from segment centers? We can draw line between positions before shapes for each adjacent body; line dark 4 then gold 2. Positions may be diagonal during tween but okay. Segment centers based interpolation.

During turn head orientation instantly snaps at tick. Could ease angle over time. Store prevDir and angle diff. Could use desiredAngle and currentAngle; smooth rotate shortest over t. Initial east. For wagons desired angle based updated body and smooth maybe snap. Store angles array. On update set desired; render angle lerp with max turn rate dt? Use angle current updated via dt in loop. We can draw head at angle=currentAngles[0], wagons. It will curve. Draw connections.

Train body occupancy rendering should visually correspond to direction. Locomotive head center at cell; extends 22, head cell bounds25, fine. wagon 19, fit.

Effects:
- steam puffs from locomotive stack generated time-based: array puffs {x,y,age}; spawn every 0.14s at stack position. Since canvas coordinates and rotating stack world position. Update puffs drift opposite direction slightly/up, fade. Draw as white circles.
- pickup sparkles burst at passenger cell and floating text.
- ticket score toast UI.
- locomotive headlight maybe subtle radial.

Background animated:
- small flags sway? clouds drifting along top but top platform occupied. Could draw birds in grass? No.
- trees outside tracks? Grid fills entire.
- small station clock? Not necessary.
- sunlit diagonal overlay.

Start overlay HTML:
<div class="start-overlay" id="startOverlay">
 <div class="start-card">
  <div class="mini-engine">SVG</div>
  <span class="eyebrow">NORTHBOROUGH RAILWAY</span>
  <h2>All aboard!</h2>
  <p>Collect every waiting passenger and grow your little train.</p>
  <div class=key-callout>Click or press a key to start</div>
  <button id=startBtn>Take the controls <span>→</span></button>
  <small>or watch the conductor demonstrate</small>
 </div>
</div>
Need exactly phrase. Maybe "CLICK OR PRESS A KEY TO START". Clear.

Could also allow click overlay itself to start? Button. Clicking anywhere canvas maybe.
- initial game AI starts instantly. Passengers behind visible. Overlay maybe center with board visible.
- Demo status in board header "CONDUCTOR DEMO" with animated dot.
- after human "YOU'RE DRIVING".
- pause button top maybe.

HTML structure:
<header class=topbar>
 brand
 stats four? width.
 <div class=stats>
  stat score <strong id=score>000</strong>
  stat passengers...
 </div>
 <button id=modeBtn class=mode-toggle>...
</header>
<main class=layout>
 <section class=board-card>
  <div class=board-toolbar>
   <div class=line-badge><span>07</span><div><b>Pine Line</b><small>Northborough → Harbor</small></div></div>
   <div class=board-tools><span id=modeStatus>...</span><button id=pause>...</button></div>
  </div>
  <div class=canvas-wrap>
   <canvas id=gameCanvas ...></canvas>
   overlay...
   <div id=toast ...></div>
   <div class=corner route? canvas not.
  </div>
  <div class=board-footer>
   <div class=control-copy><kbd>↑</kbd>... "Steer the train" </div>
   <div class=legend>... gold circle Waiting passenger ... red etc</div>
   <button restart>
  </div>
  <div class=touch-controls>...
 </section>
 <aside>
  <div class=side-card next-card>
   label NEXT PICKUP
   <div class=passenger-line><div id=avatar ... dynamic></div><div><strong id=nextName></strong><span id=nextDest></span></div><span class=ticket-num>...</span></div>
   <div class=route-line progress maybe>
  </div>
  <div class=side-card route-card>
    "Today's route" and list 3 station dots perhaps
  </div>
  <div class=side-card tip-card> tip / keyboard
 </aside>
</main>
Maybe footer small "A tiny railway adventure" etc.

Responsive:
@media max-width 900: layout grid 1fr; aside display grid grid-template-columns repeat(3,1fr); stats maybe.
@media max-width 620:
 app width calc100%-20, padding 12; header flex-direction column align stretch; brand; stats display grid 4 columns gap6; stat labels tiny; main; board toolbar compact; side card grid 1fr? hide route tip? canvas aspect ratio 18/10 still. Overlay card width calc100%-24, padding 18; start h2 28.
Touch controls display flex (coarse pointer and max-width620).
At 375, canvas 345x192, overlay height card maybe 250 > canvas. Need ensure overlay card fits 192. We could canvas ratio on mobile change to 4/3? But logical grid 18x10 squashed if CSS aspect changed. We can set canvas width 100% height:auto preserves. 345x192. Overlay compact: mini engine hidden, eyebrow 10, h2 24, p 12, callout, button, small hidden. Height ~175. Fine.
Board toolbar 44, canvas 192, footer maybe 60 =>296. Side cards below; screenshot at 800 shows all.

No scrollbar horizontal. use overflow-x hidden.

Accessibility:
Canvas aria-label, buttons. Keyboard.
Sound? Could omit to avoid autoplay issues. Could add click sound but no need. Need interesting visual. "Wagon count" stat.

Data UI updates. score padded 3/4.

Passenger pick and UI:
Passenger object {id,name,dest,color,skin,cell,platform:'top'/'bottom',zone}. Names.
initial passengers 5 positions:
Mia cell (2,0) top dest "Pine Grove"; Owen (7,0) top "Pine Grove"; Sofia (15,0) top "Sunset Quay"; Jules (4,9) bottom "Market Street"; Priya (13,9) bottom.
Initial train head maybe (5,5), body [5,5],[4,5],[3,5],[2,5]. Wagon colors.
Need not spawn passenger in body. Yes.
Maybe target ring highlights.

Spawning:
const PLATFORM_ZONES = top cells [1..6] name Pine Grove, [11..16] Sunset Quay; bottom [3..8] Market Street,[12..15] Old Town. Random from zones avoiding body and current passengers. At first, spawn predetermined but assign zones. After pick spawn after 0.2 sec maybe immediate. Keep 5 passengers.
When collecting at next cell: find passenger whose cell===next. Remove; addWagon=true; score += 100 + (streak*20). newPassenger spawn at random after maybe schedule. Add effect at next. Update UI.
Train length increases one wagon. body unshift then if pickup don't pop -> length+1. The occupancy of new tail.
Station destinations:
Mia -> Pine Grove
Noah -> Sunset Quay
Elena -> Market Street
Theo -> Old Town
Zara -> Rosewater
Maybe 6 names.
Side next pickup should show closest passenger (distance Manhattan), not necessarily actual collected order.
progress route: list station labels with counts? We can use route list Pine Grove, Market Street, Sunset Quay and status dots based any pickups. HTML dynamic.
Side panel "Next stop" shows name destination and platform "North platform / South platform".
"Tickets collected" etc.

Maybe have a "Station board" in side listing current passengers compact rows. This would be more engaging than static. Create div id=passengerList with 3 rows names and colored icons. CSS.
Next card with big illustrated avatar generated HTML: circle with emoji? Avoid emoji inconsistent. Could use SVG icons updated via innerHTML. Use simple inline SVG person in HTML. Dynamic color. Better draw avatar in canvas? Side can use CSS .avatar with head/body colored pseudo elements.
< div class="avatar" style --coat ...><span class=head> etc? CSS:
.avatar {width46;height46;border-radius:50%;background:#f6dfb7;position:relative}
.avatar:before head circle top10 left19 9x9 background skin
.avatar:after body rounded bottom.
Add hair via box-shadow etc. Fine.
Passenger list rows small dot.

Brand SVG locomotive inline allowed. Use same maybe.

Game physics:
- trainStep called when accumulator >= interval. At each update, determine dir:
 if human and queue not empty use shift and validate.
 else AI.
 If paused no.
- Set prevBodyPositions? body updated.
Implementation details:
let body=[{x:5,y:5},...], direction {x:1,y:0}, previousHead...
const state.
function step() {
 let dir=mode==='demo'?chooseAIDirection():popDirection();
 const next={x:head.x+dir.x,...}
 if out or collides => crash()
 // visual interpolation: headFrom={...head}; headTo=next; moveProgress=0
 body.unshift(next); // body object
 if pickup body.push(body[body.length-1])? To add wagon, after unshift and no pop: body.push(copy(body[last])).
 else body.pop()
 // But body order and animation positions:
 oldBody = body.map before
 newBody ...
 render positions for i:
   if i==0: lerp oldHead->newHead
   else: lerp oldBody[i] -> newBody[i] where newBody[i]=oldBody[i-1].
 We'll store animationOld = oldBody, animationNew=body, animationProgress=0 each tick.
 At render t=eased progress:
  positions[i]=lerp(animationOld[i],animationNew[i],t)
 But for i=0 animationNew[0]=new head; good. For i=1 newbody[1]=old head; so wagon1 moves from old body1 to old head while head moves oldhead->next. This means loco and wagon might overlap during transition (loco tail at old head -22, wagon at near old head), but coupling line. In a physical train, segment1 should follow same head path at distance one cell: as head moves old->new, segment1 should move oldbody1->oldhead (same movement), so during first half, segment1 at halfway oldbody1->oldhead while head halfway oldhead->new, distance .5? Actually if oldbody1=oldhead-cell, new segment1=oldhead, midpoint oldhead-cell/2. head midpoint oldhead+dir/2. distance 1 cell along axis, good. It doesn't overlap because oldhead path straight; okay. At turn, wagon1 moves straight into old head, while head turns; physical coupling diagonal slight, fine.
 position i for i>=1 = lerp(oldBody[i], newBody[i],t). Newbody i = oldbody i-1. good.
 When length grows, oldbody last pos same and newbody last adds duplicate of old tail; render new wagon fade/scale in at tail. Need animate index >=oldlength: opacity maybe.
 update scores and spawn.
}
- Movement interval 0.18. Animation progress uses actual dt/interval.
- human queued directions.
- if no queue keep direction.

Collision:
body.slice(0,-1) excludes tail; when next equals tail allowed. But when passenger adds tail duplicate old tail, okay.
- Demo restart: crash sets mode demo state gameOver? We don't want overlay. schedule reset in 1s, flash "The conductor is rerouting..." maybe.
 Human crash: gameOver=true, show gameover overlay with score and button; stop movement, can restart.
- Reset: body initial, direction east, passengers reset, score=0? If demo crash, restart maybe score reset. Better resetRound resets all including score. In demo never likely crash. If human gameover then reset.
- Initial body length maybe 4. Stat length "4 cars".
- Add wagon upon pickup; color sequence. Color assignment based body index. Initial wagon colors coral, teal, yellow; upon pickup next color purple etc.
- If pickup, spawn after e.g. 250ms. While game continues, count may drop to4 then back. Fine.

AI choice implementation robust enough. Let's design exact:
const DIRS = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
function aiDirection() {
 const blocked = new Set(body.slice(0,-1).map(key)); // excludes tail
 const options = DIRS.map(dir=>{
   next...
   if invalid/collision return null
   // occupancy after moving: simulated=[next,...body.slice(0,-1)] (tail removed)
   const space = floodSize(next, simulated.slice(0,-1?)).
   return {dir,next,space}
 }).filter.
 if none return direction.
 // choose target passengers via BFS distances from each target:
 let best={score:Infinity}
 for p:
   const dist= bfsDistances(p.cell, blocked); // blocked excludes current head maybe blocked includes head? body.slice(0,-1) includes head? body.slice(0,-1) includes indices 0..len-2, includes head. BFS from target with blocked head would block start head causing no route to adjacent? Distances through blocked head matter but BFS doesn't need pass head for candidate distances; dist[current] may -1 but dist[next] works if path from target doesn't cross head. Since blocked includes head, this represents snake body and current position. But next path from head naturally. Dist map target->neighbors; dist[next] might 1 etc. Need compare. blocked includes head means candidate next adjacent target gets dist1, good; current blocked irrelevant except can't traverse.
   candidate d = dist[next] (distance from target to next), choose lower.
 For each p compute min candidate dist.
 Let nearest p.
 For candidates calculate d=distMap[next]; if >=0.
 Need avoid candidate whose route through head? blocked includes head means dist[next] from target may path that crosses head to next, giving distance but actual next->target route would cross head only after moving head vacates old cell; actually once head moves, old head becomes segment and is blocked! So path through old head is indeed blocked because neck remains at old head? After move, body becomes [next, oldhead,...], so old head occupied. Thus excluding old head from blocked is correct for path after move. Use blockedAfter = body.slice(0,-1)? Wait old body [h,b1,b2,b3]. After move no pickup: [n,h,b1,b2]. occupied blocking n,h,b1,b2 (exclude b3 tail). For path from next, blocked should [h,b1,b2] = old body.slice(0,-1) (excludes old tail b3). includes old head. This path from next can go around; to reach target, it cannot pass old head but can other way. So distMap from target with blocked old head/body excluding tail is right. candidate dist direct path target to next avoiding body. Great. If target is adjacent next, d=1.
 Current head in blocked but candidate next not.
 Choose nearest target by min d.
 Then for candidates with d = bestD choose max space. But if bestD unreachable for some p, -1.
 Space function from next with blocked set same plus maybe next? flood starts next despite blocked.
 To avoid paths that trap, require space. Score = d*100 - space. For exact nearest, choose max space among d <= bestD+1? Sometimes detouring one extra yields much larger space. Could choose d - min(space/100,2). We want pickup. score = d + (space<15?8:0). Choose minimum. Ties space. This might choose farther with tiny penalty no. Use score = d*10 + (space<18?15:0), so safety matters.
 If no target reachable, choose max space and prefer continuing direction if safe.
 floodSize with blocked old body except tail (includes old head); after move old head still occupied so correct. But start next; can explore all other free.
 Number space on 18x10.
 AI may choose target p behind itself requiring detour. good.
 Candidate could move into old tail because blocked excludes tail; valid. flood blocked set excludes tail although old tail vacates unless pickup. good.
 Pickup tail doesn't vacate, so blocked should include tail then; minor.
- If target path direct crosses passenger cell? passengers only platforms.
- AI perhaps can be trapped with body after turn despite flood, but updates.
- At edge choose.

Collision visual: flash canvas overlay red, train shake, particles. Human overlay.

Demo should visibly move within 3 sec. At 0.17 sec 17 steps. Passengers rings pulse. Good.

Overlay initial could obscure but behind board enough. Position card maybe left center? To show train, card maybe center? Board center train moves. We could make overlay card anchored bottom-left or center-left with translucent; but requirements screenshot judged visual. Center card can cover some board but train visible around. Use overlay card at left 50%? Start screen classic. Maybe center 360x? It's okay. Could position top? Board width 894, card 360 center; board visible 2/3. Passengers around edges. Good.

Make canvas decorative labels:
- At top left sign "PINE GROVE" wooden sign at x100,y? On platform top y0..14. Sign would take height 24 and obscure person. Could place tiny hanging station signs at top boundary x45 and x780, with posts. But people at x125 etc. Draw sign in background before passengers. Signs can extend to y32 over track. Hmm.
Could create station awnings top/bottom as tan strips at y0 and yH-12. Sign text tiny 9 px. Passengers on top anchored at y=9, target ring at row center. top person height 16 maybe overlap sign. Use stations at far edges x0-160 and x720-900, while passengers not exactly there? Passenger at x125 could overlap sign. Place signs top x20 and x760, label. People at x175 etc. Fine.
Maybe use only simple "NORTH PLATFORM" text on wood strip at x14 y9, and "SOUTH PLATFORM" bottom x14 y492. Station names on destination badges.

Need make canvas text readable at scaling; 900 logical width to 894 CSS ~1 so 10px. okay.

Additional scenery: tiny signal posts at x0? Put lamp posts near platform at corners, signal green/red. Trees in grass? Grid intersections every50; can add tiny grass tufts between bed edges at corners. Add deterministic dots.

UI visual CSS:
:root colors.
body background with repeating linear-gradient at 45 deg maybe subtle.
.app.
.topbar .brand etc.
stat cards use background #fffaf0 border 1.5px solid #d8cfbc box-shadow 0 3px 0 #e4ddcc.
Logo circle yellow with border.
Board-card border 3px #173f43; border-radius 20; box-shadow 0 8px 0 rgba(23,63,67,.15), 0 20px 35px rgba.
toolbar background #173f43 color cream; display.
Canvas wrap position relative; overflow hidden; background.
footer white.
Sidebar cards.
Buttons tactile with shadow and transform on active.

Game overlay CSS:
position absolute inset0; display flex; align-items:center; justify-content:center; background rgba(20,48,49,.18); backdrop-filter: blur(1.5px); z-index.
.start-card width min(370px,calc(100%-32px)); background #fff9ea; border 2px solid; border-radius 18; padding 24; text-align center; box-shadow.
mini-engine in circle.
eyebrow.
h2.
button yellow.
Hidden .is-hidden {opacity:0; pointer-events:none; visibility:hidden; transition}
Gameover same overlay reused? Have separate id gameOverOverlay.
Toast absolute top12 center with pill.
Paused overlay maybe.

Need no external network. No localStorage.

Passenger list update. Avoid innerHTML with user content? names static safe. Build markup. Use CSS color.
Next avatar color update via style.
Board passenger list count.

Score animation: on pickup scoreEl.textContent; class bump remove via animationend. CSS pop.

Side station progress:
<div class="route-stations"><div class=station><i></i><span>Pine Grove</span><b id=...>0</b></div>...
Update counts destinations.
Maybe current destination names vary among zones. Let's have exactly 3 destinations:
Pine Grove top left
Sunset Quay top right
Market Street bottom left/right maybe Old Town =4. Side route shows three main but passengers dest can 4.
Passenger list all waiting names.

Passenger destinations:
zones:
top 1..6: "Pine Grove"
top 11..16: "Sunset Quay"
bottom 2..7: "Market Street"
bottom 11..16: "Old Town"
Station signs on canvas can label these zones at top/bottom but four labels tiny. Draw platform band labels at intervals:
top text at x160 "PINE GROVE", x740 "SUNSET QUAY"; bottom x250 "MARKET STREET", x740 "OLD TOWN".
People might overlap. Draw text at y7 and people anchor y22? Top row track center25, can't. Use platform band height20, people feet at y18, heads y4, text overlaps. We can put labels as small signs at boundaries x0 or x? Maybe draw label near zone start, people cells x+ random. For aesthetic, target rings are enough. Side panel tells destination.
Could shrink platform people: head radius3.5 at y5, body from y8 to15, feet y17; label text baseline 8 would overlap. Draw labels with translucent background at x zone center y0 height12; person offset horizontally? Target cell x center, label centered same maybe overlap. Instead sign at x=zone first cell center, person x offset +12. But pickup target cell ring still. Draw people at x center+12, with feet y17. Label at zone center maybe x=zone first center. Fine. For bottom sign at y490, person offset -12 feet y483.
We can just draw destination labels in wooden plaques at far board margin:
top at x130 label PINE GROVE; but zone persons x75..325. It may overlap.
Maybe no labels on canvas, just tiny awning colors to mark platform zones: Top left awning stripes green x50..350; top right blue x550..850; bottom left coral x150..400; bottom right purple x550..850. This makes station-like and visually appealing. Draw awnings along y0..9 / H-9..H, alternating stripe fills. Passengers anchored y17 /H-17, no overlap. Great.
Colors: top left teal/cream, top right blue/cream, bottom left coral/cream, bottom right purple/cream. Person stands below awning.
Platform track row still.

Track beds and awnings.

Maybe headlight cone in direction to enhance. Draw transparent yellow wedge in front of locomotive; helps orientation. For top-down 2.5D, cone shape from x20 to x65, y ±25. But train direction rotates; draw before train in local coords. Nice.

Render head position and rotation. currentAngle smoothed:
function angleLerp(current,target,t) diff normalized. In loop currentAngle += diff*min(1,dt*14). But desiredAngle changes each tick. wagons angles separate desired array. We can store trainAngles array corresponding body entries. On reset set 0. On step desiredAngles[0]=atan2(dir.y,dir.x); for i>=1 desired=atan2(body[i-1].y-body[i].y,...). During render update angle with dt. But render function receives dt and updates. On body growth indices new. We'll use currentAngles and targetAngles. At reset.
When a wagon is added at tail, its orientation = last orientation.
- at step compute targetAngles after body update. for i=1... old length-1 using difference; if zero fallback. new last duplicate tail -> same.
- render angle interpolation independent.
- Because current angles need smoothly turn shortest. angle diff.

Render order:
1 bg static.
2 draw awnings maybe static.
3 draw target rings/passengers? Passengers should be behind train maybe train covers them. Draw people before train so train passes "in front".
4 draw train connector lines.
5 draw wagons tail->head.
6 locomotive.
7 steam/sparkles.
8 canvas labels HUD? Maybe top-left little "DEMO" no, HTML.
9 crash flash.
Need target ring pulsing on track at row center, behind train.
Steam should maybe behind train. Spawn puffs at stack world coordinates. For local stack (x=12,y=-11) rotated. Calculate. Puffs age in seconds, rise along -local y? Top-down steam could drift up screen irrespective direction, but train stack appears offset perpendicular. We'll spawn and drift -y screen plus direction opposite slightly. draw.
Passenger pickup ring disappears; burst particles.
Pickups create floating label near target: effect type text "MIA +120" drawn rising.
Coin ticket animation? score pop.

Drawing track:
function drawTracks() called each frame perhaps static but pulse ring dynamic. Fine 900x500.
Gradient background.
Grass texture deterministic positions.
Horizontal bed: fillRect(0,cy-15,W,30) color #e4dcc6.
Vertical bed fill similarly, but this overwrites horizontal cross; draw all beds first then sleepers/rails. We can use globalAlpha to blend. Order horizontal then vertical. Cross at center same beige.
Ties: For each row, x from 10 step18 fillRect(x,cy-9,6,18) #a59577; for each col y step18 fillRect(cx-9,y,18,6).
Rails horizontal: stroke y cy±6 from 0 toW width2 color #536461. vertical x cx±6. At intersections rails cross. Highlight width1 #d9ddd3 offset.
This is a lot ~ (18*10 ties + etc) but fine.
Maybe train blends with rails; body colors strong.

Grass only visible in 20px gaps between tracks? horizontal beds every50 leave20 green strips; vertical beds cross. Good.

Awnings: top from x50 to350 and 550 to850. draw stripes 18 wide.
At top, people feet y19, ring at y25. top row track occupies y10-40, awning y0-10. Fine.
Bottom people feet y481, ring y475 (row9 center475), platform y490-500. Fine.

Passenger draw:
top: feetY=22, bottom: feetY=478. Person height18. For top, head y=6? body y=10..18. target ring center y=25 radius13; person overlaps upper part. Okay.
x offset top -10 perhaps target x? Draw at x-13 to leave ring center visible. But must visibly aligned with target. Maybe person stands x-12, y17; ring at x25. Target cell width50, enough. Use person x=cellx-12 top, +12 bottom.
Head radius4 skin; hair arc; torso 7x9 colored; arms; legs dark; tiny suitcase.
Draw name initial? target ring and person enough.
At bottom, feet=486, head=474, ring475 overlaps. Maybe person x+12.
Passenger highlights pulse.

Board path. Next target panel.

Canvas click to start human and maybe clicking not on button. overlay button. Also clicking board when no overlay maybe? Human controls only.
Keyboard:
document.addEventListener('keydown', e=>{
 const keys map.
 if key in dirs:
   e.preventDefault();
   if (!hasStarted) startHuman();
   if mode==='demo' switch human? if click then.
   queue direction if valid.
 if space: if !started startHuman; else togglePause
 if r reset
 if p toggle
});
Requirement iframe only keyboard after user clicks: okay.
When initial key arrow arrives, startHuman then queue arrow. If key was ArrowUp while current east, valid. Good.
Click start overlay button.
modeBtn toggles demo/human. Label maybe "Conductor mode" with switch.
Pause.
Restart.

AI mode status chip changes. Board mode button? HTML top mode-toggle. Use title.
On pause, overlay with "Taking a breather" maybe set #pauseOverlay.
If human presses key while paused maybe resume? Could.
Gameover overlay.

First paint: all elements render before requestAnimationFrame. Initial passengers and train. AI runs. Score 000.
No loading.

Need maybe intro card prevents board visual but translucent.
Could initial AI status visible.

Let's think of user expecting snake game: train snake travels grid, collects passengers, grows colorful wagons, avoid collisions. Yes.

Could add collision with platform ends? walls. Need rule text in side "Don't crash into the end of the line or your own coaches." Good.

Mobile controls:
<div class=touch-controls aria-label>
<button data-dir=up>▲</button>...
layout grid 3x2:
up row1 col2, left row2 col1, down row2 col2, right row2 col3.
touch-action none; pointerdown prevent default, start human, queue.
At max-width700 display. On desktop maybe also footer arrows keyboard.

We need maybe ensure board canvas crisp after resize; fixed backing dpr and CSS responsive works.

HTML code no external.
Potential issue CSS backdrop-filter okay.
Use SVG brand.

Let's draft detailed code.

Let's design canvas draw functions exact.

Constants:
const W=900,H=500,COLS=18,ROWS=10,CELL=50;
const colors.
const canvas, ctx.
let dpr=Math.min(window.devicePixelRatio||1,2);
canvas.width=W*dpr;canvas.height=H*dpr; canvas.style.aspectRatio=`${W}/${H}`;
ctx.setTransform(dpr,0,0,dpr,0,0);
Because HTML canvas attributes width=900 height=500 but script sets. CSS width 100%, height:auto.
Drawing ctx.setTransform(dpr...). We may save/restore.

Game state:
let body=[], direction, directionQueue=[], passengers=[], score=0, collected=0, streak=0, mode='demo', hasStarted=false, paused=false, gameOver=false, restartTimer=0, respawnTimer=0, animationProgress=0, lastStepTime maybe.
let oldBody=[], targetAngles=[], currentAngles=[];
let effects=[], puffs=[], particle arrays.
let nextPassengerId=1;
let routeCounts={...}.
const passengerRoster array.
resetGame(resetScore=true) {
 body=...
 direction...
 passengers=...
 score=0 if ...
 effects...
 spawn no duplicates
 currentAngles...
 updateUI
}
But when demo crash reset score maybe; manual restart yes.
Initial predetermined:
makePassenger(name,dest,color,skin,zone index)
Positions array.
Need pass object cell object. spawnPassenger:
 zones array with name, platform, cells.
 choose random cell, ensure !body.some and !passengers.some.
 if no available fallback any platform cell.
 roster cycle shuffled; passenger id.
Initial 5 specified with cells. Names and colors. e.g.
{id:1,name:'Mia',dest:'Pine Grove',coat:'#e55f4e',skin:'#e7ad82',cell:{x:2,y:0},platform:'top'}
{id2 Noah ... x7,y0}
{id3 Zoe x12 y0}
{id4 Luca x4 y9}
{id5 Amara x14 y9}
Maybe zone destination consistent: x2 Pine, x7 Pine, x12 Sunset, x4 Market, x14 Old Town.
Spawn after uses corresponding zone.
Roster pool 12 names.
Use deterministic initial.

Train body initial at x5,y5; direction east. wagons color index by (i-1)%palette; initial length4 -> 3 wagons. Display train length "4". Add wagon = one passenger coach.
Passenger coach colors rotate.

Score maybe 000; when pick 120 etc.
updateStats text:
score.toString().padStart(3,'0')
passengerCount `${collected}`
length `${body.length}`
streak maybe "×"+streak? Header stats 3 only plus mode. Let's do score, passengers, cars. Side can streak.
Top width fine.
At 375 stats 3 cols.

DOM:
score, passengerCount, carCount.
route counts elements id count-pine etc.
next name dest platform.
waitingList.

next pickup choose min Manhattan to head, tie maybe.
updateSide every pickup or step (distance changes). updateSide called each step maybe DOM overhead okay 5hz. Better each step 5.5/s fine.
Passenger list rows sorted nearest maybe first 3.
Build HTML with static names. We can use template strings. CSS styles using color.
For row include dot, name, dest; right platform arrow.
Need avoid DOM churn causing animations each step; okay. But list would flicker. Update only if changed key string. track lastSideKey.
Avatar CSS update on next target changes.

Score bump class.

Canvas ring:
for p:
 pulse=1+sin(time*4+p.id)*.08;
 ctx.save translate(cx,cy); stroke '#f2b544' width2; setLineDash([4,4]); arc radius13*pulse; rotate time; fill rgba gold .08.
 draw small ticket icon? Person at x offset.
 ring may indicate target.
 Also maybe draw dashed arrow from train to target? Helpful but could clutter. Side next.
No.

drawPeople:
function drawPerson(p,t) {
 const x = p.cell.x*CELL+CELL/2 + (p.platform==='top'?-12:12);
 const feet = p.platform==='top'?24:H-24;
 // draw suitcase
 body.
}
Because bottom ring row9 center475, feet476? p platform bottom feet=H-22=478. Person head=464, overlaps ring but offset.
Top feet=22, head=8.
Person 18 high.
At top awning y0-10, head 8 maybe under awning, okay.
Need draw persons after tracks but before train.
Suitcase small square at x+5, feet-2.
Backpack.
Use p.coat.

Awnings draw top/bottom:
zones = [{x1:0,x2:350,color:'#3e8f87'}, {x1:550,x2:900,color:'#4d79a7'}, {x1:100,x2:400,color:'#d96b55'}, {x1:550,x2:850,color:'#75689b'}]
top zone 0 maybe starts0. Draw stripes from x1. bottom.
Top awning y0 h10 with rounded? stripes.
Add dark line y9.
Destination text maybe signs centered but people; can put tiny plaque on awning with text baseline8 in cream, e.g. centered x175; 9px. Awning h12, text 8px. Person head at y7 could overlap if same x. But sign just 130 wide; many passengers. Could omit labels; colors are enough, side tells.

Maybe draw small station clock at top middle x450? Not a zone, between awnings; can draw circular cream clock on pole extending to 30, over track row0 center x450, but train may pass under. Nice visual. drawStationClock at x450,y12 circle radius11; post down to36; hands. This could make top middle scene. But tracks grid; clock is scenery. draw behind train.
Also bottom bench? Maybe x450,y487 tiny bench. But grid.
Trees? no.

Draw grid track and awnings static.

World order:
drawBackground (gradient, grass)
drawTracks
drawStationDetails (awnings, clock)
drawPassengers
drawPickupEffects behind? particles.
drawHeadlight and connectors/train
drawParticles/puffs/text
drawCrash tint

Wagon drawing details:
function roundedRect(ctx,x,y,w,h,r) custom.
drawWagon(x,y,angle,color,index,t) {
 ctx.save translate rotate
 // local x axis forward
 // connector front perhaps line x18..22
 // shadow rounded rect -20,-12,40,26
 // wheels: at (-11,±13),(11,±13) dark circles/rect
 // body outer dark #214248 rounded -18,-11,36,22
 // colored rect -17,-10,34,20
 // roof highlight maybe fillRect -15,-8,30,4 rgba
 // windows 3 at x=-10,-2,6? each 5x8 dark frames
 // passengers dots
 // lower stripe dark
 // coupler front x18 line
 ctx.restore
}
Since local width 36 and centers 50 apart, gap14 filled connector line.
Connector draw between positions maybe center to center visible under wagons, line width5 dark then width2 gold. Draw from position[i] to position[i-1] for each adjacent. But center-to-center line would show through body but hidden by shapes. good.

Locomotive:
ctx.translate rotate.
headlight cone before:
ctx.beginPath moveTo(20,0) lineTo(66,-27) lineTo(66,27) close fill rgba(255,219,110,.12); maybe stroke.
shadow.
wheels: four black circles at x=-12,10 y=-14/14 radius4, with hubs.
chassis dark rounded x=-23,-13,w46,h26.
boiler gradient? createLinearGradient(-7,-10,20,10) from #287f7a to #164f55. rounded x=-8,-10,w30,h20.
front cowcatcher shape at x21? polygon.
cabin x=-24,-12,w18,h24 dark teal; roof x=-26,-14,w22,h28 cream/gold? draw.
window x=-19,-7,w10,h8 pale blue with shine.
boiler stripe gold thin.
headlight at x23,0 radius4 fill #ffdd72 plus glow.
stack at x10,-11 circle5 dark, smaller.
bell x0,-12.
domes.
red buffer.
Need train length head center extends x24, fits cell 25 exactly.
Coupler rear line x-30 to-22.

Colors. Maybe locomotive has face on front? Could add tiny eyes on boiler? Train with passengers. Could draw headlights as eyes? Request little train. Better traditional.

Wagons colorful with tiny passengers visible.

Angles:
get position for each i:
if i<oldBody.length:
 px=lerp(oldBody[i].x,body[i].x,animProgress)
 else duplicate previous? New wagon i=body.length-1 position same as previous tail? When adding, body after unshift then no pop and push copy of old tail. body length5: oldbody length4 indices0..3. newbody index4 duplicate oldbody3. Then render index3 moves oldbody3->newbody3=oldbody2, index4 remains oldbody3 (since oldbody undefined). This creates new wagon behind at same original point while old tail moves forward; okay. For index>=old len, pos=body[i], scale maybe .8+0.2t. Actually body[4]=oldbody3; pos same, overlaps wagon3 at early t? wagon3 moves halfway from oldbody3 to oldbody2, so separates. good.
- connectors for i1..body.length-1 line current pos i to pos i-1.
- render wagons from tail to head: for i=body.length-1 downto1 drawWagon.
- then loco.
Head position should be new head interpolated. good.

Angle target arrays:
after step:
targetAngles[0]=atan2(dir.y,dir.x)
for i=1: dx=body[i-1].x-body[i].x,dy; if 0 use previous.
body growth last duplicate => dx=0 for last -> use prev.
currentAngles initialized all0 and updated render with dt*10. However when reset after crash, snap.
render uses t maybe passed dt and updates.

AI desired direction can change. No snake body rotation. Good.

Potential issue oldBody and body references:
function tick() {
 oldBody = body.map(p=>({...p}));
 const dir =...
 let next...
 collision...
 const hit = passengers.find...
 body.unshift(next);
 if(hit) { body.push({...body[body.length-1]}); } else { body.pop(); }
 // but after unshift, body last is old tail. If hit push copy same => duplicate.
 // oldBody has length old. animationNew body.
}
Colliding with body slice(0,-1) includes head and all except tail. If next == tail allowed. If hit at tail? passenger never body.
When body length and pickup, body push duplicate.
Current animation resets progress0.
Spawn passenger after setTimeout? setTimeout okay. We can use respawnTimer=0.25 update in frame.
If game over, no tick.
Human direction queue: when queued direction, set direction=dir at tick. For smooth orientation target.
Validate not reverse relative current direction. Queue max2. But if two queued e.g up,left fine.
AI direction returns candidate.
If human queue empty, continue direction; human can crash if no input.

Game interval maybe dynamic: human 0.185, demo 0.16. In frame accumulator += dt; while accumulator>=interval tick. Could tick multiple but render. Use dt max.05. On tab no huge.
animationProgress = accumulator/interval? But accumulator after tick resets. Standard:
acc +=dt;
while acc>=interval {acc-=interval; tick();}
animProgress = min(1,acc/interval); But tick inside loop body and old body. Good.
At first frame acc0.
If tick and then render, progress small. Fine.
Need ensure animation interpolation for tick if multiple: oldBody overwritten each tick, not accurate but rare.

Effects update:
particles {x,y,vx,vy,age,life,color}
texts {x,y,age,text}
puffs {x,y,age,life,size}
spawn steam every .13s; need stack world. Use current position and angle:
positions computed each render; steam spawn in loop based current locomotive world stack. Compute in updateEffects before render using headPos function approximate:
const p=getPos(0); a=currentAngles[0]; stack local(10,-11); wx=p.x+cos*a*10 - sin*a*(-11) etc.
puff drift vx=-cos(angle)*8, vy=-10 maybe.
Update age.
Pickup burst at target cell center.
Text draw with font bold 18 etc.
Also ticket particles squares.

Background grass deterministic: function hash maybe coordinates. draw 100 tiny dots, but tracks cover most. Use fixed random seeded.
Grass gradient.

Crash:
function crash() {
 gameOver=true; paused=false; effects flash.
 if mode==='demo':
  set status "REROUTING"; maybe schedule reset with countdown variable; show toast "A little detour — new route ready!"
  demoCrashTimer=1.3; mode maybe demo; tick disabled.
 else:
  show game over overlay; update final score.
}
Loop if demoCrashTimer>0 decrement; when <=0 resetRound(false? score maybe keep?) Could reset game but score reset. Better preserve score? Demo can keep collected count perhaps new train, score stays. But if crash, reset body/passengers without reset score; complexity. We can reset whole and score=0, okay screenshot not crash likely. But "demo never stops" maybe reset score. Fine.
Maybe AI collision due body leads crash. Need ensure robust. We should test mentally or use code? We cannot execute. Could reason.

AI path and body:
Initial head (5,5) east, target nearest maybe (7,0) distance7, others (2,0)8, etc. BFS target with blocked body excluding tail (head5,5 body4,5,3,5 blocked). For target (7,0), candidate east (6,5) distance? from target to6,5 =6 (horizontal+vertical), west4,5 blocked, north5,4 dist6, south5,6 dist6. tie space chooses maybe east or north. likely east. It moves toward top eventually. Good.
When snake reaches top row, picks target. At target passenger cell, body path. Then next target bottom. Long route. AI BFS avoiding body.
Potential self collision: candidate next not blocked old body excluding tail. Fine.
Could still trap due flood but likely.

AI chooses score d*10 + penalty if space<16. Candidate dist map from target with blocked old body excluding tail. A candidate with small distance may have space e.g 5 due wall; penalty15 outweigh one step10, so choose distance+1 with more space. Good.
But space calculation uses flood from next blocked old body excluding tail. Need include next itself? blocked includes old head not next; start next. okay.
When candidate into tail (old tail), after move tail vacates, space map blocked excludes tail, correct.
At pickup no tail vacates; rare.

Passenger spawn might be unreachable due body? body dynamic. platform cells maybe current body only row5 initial. okay.
AI target nearest path. After all passengers top/bottom, always.

Human score. Great.

Accessibility reduced motion? Could account but not necessary. Maybe CSS @media prefers-reduced-motion disables transitions but game still.

Need ensure board visually not just grid. Add background large diagonal shade. CSS board canvas.

Let's consider title branding. "RAIL RUSH" or "PINE LINE". User asks snake game. Name "Pine Line: Snake Express". Brand title "RAILWAY RUSH" with subtitle "A passenger-picking snake game". This explicitly signals snake.
Logo SVG 64x64 circle yellow, train side silhouette. Inline:
<svg viewBox 0 0 64 64><rect x12 y10... etc>.
Header.
Stats labels Tickets, Passengers, Train.
Mode toggle.

Side panel:
card next:
label "NEXT PICKUP" with ticket icon.
avatar + nextName, nextDest
platform badge.
progress line "Stop 1 of ?" maybe.
Station board card list.
Tip card "A little conductor's tip" with small icon and text "Pick up passengers to add bright new coaches. Avoid hitting your own wagons!"
Maybe stats inside route card: Streak + route counts.

Could use sidebar height same as board? no need.
At 1280 screenshot, board likely 893x496 plus toolbar48 footer52=596. Header maybe80; total 22+80+18+596+22=738. Sidebar card heights 210+220+120=582. nice.
At 375, header 130, board 300, sidebar cards below around? visible within 800 maybe first card and some second. Fine.

Let's compute app max width 1180, width calc(100%-36px). At 1280 =>1180 centered. header brand width300 + stats 400 + toggle140 =840, fine.
Main left =1180-268-18=894. canvas ratio1.8 ->496.7. board 48+497+52=597.
Total topbar 68, margin top20 + header68 + gap20 +597=705 + maybe footer? app bottom 20 =>725. Great.

CSS body min-height 100vh.

Overlay start card h maybe250, fits canvas497.
At mobile canvas ~190, start card must fit. We'll media reduce.

Toast top 12.

Mode toggle button style dark green. initial "AI Conductor" with little toggle knob maybe. We can use actual button:
<button id=modeToggle class=mode-toggle><span class=mode-dot></span><span id=modeToggleText>AI driving</span><span class=switch>...</span></button>. When clicked human->demo, demo->human. Has accessible.
Maybe initial overlay and status "DEMO RUNNING". Good.

Pause button in board toolbar icon.

Toolbar line badge has route icon.
Board footer restart button.

Gameover overlay card:
h2 "End of the line!"
p "You collected <b>...</b> passengers and earned..."
button "Run another route"
Secondary "Watch AI demo" maybe button. Need functionality.
Use same overlay with card. User can restart. Add second button id=demoFromOver.
Start overlay separate.

Pause overlay small card with Resume.

HTML semantic.

No storage.

Let's draft code carefully.

CSS details:
* box-sizing.
button font inherit.
.kbd.
.stats .stat min-width96.
.brand h1 letter-spacing.
.brand p.
.board-card border...
canvas {width:100%; height:auto; aspect-ratio:18/10; display:block; cursor:crosshair;}
.canvas-wrap line-height 0.
.start-overlay transition.
.start-card transform maybe.
Mini engine SVG.
.start-card .primary.
.secondary.
Side.
Use CSS variables dynamic via style.setProperty.
Passenger avatar:
.next-avatar { ...}
.next-avatar .head etc.
Maybe JS innerHTML:
avatar.innerHTML = `<span class="mini-person"><i style="background:${coat}"></i><b style="background:${skin}"></b></span>`;
CSS mini-person.
Simplify:
<div class=next-avatar id=nextAvatar><span class=avatar-head></span><span class=avatar-body></span></div>
JS set body bg and head bg via query.
CSS.
Waiting list .person-dot background.

Initial updateSide after reset.

Station counts:
route-count-pine, sunset, market.
HTML route lines.

Mobile controls buttons.

JS:
(() => {'use strict'; ...})();

Utility:
const $=...
const key=(x,y)=>y*COLS+x.
const idxToCell.

Passenger initial array and roster.
Colors skin etc.
spawnPassenger:
function randomZoneAndName() {
 const zone = zones[Math.floor...]
 const cell candidates zone.cells (x). filter not occupied and not existing.
 choose.
 const profile = roster[(nextId++) % roster.length]
 return {id,...}
}
But initial use roster objects then spawn uses same. Better profiles array 12.
initial passengers manually slice profiles with specific cells.
After pickup, spawnProfile.
When spawn after pick, profile index cycles; zone associated profile. Profiles include zone and cell random. We'll define 12:
{name:'Mia',dest:'Pine Grove',coat:'#e7604d',skin:'#d9946c',zone:'pine'}
Noah teal etc.
zone map:
pine cells x1..6 top
sunset x11..16 top
market x1..7 bottom
old x11..16 bottom.
Some initial cells:
Mia x2 top
Noah x6 top
Lina x12 top
Theo x4 bottom
Ava x14 bottom
Could use profiles first5.
When spawn uses profiles[nextProfile++ %len], choose cell random within zone, avoiding occupied.
Initial positions assigned.
Need if profile repeated okay.
name.

Passengers sorted by id.

resetGame(clearScore=true):
body start; passengers = first5 map copy assigned cells; nextProfile=5; score etc if clear.
But if reset after demo crash and clear score yes.
routeCounts.
oldBody=body map.
targetAngles body length fill0.
currentAngles.
effects=[] puffs=[].
respawnTimer=0.
updateUI.
Passenger target cell should not overlap.
When pickup remove and respawnTimer=.18; in updateEffects if >0 decrement then spawn once. Need boolean pendingSpawn.
If profile zone cell options exhausted, find any free.
spawn:
let profile=profiles[nextProfile%...]; nextProfile++;
const zone=zones[profile.zone]; candidates = zone.cells map; exclude body and passengers.
If none try other zones.
Passengers push.
No score.

reset after gameover:
score reset. gameOver false hide overlay, mode human? Button restart should human mode. If reset from mode toggle etc.
function restartHuman() { mode='human'; hasStarted=true; resetGame(true); hide overlays; updateModeUI();}
Initial reset mode demo but hasStarted false.

Initial overlay visible from HTML. startGameHuman:
hasStarted=true; mode='human'; overlay hidden; gameOver hidden; pause false; update.
On any canvas pointerdown maybe start human. But if clicking board not overlay after started do nothing.
overlay button click stops propagation.
document keydown.

AI status after initial.

Tick:
function tick(){
 if (paused||gameOver||demoCrashTimer>0) return;
 oldBody=body.map...
 let dir;
 if(mode==='demo') dir=chooseDemoDirection(); else {dir=directionQueue.shift()||direction; if reverse? validated}
 const next...
 if out crash; return.
 const occupied=body.slice(0,-1); if occupied.some(next) crash.
 direction=dir;
 const hit=passengers.find...
 body.unshift({...next});
 if(hit){ body.push({...body[body.length-1]}); handlePickup(hit,next); } else body.pop();
 // compute angles
 targetAngles = body.map((p,i)=> i0 dir angle else diff...)
 // if length adds...
 oldBody set already.
}
Collision nuance occupied includes head; next not same unless zero dir no.
When next equals old tail, body after no pickup: unshift next then pop old tail, okay.
When pickup and next equals tail impossible.

handlePickup:
passengers filter.
score +=100+(streak-1)*25; streak++; collected++;
routeCounts[dest]++.
effects particles at cell.
toast DOM "Mia is aboard! +125".
effects text.
respawn pending.
updateUI.
Sound maybe no.

AI choose:
function bfsFrom(start, blocked) {
 const dist=new Int16Array(COLS*ROWS); dist.fill(-1);
 if blocked.has(key(start))? start can be blocked? target not. queue.
 while...
}
For each target BFS from target with blocked Set old body.slice(0,-1) (includes head, excludes tail). Since target could currently be body? passengers avoid. If target blocked? no.
For candidate, d=dist[key(next)].
Need target path from next to target may pass candidate? fine.
Nearest target score maybe d.
Space function:
function floodSize(start, blocked) {
 queue; visited. For neighbors if in bounds and !blocked.has && !visited.
}
blocked after move as old body.slice(0,-1) including old head. But next may be old head? no. Tail excluded.
Candidate options compute blocked.
Then target maps. We could create dist map for all passenger each tick (5*180). Fine.
Pick best candidate:
let bestTargetMap null; let bestD=Infinity;
for p:
 map=bfs(p.cell,blocked)
 for option d=map[optIdx]; if d>=0 and d<bestD maybe.
If map candidate d. Note distance from target to candidate path could pass through current head? blocked includes head, so not.
Then evaluate all options using chosen map:
metric = d*10 + (space < 14 ? 20 : 0) + (d<0?1000:0);
also if option next equals passenger target d=1.
Could select target with global d; first find min d across targets. Then among options where map dist<=minD+1? We want safety. For each option, its d with chosen map. Choose smallest d. If candidate d=2 and another d=3 space100, pick d2 maybe. Penalty.
Use metric=d*10+(space<18?12:0); So if d2 vs d3 and d2 space5 =>20+12=32 vs30 d3 => d3. Good.
But if no target reachable, option metric=space negative.
We also need consider target itself maybe target map d is distance candidate->target; if current head is adjacent candidate, okay.
When multiple passengers, chosen nearest by min candidate d. Then options only chosen map. If candidate d chosen. Fine.
Potential candidate path to target can be through another passenger (not blocked) no issue.
If options none crash.
Safety of continuing: if candidate space small but no target reachable, pick max space.
Tie prefer current direction to avoid jitter.
Implementation:
const baseDir current. metric includes if space equal and same direction.
For each p compute map, minD. choose p minD.
Then if chosen:
 options.map metric; d>=0.
 else all option space.
Return best.dir.
Need compare d path length; if best candidate map distance maybe path from candidate to target avoiding body but once snake continues, body changes and path might close. Recompute each tick.

flood blocked set includes old head, but path to target from next might need to cross old head impossible until later; correct body occupies.
Space maybe board excluding candidate's new body; old head remains. good.

Could AI loop between two targets? targets nearest changes. It collects.
Demo crash likely rare.

render interpolation:
function getTrainPositions(){
 const t=easeInOut? use linear maybe.
 return body.map((p,i)=>{
  if(i<oldBody.length) return lerp(oldBody[i],p,t);
  return {...p}
 })
}
At tick oldBody set before, then body updated. For new index beyond old body length, p duplicate.
At reset oldBody same, t0.
For animation progress, acc/interval. But after tick we set oldBody then acc-=interval. Works.
Potential animation shows new head at t0 old head, good.
Wagon positions as oldBody[i]->body[i]. Good.

update effects uses dt.
Render text effects:
ctx.globalAlpha etc; font '800 16px Trebuchet MS'; fillText.
Canvas draw gradient headlight. Fine.

Clock:
draw at x450 y12; circle. But top row track at y25, pole. Could be.
drawTracks:
background and texture. need ctx.globalAlpha resets.
Use seeded random function deterministic:
let seed=123; rand.
for i 0..140 x rand*W etc draw small grass line if maybe.
Grid.
Track ties many. Use rounded? no.
Rails highlight.
Track beds maybe need vertical bed not obscure horizontal ties. Draw all beds then ties then rails. good.
At row beds fill #ded6c0 alpha .85; vertical same.
Maybe routes grid overwhelming. Could lighten rails #667470.
Sleepers #a98f6c.
Rail shadows.
Station detail awnings at top/bottom. drawAwning(x1,x2,color,y,down): stripes 16.
At top y=0 h11, bottom y489 h11.
Create scalloped edge circles perhaps 4px radius at y11 or489. Nice.
Clock behind? draw after awning.

Draw tracks loops 28* etc per frame fine.

Canvas board could show route badge in top right? no.

Board CSS maybe a subtle overlay pointer.
Cursor crosshair only initial; human default? CSS class human cursor default.

Pause overlay:
<div id=pauseOverlay class="mini-overlay is-hidden"><div ...><h3>Station stop</h3><button resume></div></div>
Gameover overlay:
<div id=gameOverOverlay class="start-overlay is-hidden">...
Need pause overlay background.

Toast DOM class show.
When toast show, clear timeout. setTimeout allowed. no blocked functions.
Use `window.setTimeout`.
No alert etc.

Mode toggle:
if initial click -> startHuman; if human -> mode demo, direction maybe continue, status; if demo->human.
Initial button text maybe "AI CONDUCTOR" and subtitle? button width.
Use `modeToggle.innerHTML` with dot + label. But preserve.
CSS .mode-toggle.on etc.
status chip.

Pause:
pause button toggles. If initial? can pause demo behind overlay perhaps. But overlay on top. not.
Button text icon `Ⅱ` / `▶`.
ARIA.

Reset button. If click resets human.

Keyboard direction queue:
function queueDirection(d) {
 if !hasStarted startHuman();
 if mode==='demo' mode='human';
 if directionQueue.length<2:
 const last=queue last or direction; if d.x===-last.x && d.y===-last.y return; if same return; queue.push(d)
}
At keydown, starting human before applying arrow.
Start human via key 'Enter',' ' maybe. Space pause? Initial requirement any key starts. Use if !hasStarted: startHuman; if arrow apply; else just start.
For 'r' maybe restart.
Prevent default arrows/space.
If mode demo and user presses direction, switch human.
Could not receive keyboard until click due iframe but initial click button.

Touch pointerdown queue direction. if !started start human.

Click canvas after initial overlay? Overlay intercept. Start button.
Mode toggle click doesn't start? It can switch to human, initial overlay still; maybe startHuman if toggled.

Update UI:
modeStatus inner text.
score.
next target.
Side waiting list.
Side counts.
Streak.
Maybe score flash.

`updateSide` called every tick can rebuild list. Use last signature passengers IDs + next id.
Names static.

Initial target nearest:
dist = abs(head.x-p.x)+abs(head.y-p.y)
Sort. Could choose top Mia. Side shows Mia.
Avatar style.
Route counts.

Board footer live instruction:
`statusText` "Conductor AI is collecting passengers" / "Collect gold-ring passengers • avoid collisions".
Maybe in toolbar.

Canvas drawing names? Gold ring enough. Person no label.

Passenger pickup person disappears, sparkles.

Train direction and headlight.

Passengers platform side and awning labels perhaps small. We can draw zone labels as very tiny at awning end:
in drawAwnings, put text within color band? stripe and label unreadable. Could draw at far center a cream plaque 100x10 atop awning, text 7. But people maybe. Top pine plaque centered x175; initial Mia x125, Noah x325, no overlap. Sunset plaque x725; Lina x625. bottom market x250; Theo x225 maybe overlap! Shift initial Theo x2 (125). Old x800 maybe. Let's assign initial:
Mia x2 (125)
Noah x6 (325)
Lina x12 (625)
Theo x1 (75) market
Ava x16 (825) old
Then plaques centers pine175 (125-325 could Noah at325 edge), sunset725, market225, old725. Person tiny. okay.
Draw label plaques at y0 top / y489 bottom, 96x10, cream with dark text 7px. Bottom person Ava825 no. Fine.
Awning scallops.

Maybe map looks 2D top-down but train side-ish. Fine.

CSS side avatar:
next-avatar width54 h54 background #f3dfb4 border 2 #d2c39f; border-radius 16; position.
head circle top10 left21 w12 h12 bg skin; hair maybe.
body width22 height19 bottom-1 etc.
Need JS set `--skin`, `--coat`.
CSS box-shadow inset.

Route list station rows.

Tooltip maybe.

Use SVG icons inline:
brand train.
pause button could text.

One code block only.

Let's ensure no errors:
- `ctx.roundRect` supported latest Chrome but custom safer.
- using `structuredClone` no.
- CSS `color-mix` avoid.
- `Array.at` supported but avoid.
- `window.devicePixelRatio`.
- If canvas context null impossible.

DOM ids must exist.
Initialize after DOM script at end.

Let's think of screenshot first paint at 3 seconds: AI train moved around 15 cells; passengers maybe one or two collected, scoreboard changed, colorful wagon. Great. Overlay still visible, but behind game action. Start overlay says click. The AI may collect and add wagon visible behind.
Could overlay hide a passenger; fine.

Overlay card maybe too opaque. background board still.
Game demo behind start overlay with AI. Status in toolbar.

Game difficulty human: snake standard. Directions. no mouse steering. Good.

Potential issue self-play AI target spawning always and collects; body grows, can trap. We'll make initial demo AI fairly robust. Let's deeper validate AI path choices.

Take grid 18x10, head starts (5,5), body west 3 cells. Target Mia(2,0). blocked set {head(5,5), (4,5),(3,5)} excludes tail(2,5).
BFS from target(2,0) blocked. Distances:
north (5,4)=6? from2,0 to5,4=7; east (6,5)=9; south(5,6)=6 (Manhattan 4? |3|+|1|? target2,0 to5,6=9 actually). west4,5 blocked. candidate north d7, east d9, south d7. pick north d7 or south d7. Space high. tie maybe current direction east slight preference only if metric equal; east d9 loses. It goes north.
At head5,4, body etc. Target path distance6. Candidate north5,3 d6; west4,4? BFS from target to4,4 path maybe around body? distance6 but body4,5 blocks not. east6,4 d7, south5,5 blocked. picks north.
continues top. At head5,2, target2,0. candidate west4,2 d3? Manhattan4? |2|+2=4; north5,1 d2 (Manhattan3?), target2,0 to5,1=4. south5,3 d5. picks north maybe. At head5,1, target2,0: west4,1 d2? target->4,1=3; north5,0 d1? target2,0 to5,0=3, so d3; south5,2 d4. blocked includes old head5,2 etc. picks west or north depending; north direct along top but from5,0 to2,0 passes cells4,0,3,0,2,0, no body. BFS target2,0, candidate north5,0 distance3. candidate west4,1 distance3 (via? target2,0->2,1->3,1->4,1 =3; but maybe body none). Tie spaces. Could choose west4,1, then west3,1, then north3,0 then west2,0 pick. Or north direct. Either.
Target reached, pickup. Body grows. Next target maybe Noah x6 top. At head2,0, body behind. Candidate east3,0 d? target6,0 distance4, but if body tail maybe etc. BFS blocked old body excluding tail. After pickup, tail remains old tail because added, so blocked includes all old body incl tail! Our blocked used old body.slice(0,-1) always excluding tail even when pickup; slight.
Next target nearest Noah6,0. East d4. picks east, along top, collects. Good.
Then target Lina12,0, path east, collects maybe passes other. Train along top row length growing behind, cannot reverse, will turn after Lina.
Then bottom Theo1,9, path down left. body top row long (head12,0, body extends left). To go down at x12, body tail etc, can turn south, then left bottom. likely.
As train grows, body length up to9, still 180 cells. AI BFS.
One issue top row passenger target cells can be passed through and spawn new passenger on same cell right after pickup. If spawn after .18 sec and snake continues east through target x+1 etc, new spawn random zone could be behind. okay.

AI body occupancy check: blocked old body.slice(0,-1) excludes tail, so candidate can move into tail. After tick without pickup tail pops; good.
Flood space might see path through old tail (excluded) as free, but after move tail vacates yes.
After pickup tail not pop, our blocked excluded tail incorrectly, but rare candidate into tail could collide next tick? Tick occupancy uses body.slice(0,-1) after new body; if new body includes duplicate tail, body.slice excludes only last duplicate, includes actual tail at second-last, so collision detected. It may crash if chooses tail, but AI probably not. Could adapt blocked set based hit unknown before move. We can detect if next is old tail; if passenger there no. But pickup flag known after next. We choose before. Could include tail always in blocked to be safe, but then candidate cannot move into tail even though allowed, slight. Use include tail for AI, reducing dangerous. But occupancy actual would allow. Fine. blocked=Set(all body) for AI ensures no self collision; tail never needed. Initial path unaffected. Do that. Then BFS more constrained. Better safety. AI always avoids all body, cannot choose tail. After pickup okay.
Collision actual allows tail but AI not.
Space use blocked all body plus old head? body includes head. After move old head remains occupied. good.
So blocked = all body cells. Candidate next not body. BFS map target with blocked all. This path after moving cannot cross old tail because in pickup tail stays, conservative. Great.
Flood starts next, blocked all old body. This reflects pickup worst-case.
Should AI still find route when body forms barrier across width? Grid 10 rows, body length maybe10 can form vertical barrier, then unreachable; it picks space and maybe eventually collides. Could need smarter. But body turns not walls, may create vertical line. Passengers at edges; snake could trap itself. We can implement smarter safe path using flood and target. Body length perhaps up to10, grid height10, could vertical barrier. AI collects maybe 5 before. Need demo stable for screenshot only 3 sec, even if crash later reset.
Maybe AI chooses path to target ignoring body entirely but uses safety. Could avoid.
We can cap demo growth? Real growth. Could make passengers every 3? no, pickup should add wagon. 5 initial means grows to9. Fine.

Could implement collision avoidance robust with Hamiltonian cycle pattern. Grid 18x10 even. A cycle path can traverse all cells and AI follow serpentine Hamiltonian cycle, but collecting platform passengers. Could guide AI along cycle. But snake path itself may not match. BFS good.

AI target path may enter body after turn. flood prevents low space. Good.

Human train length update after pickup body length +1. Collect target ring exactly next cell. Great.

Render track row count. C=18 rows10. Canvas W900 H500.

Let's flesh CSS responsive:
@media (max-width: 900px) {
 .layout {grid-template-columns:1fr;}
 .sidebar {display:grid; grid-template-columns:1.2fr 1fr; gap12;}
 .tip-card {grid-column:1/-1;}
 .topbar {flex-wrap:wrap;}
 .stats {margin-left:auto}
}
@media max620:
 .app width calc100%-16px;padding 10px 0 16px;
 .topbar {gap10}
 .brand h1 24
 .brand p display none
 .stats width100%; margin0; display:grid 3 cols
 .stat min-width0 padding8
 .mode-toggle position absolute top? topbar position relative; maybe mode in board toolbar instead? Header layout: brand and mode same row, stats next row. Set topbar display:grid grid-template-columns 1fr auto; .stats grid-column:1/-1; .mode toggle.
 .layout gap12.
 .sidebar grid-template-columns:1fr; tip hide maybe.
 .board-toolbar min-height44.
 .line-sub hidden.
 .board-footer .legend hidden.
 .control-copy font-size11.
 .touch-controls display:grid.
 .start-card padding14 18; .engine-badge display none; .start-card h2 margin4; p margin8; callout font11; button padding10.
 .gameover h2.
}
Need modeToggle width maybe 130; brand 200. At 375 content359, brand ~190 + toggle120 =310 okay.
Stats.

At 1280 header logo title. Brand subtitle.

Sidebar card next maybe 200. route 250. tip 120. gap14 total598.

Canvas is inside border board. Board toolbar dark.
Board-card border 3. CSS overflow hidden. Border radius.

Legend foot.

Touch controls inside board after footer, display none desktop; at mobile padding 8 background. Grid 3 cols 44.

Start overlay buttons.

Potential issue `.start-overlay.is-hidden` visibility transition; gameOver initial hidden. okay.
`overlay.classList.add('is-hidden')`.

Canvas pointerdown on start button won't canvas.
`canvas.addEventListener('pointerdown', ()=> { if(!hasStarted) startHuman(); });`.

Mode toggle click:
if !hasStarted {startHuman(); return;}
mode = mode==='demo'?'human':'demo';
if demo directionQueue=[];
updateModeUI.
When switch human after demo, current direction. good.
Gameover mode human; mode toggle can switch demo and hide gameover? implement if gameOver and switch to demo resetGame(false?) and hide. Need.
restart button calls resetHuman.

Status toolbar:
modeStatus class demo/human; innerHTML maybe `<span class=status-dot></span>AI CONDUCTOR`.
Button text.

Update mode:
modeToggle class toggle-on if demo.
modeToggleText textContent mode==='demo'?'AI driving':'You’re driving'
modeStatus.
pauseBtn aria.

AI pause behind initial? Pause button accessible but overlay catches only canvas, toolbar button clickable. clicking pause before start might show pause overlay on top of start; weird. In pause handler if !started startHuman maybe. Fine.

Toast position z-index 5 but overlay z10, behind. initial no toast.

CSS score bump.

SVG logo in brand:
circle or rounded square background yellow, draw tracks and train side.
We can make SVG 52 viewBox. No external.
button icons can be Unicode. Unicode arrows render. Fine.

Need maybe title "RAILWAY RUSH" and snake game explicit small subtitle "THE PASSENGER SNAKE". Good.

No network.

Let's start compose code. Need be careful code length maybe ~1500 lines? Desired moderate, we have token budget. We can produce complete around 900 lines. Only code block.

Before final, let's reason more about canvas and gameplay polish.

Canvas track visual might look like graph paper. Could add route arcs? Classic snake requires grid tracks. We can have only rows and columns, okay.
Maybe add dashed directional arrows? no.

Station awnings:
function drawAwning(x1,x2,color,y) {
 ctx.save; rect; stripes; scallop circles at lower edge. top lower edge y11, bottom upper? bottom awning from489 to500, scallops at y489.
 For bottom, draw y=489; text plaque.
}
Top awning labels at center. Plaque on top can obscure stripes but okay.
Stations zones x:
pine 0-350; sunset550-900; market50-400; old550-850.
Plaque:
drawPlaque(x,y,w,text,top) with rounded cream and shadow.
text baseline top y+8. font 700 7px.
top pine center175, y0 maybe plaque h11 from0 to11. People Mia x125, Noah325. plaque extends 127..223, overlaps Mia head at113? Mia person offset -12 ->113, okay. Noah offset313, okay.
sunset center725 plaque677..773; Lina x613 no.
bottom market center225 plaque177..273; Theo person offset +12 x87 no.
old center725 plaque677..773; Ava person837 no.
Good.
Clock center450 no awning. draw circle at y13 with pole to37. Could overlap train row0 center25 but behind. Add.
Perhaps benches at x450 bottom. not.

Grass texture mostly covered. At cells background strips visible.

Draw track:
Need draw vertical beds before ties; all.
Use ctx.fillStyle. horizontal bed extends full W at y center-15. vertical full H x center-15. Thus nearly entire board beige except green bands at 20px around each intersection? Let's see horizontal beds y10..40, next y60..90, so green strip y40..60 (20). Vertical beds x10..40, x60..90 etc. Their union covers most, but green vertical strips x40..60 and horizontal strips y40..60; intersection green patches 20x20. Board ~64% beige. Fine.
Rails on all rows/cols makes grid. Classic.

Could make beds translucent and background blue/green. Fine.

Render train orientation:
Locomotive local front x positive. angle for east0, south pi/2, etc. Draw headlight cone. Wagons connect.
At turn, body cells. Good.

`drawWagon` use index to vary window passengers:
window positions [-11,-2,7], each 6x9, fill #eaf4e6, dark border. At some windows show head dot colored. Since coach length36, x7+6=13. body front18. good.
color body. roof strip darker maybe.
Cargo wagons for some pickups? User specifically colorful wagons, coaches with passengers apt. all coaches.
Maybe alternating color and tiny passenger.

Locomotive:
Need local coordinates:
- shadow -25,-15,50,30
- wheels four at x[-13,10], y[-14,14] radius4
- chassis -23,-12,47,24 dark
- cabin -24,-12,18,24 fill dark; roof -26,-14,22,28 gold? maybe roof cream with red line.
- boiler -8,-11,31,22 fill teal gradient; front at23.
- nose cowcatcher x22 points.
- window cabin -20,-7 10x9 pale.
- boiler band at12 maybe gold vertical.
- stack at10,-12.
- bell at0,-12.
- headlight at23,0.
Wheels drawn behind body.
Cabin overlaps boiler at -8.
Steam stack position should be local x10,y=-12. world.
Headlight cone draw before.

Maybe draw track under wheels.

When head moves east, locomotive head extends to next cell boundary, visually train occupies &gt;1 cell, fine. Body collision grid corresponds center.

Effects:
Gold ring around waiting passenger target at center. Draw a small floating ticket icon? maybe circle with check:
draw arc and a tiny downward chevron above? Ring enough.
Could draw tiny flag pole at x+15 etc.

Draw people:
skin variable. Hair maybe a half circle.
Use coat, legs #304a4c.
top orientation people upright screen, independent train rotation. fine.
At bottom invert? Person still upright, feet on platform at bottom and head up, correct.
bottom draw body from feet-12 to feet-3, head at feet-16. With feet=488, head472; ring center475. okay.
top feet=18, head2. Awning y0-11 behind head, head on awning. okay.
Maybe top feet=22, head6, better.
Draw small shadow.

Target ring pulse at cell center. Person offset ±12.

Pickup particle location target center, not person.

Canvas overlay flash on collision:
ctx.fillStyle rgba(230,80,60,flash*.25); fillRect.
crash shake? render ctx translate random small for .3 sec. Could.

Gameover overlay should appear after crash immediate. Update final score.

No sound. Fine.

Let's ensure side list initial after reset before render.
`lastSideSignature`.
`updateSide`:
const sorted=[...passengers].sort...
const next=sorted[0].
if next:
 nextName.textContent=next.name+" is waiting";
 nextDest...
 nextPlatform text "NORTH PLATFORM"/"SOUTH PLATFORM"
 avatar vars.
 else...
signature = sorted.map(p=>p.id).join(',')+'|'+next?.id;
if differs rebuild waiting innerHTML. The list could show all5; row height32 =>160 fits card.
Use `.waiting-row`.
Update counts each time regardless.

Route counts use destinations. HTML IDs.
Score DOM update every tick not needed.

`updateScore` called pickup.

Toast:
function showToast(title, sub) {
 toast.innerHTML `<strong>${title}</strong><span>${sub}</span>`;
 class show; clear timeout; setTimeout remove 2200.
}
No untrusted.

Mode update.
Status text in board footer maybe `#driveHint`.
initial "The conductor is on demo duty".
human "Collect gold-ring passengers. Avoid collisions."
AI may show toast when picks; good.

Touch controls CSS `display:none` and media.

HTML no duplicate IDs.

Let's consider user may click board not start button while overlay intercept. Overlay background is clickable but no listener on overlay itself. Add startOverlay.addEventListener('pointerdown', e=> if e.target===startOverlay startHuman()) so clicking anywhere starts. But button click bubbles and target not overlay. Good.
Overlay card says click or press key.

Key event only after click; okay.

Game starts demo mode behind overlay. `hasStarted=false` but loop still ticks. yes.

If initial user clicks start button, startHuman. AI might have moved. Human takes current direction.
If initial user presses 'r', start human then restart maybe. fine.

If demo mode switched via button, no overlay maybe initial. okay.

Mobile pointer controls start human.

No horizontal scroll: board min-width0; sidebar; buttons. CSS.
Canvas width100%.
App max width.

Let's maybe add corner decorative frame within canvas with border? board border.

Need final only code block. Let's formulate.

Potential game timer/AI:
Loop:
let last=performance.now(), acc=0, steamClock=0;
function frame(now){
 const dt=Math.min(.05,(now-last)/1000); last=now;
 if(!paused&&!gameOver){
  acc += dt;
  const interval=mode==='demo'?0.155:0.185;
  while(acc>=interval){acc-=interval; tick(); if(gameOver||demoCrashTimer>0){acc=0;break;}}
  animProgress=Math.min(1,acc/interval);
  updateEffects(dt);
  steamClock+=dt; if>0.12 spawn...
 } else maybe update effects still for particles? On gameover should particles continue. So call updateEffects(dt) always unless paused maybe.
 if(demoCrashTimer>0){ decrement; if <=0 resetRound(true) }
 render(now/1000);
 requestAnimationFrame(frame);
}
If reset in frame mode demo, updateMode.
When pause, effects maybe continue? We can not update.
`animProgress` if tick happened; good.
`tick` may crash and set demoCrashTimer; body unchanged? crash occurs before body update; train stays. render flash.
`crash` demo sets demoCrashTimer=1.2 and effects. Do not set gameOver=true? Set `crashed=true` perhaps. We use gameOver only human. `demoCrashTimer>0` blocks ticks. Set status. In frame decrement then reset. Keep gameOver false.
`crash`:
effects flash=1; burst.
if demo {demoCrashTimer=1.1; toast; updateModeUI status 'REROUTING';} else {gameOver=true; show overlay}.
Need `updateModeUI` called each frame? status in HTML. It would overwrite rerouting. Better separate statusText function. `updateModeUI` not called until reset.
On demo crash show toast "Bumper cars!" maybe.
After reset score0. okay.

Animation update for currentAngles should continue even crash? fine.
When reset set current angles0 maybe snap.

Effect update decrement flash.
Respawn timer: if pending and not paused, decrement; spawn at zero.
At pickup same tick, pending spawn=.22. AI target list one less for .22, fine.

Initial passengers and train. score. routeCounts.

What if human hits wall. Gameover overlay. Start overlay hidden. Gameover overlay visible. Need buttons:
runAgain -> restartHuman
watchDemo -> mode demo, reset, hide.
No confirm.

`restartRound(clear)`:
gameOver=false; demoCrashTimer=0; paused=false; hide; reset.
When reset from demo crash, mode remains demo, hasStarted true (if was switched). if initial somehow no.
`resetData` score optional.
For demo crash reset score, collected.
Mode status.

If human restarts from footer, mode human.
Pause overlay.

A subtle issue overlay `startOverlay` pointer events and start button. Fine.

Let's maybe include "no long play session hidden" score initial but AI collects.

Let's write exact code now mentally. Use HTML entities in JS template? fine.

Canvas custom rounded:
function rr(ctx,x,y,w,h,r){ r=Math.min(...); ctx.beginPath(); ctx.moveTo... ctx.arcTo...; }
Need `ctx` argument shadow global. okay.
Shadows reset via save restore.

drawAwning text plaque:
ctx.font = '700 7px "Trebuchet MS", sans-serif'; baseline middle.
Color zones.
Bottom awning maybe stripe.

Grass texture deterministic:
for i, const x=(i*137.5)%W etc; y=(i*83.7)%H; if i%... draw small line. Since tracks cover, only visible green. fine.

drawBackground reset transform? ctx.save.

Rendering each frame:
ctx.setTransform(dpr,0,0,dpr,0,0); ctx.clearRect.
ctx.save; if crash shake translate; draw.
The background fill should cover entire even shake leaving edges. draw before shake or fill expanded. Do fill first then translate content. simplest no shake.
drawBackground.
drawTracks.
drawStations.
drawPassengers.
drawTrain.
drawEffects.
drawFlash.
ctx.restore.

drawEffects text should be above train. puffs maybe above too.
Particles.

drawHeadlight cone is in drawLocomotive local and rotates, perhaps over tracks.

drawTrain connectors positions; positions computed.
Angles update: currentAngles array ensure lengths.
function updateAngles(dt) { for i targetAngles; diff=((target-current+PI)%(2PI)+2PI)%2PI-PI; current += diff*Math.min(1,dt*12); }
On direction 180 impossible. smooth quarter turn in .15 sec.

When initial body angle east. draw wagons color index (i-1).
For added wagon color palette based body index, `palette[(i-1)%len]`.
Initial color coral, teal, yellow. Great.

`drawWagon` passenger dots index deterministic `(i*3+j)%3`. window fill. tiny colored circle.
Couplers.

Text effect uses "MIA ABOARD +125" maybe.

Side route station counts. update.

Initial waiting list ordering by nearest. Theo bottom maybe Manhattan6 vs Mia7 etc. Let's calculate head5,5: Mia2,0 d8; Noah6,0 d6; Lina12,0 d12; Theo1,9 d8; Ava16,9 d15. next Noah. Fine. After AI maybe Noah.
Names in panel.

Board toolbar status.

We can perhaps show tiny badge "PICKUP" around ring. no.

Let's ensure HTML canvas accessibility: role=img? For game use aria-label. tabindex=0. On click focus canvas. keyboard global.
`canvas.tabIndex=0`.

Could focus canvas on start button. focus triggers no keyboard? iframe receives after click anyway.
`canvas.focus({preventScroll:true})`.

Mobile controls pointerdown and click could duplicate? pointerdown only, button also keyboard click maybe. Add click for keyboard? not needed. Buttons pointerdown queue; preventDefault stops click. For keyboard activation no. Could add `click` if detail===0. fine.

Let's consider browser sandbox might disallow `canvas.focus({preventScroll:true})`? works.
No network.

Potential user asks "make me" not explanation, code only per developer.

We should output one fenced code block. Great.

Before final, maybe we can enrich game with "tickets" and route. Good.

Let's formulate complete file around 1000 lines. Ensure no backticks inside that close fence; HTML JS template literals use backticks but fence is triple so okay. No `</script>` in strings. fine.

Let's write.