Models 44
Train snakeOne-line

Qwen3.8 Flash

by QwenReasoningClean
Open in new window

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

How it went

Total time
14 min 52 s
Cost
$0.028
Output tokens
60,143
Spent thinking
48,897 tokens
Speed
67.8 tok/s
Console errors
0
First token after
5.3 s
First visible text after
13 min 11 s
Prompt tokens
418
Code size
32.0 KB
Finish reason
stop
Served by
Alibaba
Model id
qwen/qwen3.8-flash
Reasoning sent
model default
Max output
64,000 tokens
Generated
Nov 11, 2026

What it was asked

The prompt
“make me a snake game where the snake is a little train with a locomotive and colorful wagons, picking up passengers waiting on the platform”
System prompt, sent with every prompt
You are generating a self-contained web demo. It will be rendered automatically in a sandboxed iframe and compared side by side with the output of other AI models given the exact same task.

Output rules:
- Reply with exactly ONE fenced code block containing the complete file: ```html (or ```svg when the task asks for an SVG). No text before or after the code block.
- Everything must be inline: CSS in <style>, JavaScript in <script>. No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task.
- Do not use localStorage, sessionStorage, cookies, IndexedDB, alert(), confirm(), prompt() or window.open(): they are blocked in the sandbox and will throw.
- The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar.
- Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction.
- Everything that makes the demo interesting must be visible within the first seconds: nothing hidden behind a high score, a later level or a long play session.
- Games must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, so the game can be judged in action without playing.
- The iframe only receives keyboard events after the user clicks it: if the demo uses the keyboard, display a clear "Click or press a key to start" message.
- The code must run with zero errors in the console of the latest Chrome.

Code run-1.html · 1141 lines

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Station Snake — Little Train Pickup</title>
  <style>
    :root {
      --ink: #e9eeff;
      --muted: #a9b8dc;
      --panel: rgba(22, 32, 58, 0.72);
      --panel-border: rgba(90, 130, 210, 0.32);
    }

    * { box-sizing: border-box; }

    html, body {
      margin: 0;
      min-height: 100%;
      background:
        radial-gradient(circle at 12% 0%, #243a6e 0%, rgba(36,58,110,0) 35%),
        radial-gradient(circle at 88% 10%, #1c6668 0%, rgba(28,102,104,0) 32%),
        linear-gradient(160deg, #091022, #0c1428 45%, #070c19);
      color: var(--ink);
      font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
      overflow-x: hidden;
    }

    body {
      min-height: 100vh;
      display: block;
    }

    .wrap {
      width: min(100%, 1180px);
      margin: 0 auto;
      padding: clamp(10px, 2vw, 22px);
      display: flex;
      flex-direction: column;
      gap: clamp(10px, 2vw, 16px);
      min-height: 100vh;
    }

    header {
      display: flex;
      flex-direction: column;
      gap: 6px;
    }

    h1 {
      margin: 0;
      font-size: clamp(1.45rem, 3.5vw, 2.35rem);
      font-weight: 900;
      letter-spacing: 0.03em;
      text-shadow: 0 4px 18px rgba(0,0,0,.42);
    }

    .subtitle {
      margin: 0;
      color: var(--muted);
      font-size: clamp(.88rem, 2vw, 1.05rem);
    }

    .hud {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(106px, 1fr));
      gap: 8px;
    }

    .stat {
      background: linear-gradient(180deg, rgba(33,48,85,.76), rgba(18,27,50,.76));
      border: 1px solid var(--panel-border);
      border-radius: 14px;
      padding: 9px 11px;
      box-shadow: inset 0 1px 0 rgba(255,255,255,.08), 0 8px 18px rgba(0,0,0,.16);
      min-width: 0;
    }

    .stat span {
      display: block;
      font-size: .63rem;
      text-transform: uppercase;
      letter-spacing: .09em;
      color: #8fa5d4;
      margin-bottom: 3px;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }

    .stat strong {
      font-size: clamp(1rem, 2.5vw, 1.35rem);
      font-weight: 900;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }

    .board-wrap {
      position: relative;
      width: 100%;
      max-width: 1000px;
      margin: 0 auto;
      aspect-ratio: 4 / 3;
      max-height: min(63vh, 640px);
      min-height: 240px;
      border-radius: 22px;
      overflow: hidden;
      background: #091021;
      box-shadow:
        0 24px 58px rgba(0,0,0,.48),
        inset 0 0 0 1px rgba(126,169,255,.28),
        inset 0 0 42px rgba(0,0,0,.45);
      touch-action: none;
      cursor: pointer;
    }

    canvas {
      display: block;
      width: 100%;
      height: 100%;
    }

    .overlay {
      position: absolute;
      inset: 0;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      gap: 10px;
      padding: 18px;
      text-align: center;
      background: rgba(5, 10, 22, .56);
      backdrop-filter: blur(2px);
      color: #fff;
      pointer-events: none;
      transition: opacity .22s ease, visibility .22s ease;
    }

    .overlay.hidden {
      opacity: 0;
      visibility: hidden;
    }

    .big {
      font-size: clamp(1.22rem, 4vw, 2.1rem);
      font-weight: 900;
      text-shadow: 0 4px 20px rgba(0,0,0,.55);
    }

    .small {
      font-size: clamp(.84rem, 2.3vw, 1.05rem);
      color: #d7e1ff;
      max-width: 640px;
      line-height: 1.35;
    }

    .controls {
      display: flex;
      justify-content: center;
      gap: 10px;
      flex-wrap: wrap;
    }

    .dirbtn {
      width: clamp(48px, 13vw, 64px);
      height: clamp(48px, 13vw, 64px);
      border-radius: 16px;
      border: 1px solid rgba(119, 156, 232, .38);
      background: linear-gradient(180deg, rgba(35,52,88,.85), rgba(18,27,51,.85));
      color: #f6f9ff;
      font-size: clamp(1.1rem, 4vw, 1.45rem);
      display: grid;
      place-items: center;
      cursor: pointer;
      touch-action: manipulation;
      user-select: none;
      -webkit-tap-highlight-color: transparent;
      box-shadow: inset 0 1px 0 rgba(255,255,255,.1), 0 8px 16px rgba(0,0,0,.18);
    }

    .dirbtn:active {
      transform: translateY(1px) scale(.98);
      background: linear-gradient(180deg, rgba(53,79,132,.9), rgba(25,38,70,.9));
    }

    footer {
      color: #8ba0cc;
      font-size: .83rem;
      text-align: center;
      line-height: 1.4;
    }

    kbd {
      background: rgba(53,76,124,.6);
      border: 1px solid rgba(119, 156, 232, .38);
      border-radius: 7px;
      padding: 1px 5px;
      font-size: .85em;
      color: #eaf0ff;
    }

    @media (min-width: 860px) {
      .controls { display: none; }
    }

    @media (max-width: 520px) {
      .hud { grid-template-columns: repeat(2, minmax(0, 1fr)); }
      .stat span { font-size: .58rem; }
    }
  </style>
</head>
<body>
  <main class="wrap">
    <header>
      <h1>🚂 Station Snake</h1>
      <p class="subtitle">A snake as a little train: locomotive head, rainbow wagons, and passengers waiting on platform tiles.</p>
    </header>

    <section class="hud" aria-label="Game stats">
      <div class="stat"><span>Passengers</span><strong id="scoreVal">0</strong></div>
      <div class="stat"><span>Wagons</span><strong id="lengthVal">3</strong></div>
      <div class="stat"><span>Speed</span><strong id="speedVal">1.0x</strong></div>
      <div class="stat"><span>Best</span><strong id="bestVal">0</strong></div>
      <div class="stat"><span>Status</span><strong id="statusVal">Demo train</strong></div>
    </section>

    <section class="board-wrap" id="boardWrap" aria-label="Game board">
      <canvas id="game" width="900" height="675"></canvas>
      <div class="overlay" id="overlay">
        <div class="big" id="overlayTitle">🚂 Click or press a key to start</div>
        <div class="small" id="overlaySub">A driverless demo train is already running. Use arrows, WASD, swipe, or tap a direction to take the controls.</div>
      </div>
    </section>

    <section class="controls" aria-label="Touch controls">
      <button type="button" class="dirbtn" data-dir="left" aria-label="Left">◀</button>
      <button type="button" class="dirbtn" data-dir="up" aria-label="Up">▲</button>
      <button type="button" class="dirbtn" data-dir="down" aria-label="Down">▼</button>
      <button type="button" class="dirbtn" data-dir="right" aria-label="Right">▶</button>
    </section>

    <footer>
      Arrows / <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> to drive • Click or press a key to start • Demo mode keeps running while you are not playing
    </footer>
  </main>

  <script>
    (() => {
      const COLS = 20;
      const ROWS = 15;
      const BASE_INTERVAL = 112;

      const RIGHT = { x: 1, y: 0 };
      const LEFT  = { x: -1, y: 0 };
      const DOWN  = { x: 0, y: 1 };
      const UP    = { x: 0, y: -1 };
      const DIRS  = [RIGHT, DOWN, LEFT, UP];

      const KEY_DIR = {
        arrowright: RIGHT, d: RIGHT,
        arrowleft: LEFT, a: LEFT,
        arrowdown: DOWN, s: DOWN,
        arrowup: UP, w: UP
      };

      const DIR_BTN = {
        right: RIGHT,
        left: LEFT,
        down: DOWN,
        up: UP
      };

      const LOCO_COLOR = '#d62828';
      const WAGON_COLORS = [
        '#ffd166', '#06d6a0', '#118ab2', '#ef476f',
        '#8338ec', '#fb8500', '#00b4d8', '#9d4edd'
      ];
      const PERSON_COLORS = [
        '#ef476f', '#ffd166', '#06d6a0', '#118ab2',
        '#8338ec', '#fb5607', '#ff006e', '#48cae4'
      ];

      let canvas, ctx;
      let boardWrap, overlay, overlayTitle, overlaySub;
      let scoreEl, lengthEl, speedEl, bestEl, statusEl;

      let viewW = 0;
      let viewH = 0;
      let pixelW = 0;
      let pixelH = 0;
      let dpr = 1;
      let cell = 24;
      let offX = 0;
      let offY = 0;
      let bgCanvas = null;

      let snake = [];
      let dir = RIGHT;
      let queued = [];
      let food = { x: 9, y: 7 };
      let passengerColor = PERSON_COLORS[0];
      let passengerColor2 = PERSON_COLORS[2];

      let score = 0;
      let best = 0;
      let stepInterval = BASE_INTERVAL;
      let accumulator = 0;
      let lastTime = 0;
      let time = 0;
      let state = 'DEMO';
      let gameOverAt = 0;
      let finalTitle = '';
      let swipe = null;

      const same = (a, b) => a.x === b.x && a.y === b.y;
      const wrapAdd = (p, d) => ({
        x: (p.x + d.x + COLS) % COLS,
        y: (p.y + d.y + ROWS) % ROWS
      });
      const idxOf = p => p.y * COLS + p.x;
      const pickColor = () => PERSON_COLORS[(Math.random() * PERSON_COLORS.length) | 0];

      function roundRect(c, x, y, w, h, r) {
        r = Math.min(r, Math.abs(w) / 2, Math.abs(h) / 2);
        if (r < 0) r = 0;
        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 circle(c, x, y, r) {
        c.beginPath();
        c.arc(x, y, Math.max(0.1, r), 0, Math.PI * 2);
        c.fill();
      }

      function center(p) {
        return {
          x: offX + (p.x + 0.5) * cell,
          y: offY + (p.y + 0.5) * cell
        };
      }

      function wrapDist(a, b) {
        let dx = Math.abs(a.x - b.x);
        let dy = Math.abs(a.y - b.y);
        if (dx > COLS / 2) dx = COLS - dx;
        if (dy > ROWS / 2) dy = ROWS - dy;
        return dx + dy;
      }

      function setFood(p) {
        food = { x: p.x, y: p.y };
        passengerColor = pickColor();
        passengerColor2 = pickColor();
      }

      function spawnFood() {
        const occupied = new Uint8Array(COLS * ROWS);
        for (const s of snake) occupied[idxOf(s)] = 1;

        const empty = [];
        for (let y = 0; y < ROWS; y++) {
          for (let x = 0; x < COLS; x++) {
            if (!occupied[y * COLS + x]) empty.push({ x, y });
          }
        }

        if (!empty.length) return false;
        setFood(empty[(Math.random() * empty.length) | 0]);
        return true;
      }

      function updateHUD() {
        if (!scoreEl) return;
        scoreEl.textContent = score;
        lengthEl.textContent = snake.length;
        bestEl.textContent = best;
        speedEl.textContent = (BASE_INTERVAL / stepInterval).toFixed(1) + 'x';
        statusEl.textContent =
          state === 'DEMO' ? 'Demo train' :
          state === 'PLAYING' ? 'Driving' :
          'Stopped';
      }

      function updateOverlay() {
        if (state === 'PLAYING') {
          overlay.classList.add('hidden');
          return;
        }

        overlay.classList.remove('hidden');

        if (state === 'DEMO') {
          overlayTitle.textContent = '🚂 Click or press a key to start';
          overlaySub.textContent = 'A driverless demo train is already running. Use arrows, WASD, swipe, or tap a direction to take the controls.';
        } else if (state === 'GAME_OVER') {
          overlayTitle.textContent = finalTitle || '💥 Game over';
          overlaySub.textContent = 'Click or press a key to restart. A self-playing demo train will return in a moment.';
        }
      }

      function resetGame(mode) {
        const midY = Math.floor(ROWS / 2);

        snake = [
          { x: 3, y: midY },
          { x: 2, y: midY },
          { x: 1, y: midY }
        ];
        dir = RIGHT;
        queued = [];
        score = 0;
        stepInterval = BASE_INTERVAL;
        accumulator = 0;
        state = mode;
        gameOverAt = 0;
        finalTitle = '';

        setFood({ x: 9, y: midY });

        updateHUD();
        updateOverlay();
      }

      function startGame(nd) {
        resetGame('PLAYING');
        if (nd) requestDir(nd);
        updateOverlay();
      }

      function requestDir(nd) {
        if (state !== 'PLAYING') return;

        const last = queued.length ? queued[queued.length - 1] : dir;
        if (nd.x === -last.x && nd.y === -last.y) return;
        if (nd.x === last.x && nd.y === last.y) return;

        queued.push(nd);
        if (queued.length > 3) queued.shift();
      }

      function isSafeActualMove(p, grow) {
        const tail = snake[snake.length - 1];

        for (let i = 0; i < snake.length; i++) {
          if (p.x === snake[i].x && p.y === snake[i].y) {
            if (i === snake.length - 1 && !grow) return true;
            return false;
          }
        }

        return true;
      }

      function occupancyAfterMove(newHead, grow) {
        const occ = new Uint8Array(COLS * ROWS);

        if (grow) {
          for (let i = 0; i < snake.length; i++) occ[idxOf(snake[i])] = 1;
        } else {
          for (let i = 0; i < snake.length - 1; i++) occ[idxOf(snake[i])] = 1;
        }

        occ[idxOf(newHead)] = 1;
        return occ;
      }

      function bfsDistance(start, target, occ) {
        if (same(start, target)) return 0;

        const seen = new Uint8Array(COLS * ROWS);
        const dist = new Int16Array(COLS * ROWS);
        const queue = [idxOf(start)];
        seen[queue[0]] = 1;

        for (let qh = 0; qh < queue.length; qh++) {
          const id = queue[qh];
          const x = id % COLS;
          const y = (id / COLS) | 0;
          const dcur = dist[id];

          for (const d of DIRS) {
            const nx = (x + d.x + COLS) % COLS;
            const ny = (y + d.y + ROWS) % ROWS;
            const nid = ny * COLS + nx;

            if (nx === target.x && ny === target.y) return dcur + 1;
            if (seen[nid]) continue;
            if (occ[nid]) continue;

            seen[nid] = 1;
            dist[nid] = dcur + 1;
            queue.push(nid);
          }
        }

        return -1;
      }

      function floodCount(start, occ) {
        const seen = new Uint8Array(COLS * ROWS);
        const queue = [];
        let count = 0;

        const add = id => {
          if (seen[id] || occ[id]) return;
          seen[id] = 1;
          queue.push(id);
        };

        for (const d of DIRS) {
          const nx = (start.x + d.x + COLS) % COLS;
          const ny = (start.y + d.y + ROWS) % ROWS;
          add(ny * COLS + nx);
        }

        for (let qh = 0; qh < queue.length; qh++) {
          const id = queue[qh];
          count++;
          const x = id % COLS;
          const y = (id / COLS) | 0;

          for (const d of DIRS) {
            const nx = (x + d.x + COLS) % COLS;
            const ny = (y + d.y + ROWS) % ROWS;
            add(ny * COLS + nx);
          }
        }

        return count;
      }

      function chooseAI() {
        let bestDir = null;
        let bestScore = -Infinity;

        for (const d of DIRS) {
          if (d.x === -dir.x && d.y === -dir.y) continue;

          const h = wrapAdd(snake[0], d);
          const grow = same(h, food);

          if (!isSafeActualMove(h, grow)) continue;

          const occ = occupancyAfterMove(h, grow);
          const path = grow ? 0 : bfsDistance(h, food, occ);
          const free = floodCount(h, occ);

          let s = free * 4;

          if (path >= 0) {
            s += 300 - Math.min(path, 300);
          } else {
            s -= 220;
            s += 60 - Math.min(60, wrapDist(h, food));
          }

          if (grow) {
            s += 250;
            if (free < 5) s -= 400;
            else if (free < 16) s -= (16 - free) * 8;
          }

          if (d === dir) s += 6;

          if (s > bestScore) {
            bestScore = s;
            bestDir = d;
          }
        }

        if (bestDir === null) {
          for (const d of DIRS) {
            const h = wrapAdd(snake[0], d);
            const grow = same(h, food);
            if (isSafeActualMove(h, grow)) return d;
          }
          return dir;
        }

        return bestDir;
      }

      function die() {
        if (state === 'GAME_OVER') return;
        state = 'GAME_OVER';
        gameOverAt = performance.now();
        finalTitle = '💥 Game over — passengers: ' + score;
        updateHUD();
        updateOverlay();
      }

      function winGame() {
        if (state === 'GAME_OVER') return;
        state = 'GAME_OVER';
        gameOverAt = performance.now();
        finalTitle = '🎉 Station cleared! Passengers: ' + score;
        updateHUD();
        updateOverlay();
      }

      function step() {
        if (state === 'DEMO') {
          dir = chooseAI();
        } else if (state === 'PLAYING') {
          while (queued.length) {
            const q = queued.shift();
            if (!(q.x === -dir.x && q.y === -dir.y)) {
              dir = q;
              break;
            }
          }
        }

        const newHead = wrapAdd(snake[0], dir);
        const grow = same(newHead, food);

        if (!isSafeActualMove(newHead, grow)) {
          die();
          return;
        }

        snake.unshift(newHead);

        if (!grow) {
          snake.pop();
        } else {
          score++;
          if (score > best) best = score;
          stepInterval = Math.max(70, BASE_INTERVAL - score * 1.5);
          updateHUD();

          if (!spawnFood()) {
            winGame();
            return;
          }
        }

        updateHUD();
      }

      function resize() {
        const rect = boardWrap.getBoundingClientRect();
        viewW = Math.max(1, rect.width);
        viewH = Math.max(1, rect.height);
        dpr = Math.min(2, window.devicePixelRatio || 1);

        pixelW = Math.floor(viewW * dpr);
        pixelH = Math.floor(viewH * dpr);

        canvas.width = pixelW;
        canvas.height = pixelH;
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

        cell = Math.floor(Math.min(viewW / COLS, viewH / ROWS));
        cell = Math.max(10, cell);

        offX = Math.floor((viewW - cell * COLS) / 2);
        offY = Math.floor((viewH - cell * ROWS) / 2);

        renderBackground();
      }

      function renderBackground() {
        if (cell <= 0 || pixelW <= 0 || pixelH <= 0) return;

        bgCanvas = document.createElement('canvas');
        bgCanvas.width = pixelW;
        bgCanvas.height = pixelH;

        const b = bgCanvas.getContext('2d');
        if (!b) return;

        b.setTransform(dpr, 0, 0, dpr, 0, 0);

        const grad = b.createLinearGradient(0, 0, viewW, viewH);
        grad.addColorStop(0, '#0b1328');
        grad.addColorStop(0.45, '#121e3f');
        grad.addColorStop(1, '#070c1a');
        b.fillStyle = grad;
        b.fillRect(0, 0, viewW, viewH);

        for (let y = 0; y < ROWS; y++) {
          for (let x = 0; x < COLS; x++) {
            const px = offX + x * cell;
            const py = offY + y * cell;
            const inset = Math.max(1, cell * 0.07);
            b.fillStyle = ((x + y) % 2) ? '#16264d' : '#121d3a';
            roundRect(b, px + inset, py + inset, cell - inset * 2, cell - inset * 2, cell * 0.17);
            b.fill();
          }
        }

        b.save();
        b.setLineDash([Math.max(2, cell * 0.1), Math.max(3, cell * 0.18)]);
        b.strokeStyle = 'rgba(170, 205, 255, 0.08)';
        b.lineWidth = Math.max(1, cell * 0.045);

        for (let y = 0; y < ROWS; y++) {
          const cy = offY + (y + 0.5) * cell;
          b.beginPath();
          b.moveTo(offX + cell * 0.1, cy);
          b.lineTo(offX + cell * (COLS - 0.1), cy);
          b.stroke();
        }

        for (let x = 0; x < COLS; x++) {
          const cx = offX + (x + 0.5) * cell;
          b.beginPath();
          b.moveTo(cx, offY + cell * 0.1);
          b.lineTo(cx, offY + cell * (ROWS - 0.1));
          b.stroke();
        }

        b.restore();

        b.strokeStyle = 'rgba(255,255,255,.13)';
        b.lineWidth = Math.max(2, cell * 0.07);
        roundRect(b, offX - cell * 0.1, offY - cell * 0.1, cell * COLS + cell * 0.2, cell * ROWS + cell * 0.2, cell * 0.35);
        b.stroke();
      }

      function adjacent(a, b) {
        const dx = Math.abs(a.x - b.x);
        const dy = Math.abs(a.y - b.y);
        return (dx === 1 && dy === 0) || (dx === 0 && dy === 1);
      }

      function orientationAt(i) {
        if (i === 0) return dir;

        const prev = snake[i - 1];
        const cur = snake[i];

        let dx = prev.x - cur.x;
        let dy = prev.y - cur.y;

        if (dx > 1) dx -= COLS;
        if (dx < -1) dx += COLS;
        if (dy > 1) dy -= ROWS;
        if (dy < -1) dy += ROWS;

        if (Math.abs(dx) >= Math.abs(dy)) {
          return { x: dx >= 0 ? 1 : -1, y: 0 };
        }

        return { x: 0, y: dy >= 0 ? 1 : -1 };
      }

      function carColor(index) {
        if (index === 0) return LOCO_COLOR;
        return WAGON_COLORS[(index - 1) % WAGON_COLORS.length];
      }

      function drawSmoke() {
        for (let i = 0; i < 3; i++) {
          let tt = (time * 1.7 - i * 0.33) % 1;
          if (tt < 0) tt += 1;

          ctx.globalAlpha = 0.28 * (1 - tt);
          ctx.fillStyle = '#e8ecff';
          ctx.beginPath();
          ctx.arc(
            cell * 0.52 + tt * cell * 0.48,
            Math.sin((time * 3 + i) * 2.2) * cell * 0.075,
            cell * 0.1 * (0.7 + tt * 0.8),
            0,
            Math.PI * 2
          );
          ctx.fill();
        }
        ctx.globalAlpha = 1;
      }

      function drawCar(pos, orient, index, isHead) {
        const c = center(pos);
        const angle =
          orient.x === 1 ? 0 :
          orient.x === -1 ? Math.PI :
          orient.y === 1 ? Math.PI / 2 :
          orient.y === -1 ? -Math.PI / 2 : 0;

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

        const len = cell * 0.86;
        const wid = cell * 0.66;

        ctx.fillStyle = 'rgba(0,0,0,.27)';
        roundRect(ctx, -len / 2 + cell * 0.025, -wid / 2 + cell * 0.045, len, wid, cell * 0.16);
        ctx.fill();

        ctx.fillStyle = carColor(index);
        roundRect(ctx, -len / 2, -wid / 2, len, wid, cell * 0.16);
        ctx.fill();

        ctx.strokeStyle = 'rgba(0,0,0,.35)';
        ctx.lineWidth = Math.max(1, cell * 0.038);
        roundRect(ctx, -len / 2, -wid / 2, len, wid, cell * 0.16);
        ctx.stroke();

        ctx.fillStyle = 'rgba(255,255,255,.14)';
        roundRect(ctx, -len * 0.36, -wid * 0.32, len * 0.72, wid * 0.34, cell * 0.08);
        ctx.fill();

        if (isHead) {
          ctx.fillStyle = 'rgba(0,0,0,.24)';
          roundRect(ctx, -len * 0.48, -wid * 0.42, len * 0.28, wid * 0.84, cell * 0.08);
          ctx.fill();

          ctx.fillStyle = 'rgba(255,240,180,.88)';
          roundRect(ctx, -len * 0.42, -wid * 0.24, len * 0.16, wid * 0.48, cell * 0.05);
          ctx.fill();

          ctx.fillStyle = 'rgba(255,255,255,.17)';
          roundRect(ctx, -len * 0.06, -wid * 0.25, len * 0.38, wid * 0.5, cell * 0.06);
          ctx.fill();

          ctx.fillStyle = '#101520';
          roundRect(ctx, len * 0.06, -cell * 0.08, cell * 0.16, cell * 0.16, cell * 0.04);
          ctx.fill();

          ctx.fillStyle = '#334052';
          circle(ctx, len * 0.14, 0, cell * 0.05);

          ctx.fillStyle = '#ffd166';
          circle(ctx, len * 0.45, 0, cell * 0.07);

          ctx.fillStyle = '#08111f';
          const wr = cell * 0.075;
          for (const side of [-1, 1]) {
            for (const xx of [-len * 0.28, len * 0.28]) {
              ctx.beginPath();
              ctx.ellipse(xx, side * wid * 0.56, wr, wr * 0.62, 0, 0, Math.PI * 2);
              ctx.fill();
            }
          }

          drawSmoke();
        } else {
          ctx.fillStyle = 'rgba(0,0,0,.22)';
          roundRect(ctx, -len * 0.4, -wid * 0.12, len * 0.8, wid * 0.24, cell * 0.05);
          ctx.fill();

          ctx.fillStyle = 'rgba(255,255,255,.17)';
          roundRect(ctx, -len * 0.4, -wid * 0.12, len * 0.8, wid * 0.1, cell * 0.04);
          ctx.fill();

          ctx.fillStyle = '#fff3b0';
          const ws = cell * 0.13;
          for (let k = -1; k <= 1; k++) {
            roundRect(ctx, k * len * 0.24 - ws / 2, -ws * 0.4, ws, ws * 0.8, cell * 0.035);
            ctx.fill();
          }

          ctx.fillStyle = '#08111f';
          const wr = cell * 0.075;
          for (const side of [-1, 1]) {
            for (const xx of [-len * 0.28, len * 0.28]) {
              ctx.beginPath();
              ctx.ellipse(xx, side * wid * 0.56, wr, wr * 0.62, 0, 0, Math.PI * 2);
              ctx.fill();
            }
          }
        }

        ctx.restore();
      }

      function drawTrain() {
        const minW = Math.max(2, cell * 0.2);

        for (let i = snake.length - 1; i > 0; i--) {
          const a = snake[i];
          const b = snake[i - 1];
          if (!adjacent(a, b)) continue;

          const c1 = center(a);
          const c2 = center(b);

          ctx.fillStyle = 'rgba(9,17,34,.85)';

          if (a.y === b.y) {
            const x = Math.min(c1.x, c2.x);
            const w = Math.abs(c1.x - c2.x);
            roundRect(ctx, x, c1.y - minW / 2, w, minW, minW * 0.28);
            ctx.fill();
          } else {
            const y = Math.min(c1.y, c2.y);
            const h = Math.abs(c1.y - c2.y);
            roundRect(ctx, c1.x - minW / 2, y, minW, h, minW * 0.28);
            ctx.fill();
          }
        }

        for (let i = snake.length - 1; i >= 0; i--) {
          drawCar(snake[i], orientationAt(i), i, i === 0);
        }
      }

      function drawPerson(px, py, shirt, s) {
        ctx.fillStyle = '#ffd6a5';
        circle(ctx, px, py - s * 1.45, s * 0.65);

        ctx.fillStyle = shirt;
        roundRect(ctx, px - s * 0.75, py - s * 0.55, s * 1.5, s * 1.25, s * 0.35);
        ctx.fill();

        ctx.strokeStyle = shirt;
        ctx.lineWidth = s * 0.22;
        ctx.lineCap = 'round';
        ctx.beginPath();
        ctx.moveTo(px - s * 0.8, py - s * 0.25);
        ctx.lineTo(px - s * 1.05, py + s * 0.35);
        ctx.moveTo(px + s * 0.8, py - s * 0.25);
        ctx.lineTo(px + s * 1.05, py + s * 0.35);
        ctx.stroke();

        ctx.strokeStyle = '#203047';
        ctx.lineWidth = s * 0.25;
        ctx.beginPath();
        ctx.moveTo(px - s * 0.3, py + s * 0.7);
        ctx.lineTo(px - s * 0.3, py + s * 1.15);
        ctx.moveTo(px + s * 0.3, py + s * 0.7);
        ctx.lineTo(px + s * 0.3, py + s * 1.15);
        ctx.stroke();
      }

      function drawFood() {
        if (!food) return;

        const c = center(food);
        const pulse = 0.94 + Math.sin(time * 4) * 0.05;

        ctx.save();
        ctx.translate(c.x, c.y);

        const glow = ctx.createRadialGradient(0, 0, 0, 0, 0, cell * 0.8 * pulse);
        glow.addColorStop(0, 'rgba(255,230,160,.23)');
        glow.addColorStop(1, 'rgba(255,230,160,0)');
        ctx.fillStyle = glow;
        ctx.beginPath();
        ctx.arc(0, 0, cell * 0.8 * pulse, 0, Math.PI * 2);
        ctx.fill();

        ctx.fillStyle = '#1f4e6b';
        roundRect(ctx, -cell * 0.45 * pulse, -cell * 0.45 * pulse, cell * 0.9 * pulse, cell * 0.9 * pulse, cell * 0.17);
        ctx.fill();

        ctx.strokeStyle = '#ffd166';
        ctx.lineWidth = Math.max(1.5, cell * 0.055);
        roundRect(ctx, -cell * 0.45 * pulse, -cell * 0.45 * pulse, cell * 0.9 * pulse, cell * 0.9 * pulse, cell * 0.17);
        ctx.stroke();

        ctx.fillStyle = '#2c6b8f';
        roundRect(ctx, -cell * 0.32 * pulse, -cell * 0.32 * pulse, cell * 0.64 * pulse, cell * 0.64 * pulse, cell * 0.1);
        ctx.fill();

        const s = cell * 0.14 * pulse;
        ctx.fillStyle = 'rgba(255,255,255,.9)';
        roundRect(ctx, -s * 1.35, -cell * 0.35 * pulse, s * 2.7, s * 1.0, s * 0.25);
        ctx.fill();

        ctx.fillStyle = '#111827';
        roundRect(ctx, -s * 1.05, -cell * 0.29 * pulse, s * 0.95, s * 0.28, s * 0.08);
        ctx.fill();

        ctx.fillStyle = '#ffd166';
        ctx.fillRect(s * 0.18, -cell * 0.29 * pulse, s * 0.48, s * 0.28);

        drawPerson(-cell * 0.12 * pulse, cell * 0.04 * pulse, passengerColor, cell * 0.095 * pulse);
        drawPerson(cell * 0.13 * pulse, cell * 0.06 * pulse, passengerColor2, cell * 0.08 * pulse);

        ctx.restore();
      }

      function draw() {
        if (!ctx || cell <= 0 || pixelW <= 0 || pixelH <= 0) return;

        ctx.save();
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        if (bgCanvas) {
          ctx.drawImage(bgCanvas, 0, 0);
        } else {
          ctx.fillStyle = '#091021';
          ctx.fillRect(0, 0, pixelW, pixelH);
        }
        ctx.restore();

        drawFood();
        drawTrain();
      }

      function frame(ts) {
        const dt = lastTime ? Math.min(100, ts - lastTime) : 0;
        lastTime = ts;
        time = ts / 1000;

        if (state === 'DEMO' || state === 'PLAYING') {
          accumulator += dt;
          while (accumulator >= stepInterval) {
            accumulator -= stepInterval;
            step();
            if (state === 'GAME_OVER') break;
          }
        } else if (state === 'GAME_OVER') {
          if (ts - gameOverAt > 1700) resetGame('DEMO');
        }

        draw();
        requestAnimationFrame(frame);
      }

      function onPointerDown(e) {
        const target = e.target;

        if (state !== 'PLAYING') {
          if (target.closest && target.closest('.dirbtn')) return;
          e.preventDefault();
          startGame();
          return;
        }

        if (boardWrap.contains(target)) {
          swipe = { x: e.clientX, y: e.clientY };
        }
      }

      function onPointerMove(e) {
        if (!swipe || state !== 'PLAYING') return;

        const dx = e.clientX - swipe.x;
        const dy = e.clientY - swipe.y;

        if (Math.hypot(dx, dy) > 22) {
          if (Math.abs(dx) > Math.abs(dy)) requestDir(dx > 0 ? RIGHT : LEFT);
          else requestDir(dy > 0 ? DOWN : UP);
          swipe = null;
        }
      }

      function onPointerUp(e) {
        if (!swipe) return;

        const dx = e.clientX - swipe.x;
        const dy = e.clientY - swipe.y;

        if (state === 'PLAYING' && (Math.abs(dx) > 18 || Math.abs(dy) > 18)) {
          if (Math.abs(dx) > Math.abs(dy)) requestDir(dx > 0 ? RIGHT : LEFT);
          else requestDir(dy > 0 ? DOWN : UP);
        }

        swipe = null;
      }

      function onPointerCancel() {
        swipe = null;
      }

      function onDirButton(e) {
        e.preventDefault();
        const nd = DIR_BTN[e.currentTarget.dataset.dir];
        if (!nd) return;

        if (state !== 'PLAYING') startGame(nd);
        else requestDir(nd);
      }

      function onKeyDown(e) {
        if (e.ctrlKey || e.metaKey || e.altKey) return;

        const k = (e.key || '').toLowerCase();
        const nd = KEY_DIR[k];

        if (state !== 'PLAYING') {
          if (nd || k === ' ' || k === 'enter') {
            e.preventDefault();
            startGame(nd);
          }
          return;
        }

        if (nd) {
          e.preventDefault();
          requestDir(nd);
        }
      }

      function init() {
        canvas = document.getElementById('game');
        ctx = canvas.getContext('2d');

        boardWrap = document.getElementById('boardWrap');
        overlay = document.getElementById('overlay');
        overlayTitle = document.getElementById('overlayTitle');
        overlaySub = document.getElementById('overlaySub');

        scoreEl = document.getElementById('scoreVal');
        lengthEl = document.getElementById('lengthVal');
        speedEl = document.getElementById('speedVal');
        bestEl = document.getElementById('bestVal');
        statusEl = document.getElementById('statusVal');

        if (!ctx) return;

        resize();
        resetGame('DEMO');

        window.addEventListener('resize', resize);
        window.addEventListener('orientationchange', () => setTimeout(resize, 50));

        if (window.ResizeObserver) {
          new ResizeObserver(() => resize()).observe(boardWrap);
        }

        window.addEventListener('pointerdown', onPointerDown, { passive: false });
        window.addEventListener('pointermove', onPointerMove, { passive: true });
        window.addEventListener('pointerup', onPointerUp, { passive: true });
        window.addEventListener('pointercancel', onPointerCancel, { passive: true });
        window.addEventListener('keydown', onKeyDown);

        document.querySelectorAll('.dirbtn').forEach(btn => {
          btn.addEventListener('pointerdown', onDirButton, { passive: false });
        });

        requestAnimationFrame(frame);
      }

      init();
    })();
  </script>
</body>
</html>
Reasoning trace (48,897 tokens)
We need answer user's request: "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". Need output exactly one code block html or svg? Task says self-contained web demo. Game, must start in self-playing demo mode behind Click or press a key to start. Need visible first seconds. No external network. We need produce HTML file with inline CSS JS. Need think in same language as user's request: English. Need design a snake game but train-themed: locomotive head and colorful wagons as snake body. Picking up passengers waiting on platform instead of food. Need implement AI autopilot demo that moves snake to passengers. Need ensure no errors, responsive. Need maybe draw on canvas.

Need satisfy constraints: no localStorage etc. Game must start in self-playing demo mode behind "Click or press a key to start" message. Meaning: when loaded, maybe canvas visible, overlay "Click or press a key to start" and behind it self-playing demo? They say games must start in self-playing demo mode behind a "Click or press a key to start" message, so can judge action without playing. Could show overlay semi transparent, with demo running behind. Click or key starts game (stops autopilot, control). The message must be visible. Need no interaction required to see action. The demo should be active immediately. But if user clicks iframe and presses key, can play. Keyboard events only after user clicks iframe. Need clear message.

Need implement snake logic grid. Train aesthetic: head locomotive, body segments as wagons colorful, perhaps wheels/chimney/puffs. Food passengers on platforms (maybe small people icons). Need collision, growth, speed. AI demo: BFS shortest path avoiding self and walls, fallback to safe move. Need ensure demo not immediately dies. Snake body as deque coordinates. Direction. Grid maybe 24x18? Canvas responsive. Cell size. Need render nice.

Important: side-by-side with other models, must look polished. Need adapt down to 375px. Canvas size fit container, no horizontal scrollbar. Use flex column. Title, score, best? Best cannot use localStorage; can keep session best in memory. HUD. Overlay. Controls maybe keyboard arrows/WASD; touch buttons for mobile? Nice. Self-playing demo visible. Need start message. Need game over restart? Can implement. But self-playing demo behind message; after start, AI off, user controls. If user doesn't start, demo continues. If demo dies, perhaps restart demo automatically? It should be interesting first seconds. Need avoid death: robust AI, maybe wrap? Snake games can be on platforms with walls. AI BFS can avoid most. Could choose no self collision? Could allow wrap-around for easier demo? But train on platform maybe circular rails? If wrap, less danger. But snake usually dies on walls. To keep demo active, can have walls? Could implement toroidal wrap? But picking passengers on platforms maybe grid, maybe walls at edges. If demo AI with BFS, likely safe. But to guarantee demo continues, maybe use wrap-around (rails loop around station). It simplifies. But snake classic dies walls; not required. We can make edges are station walls? If wrap, AI easier and no death. Could be more fun. But "picking up passengers waiting on platform" perhaps food spawn on random cells. Need collision self. If wrap, can move through edges. Use modular. AI path BFS with wrap. Then won't die unless self-closes and all moves unsafe; fallback to random safe maybe. If length grows and fills grid, game over; unlikely. Need maybe start demo with short snake and passengers near path.

Need code quality. Need maybe canvas with fixed grid but CSS scaling. Use devicePixelRatio for crisp. Resize handler.

Need game loop: requestAnimationFrame with fixed tick interval e.g. 110ms decreasing with score. Input queue to prevent reversing. AI move when demo or if autopilot? Start message: demoMode = true. Click or key => startGame: demoMode = false, reset? Need maybe reset to player start with same score. On first key, prevent default and start. Could either start immediately from current demo state; but better reset game with player control. Message: "Click or press a key to start • Demo train is running". If click overlay, start. If key, start. Need touch start maybe. Also on game over, overlay "Press to restart" maybe. Could start demo again? But user requested self-playing demo behind message; after start user controls. If game over, show overlay click to restart demo? Hmm.

Need render train:
- Each segment square rounded? Head locomotive: front color maybe red, wheels? Body wagons: colors array [blue, green, orange, purple, teal...]. Wagons as connected cars: draw rectangle with windows, roof, wheels maybe. Since grid cells maybe 28px. At small, still fine.
- Need direction orientation for head and wagons. Head front has chimney/puff. Could draw head segment as locomotive facing direction, with cowcatcher? Simpler: rounded square with dark base, colored top. But side-by-side models, better visual. Need code not too long? We can draw stylized.

Segments positions along path. We can draw body from tail to head. Need connect segments to avoid gaps? Grid adjacent; but diagonal none. Use cell size. For wagons, if horizontal, car length along direction; if vertical. Need each segment centered. Could draw car as rounded rect with slight shrink 0.86 cell, plus wheels on sides if horizontal or top/bottom if vertical? Train orientation. We can compute orientation for each segment: vector from previous segment? For head, direction. For body, orientation from next towards previous? Need stable.

Maybe draw each car as rounded square with roof stripe and two windows. Wheels: circles near ends? For horizontal, cars along x; wheels at bottom (two circles under car). For vertical, wheels at side. The train moves; head is locomotive with front, chimney, smoke. Could draw all as rectangles oriented along path.

Food passenger: draw little person: head circle, body rounded rectangle, arms maybe; maybe platform pad below? Spawn on empty cells. Different passenger colors. Could draw multiple passengers? One pickup item. Maybe "platform" means food tile has platform: rounded rectangle base with person. Could pick up one. Need colorful wagons: each car color fixed by index. Need wagons behind locomotive, so when grows add new wagon at tail. Head remains locomotive color. Nice.

Need AI:
- grid cols, rows, wrap. State: snake array from head to tail. blocked set. food pos.
- For each move (4 dirs), new head; if blocked (snake except tail maybe; tail moves away unless growing; if food not in path, tail safe) or self collision. Use BFS from candidate head to food? To choose path and first move. Could implement safeMoves = dirs where newHead not self (excluding tail if not growing) and not occupied. If none, fallback any? Then choose by: if food reachable, BFS from candidate to food returning first move and distance; choose shortest distance that is reachable and after reaching maybe safe. If no reachable, choose safe move maximizing free space (or avoiding dead ends). Need ensure not immediate death. Simple:
  - For candidate dirs, if newHead == food then take (shortest).
  - Else BFS to food from newHead using current snake occupancy (maybe exclude tail if moving not grow? If newHead != food, after candidate move snake body tail moves if no growth; occupancy excluding tail maybe accurate; for BFS we can use occupancy of snake after candidate move? Complex. Simpler blocked = snake cells excluding last (tail), but if candidate newHead == food then growth tail stays; for candidate not food, tail will move. If food may be occupied? no. Use blocked = set of all snake positions except tail (because tail will vacate if not growth). For BFS to food, treat occupied as blocked.
  - If BFS path exists from candidate to food, choose candidate with path length shortest. If tie, maybe choose path that preserves more space (or avoids corners). Need maybe ensure after following path first step. Good.
  - If no reachable, choose safe move with maximum flood-fill free space from newHead (excluding snake after move). Fallback safe move not reversing.
- Need prevent reversing: dir opposite current. Input queue.
- Wrap: newHead = (x+dx+cols)%cols etc.
- Self collision: newHead in snake[0..-1]? If tail is moving and not growing, collision with tail allowed? Classic: if not eating, tail leaves cell this step, so moving into tail is safe. But with wrap and multiple segments, could move into old tail. For collision, if newHead equals tail and not eating -> allow. Need handle: if newHead is any snake except tail, die; if tail and not food, safe. In AI blocked exclude tail. For rendering, okay. But with length 1? tail=head? Need handle.
- Growth: if newHead equals food, snake unchanged (tail not removed), score++, speed maybe, spawn new food not occupied. If length too high maybe win? Could not need.

Need ensure demo AI doesn't trap. Could use lookahead/flood to prefer moves that don't reduce free space. BFS path to food can trap after eating. But likely okay. Could implement more robust: after candidate path to food, simulate eating? Simpler but enough. Could also use "tail chase" fallback: if no food reachable, move toward tail if possible to survive. Classic.

Need choose grid size: 21x13? For 1280x800: if canvas width 100%, height maybe 460? Cell 30 -> 20x15? Let's calculate. Container max width 1024? Use canvas aspect maybe 4:3 or 16:9. Need fit down to 375. Better grid 20x15, cell size dynamic based on canvas: cell = floor(min(width/cols, height/rows)). Draw within canvas. Canvas size from client rect. Set canvas width = floor(clientWidth * dpr) etc. But internal logical canvas size can match display css; use ctx scaled by dpr? For crisp. Simpler: canvas.width = canvas.clientWidth*dpr, height same, ctx.scale(dpr,dpr). Need logical width = clientWidth, height = clientHeight. Cell = floor(min((logicalWidth - pad)/cols, (logicalHeight - pad)/rows)); grid pixel dims = cell*cols/cell*rows; offset center.

Layout: Header title. HUD with score, length, speed, best. Canvas wrapper. Overlay centered absolute over wrapper. Footer instructions. On small, maybe compact. Need not horizontal scrollbar. Use box-sizing.

Need self-playing demo visible in wrapper. Could draw grid background as platform? Maybe station-like background: grid cells as track segments? Need passengers waiting on platforms: food as platform with person. The rest as tracks? For train theme: draw rails in each cell? Need avoid too noisy. Could draw background as dark green/gray station floor, grid lines, and food platform. Train on track: body cells maybe have track? We can draw grid as tracks: faint dotted lines. But food can be platform tiles. Hmm.

Maybe background: grid of light gray cells (station floor). Food passenger stands on a colored platform pad. Train cars on track. Good.

Need colors: locomotive maybe red/dark gray; wagons rainbow. Passenger colors random. Platform maybe brown/yellow. Overall aesthetic: playful, bright. Use CSS gradient background. Canvas: draw rounded grid cells. Use shadows? Keep performance.

Detailed render:
- clear canvas, background: linear gradient? Could paint each cell: for row/col, alternate shades? Draw as station floor with tile pattern. Maybe subtle grid lines.
- Draw rails? Could draw thin dashed lines at center for each cell? Not necessary.
- Draw food: platform: rounded rectangle occupying cell, maybe color by passenger type. Person: circle head, body. Could have two passengers? Maybe "picking up passengers waiting on the platform". Could spawn a "stop" with 1-3 passengers? But snake food should be one pickup. We can visually place passenger on platform. If passenger type maybe train ticket? Simple.

Draw train:
- Need for each segment position and orientation. Let positions = [head, ..., tail]. For each i:
  - Determine orientation vector: head direction = [dir.x, dir.y] (or maybe from head to previous? Actually head faces movement direction: current dir. If dir stored as movement direction from head to next? If head moving right, head faces right. Direction currentDir = [dx,dy]. Good.
  - For body segment i>0: orientation from segment i-1 to segment i? The car follows path. Vector from current segment to next forward segment: if horizontal? Could use normalized difference. For wrap, need handle. We'll have snake positions not necessarily adjacent due to wrap? If snake head wraps, positions can jump e.g. head at x=0, next at x=cols-1. Need orientation account wrap? For car orientation, if positions differ by more than 1 due to wrap, need compute delta modulo. We can define orientation for segment i as direction from segment i to segment i-1 (toward head), if not same: dx = x_i-1 - x_i; if >1 dx-=cols; if <-1 dx+=cols. This gives forward direction along path from segment to head. For head, use currentDir. For wagons, use orientation to head. Draw car aligned with orientation: if horizontal (abs(dx)>=abs(dy)) direction horizontal (left/right) else vertical. Car's axis along orientation. Could draw as rounded rectangle centered. Wheels orientation.

Car dimensions: base cell*0.86. length along axis maybe 0.82*cell? width perpendicular maybe 0.64*cell. To connect adjacent cars, maybe draw coupling line? We can draw a small rounded rectangle coupling between segments if adjacent. Could do for all but head? But maybe just each car overlapping. For snake, positions adjacent; drawing centered rounded rect leaves gaps between cells. Add a connector: if adjacent distance <=1 (or wrap) draw line/rectangle between current and next. Or car length 1.12 cell? But overlap may look like train. Could draw car length 0.95 cell, connector 0.5 cell between centers. Maybe okay.

Need avoid complex wrap visuals; can treat if segments not adjacent due to wrap, skip connector. But cars still in cells.

Head locomotive: Draw bigger car maybe with front nose. Could use same car orientation but with details: cab at back, chimney at front, smoke. Need draw rounded rect with colored base (e.g. #d62828), dark roof stripe, front cowcatcher? Simpler:
  - car as base. Add dark top band. Add windows (2 small). Add chimney near front: rectangle/circle with puffs (animated maybe). Add "headlight" front. Wheels. Could use current tick for animation: wheels rotate? Need maybe simple: draw wheels with spokes? Not necessary but nice. We can animate smoke puffs by time.

Wagon colors: array ["#ffbe0b", "#8338ec", "#3a86ff", "#ff006e", "#06d6a0", "#fb5607", "#2ec4b6", "#e71d36"?] but locomotive red, wagons distinct. Maybe use HSL: for i>0 color = `hsl(${(i*47+180)%360},75%,62%)`. Need ensure contrast. Add roof color darker: shade. Windows white/yellow.

Passenger food:
- Use a stable passenger style per spawn. Could pick color array. Draw platform: rounded rectangle brown/yellow with maybe "stop" sign? Person: head color skin, torso color. Maybe add ticket.
- To be "passengers waiting on the platform": draw a little person with two arms and head, maybe multiple? We can draw 2 passengers near edge? But snake cell. Could draw one person in middle. Maybe platform has small sign.

Need game states: DEMO, PLAYING, GAME_OVER, maybe PAUSED? Could add pause by P? Not necessary.
- Overlay text. In DEMO: "🚂 Click or press a key to start". In PLAYING: maybe no overlay. In GAME_OVER: "Game over ... Click or press a key to restart". Need if key after click? Use keydown, mousedown/touchstart. To avoid multiple starts, if state == demo or game_over, start/reset. If key used during game to turn, no need start. But if game over, start again. Could start as player or demo? If game over after user dies, click restarts player. If initial load, click starts player. But maybe on game over, could switch to demo if not pressed? Hmm.

Need keyboard input: arrows/WASD; maybe prevent default for space? If click canvas then press. Add document listener? Since iframe receives key only after click. But if key pressed on body? It will bubble. Use window.addEventListener('keydown').
- If state DEMO and key, startGame('player'). If PLAYING, set nextDir. Need prevent immediate reverse: queue directions. Could maintain inputQueue: push desired if not opposite of last in queue and not same. For AI, not use queue.

Need self-play after game over? Could set state DEMO and reset, overlay. Maybe better: when game over, after 1.2s automatically return to demo? But user may want restart. Could show overlay "Click or press key to restart". Behind overlay maybe demo? Constraint says start in self-playing demo mode behind message; after game over not necessarily. But to keep action, if user hasn't interacted? If user played and died, could show game over. It's okay. However if no one clicks, the first screenshot at 3s should be action. It will be demo.

Need ensure demo does not require keyboard focus. Use rAF.

Potential issue: Canvas may be behind overlay but pointer-events none? Overlay pointer-events auto for click; if click anywhere start. Use wrapper click. But if overlay covers, okay.

Need no console errors: careful with ctx null? No external. Use requestAnimationFrame.

Let's draft code:
```
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Station Snake — Train Pickup</title>
<style>
:root { ... }
* { box-sizing: border-box; }
html, body { margin:0; min-height:100%; background: linear-gradient(...); font-family:...; color:...; overflow-x:hidden; }
.wrap { max-width:1020px; margin:0 auto; padding:16px; display:flex; flex-direction:column; gap:12px; min-height:100vh; }
header ...
.hud { display:flex; flex-wrap:wrap; gap:8px; }
.stat { background:...; padding:...; border-radius:...; }
.board-wrap { position:relative; width:100%; aspect-ratio: 20 / 15; background:...; border-radius:18px; overflow:hidden; box-shadow...; }
canvas { width:100%; height:100%; display:block; }
.overlay { position:absolute; inset:0; display:flex; flex-direction:column; justify-content:center; align-items:center; text-align:center; gap:10px; background:rgba(18,26,45,.58); backdrop-filter: blur(1px); color:#fff; }
.overlay.hidden { display:none; }
.big { font-size:clamp(1.25rem,4vw,2rem); font-weight:800; }
.small { font-size:...; }
footer...
@media max-width 500: ...
</style>
</head>
<body>
<div class="wrap">
 <header><h1>Station Snake</h1> <p>... </p></header>
 <div class="hud"> ... Score ... Length ... Speed ... Status ... </div>
 <div class="board-wrap" id="boardWrap">
  <canvas id="game" tabindex="0"></canvas>
  <div class="overlay" id="overlay"> ... </div>
 </div>
 <footer>Arrow keys / WASD, mobile buttons maybe</footer>
</div>
<script>
...
</script>
</body>
</html>
```
Need no horizontal scrollbar. aspect-ratio maybe not supported? latest Chrome yes. But if canvas wrapper too high on small? aspect 20/15 (4:3) okay. For 375 width -> 281 height plus header/hud maybe fits 800. Good.

Touch controls? Could include on-screen buttons for directions maybe nice. But keyboard? For mobile, user can tap arrows. Need no conflict. Could add 4 buttons under footer. But screenshot first 3s maybe visible. Need not too large. Could use CSS grid buttons. But if too tall, adapt. Maybe add compact touch pad: "Swipe or tap arrows". Implement tap buttons. But overlay start must appear. Touch start maybe start. Could add swipe on canvas? But if click overlay start, then swipe. Could use pointerdown/move. Simpler: four buttons with data-dir, pointerdown set direction and preventDefault. Also swipe detection. Might be nice. Need ensure not horizontal scroll. Buttons flex row or D-pad. For demo, maybe hidden until start? Always visible. Could take vertical space. Maybe compact. Use footer with instructions and touch D-pad only when playing? Could be visible. For 375, 4:3 board plus header/hud/buttons may exceed 800? Let's estimate: header ~70, hud ~70, board aspect 281, footer/buttons ~120, padding ~60 = 602, ok. Could be fine.

Need HUD update: score, length, speed, best, status. Use DOM elements.

Canvas logical size:
- Need resize: `function resize() { const rect = boardWrap.getBoundingClientRect(); const dpr = Math.min(2, window.devicePixelRatio || 1); canvas.width = Math.floor(rect.width*dpr); canvas.height = Math.floor(rect.height*dpr); ctx.setTransform(dpr,0,0,dpr,0,0); cell = Math.floor(Math.min(rect.width / COLS, rect.height / ROWS)); offsetX = (rect.width - cell*COLS)/2; offsetY = ...; }` Use CSS logical px. Need if rect zero. rAF.
- Draw all in logical coordinates (rect.width, rect.height). Need store `viewWidth`, `viewHeight`. Use ctx.save/restore.

Grid dimensions COLS=20 ROWS=15. Cell maybe 46 at 920 width? Good.

Snake data:
```
let snake, dir, nextQueue, food, foodColor, passengers, score, best=0, speedInterval, state, demoMode, tick=0, lastTime=0, time=0;
const dirs = [ {x:1,y:0}, {x:-1,y:0}, {x:0,y:1}, {x:0,y:-1} ];
```
Need current direction as index? Use `dir = {x:1,y:0}`. For AI candidate dirs, exclude opposite: `d.x === -dir.x && d.y === -dir.y`.
Input queue: store dir objects. In playing update: if queue length >0, find valid dir not opposite current. Apply first. But if AI uses dir? For demo set dir directly.
Move:
```
function step() {
  if (state === 'DEMO') { dir = chooseAI(); } else { dir = consumeInput(); }
  const newHead = wrapAdd(snake[0], dir);
  const willGrow = (newHead.x===food.x && newHead.y===food.y);
  const tail = snake[snake.length-1];
  if (!willGrow) {
    // check collision with any except tail? if length 1 tail same, moving away from itself? Actually with length 1, newHead cannot equal head? It can move to adjacent not head because opposite blocked but wrap maybe if grid 1? Not. Use: if cell occupied by snake.slice(0,-1) die.
  }
  if (occupiedExceptTail(newHead) && !willGrow) die? But if newHead==tail and not grow allow.
  if (occupiedAll(newHead) && willGrow) die? If food on body shouldn't happen. If newHead on body but not tail and willGrow? die. If newHead == tail and willGrow? Food cannot tail; tail not food.
```
Better:
```
function isBlockedForMove(x,y, willGrow) {
  const tail = snake[snake.length-1];
  const isTail = x===tail.x && y===tail.y;
  for (i=0; i<snake.length; i++) if (snake[i].x===x && snake[i].y===y) { if (i === snake.length-1 && !willGrow) return false; return true; }
  return false;
}
```
If blocked -> die.
- Push newHead unshift; if !willGrow pop tail. If willGrow spawn food.
- Need ensure newHead not outside due wrap.
- Score, speed. speedInterval = Math.max(70, 115 - score*2)? Maybe. Need display.

Food spawn:
```
function spawnFood() {
 const empty = []; for all cells if not in snake (maybe set) push. choose random.
 food = empty[rand]; passengerType = rand int 0..3;
}
```
Need set for efficiency but small.

AI choose:
```
function chooseAI() {
 const candidates = dirs.filter(validAI); // not reverse and not immediate collision (with snake after move? use isBlockedForMove with willGrow false? If candidate on tail but moving no grow if candidate != food; if candidate==food grow and tail stays; if candidate==tail and candidate!=food safe). Need check candidate != food for grow.
 if (candidates.length === 0) return dir; // maybe die; but choose random? Could return dir and die.
 // For each candidate: if newHead==food return candidate immediately? Need ensure safe: newHead==food and not blocked. If candidate on tail? food not tail. If blocked? not candidate.
 let scored = [];
 for each cand:
   const h = wrapAdd(snake[0], cand);
   const grow = h==food;
   const pathLen = bfsDistanceToFoodAfterCandidate(cand, h, grow);
   let score = 0;
   if (pathLen > 0) score += 1000 - pathLen; // prefer short; if immediate food pathLen=0, score 1000.
   else score -= 5000;
   const free = floodFreeAfterCandidate(cand, grow? h: h? simulate? maybe after move before next);
   score += free * 3;
   // center preference? not needed.
   scored.push({d:cand, score});
 sort descending.
 // To prevent oscillating, use stable tie and maybe randomize among top scores.
 return top.d.
}
```
Need BFS for candidate after move: We need occupancy after candidate move? For path to food from candidate newHead. If candidate move will grow, occupancy = snake (newHead included) before removing tail. If not grow, occupancy = [newHead] + snake.slice(0,-1) (tail removed). But candidate is first step; for path length >0, from newHead to food, moving one step at a time, tail continues moving. A static occupancy after candidate move is approximate, but okay. We can build blocked set: If grow, include newHead + all snake. If not, include newHead + snake.slice(0,-1). Then BFS from newHead to food (food not blocked? if food maybe blocked but we are already at food; if not, food cell empty). But if not grow and food cell equals tail? It won't because food not in snake. Good.
- For immediate h == food: return path 0.
- BFS with wrap, blocked set, target food. Return distance if reachable else -1.
- flood free after candidate: same blocked, count reachable empty cells from h. Prefer larger. Need maybe limit? Grid small.

Could be more robust with Hamiltonian-like? Not necessary.

Potential issue: If candidate immediate move onto food and path grows, but if no safe after eating, BFS with grown occupancy can be -1? We can still choose immediate food because score high. Could die if next. But okay. Could prefer immediate food only if after eating there is a reachable tail/space? Maybe. Could use path score for immediate too. Let's design:
```
function evaluate(cand):
 h=wrap; grow = eq(h,food); occAfter = buildOcc(h, cand, grow?) // Need for simulation after candidate move, if grow true no tail removed.
 // if h is blocked for actual move: invalid.
 // compute futureScore:
 if grow:
   // simulate new snake [h,...snake] with food eaten; spawn? For score, need find distance to next possible empty cell? Could use flood from h in new snake. But immediate pickup desirable. pathToCurrentFood = 0.
 else:
   pathToFood = bfs(h, food, occAfter)
 free = floodCount(h, occAfter)
 score = (pathToFood>=0 ? 1000 - pathToFood : -10000) + free*5 + maybe tailDistance*? 
 // Also prefer moves toward tail if no food: compute flood, and safe tail reachable.
```
- Need avoid pathing into food causing trap. But fine.

Could implement BFS and flood functions using queue arrays.

Need occupied cells set. Since grid small, use boolean array `idx = y*COLS+x`. Build occ after move:
```
function makeOccAfter(headPos, grow) {
 const arr = new Uint8Array(COLS*ROWS);
 // simulate snake after move:
 if (!grow) { // headPos unshift, remove tail
   for (i=0; i<snake.length-1; i++) arr[idx(snake[i])]=1;
 } else { for i=0..snake.length-1 arr[idx(snake[i])]=1; }
 arr[idx(headPos)] = 1;
 return arr;
}
```
But note for not grow, if headPos equals old tail (safe), snake after move: new head at old tail; old tail removed; but newHead and tail same? Example moving into tail while not eat. `snake` old positions; after move: newHead unshift, pop tail -> positions [oldTail, oldHead, ... oldSecond] etc. Our arr adding old snake[0..len-2] plus newHead (oldTail) yields correct? For i=0 to len-2 includes oldHead..oldSecondLast; newHead oldTail; missing old last. Good. For length 1, not grow loop len-1=0 none, arr[newHead]=1; after move one segment at newHead. Good.
For grow: add all old snake plus newHead. But if newHead == tail? grow only if food; food not tail so no. Good.

ValidAI move:
```
function canMoveTo(pos, grow?) { ... }
```
We need candidate validity before actual step. Use `willGrow = pos==food`; blocked for actual: if grow, check if pos occupied by any snake (should false) -> invalid if true. If not grow, check if pos equals tail -> allowed; else if any snake -> invalid.
Simplify use `isMoveSafe(pos, grow)` as above.

BFS path to food with static occ after candidate. Need target may be blocked? Food not in snake, but after candidate if candidate head == food handled; if food in old snake? spawn avoids. Good.

Flood count: from headPos, blocked arr; if head blocked? no. Count reachable empty cells (including starting? Starting is blocked, but free cells around. Maybe if starting considered empty? For safety, flood from start's neighbors or mark start as passable? Since after move, head occupies current, cannot move into current. Free space reachable from head includes head and empty cells? Usually count empty cells reachable excluding occupied head; to evaluate open area, start at head but treat head as passable for movement? Movement from head to adjacent empty cells; count reachable empty cells. If treat head passable, start with head in queue but don't count head? Use arr for blocked; queue start, visited; if not blocked and not food? Count empty cells including food? We can count cells not blocked. Start is blocked; if we start queue at head, skip blocked? Could start by queue head and allow head as special passable. Simpler: create visited; queue all adjacent passable from head (not blocked). Count. Also include food as passable? For free space, include food too maybe. But if food blocked? no.
```
function floodFreeAfter(head, occ) { count =0; queue = dirs from head if !occ[nidx]; visited; while ...; if !occ[cur] {count++; neighbors...}; return count; }
```
BFS path: target food; passable if !occ or cur==food? food not occ. Need not include occupied. Return distance or -1. Use queue.

AI top choose: candidates sorted; but if all negative, choose candidate with largest score and safe. Could randomize if tie. Need if candidate immediate food but no path? We assign pathLen=0 if h==food; score high.

Could add if demoMode and score>50 maybe speed too fast? Not necessary.

Input queue and direction update:
- Use `nextDir` queue. On input, if state PLAYING: push desired if not opposite of current or last queued and not equal. But if queue empty and desired opposite, ignore. If queue nonempty, compare last.
```
function requestDir(nd) {
 const last = queued.length? queued[queued.length-1] : dir;
 if (nd.x === -last.x && nd.y === -last.y) return;
 if (nd.x === last.x && nd.y === last.y) return;
 queued.push(nd); if (queued.length>2) queued.shift()? maybe keep 3.
}
```
In step: `while(queued.length) { const d=queued.shift(); if(!(d.x===-dir.x&&d.y===-dir.y)) {dir=d; break;} }` Then move.

Need startGame:
```
function resetGame(mode) {
 snake=[{x:3,y:Math.floor(ROWS/2)}... maybe length 3] ; dir={x:1,y:0}; queued=[]; score=0; speed=...; spawnFood(); state=mode; overlay hide if PLAYING; if mode=='DEMO' show message.
}
```
For demo initial: `resetGame('DEMO')` and maybe pre-place passengers ahead to ensure action. Could set first food at x=10,y=middle. But spawn random okay? To make visible action in first 3s, better deterministic initial food ahead along track. On reset for demo: food = {x: COLS-2? or 8, y: mid}. For player start too maybe random. Need ensure not near head. We can set initial food deterministic ahead. For player after reset, maybe also ahead. But random is okay. Let's set initial food at (10, mid) for demo and (random) for player? To be interesting, initial passenger ahead. Use `spawnFood()` that avoids immediate but random. Could choose ahead if random too far? Better initial food at (9, mid) or (5, mid) ahead. For demo, first few seconds action. If start game from demo, maybe reset with player and set food ahead (9, mid). For subsequent player deaths reset with random.

Need best score: update on score > best. Session only.

Overlay messages:
```
<div class="overlay" id="overlay">
 <div class="big" id="overlayTitle">🚂 Click or press a key to start</div>
 <div class="small" id="overlaySub">A driverless demo train is already running. Use arrows/WASD, swipe, or tap the track.</div>
</div>
```
Game over title changes.

When state DEMO, overlay visible; but demo running. On start (player), hide. On game over, overlay visible with restart.

Touch/swipe:
- Add pointer events on boardWrap after start? We can allow swipe even when demo? But if pointerdown overlay start; if swipe after start. Use canvas pointerdown record; pointerup calculate; if state PLAYING request. If state DEMO/GAME_OVER start on tap. But overlay pointer events? We can attach to boardWrap with `pointerdown` to start if not playing; if playing set swipe. Overlay pointer-events none? But if overlay covers, pointerdown can still bubble if pointer-events none? Set overlay pointer-events:none so boardWrap handles? But then click text not interactive? We only need pointerdown anywhere. Could set overlay pointer-events: none and listen on window or boardWrap. Good. The overlay is visual. But if user clicks button D-pad, separate.
- For pointerdown: if state != PLAYING: start. Else startSwipe={x,y,t}. pointermove/up: if playing and moved >20, choose dir.
- Need prevent scrolling? On touch, use `touch-action: none` on boardWrap. Good.

D-pad buttons:
```
<div class="dpad" id="dpad" aria-hidden? >
 <button data-dir="up">▲</button> <button data-dir="left">◀</button> <button data-dir="down">▼</button> <button data-dir="right">▶</button>
</div>
```
On click: if state != PLAYING startGame('PLAYER')? If click dpad while game over should restart player and move. Good. Need prevent focus? `pointerdown` maybe. Use event.preventDefault? For buttons, can.

Keyboard: If state != PLAYING and key not F5? any key start? If key is arrow/WASD, startGame and set direction. If state PLAYING, arrow/WASD. If Space? maybe start.
```
window.addEventListener('keydown', e => {
 const key = e.key.toLowerCase();
 if (state !== 'PLAYING') {
   if (['arrowup','w',...].includes(key) || key===' '|| key==='enter') { e.preventDefault(); startGame('PLAYER', dirFromKey); }
   return;
 }
 const d = keyToDir[key]; if(d){ e.preventDefault(); requestDir(d); }
});
```
But if state DEMO, any key? Need if tabbing to button and Enter? Could start. Maybe any key except F12? Simpler: if key not modifier and state != PLAYING, start. But if browser shortcuts? Avoid. Start on arrow/WASD/space/enter. The message says Click or press a key. Good.

Need game over: If player dies, state GAME_OVER; show overlay. Could after 1.5s if no action, return to demo? Maybe to keep action visible. But if user dies, maybe should not automatically resume? For side-by-side judging, if someone plays and dies, demo can still run? Not necessary. But constraint: self-playing demo behind start; maybe after game over we could set state DEMO automatically after 1.5s to show action. But user may want to click. Could show overlay "Click or press to restart" and behind it a new demo? We could call resetGame('DEMO') on game over if not? But then overlay title. Let's think: Game over occurs. If we immediately restart demo behind overlay, the board continues with self-play, satisfying action. But user sees game over? Need distinguish. Could set state = 'DEMO_AFTER_OVER'? But then click starts player. Better: when die, if mode was PLAYER, show overlay and after 1s resetDemo(); set state='DEMO', overlay title maybe "Game over — demo train running; click/key to restart". But then score lost. Could keep final overlay? If screenshot after 3s, if game over occurred early? Not likely. Need robust.
Maybe on death set state = 'GAME_OVER'; update loop still runs? We can freeze? But self-playing demo behind message initial only. If game over freezes, maybe not. Could auto-return to demo if user idle for 2s. That ensures action. Implement:
```
function die() { state='GAME_OVER'; gameOverAt=performance.now(); updateOverlay(); }
// in rAF if GAME_OVER and now - gameOverAt > 1800 -> resetGame('DEMO'); state='DEMO'; updateOverlay();
```
This keeps action. If user clicks within, start player. Good. But best? score.

Need update overlay function:
- DEMO: title "🚂 Click or press a key to start", sub "Self-playing demo train is picking up passengers".
- GAME_OVER: title "💥 Game over — Score: X", sub "Click or press a key to restart • demo train will return soon".

When auto demo after game over, overlay remains DEMO. Good.

Need status display: "Demo", "Playing", "Game over".

Speed: update tick interval. Use `lastStep` and `stepInterval`. If stepInterval decreases, demo might speed. Maybe base 125, -1 per score to min 70.

Rendering details in JS:
```
function draw() {
 ctx.clearRect(0,0,viewW,viewH);
 drawBackground();
 drawFood();
 drawTrain();
 // maybe draw HUD? Not.
}
```
Background:
- Paint all canvas with gradient? We can fill rect with #1b263b? Actually station floor maybe lighter. Use `ctx.fillStyle = '#0f172a'`? Need bright. Let's make board dark blue/teal with track.
- Draw tile pattern: for each cell: base `#12213a`, if (r+c)%2 then `#162844`. Border lines `#234066` alpha. Draw rounded? Full grid rectangular board maybe enough. Add subtle platform areas? Maybe draw rails: for each cell, if segment? Could draw faint dashed track under entire grid: horizontal/vertical lines center. But train can move both directions. Maybe draw grid of dashed cross? Or not.
- Since passengers on platforms, maybe draw station platform edges? Could draw a row at bottom? Hmm.
Better aesthetic: board background as dark asphalt with faint rails: For each cell, draw small track pattern if near food? Maybe too much.
Could draw a platform rectangle around each cell? Not.

Draw each cell as rounded rect `cell*0.95` with radius 4, alternating colors. Then draw grid dots. That looks like platform tiles.
For food platform, draw more distinct: rounded rect in passenger theme, with stripes and sign. For train, draw over cells.

Need maybe draw a "platform" border around entire board? Use wrapper CSS border gradient. Canvas background maybe dark.

Draw rounded helper:
```
function roundRect(x,y,w,h,r) { ctx.beginPath(); ... }
```
Use modern `ctx.roundRect`? Chrome supports, but safer implement.

Draw train functions:
- Need time for smoke/wheels. `performance.now()/1000`.
- For each segment draw from tail to head? If draw tail first, head last. For wagons colors. Need orientation array computed:
```
function segmentOrientation(i) {
 if (i===0) return dir;
 const a=snake[i-1], b=snake[i]; let dx=a.x-b.x, dy=a.y-b.y; if (dx>1) dx-=COLS; if (dx<-1) dx+=COLS; if (dy>1) dy-=ROWS; if (dy<-1) dy+=ROWS; if (Math.abs(dx) >= Math.abs(dy)) return {x: dx>=0?1:-1, y:0}; else return {x:0, y:dy>=0?1:-1};
}
```
Actually from current segment to previous toward head; for head uses movement dir (from head to empty forward). If segment i, previous i-1 is ahead, so orientation forward along train? If previous is right of current, orientation east. Good. For head, if dir east. For body, if snake[0] east of snake[1], orientation east. Good.
- Draw connectors: for i>0 if segments adjacent (not wrap jump?), draw coupling between centers. We can skip if abs delta <=1 and not? Actually if wrap, positions may be adjacent through wrap but centers far; drawing connector would cross canvas. Skip connector if not adjacent in pixel: `Math.abs(a.x-b.x)<=1 && Math.abs(a.y-b.y)<=1`; else skip. Then draw car.
- Car function:
```
function drawCar(pos, orientation, color, isHead, index) {
 const cx = offsetX + (pos.x+0.5)*cell, cy = offsetY + (pos.y+0.5)*cell;
 const horiz = Math.abs(orientation.x) > Math.abs(orientation.y);
 const len = cell*0.82; const width = cell*0.62;
 const w = horiz ? len : width; const h = horiz ? width : len;
 ctx.save(); ctx.translate(cx,cy); if (!horiz && orientation.y === -1?) maybe rotate? Since car centered no need rotate except windows positions. Use x,y based on orientation? Could draw with horizontal if orientation east/west? For vertical, width/height swapped. But for head front, need direction sign. We can draw with ctx.rotate angle: if horiz: angle = orientation.x > 0 ? 0 : Math.PI; if vert: angle = orientation.y > 0 ? Math.PI/2 : -Math.PI/2. Then draw car with local x forward.
```
Good. Use local coordinate where +x forward. Car length 0.84cell, width 0.64cell. Draw base rounded rect centered: x=-len/2, y=-width/2. Add roof stripe. Windows: two rectangles at x=-0.1*len? For locomotive: cab at rear (negative x), chimney/front at positive.
- Wheels: if using rotated, draw below? We can draw wheel pairs along sides local y +/- width/2 + small, x = -len*0.2 and len*0.2. They are circles. But rotation changes orientation; in vertical, wheels on top/bottom? Since local +x forward, +y sideways. After rotation, okay. Wheels should be outside body? Draw after body? Maybe dark circles at y = +/- (width/2 - small?) Need visible. We can draw wheels on top and bottom? For train viewed from above, wheels are not visible? Since top-down, maybe draw wheel hubs as dark circles inside body? Hmm. Maybe side-view? Our board top-down. A top-down train car: rectangle with windows, wheels not visible. For colorful wagons, maybe no wheels. But user wants train, so draw roof/wheels as decorative: maybe dark side stripes resembling wheels on sides? Could draw small black ovals along both sides outside body to suggest wheels (top-down wheels at sides). With rotation, okay. But may clutter. Alternatively draw wheels as dark small circles at corners. Good.
- Color shading: body color, roof darker with 15% overlay. Add windows white/yellow with dark border. Add coupling line.
- Head locomotive details:
  - draw base as rounded rect with head color (#d62828 maybe) or maybe steam locomotive green? "locomotive" could be red. Use `locoColor = '#c1121f'`, roof dark. Add black cowcatcher front: small triangle at front? In rotated local, front x = len/2. Draw `ctx.moveTo(len/2, -width/2); lineTo(len/2+cell*0.12, 0); lineTo(len/2, width/2)`? But if extends outside cell, okay slight. Add cab at back: dark rectangle. Add chimney: small circle/rect at front: `roundRect(len*0.12, -0.12cell, 0.16cell,0.24cell)` dark. Add smoke puffs ahead (local +x + offset) maybe drawn after with alpha depending time.
  - Add headlight: small yellow circle at front edge.
  - Add "driver" maybe.
- Wagon details:
  - color by index. Add roof stripe lighter/darker. Windows: for horizontal/rotated, draw two white squares. Could draw cargo stripes? Add `ctx.fillStyle = darken(color, 0.2)` roof band along center? Since top-down, a central stripe maybe. Need distinct colors. Add small dark border. Maybe cargo icons? Simple.
- Need `shadeColor` helper to darken/lighten HSL? Could use `ctx.globalAlpha` overlays. For roof: draw darker rectangle using `rgba(0,0,0,.22)`. For highlight: `rgba(255,255,255,.18)`. Color can be HSL string; overlay works.
- Windows color: `#fff3b0`.

Draw passenger food:
- Need orientation? No. Use `passengerColor` array. Draw platform: base rounded rect `cell*0.9`, with stripe maybe. Sign: small yellow rounded rect? Draw person(s). Maybe draw 2 tiny passengers? We can draw one central person. If "passengers" plural, maybe draw 1-3 on platform based on ticket value? But snake pickup one. Could draw a platform with a ticket and person; okay.
- Platform: fill `#7b5f4a`? Add white dots? Maybe better: rounded rect in food color with border. Draw person:
  - body: rounded rect (torso) at y center, head circle above. Arms small lines. Legs. Use `ctx.fillStyle = shirtColor`.
  - Maybe luggage: small square.
- Add pulsing glow: `ctx.shadowColor`, `shadowBlur`? Could be nice. But performance small. Use time pulse `scale`.

Need spawn passenger with type: random shirt color.

Potential issue with draw connectors: if we draw connector before cars, cars overlap. For adjacent cells, connector can be rectangle between centers with width 0.18 cell, color dark. But skip wrap. Implementation:
```
for i=snake.length-1; i>0; i-- { if adjacent draw connector between centers color '#1d2733'; }
for i=snake.length-1; i>=0; i-- { drawCar... }
```
But connector color dark. For cars color, draw from tail to head; for wagons connector should be visible. Good.

Need maybe draw segments sorted so head on top. Tail first, connectors first then cars tail->head.

AI performance: BFS and flood per candidate 4 times per tick. Grid 300 cells; fine. Need use `Uint8Array` maybe. Could implement with typed arrays. In chooseAI, candidate newHead. Need build occ. For each candidate, call `bfsPath` and `floodFree` each scanning. Fine.

Let's code AI robustly:
```
function same(a,b){return a.x===b.x && a.y===b.y;}
function wrapPoint(p,d){ return {x:(p.x+d.x+COLS)%COLS, y:(p.y+d.y+ROWS)%ROWS}; }
function idx(p){return p.y*COLS+p.x;}
function occupancyAfterMove(newHead, willGrow) {
 const occ = new Uint8Array(COLS*ROWS);
 if (!willGrow) { for (let i=0; i<snake.length-1; i++) occ[idx(snake[i])] = 1; }
 else { for (let i=0; i<snake.length; i++) occ[idx(snake[i])] = 1; }
 occ[idx(newHead)] = 1;
 return occ;
}
function isSafeActualMove(newHead, willGrow) {
 const tail = snake[snake.length-1];
 for (let i=0; i<snake.length; i++) {
   if (newHead.x === snake[i].x && newHead.y === snake[i].y) {
     if (i === snake.length-1 && !willGrow) return false? Wait if newHead equals tail and !willGrow allowed -> should return true. Need if i===last && !grow => safe continue? But if also another same? snake positions unique except len? unique. So return true.
     return false;
   }
 }
 return true;
}
```
Actually if newHead equals tail and !grow, loop finds i last, return true; if not tail false. If grow and equals tail? food not tail; if true false. Good.
But if willGrow true and newHead equals any snake? invalid. Good.

BFS:
```
function bfsDistance(start, target, occ) {
 if (same(start,target)) return 0;
 const seen = new Uint8Array(COLS*ROWS);
 const dist = new Int8Array(COLS*ROWS); fill -1? Int16.
 const q = [start]; seen[idx(start)]=1; dist[idx(start)]=0;
 for head=0; ... {
   const p = q[head];
   for d in dirs { const np=wrap(p,d); if (same(np,target)) return dist[idx(p)]+1; const id=idx(np); if(seen[id]) continue; if(occ[id]) continue; seen[id]=1; dist[id]=dist[idx(p)]+1; q.push(np); }
 }
 return -1;
}
```
But if start is occupied by occ (head) yes occ start=1. We don't allow moving into occupied; start should be allowed as current head. In BFS, we skip `occ[id]`, but target not occupied. Need for moving from start to adjacent, if adjacent occupied skip. But if start marked occupied, we set seen and don't check occ for start. Good.
Potential if target food cell also occupied? no. If food cell equals start? handled before.

Flood:
```
function floodCount(start, occ) {
 let count=0;
 const seen=new Uint8Array(COLS*ROWS); const q=[];
 for d dirs { np=wrap(start,d); id=idx(np); if (seen[id]||occ[id]) continue; seen[id]=1; q.push(np); }
 while q... { cur=q.shift? use index; count++; for dirs ... }
 // maybe include target food if passable? Food not occ. Good.
 // If snake length 1, occ head, neighbors count.
 return count;
}
```
Could include start as passable if head cell? not needed.

AI choose candidates:
```
function chooseAI() {
 const candidates = [];
 for (const d of dirs) {
  if (d.x === -dir.x && d.y === -dir.y) continue;
  const h = wrapPoint(snake[0], d);
  const grow = same(h, food);
  if (!isSafeActualMove(h, grow)) continue;
  candidates.push({d, h, grow});
 }
 if (!candidates.length) return dir; // no safe -> die? Could choose reverse? dir reverse already invalid? If no candidates (e.g. trapped), returning dir may die. But AI can't do. Could return first dir from safe ignoring reverse. But okay.
 let best = null;
 for (cand) {
  const occ = occupancyAfterMove(cand.h, cand.grow);
  const path = cand.grow ? 0 : bfsDistance(cand.h, food, occ);
  const free = floodCount(cand.h, occ);
  // if path == -1 but grow? grow path=0.
  // compute tailSafety? Could compute distance to tail in new state maybe if path -1.
  let score = free * 4;
  if (path >= 0) score += 300 - path;
  else score -= 1000 + free? Hmm.
  // For grow immediate, maybe score += 220 (shortest) but can trap; free counts after growth (less). If no free, still maybe choose? Could reduce if free==0.
  if (cand.grow) score += 230; // prefer immediate but not at death
  if (path === 0) score += 230; // same
  // If no path, but can reach tail to chase? Could set pathTail = bfsDistance to old tail? Need not.
  // Tie random.
  if (best === null || score > best.score || (score === best.score && Math.random()<0.3)) best = {d:cand.d, score};
 }
 return best.d;
}
```
But path = -1 for many; free drives. Need if candidate immediate grow but after growth occ is full? free=0, score = 0+230. If another no path but free large score=free*4; if free>60, no path better than immediate death. But immediate likely free not zero. Good.
Could include if path>=0, prioritize path over free: path=20 score=280+free*4; immediate=230+free*4; if free similar, path 0 immediate okay, path 10 score 290; maybe choose path10 over immediate if free same by +60? That would choose not eat until closer? Actually if path candidate is adjacent to food? path=1, immediate candidate path=0; immediate gets +230 vs path candidate +299? Wait `score = free*4 + (path>=0 ? 300-path : -1000); if cand.grow score +=230; if path==0 score+=230`. For immediate: path=0 => 300 +230 =530. For adjacent but not on food: path=1 =>299. Immediate higher. Good.
Need ensure path for candidate not grow: occ after move; if candidate moves to safe but BFS path to food may be -1 due static occ, but actual moving could open tail. Approx fine.

Better if no path but free large choose. Need no path candidates maybe all; choose max free. Good.

Potential BFS path with occ after move includes tail removed if not grow; but static for further steps may block because tail moves away; this makes path too pessimistic but okay. Could build occ from snake without tail for BFS always? Already if not grow occ excludes old tail. Good.

Need maybe avoid path that passes through tail cell in future? static occ includes new snake positions except old tail; but after subsequent moves, more tail cells free. Fine.

Potential immediate growth occ includes all old snake plus head; flood less. Good.

Need maybe if length > COLS*ROWS-2 game win? Could state WIN? Not needed. If no empty food spawn, maybe set win overlay. Implement: if empty.length==0 => `winGame()` state GAME_OVER maybe "All passengers picked". Could auto demo. Good.

SpawnFood:
```
function spawnFood() {
 const occupied = new Uint8Array(COLS*ROWS); for s snake occupied[idx]=1;
 const empty = []; for y,x if !occ push {x,y};
 if (!empty.length) return false;
 // For initial demo, if firstSpawn and snake not moved? choose ahead.
 let choice;
 if (firstFood && state==='DEMO'? or just once): maybe choose cell along current dir ahead within 6.
 else random.
 food=choice; passengerColor = PALETTE[rand]; passengerType = rand 0..2;
 return true;
}
```
Need if initial reset demo, set `forceAheadFood` boolean. Could set after reset: `setFood(10,mid)`. Simpler in reset: set food explicit if mode DEMO or initial; else random. For player start from demo, maybe reset with explicit ahead too? Use initial `firstFood=true`.

Reset:
```
function resetGame(mode) {
 const midY = Math.floor(ROWS/2);
 snake = [{x:3,y:midY},{x:2,y:midY},{x:1,y:midY}]; // length 3, head at 3 dir right.
 dir = {x:1,y:0}; queued = []; score=0; stepInterval=115; food = {x:9,y:midY}; passengerColor=random; state=mode; gameOverAt=0; updateHUD(); updateOverlay();
 // if mode PLAYER start immediately maybe hide overlay.
}
```
If user clicks, resetGame('PLAYER'), overlay hide. But if reset player with same initial food ahead. Good.
Need maybe if COLS >10. midY.

Game loop:
```
let raf;
function loop(now) {
 if (!lastFrame) lastFrame=now;
 const dt=now-lastFrame; lastFrame=now; time=now/1000;
 if (state==='DEMO' || state==='PLAYING') {
   accumulator += dt; while(accumulator >= stepInterval) { accumulator -= stepInterval; step(); if(state==='GAME_OVER') break; }
 } else if (state==='GAME_OVER') {
   if (now - gameOverAt > 1800) { resetGame('DEMO'); }
 }
 resizeIfNeeded();
 draw();
 raf=requestAnimationFrame(loop);
}
```
Use `accumulator`. On state changes.
- If demo step interval maybe same. If user hasn't started, demo runs. If game over freeze then auto demo.

Need step for player and demo both similar. In `step()`:
```
if (state==='DEMO') { const ai = chooseAI(); dir=ai; }
else if (state==='PLAYING') { while(queue.length) { const q=queue.shift(); if (q.x===-dir.x && q.y===-dir.y) continue; dir=q; break; } }
const newHead = wrapPoint(snake[0], dir);
const willGrow = same(newHead, food);
if (!isSafeActualMove(newHead, willGrow)) { die(); return; }
snake.unshift(newHead);
if (!willGrow) snake.pop(); else { score++; if(score>best) best=score; stepInterval = Math.max(68, 112 - score*1.8); updateHUD(); if(!spawnFood()) win(); }
updateHUD length.
```
Need if state DEMO and score changes? In demo, should score count? It's a demo, could increase. If user starts, reset to 0. HUD shows demo score. Fine. But if demo dies? die sets game over then auto. For demo, death maybe after gameover? It auto resets.
- For speed in demo maybe same as player. Good.

Potential issue: When demo starts, state DEMO, overlay visible, user presses key and we resetGame('PLAYER'). But key may be e.g. ArrowUp, should set direction? Since start game with initial dir right; if key arrowup maybe set queued. Implement `startGame(nd)`: `resetGame('PLAYER'); if (nd) requestDir(nd); overlay hidden; canvas.focus();`. If user presses left at start, ignored because opposite right. Good.

Need click start: `startGame()`.
`onPointerDown`: if state DEMO or GAME_OVER => startGame. Else record swipe. Need if click on D-pad, separate.

Need pointer up swipe:
```
let swipeStart=null;
boardWrap.addEventListener('pointerdown', e => { if(state!=='PLAYING') startGame(); else swipeStart={x:e.clientX,y:e.clientY}; });
boardWrap.addEventListener('pointerup', e => { if(swipeStart && state==='PLAYING') { const dx=e.clientX-swipeStart.x; dy=...; if abs>18 set dir; } swipeStart=null; });
```
Need if tap no movement start? It started on pointerdown. Good.

Need D-pad:
```
document.querySelectorAll('[data-dir]').forEach(btn => btn.addEventListener('pointerdown', e => { e.preventDefault(); const d=dirFromName; if(state!=='PLAYING') startGame(d); else requestDir(d); }));
```
If user taps dpad while DEMO, start game with direction maybe if right initial same; okay.

Need CSS for D-pad. Could create grid with buttons. To avoid too tall, maybe show on mobile only? We can hide desktop? But if visible, no problem. Use `.controls { display:flex; justify-content:center; gap:10px; }` Buttons small. On desktop maybe not necessary but okay.

Potential problem with `aspect-ratio` + overlay absolute. Fine.

Need draw train after food; if food under train? Not.

Let's design drawTrain more concretely:
```
function drawTrain() {
  // connectors
  ctx.lineCap = 'round';
  for (let i=snake.length-1; i>0; i--) {
    const a=snake[i], b=snake[i-1];
    const dx=Math.abs(a.x-b.x), dy=Math.abs(a.y-b.y);
    if ((dx===1&&dy===0) || (dx===0&&dy===1)) {
      const p = wrapPoint? Actually positions adjacent no wrap. Draw rectangle between centers:
      const cx1 = offX+(a.x+0.5)*cell, cy1=..., cx2=..., cy2=...
      ctx.fillStyle = '#20304b'; // coupling
      if (dx===1) roundRect(Math.min(cx1,cx2), cy1-cell*0.11, Math.abs(cx1-cx2), cell*0.22, cell*0.08); else...
      ctx.fill();
    }
  }
  for (let i=snake.length-1; i>=0; i--) drawSegment(snake[i], orientation(i), i, i===0);
}
```
- Connector for vertical: x=min(cx), y=min(cy), width cell*0.22, height diff.
Need if cell is small.

Segment drawing:
```
function drawSegment(pos, orient, index, isHead) {
 const cx = offX + (pos.x+0.5)*cell, cy=...;
 const angle = orient.x===1?0: orient.x===-1?Math.PI: orient.y===1?Math.PI/2:-Math.PI/2;
 ctx.save(); ctx.translate(cx,cy); ctx.rotate(angle);
 const len=cell*0.84, wid=cell*0.64;
 // shadow?
 // body
 const color = isHead ? '#d90429' : wagonColor(index);
 ctx.fillStyle = color; roundRect(-len/2, -wid/2, len, wid, cell*0.14); ctx.fill();
 // border
 ctx.strokeStyle='rgba(0,0,0,.35)'; ctx.lineWidth=Math.max(1,cell*0.035); ctx.stroke();
 // roof/cab stripe
 if (isHead) {
   // rear cab dark
   ctx.fillStyle='rgba(0,0,0,.28)'; roundRect(-len*0.48, -wid*0.32, len*0.26, wid*0.64, cell*0.08); ctx.fill();
   // front boiler highlight
   ctx.fillStyle='rgba(255,255,255,.18)'; roundRect(-len*0.1, -wid*0.2, len*0.35, wid*0.4, cell*0.06); ctx.fill();
   // chimney
   ctx.fillStyle='#0d1117'; roundRect(len*0.05, -cell*0.07, cell*0.16, cell*0.14, cell*0.04); ctx.fill();
   ctx.fillStyle='#3e4a5f'; circle(len*0.13,0, cell*0.05) maybe.
   // headlight
   ctx.fillStyle='#ffd166'; ctx.beginPath(); arc(len*0.43,0, cell*0.07); fill; glow.
   // smoke puffs: draw after? In local coords, ahead:
   const t=(time*3 + index) % 1; for k=0;k<3;k++ { ... }
 } else {
   // wagon roof stripe
   ctx.fillStyle='rgba(255,255,255,.18)'; roundRect(-len*0.38, -wid*0.28, len*0.76, wid*0.56, cell*0.07); ctx.fill();
   // windows: 2 or 3 small rectangles, depending cell.
   ctx.fillStyle='#fff3b0'; for k=-1..1? draw roundRect((k*len*0.25)-cell*0.06, -cell*0.07, cell*0.12, cell*0.14,...)
 }
 // wheels: dark side ovals along bottom and top? In top-down, draw black ovals along sides outside? But after rotation, okay. For each side +/- wid/2, x positions -len*0.25, len*0.25. Draw small rect/circle partially outside body: fill '#1d2430'. This may look like wheels. Do for head and wagons.
 for (const side of [-1,1]) for (const xx of [-len*0.28,len*0.28]) { ctx.beginPath(); ctx.ellipse(xx, side*wid*0.56, cell*0.08, cell*0.045, 0, 0, 2π); ctx.fill(); }
 // cargo/passengers? For wagons maybe passengers? But wagons colorful, not passenger. Could draw tickets? Not.
 ctx.restore();
}
```
Need `circle` helper. Smoke: could draw outside rotate? If inside save rotate, local x front positive. Smoke puffs at x = len/2 + t*cell*0.6, y = sin? alpha. But rotation handles direction. Draw after body. `for k=0;k<3;k++){ const tt=(time*2 - k*0.3)%1; if(tt<0) tt+=1; ctx.globalAlpha=0.35*(1-tt); circle(len*0.45 + tt*cell*0.45, (Math.sin((time*5+k)*2)*cell*0.08), cell*0.12*(0.7+tt));}`. Need reset alpha.
Could be nice.

But draw wheels after roof, before smoke. Use `ctx.fillStyle='#0f141f'`.
Wagon color: maybe index starting 0 head; wagonIndex = i-1. Use palette:
```
const WAGON_COLORS = ['#ffd166','#06d6a0','#118ab2','#ef476f','#8338ec','#fb8500','#00b4d8','#ff006e'];
function wagonColor(i) { return i<=0 ? LOCO_COLOR : WAGON_COLORS[(i-1)%WAGON_COLORS.length]; }
```
Need maybe avoid same adjacent after many. okay.

Food drawing:
```
function drawFood() {
 const cx=..., cy=...;
 const pulse = 1 + Math.sin(time*4)*0.04;
 const size=cell*0.84*pulse;
 ctx.save(); ctx.translate(cx,cy); ctx.scale(pulse,pulse);
 // platform: rounded rect base color '#2b3e55'? maybe platform tile.
 ctx.fillStyle = passengerPlatformColor? '#5f0f40'?? Let's pick based on passengerColor? Platform darker.
 roundRect(-cell*0.42, -cell*0.42, cell*0.84, cell*0.84, cell*0.14); fill;
 ctx.fillStyle = '#ffffff20'; roundRect top?;
 // ticket border? Maybe draw sign "P"? no.
 // person(s):
 drawPassenger(-cell*0.08, 0, passengerColor, 0.8);
 if (passengerCount>1) second smaller? maybe.
 ctx.restore();
}
```
Need drawPerson helper in local centered:
```
function drawPerson(scale, shirt, x, y) {
 const s = cell*0.16*scale;
 ctx.fillStyle = '#ffd6a5'; // head? maybe skin? Could use dark/white? Use '#ffe5d4'.
 circle(x, y-s*1.4, s*0.62);
 ctx.fillStyle=shirt; roundRect(x-s*0.55, y-s*0.75, s*1.1, s*1.1, s*0.28);
 // legs: two small rects darker
 // arms maybe
}
```
Could draw multiple passengers? If cell, two people side by side. For "passengers" maybe draw two: one large, one small. But snake pickup. Use 2 persons? Might look cluttered. We can draw one central with luggage. Maybe enough. To emphasize platform, draw three ticket dots? Hmm.
Could draw a platform with a "stop" sign and one passenger. Good.

Need maybe passengers waiting on platform are food; train car picks up. Nice.

Board background details:
```
function drawBackground() {
 // fill dark gradient
 const g=ctx.createLinearGradient(0,0,viewW,viewH); g.addColorStop(0,'#0b132b'); g.addColorStop(1,'#1a1a2e'); ctx.fillStyle=g; ctx.fillRect(0,0,viewW,viewH);
 // cells:
 for y... for x... { ctx.fillStyle=((x+y)%2?'#162243':'#121b35'); roundRect(offX+x*cell+1, offY+y*cell+1, cell-2, cell-2, cell*0.18); fill; ctx.strokeStyle='rgba(255,255,255,0.035)'; stroke; }
 // faint rails: if horizontal? maybe draw dotted lines every other row? Actually train can move all directions. Draw small cross in each cell? Could draw dot center.
 // maybe draw border around grid: ctx.strokeStyle='rgba(255,255,255,0.12)'; strokeRect(offX, offY, cell*COLS, cell*ROWS).
}
```
Need performance: drawing rounded rects 300 each frame; okay. Could pre-render background to offscreen canvas? Simpler not needed. But 300 roundRect + strokes per frame at 60 maybe okay. But maybe heavy. Can optimize: draw cells with fillRect, border lines only every 2 cells. Or pre-render background pattern in `bgCanvas` on resize. Since grid size fixed, can render once. Let's implement to keep performance and cleaner.

Pre-render background:
- On resize, create `bgCanvas` or use `ctx`? Use offscreen `document.createElement('canvas')` width logical, height logical. Draw grid once. Then draw each frame `ctx.drawImage(bgCanvas,0,0,viewW,viewH)`. Need set dpr? Since bg canvas at dpr? Could draw in CSS pixels with ctx scaled. If offscreen without dpr might be blurry. Better make bg canvas width = canvas.width, height = canvas.height, scale by dpr. Then draw with ctx.setTransform? Hmm.
- In resize, `viewW = rect.width; viewH=rect.height; canvas.width = Math.floor(viewW*dpr); ... ctx.setTransform(dpr,0,0,dpr,0,0);` For bgCanvas, create width canvas.width, height canvas.height; `bctx.setTransform(dpr,0,0,dpr,0,0)` and draw in logical coords. Each frame before drawing, maybe use `ctx.drawImage(bgCanvas, 0, 0, viewW, viewH)` because transform dpr scales: drawImage with logical dimensions? If ctx transformed by dpr and bgCanvas has pixel dimensions, drawing into logical rect `0,0,viewW,viewH` scales correctly. Use `ctx.save(); ctx.setTransform(1,0,0,1,0,0); ctx.drawImage(bgCanvas,0,0); ctx.restore();` Simpler. But bgCanvas size equals canvas pixel size. Each frame restore dpr. Could do `ctx.drawImage(bgCanvas, 0, 0)` with transform? If ctx scaled, it would scale by dpr again (too big). So draw in identity transform. Let's store `pixelW`, `pixelH`. In draw: `ctx.save(); ctx.setTransform(1,0,0,1,0,0); ctx.drawImage(bgCanvas,0,0); ctx.restore();` then continue. Good.
- Resize bg: if dimensions change, render. Use `cell`, `offX`, `offY` based on logical. `renderBackground()` draws.

Need `viewW`, `viewH`, `pixelW`, `pixelH`, `dpr`.

Background render:
```
function renderBackground() {
 const bg = document.createElement('canvas'); bg.width=pixelW; bg.height=pixelH; const bctx=bg.getContext('2d'); bctx.setTransform(dpr,0,0,dpr,0,0); // maybe fill logical.
 const g=bctx.createLinearGradient(0,0,viewW,viewH); ... fillRect 0,0,viewW,viewH.
 for cells... roundRect with bctx. Use helper that takes ctx param? We can make `roundRect(ctx,...)`.
 // draw station rails? maybe draw dashed lines: for each row center horizontal dashed, for each col vertical dashed alpha low. Could look like track grid.
 // Add vignette.
 bgCanvas = bg;
}
```
Need `roundRect` works on any ctx. Good.

Potential issue: creating new canvas on every resize. Fine.

HUD update DOM:
```
scoreEl.textContent=score; lengthEl=snake.length; speedEl=Math.round((1000/stepInterval)/?); bestEl=best; statusEl=state=='DEMO'?'Demo train':state=='PLAYING'?'Driving':'Game over';
```
Speed as "1.0x" maybe `(115/stepInterval).toFixed(2)`.

Need CSS nice. Use fonts: system-ui; no external.

Edge cases:
- If canvas not resized before first draw? Initialize in `resize()`. Call reset, resize, loop. If viewW 0, cell 0? boardWrap aspect gives non-zero. Use fallback if cell <10. Good.
- If wrap snake orientation connector skip non-adjacent. For wrap, cars jump visually; okay.
- If length 3 initial head at x=3,y=7; wagons at x=2,1. Direction right. Food at x=9,y=7. AI demo moves right, picks after ~6 ticks. Visible before 3s? stepInterval 115ms -> 6 ticks =0.7s. Good. If BFS? It will choose right candidate because food ahead. Need immediate candidates: moving right path to food. It will likely pick right. Good.
- If input start before pickup, reset to same start with food ahead; user controls.

Need test AI mentally: initial snake head 3,7, dir right. candidates: right new head 4,7 grow false? no food. occ after move includes snake except tail? [3,2]? plus 4. BFS to food 9,7 path 5. free count large. left new head 2,7 occupied? not grow, isSafe? newHead at snake[1] -> occupied -> invalid. up/down path maybe? score less or similar. Right selected. Good.

At food pickup: newHead == food, occ after grow includes full snake plus head. BFS immediate path=0. score high. Good.

Potential if AI chooses up/down equally free but right path short. Good.

Need handle no safe candidates returning dir can die; but maybe if length small and wrap grid, always safe except self. If trapped, game over.

Could make demo more interesting by making passengers have ticket values? Not needed.

Need maybe "colorful wagons" - as train grows, new wagon at tail color palette. Head stays locomotive. Good.

Potential rendering: The car local rotation: For vertical down (`dir.y=1`), angle = Math.PI/2. Local +x maps to down? Yes, x axis rotated 90deg clockwise, +x -> +y screen down. For vertical up angle = -Math.PI/2, +x -> up. For horizontal left angle = Math.PI, +x -> left. Good.
Wheels local y side; after rotation, for vertical, wheels on sides left/right? Top-down okay.
Front smoke puffs at +x; for head up, puffs above.

Need draw passenger food maybe with pulse and maybe shadow. Could draw a platform sign: small pole? Use simple.
```
function drawFood(ctx) {
 const x=offX+food.x*cell, y=offY+food.y*cell;
 ctx.save(); ctx.translate(x+cell/2,y+cell/2); const s=cell;
 // shadow
 ctx.fillStyle='rgba(0,0,0,.22)'; roundRect(-s*0.42,-s*0.42,s*0.84,s*0.84,s*0.18); fill; maybe blur? no.
 // platform
 ctx.fillStyle='#f4a261'; roundRect(-s*0.4,-s*0.4,s*0.8,s*0.8,s*0.16); fill;
 ctx.strokeStyle='#0b132b'; lineWidth=s*0.05; stroke;
 // yellow safety stripe? Maybe draw ticket.
 ctx.fillStyle='#ffffff'; roundRect(-s*0.22,-s*0.26,s*0.44,s*0.14,s*0.06); fill; // sign
 // passengers: two small if s large. Use drawPerson at y=s*0.08.
 drawPerson(-s*0.14, s*0.04, passengerColor, s*0.14);
 drawPerson(s*0.14, s*0.06, passengerColor2, s*0.12);
 // luggage
 ctx.restore();
}
```
Need helper `drawPerson(px,py, shirt, headR)` in screen coords after translate and scale.
```
function drawPerson(px,py,shirt,s) {
 ctx.fillStyle='#ffdfc2'; circle(px, py-s, s*0.7);
 ctx.fillStyle=shirt; roundRect(px-s*0.75, py-s*0.55, s*1.5, s*1.4, s*0.35); fill;
 ctx.fillStyle='rgba(0,0,0,.25)'; roundRect(px-s*0.65, py+s*0.75, s*0.5, s*0.5, s*0.2); // leg? Maybe not.
}
```
Need keep simple.

Could use `ctx.filter = 'blur(...)'` for glow? Some browsers. But avoid perf. Use radial gradient glow behind food:
```
const grad=ctx.createRadialGradient(0,0,0,0,0,cell*0.6); grad.addColorStop(0,'rgba(255,255,255,.18)'); add transparent; fillRect.
```
Nice.

Need maybe draw passenger platform with small "ticket" text? Could draw "P" or person. Text maybe crisp? Use font? Could draw small ticket rectangle with notch. But no need.

Potential issue: if `roundRect` implemented with `beginPath` and lines, need radius min. Use:
```
function roundRect(ctx,x,y,w,h,r){ r=Math.min(r,w/2,h/2); ctx.beginPath(); ctx.moveTo(x+r,y); ctx.arcTo(x+w,y,x+w,y+h,r); ctx.arcTo(x+w,y+h,x,y+h,r); ctx.arcTo(x,y+h,x,y,r); ctx.arcTo(x,y,x+w,y,r); ctx.closePath(); }
```
No fill/stroke inside.

Need use `ctx.globalAlpha` and reset.

Let's plan code structure:
```
<script>
(() => {
 const COLS=20, ROWS=15;
 const dirs = [{x:1,y:0},{x:0,y:1},{x:-1,y:0},{x:0,y:-1}]; // maybe order for AI: right,down,left,up.
 const keyMap = { 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} };
 const WAGON_COLORS = [...]; const LOCO_COLOR='#d62828';
 let canvas, ctx, boardWrap, overlay,...; let viewW=0,viewH=0,pixelW=0,pixelH=0,dpr=1; let cell=24, offX=0,offY=0; let bgCanvas;
 let snake, dir, queued, food, passengerColor, passengerColor2, score=0, best=0, stepInterval=115, accumulator=0, lastTime=0, now=0, state='DEMO', gameOverAt=0, raf=0;
 const isSame = (a,b)=>a.x===b.x&&a.y===b.y;
 const wrapAdd=(p,d)=>({x:(p.x+d.x+COLS)%COLS, y:(p.y+d.y+ROWS)%ROWS});
 const idxOf=p=>p.y*COLS+p.x;
 ...
 function init() { elements; resize(); resetGame('DEMO'); window.addEventListener...; loop; }
 window.addEventListener('resize', resize);
 init();
})();
</script>
```
Need elements before DOM? Script at body end. Fine.

Detailed `resize`:
```
function resize() {
 const rect = boardWrap.getBoundingClientRect();
 viewW = Math.max(1, rect.width); viewH = Math.max(1, rect.height);
 dpr = Math.min(2, window.devicePixelRatio||1);
 pixelW = Math.floor(viewW*dpr); pixelH = Math.floor(viewH*dpr);
 canvas.width=pixelW; canvas.height=pixelH;
 ctx.setTransform(dpr,0,0,dpr,0,0);
 cell = Math.floor(Math.min(viewW/COLS, viewH/ROWS));
 offX = Math.floor((viewW - cell*COLS)/2); offY = Math.floor((viewH - cell*ROWS)/2);
 renderBackground();
}
```
If aspect ratio exactly COLS/ROWS=20/15=4:3, cell fits. `boardWrap` aspect-ratio: 4/3. Good. But if CSS adds padding? wrapper width includes padding? Use border box, aspect width/height. Good.
`ctx.setTransform` after canvas.width resets; okay.

renderBackground uses current cell/off; if cell 0? maybe. Need roundRect helper with ctx.
```
function renderBackground() {
 bgCanvas = document.createElement('canvas'); bgCanvas.width=pixelW; bgCanvas.height=pixelH; const b=bgCanvas.getContext('2d'); b.setTransform(dpr,0,0,dpr,0,0);
 // fill entire
 const g=b.createLinearGradient(0,0,viewW,viewH); ...; b.fillRect(0,0,viewW,viewH);
 // tile grid: maybe inset around board? fill cells only in offX...; outside maybe same dark.
 // draw subtle outer glow.
 for y... { for x... { const px=offX+x*cell+1, py=offY+y*cell+1, size=cell-2; b.fillStyle = ((x+y)%2?'#162843':'#121c36'); roundRect(b,px,py,size,size,cell*0.18); b.fill(); } }
 // rails: draw faint dashed along centers for each row/col? Use b.strokeStyle rgba(255,255,255,0.04); setLineDash([2,6]); lineWidth cell*0.08.
 for y 0..ROWS-1 { b.beginPath(); b.moveTo(offX, offY+(y+0.5)*cell); b.lineTo(offX+cell*COLS, ...); b.stroke(); }
 for x ... vertical. b.setLineDash([]);
 // border around board: b.strokeStyle='rgba(255,255,255,.12)'; roundRect(offX-2,offY-2,cell*COLS+4,cell*ROWS+4,cell*0.2); stroke.
}
```
Maybe too much but nice. Use line dash maybe supported. Good.

Draw:
```
function draw() {
 if (!ctx || !bgCanvas) return;
 ctx.save(); ctx.setTransform(1,0,0,1,0,0); ctx.clearRect(0,0,pixelW,pixelH); ctx.drawImage(bgCanvas,0,0); ctx.restore();
 // Now ctx transform dpr. draw food, train, maybe overlays? Overlay CSS.
 drawFood(); drawTrain();
}
```
Need after `ctx.save/restore`, transform dpr remains. Good.
Could draw a subtle shadow/vignette after? Not needed.

DrawFood uses `ctx.save(); ctx.setTransform(dpr,0,0,dpr,0,0)? Already. Use logical.

Need `wagonColor` function: if i ===0 loc. If i>0. Maybe use index modulo.

Segment orientation: For head, maybe if dir changes but snake segment behind not? Good.
```
function orientationAt(i) {
 if (i===0) return dir;
 const prev=snake[i-1], cur=snake[i];
 let dx=prev.x-cur.x, dy=prev.y-cur.y;
 if (dx > 1) dx -= COLS; if (dx < -1) dx += COLS;
 if (dy > 1) dy -= ROWS; if (dy < -1) dy += ROWS;
 if (Math.abs(dx) >= Math.abs(dy)) return {x: dx>=0?1:-1, y:0};
 return {x:0, y:dy>=0?1:-1};
}
```
If prev == cur? not.
Need head orientation could be dir but if dir hasn't applied? In step, after move, dir corresponds to new head. For rendering before step, okay.

Connector adjacency:
```
function adjacent(a,b) { const dx=Math.abs(a.x-b.x),dy=Math.abs(a.y-b.y); return (dx===1&&dy===0)||(dx===0&&dy===1); }
```
Wrap jumps not adjacent.

Draw segment details; maybe implement helpers:
```
function fillRound(ctx,x,y,w,h,r){ roundRect(ctx,x,y,w,h,r); ctx.fill(); }
function strokeRound(ctx,x,y,w,h,r){ roundRect(ctx,x,y,w,h,r); ctx.stroke(); }
function circle(ctx,x,y,r){ ctx.beginPath(); ctx.arc(x,y,r,0,Math.PI*2); ctx.fill(); }
```

Need draw head with windows? For locomotive maybe cab windows. Use after roof:
```
// cab windows dark
ctx.fillStyle='rgba(255,255,255,.25)'; roundRect(-len*0.44,-wid*0.2,len*0.14,wid*0.4,cell*0.04); fill;
```
Add chimney.
Add wheels: Need draw wheels before details? If wheels outside body, after body and border. Draw black ellipses at side `side*wid*0.54` (outside). But rotated, okay.
```
ctx.fillStyle='#08111f';
for(let side=-1; side<=1; side+=2) { for(const xx of [-len*0.3, len*0.3]) { ctx.beginPath(); ctx.ellipse(xx, side*wid*0.54, cell*0.075, cell*0.045, 0,0,2*Math.PI); ctx.fill(); } }
```
But if width 0.64 cell, side 0.54 -> 0.346 from center; wheel center outside? body edge 0.32. Good. Ellipse rx 0.075, ry 0.045. Could be subtle.
For wagons, maybe draw cargo stripe/roof, windows, wheels.
```
// wagon roof: light stripe along center length
ctx.fillStyle='rgba(255,255,255,.16)'; roundRect(-len*0.32,-wid*0.3,len*0.64,wid*0.6, cell*0.08); fill;
// dark band at ends? maybe.
// windows: three small yellow rectangles along local x positions.
const windowX = [-len*0.22, 0, len*0.22]; ctx.fillStyle='#fff3b0'; for(x of windowX) roundRect(x-cell*0.07, -cell*0.055, cell*0.14, cell*0.11, cell*0.04); fill;
// border.
```
For locomotive, maybe windows cab only; but okay.
Add smoke with globalAlpha.

Potential if cell small (375 width, grid 20 -> cell ~18, details too dense). Use scaled values relative to cell; okay. Maybe windows not visible but fine.

Food person details scale; if cell small, okay.

Need maybe draw passenger platform with a sign: a rounded rect yellow and a person. Let's design:
```
function drawFood() {
 if (!food) return;
 const cx=offX+(food.x+0.5)*cell, cy=offY+(food.y+0.5)*cell;
 const pulse=0.94+Math.sin(time*4)*0.04;
 ctx.save(); ctx.translate(cx,cy);
 // glow
 const glow=ctx.createRadialGradient(0,0,0,0,0,cell*0.75*pulse); glow.addColorStop(0,'rgba(255,255,255,.18)'); glow.addColorStop(1,'rgba(255,255,255,0)'); ctx.fillStyle=glow; ctx.beginPath(); ctx.arc(0,0,cell*0.75*pulse,0,Math.PI*2); ctx.fill();
 // platform
 ctx.fillStyle='#2f6690'; roundRect(-cell*0.44*pulse, -cell*0.44*pulse, cell*0.88*pulse, cell*0.88*pulse, cell*0.18); ctx.fill(); ctx.strokeStyle='#ffd166'; ctx.lineWidth=Math.max(1.5,cell*0.05); ctx.stroke();
 // stripes (platform edge) maybe draw two yellow dots.
 // passengers: two persons.
 drawPerson(-cell*0.13, cell*0.03, passengerColor, cell*0.095);
 drawPerson(cell*0.14, cell*0.05, passengerColor2, cell*0.085);
 // ticket sign: small rectangle with lines? maybe no.
 ctx.restore();
}
```
Need `drawPerson(px,py,shirt,s)` with local in current transform. Use relative to platform. s maybe cell*0.1.
```
function drawPerson(px, py, shirt, s) {
 ctx.fillStyle='#ffdfc1'; ctx.beginPath(); ctx.arc(px, py - s*1.4, s*0.7,0,Math.PI*2); ctx.fill();
 ctx.fillStyle=shirt; roundRect(px-s*0.8, py-s*0.55, s*1.6, s*1.4, s*0.4); ctx.fill();
 // arms
 ctx.strokeStyle=shirt; ctx.lineWidth=s*0.3; ctx.lineCap='round'; ctx.beginPath(); ctx.moveTo(px-s*0.9,py-s*0.2); ctx.lineTo(px-s*1.2,py+s*0.3); ctx.moveTo(px+s*0.9,py-s*0.2); ctx.lineTo(px+s*1.2,py+s*0.3); ctx.stroke();
 // legs
 ctx.strokeStyle='#1d2430'; ctx.lineWidth=s*0.35; ctx.beginPath(); ctx.moveTo(px-s*0.35,py+s*0.85); ctx.lineTo(px-s*0.35,py+s*1.4); ctx.moveTo(px+s*0.35,py+s*0.85); ctx.lineTo(px+s*0.35,py+s*1.4); ctx.stroke();
}
```
Maybe too big but nice.

Passenger colors:
```
const PERSON_COLORS=['#e63946','#457b9d','#2a9d8f','#e9c46a','#f4a261','#9b5de5','#00b4d8','#ef476f'];
function pickPersonColor(){ return PERSON_COLORS[(Math.random()*PERSON_COLORS.length)|0]; }
```

HUD status: `status` element. Update in loop? updateHUD after step.

Potential issue: Overlay hidden when state PLAYING; but if user starts and immediately dies, overlay visible. Good.

Need maybe start demo immediately on load. `resetGame('DEMO'); state='DEMO'; updateOverlay();` and loop starts. The overlay says message, board behind. Good.

Need `updateOverlay`:
```
function updateOverlay() {
 if (state === 'PLAYING') overlay.classList.add('hidden'); else overlay.classList.remove('hidden');
 if (state==='DEMO') { title.textContent='🚂 Click or press a key to start'; sub.textContent='The driverless demo train is already running. Arrows / WASD / swipe to take the controls.'; }
 else if (state==='GAME_OVER') { title.textContent='💥 Game over — passengers: '+score; sub.textContent='Click or press a key to restart. A demo train will keep running.'; }
}
```
If state DEMO after game over auto, show DEMO message. Good.

Need when demo running, if score increments, overlay still covers but title maybe "Demo train". Fine.

Potential issue: If user clicks start but keyboard focus not on canvas; document keydown works. Good.

Need avoid page scroll on touch swipe: `boardWrap` `touch-action:none`. Also buttons `touch-action:manipulation`.

Need ensure no horizontal scrollbar from aspect-ratio? If max-width plus padding maybe on 375 viewport: wrap padding 12; content width 351; aspect height 263; okay. Use `overflow-x:hidden` body. Good.

Potential issue: `canvas` with `width:100%; height:100%` and boardWrap aspect. Canvas internal pixel size from rect. Good.

Potential issue: `boardWrap` `position:relative` and overlay `pointer-events:none`? If overlay pointer-events none, pointerdown on boardWrap works. But if user clicks overlay text visually, it triggers boardWrap. Good. Need not have overlay pointer-events auto because then event target overlay but bubbles to boardWrap if child? yes. If pointer-events none, easier. But if overlay has button? none. Use none.

CSS:
```
.overlay { pointer-events:none; }
```
But user can press key because window listens. Good.

Need focus? Not required. Could canvas tabindex for keyboard? Window listens anyway. If iframe focus? User click on boardWrap gives focus? Key events to window. Fine.

Need maybe prevent default on pointerdown to avoid double-click scroll? Use `e.preventDefault()` if state != PLAYING or if buttons. But if we preventDefault on boardWrap, could prevent keyboard focus? not needed. For pointerdown swipe, call e.preventDefault? For touch, okay. But if state playing and pointerdown prevent default may interfere with text selection? no. Add `boardWrap.addEventListener('pointerdown', onPointerDown, { passive:false })`. In onPointerDown, if state != PLAYING { e.preventDefault(); startGame(); } else { swipeStart = {...}; } For playing pointerdown on canvas, if we preventDefault, could stop click but okay. We don't need click. For pointermove/up, if playing. On pointerup, if state != PLAYING ignore.

Potential issue: if state != PLAYING and user taps D-pad button, startGame called; but boardWrap pointerdown may not trigger if button separate. D-pad pointerdown start. Good.

Need D-pad layout: maybe `.controls { display:flex; justify-content:center; gap:14px; margin-top:4px; }` with buttons large. Use unicode arrows. On desktop, maybe hide? Could show but fine. Could style as circular.

Need if screen small, buttons wrap? Use `flex-wrap:wrap`.

Need HUD with best and status.

Let's think through game loop and timers:
```
function frame(ts) {
  const dt = lastTime ? ts-lastTime : 0; lastTime=ts; time=ts/1000;
  if (state==='PLAYING'||state==='DEMO') {
    accumulator += dt;
    while (accumulator >= stepInterval) { accumulator -= stepInterval; step(); if(state==='GAME_OVER') break; }
  } else if (state==='GAME_OVER') {
    if (ts - gameOverAt > 1500) resetGame('DEMO');
  }
  draw();
  raf=requestAnimationFrame(frame);
}
```
If resetGame('DEMO') during frame, okay. step could die, state GAME_OVER; break while. Draw after. Need if state GAME_OVER, don't step. Good.

`die()`:
```
function die() { state='GAME_OVER'; gameOverAt=performance.now(); if(score>best) best=score; updateHUD(); updateOverlay(); }
```
If demo dies, status Game over but after 1.5s auto demo. Could title says demo will return.

`winGame()` when spawnFood no empty:
```
state='GAME_OVER'; gameOverAt=performance.now(); overlayTitle = '🎉 All passengers picked!'; updateHUD(); updateOverlay();
```
But in updateOverlay maybe special? Could store `finalMessage`. Use `overlayTitle.textContent`.
Need if win, after delay reset demo.

Potential if state DEMO and score high length; speed. Not issue.

Need maybe demo mode if user hasn't clicked but game auto demo; when user presses start, resetGame('PLAYER'). Score resets. That might feel weird because they click and current demo resets. It's okay for start. Could instead keep current demo state and take control? But reset is clearer. The demo can be judged before start. After click, new player game. Good.

Need maybe show "Demo" HUD status. When state PLAYING, status "Driving". If game over, "Stopped".

Potential code issue: `queued` stores dir objects; in step for player, if queue empty keep current. Good.

`requestDir(nd)`: compare to last queued or dir. Need use same. If state DEMO and startGame called with nd, request may push but since queued not used until PLAYING. okay.
```
function requestDir(nd) {
  if (state !== 'PLAYING') return;
  const last = queued.length ? queued[queued.length-1] : dir;
  if (nd.x === -last.x && nd.y === -last.y) return;
  if (nd.x === last.x && nd.y === last.y) return;
  queued.push(nd);
  if (queued.length > 3) queued.shift();
}
```
But in `startGame` after reset, state='PLAYER'? Need ensure state set PLAYING before request. Do:
```
function startGame(nd) { resetGame('PLAYING'); if (nd) requestDir(nd); canvas.focus(); }
```
`resetGame` sets state. Good.

`keydown`: If state not playing, start; else request. Need if user holds key? keydown repeats; requestDir handles same. `if (['arrowup','arrowdown','arrowleft','arrowright','w','a','s','d',' ','enter'].includes(key)) { e.preventDefault(); if(state!=='PLAYING') startGame(dirFromKey); else requestDir(dirFromKey); }` If key is 'space' with no dir, start without dir. But dirFromKey undefined. Use mapping for arrows/wasd. If space/enter start only.
```
const key = e.key.toLowerCase();
const nd = keyMap[key];
if (state !== 'PLAYING') { if (nd || key===' ' || key==='enter') { e.preventDefault(); startGame(nd); } return; }
if (nd) { e.preventDefault(); requestDir(nd); } else if (key==='p') toggle pause? Not.
```
Need not preventDefault for all keys (Ctrl etc). Good.

`onPointerDown`: if state != PLAYING: startGame(). But if state DEMO and user clicks, starts. If game over and clicks, starts. Good. For buttons, same. If state PLAYING, start swipe. Need if boardWrap pointerdown after game over before auto? start.

Swipe up/down:
```
if (Math.abs(dx) > Math.abs(dy)) requestDir(dx>0?dirRight:dirLeft); else requestDir(dy>0?dirDown:dirUp);
```
Threshold 18.

Need D-pad `dirMap` = {right:{x:1,y:0}, left:{x:-1,y:0}, down:{x:0,y:1}, up:{x:0,y:-1}}.

Potential issue: buttons inside controls may receive pointerdown and also bubble to document? separate. We don't attach to body? Maybe if user taps button, no boardWrap. Good.

Need maybe update speed display as `Math.max(0.9, 112/stepInterval).toFixed(1)`? If stepInterval 115 ->0.97? Hmm. Use base 112: `speedMult = (112 / stepInterval).toFixed(2)`. At start 1.0. As faster >1. Could be weird >1.5. Fine.
```
speedEl.textContent = (112/stepInterval).toFixed(1)+'x';
```

Need maybe update HUD length and score each step. Call `updateHUD` in step and after reset. But in loop could call `updateHUD` every frame? Not needed. Use `updateHUD()`.

Potential issue: `chooseAI` called each step; if it returns direction causing immediate reverse? It filters reverse. Good. But if `dir` changes in step, next choose based on new dir. Good.

Need if demo mode starts and player clicks, resetGame('PLAYING') while accumulator may carry. `resetGame` should `accumulator=0; queued=[]; lastTime=performance.now?` Actually frame loop uses accumulator. Reset accumulator to 0 to prevent multiple steps due to leftover. Good.

Need `stepInterval` maybe `Math.max(70, 112 - score * 1.4)`. Score starts 0. At score 30 => 70. Good.

Could add passenger value? Not.

Let's consider if the AI can get stuck in infinite path loops because of no path? It will choose flood free. If it cannot reach food due to body blocking, may wander until opens path. BFS uses occupancy after current candidate static; may still think unreachable but path could open. Fine.

Could make AI deterministic by tie random but not too random. Need choose among candidates with equal score. If equal free/path, use `dirOrder` priority? Could add small bonus for current dir to smooth motion: `+2 if cand.d === dir`. But if path? Good. Add smoothness.
```
let score = free*4 + (path>=0 ? 300-path : -1000); if(cand.d===dir) score += 5; if(cand.grow) score += 250; if(path>=0 && path<bestPath?)... sort.
```
Tie: if best.score - score > epsilon choose; else maybe pick first due dir order. Use dir order [right,down,left,up]. Smooth bonus enough.
Need candidate immediate grow path=0 score high; if grow and free small, still high. Could if free==0 maybe no future; add penalty if free < snake length? Hard. But immediate food usually okay. Could compute if path to tail? maybe if grow and free < 5, reduce. Use:
```
if (cand.grow) { score += 250; if(free < 12) score -= (12-free)*8; }
```
This avoids eating into trap when no room. But if free 10, immediate still high. Good.
For no path, score maybe -1000 + free*4. If free 60 -> -760. Candidate with path 50 -> 250+free*4 = 490? If free 60 => 490, choose path. If no path but free 100 => -600? Actually path absent always worse unless no path candidates. Need if all no path choose max free. Good.

Could also if path exists but after eating trap? Not.

Need `bfsDistance` with static occ may return -1 if food behind body but tail movement would clear. Could AI still survive because free. Good.

Maybe implement path to tail if no food reachable as fallback. For no path, prefer candidate that can reach tail (to follow tail and avoid trap). Compute `bfsDistance(candidate, tailCell?)`? Tail after move maybe if grow? For not grow, old tail removed. Need find tail of new snake: if grow tail old last; else tail old secondLast? Simpler not needed. But could improve. Let's implement tail chase: For candidate not grow and no food path, compute distance to nearest tail segment maybe. Use `tailForOcc = occupancyAfterMove` no. New snake after candidate not grow: tail = snake[snake.length-2] if length>1? Wait old snake [head, b1,... tailOld]. After move not grow: [h, head, b1,..., before tail] length same; new tail = old tail? Let's simulate: length 4 old [0,1,2,3] indices. After unshift h, pop 3 -> [h,0,1,2]. New tail index old 2 = snake[len-2]. If length 1 no tail. For grow: [h,0,1,2,3], tail old 3. So compute tailCell = if grow? snake[last] : snake[len-2]. If length 1 maybe. For no path, score += 50 - tailDist if reachable. But occupancy static. Could implement `distanceToTail = bfsDistance(cand.h, tailCell, occ)` if tailCell not occ? But tailCell is occupied in occ, BFS target may be occupied; BFS target should be passable even if occupied? In classic snake, tail will vacate, so chase tail allowed. For current occupancy after move, tailCell is part of body and will be blocked until moving? But BFS target blocked; need treat target as passable. Our BFS checks if target is food? It uses target and if adjacent returns before occ check, but if starting? For tail, if target occupied, from neighbors if `same(np,target)` returns dist+1 before occ check? In code I planned: check same(np,target) then continue? If target occupied but we want reachable to tail even if blocked, BFS should allow target as goal. Current code would return if neighbor same target before checking occ. Good. But if start==target? Not possible? Could be moving into tail? candidate safe if h==tail not grow? Then tailCell? For length 2, old tail = snake[1]; candidate to tail maybe safe; occupancyAfterMove not grow: old snake len-2? len=2, tailCell=snake[0] (old head)? Not correct. For chase tail maybe not critical.
Add simple `tailBonus`: if path<0, `const distTail = distanceToTail(cand.h, occ, grow); if (distTail >=0) score += 80 - Math.min(distTail, 20)*2;` Need implement distanceToTail where target is tail cell and treat target passable. But BFS function currently target not occ? If target occ, neighbor target returns distance; but if start==target? We should return 0 maybe. But target occ, if start==target not allowed? If h==tail? not grow; occupancyAfterMove includes new head (tail old?) and old snake except old tail. For length2 moving into old tail: old snake [head,tail], newHead=tail (safe). grow? food? no. occupancyAfterMove not grow: loop snake.length-1 = old snake[0] only; occ[newHead]=1; target tailCell=snake[len-2]=snake[0] (old head), not h. Not useful.
Maybe skip tail bonus.

Need if no safe candidates? chooseAI returns dir. But actual step will die. Good. Could maybe if demo and no safe, not return reverse? no. Could try reverse? But reverse invalid. If no safe, game over.

Need maybe if length gets too big (close to max cells), AI can't avoid; okay.

Now, ensure no external CDN. Good.

Let's write final HTML code carefully.

Potential bug: `state` variable set to strings `'DEMO'`, `'PLAYING'`, `'GAME_OVER'`. `resetGame(mode)` sets `state=mode`; if mode 'PLAYER', set 'PLAYING'? Use function:
```
function resetGame(mode) { state = mode; ... updateOverlay(); }
function startGame(nd) { resetGame('PLAYING'); requestDir(nd); }
```

Need elements: `overlayTitle`, `overlaySub`, `scoreVal`, `lengthVal`, `speedVal`, `bestVal`, `statusVal`.

DOM update: `lengthVal.textContent = snake.length;`

Need maybe if canvas context `getContext('2d')` can be null in rare? We can assume.

Potential bug with `roundRect` on bctx if r negative? Use max. Good.

Let's design CSS more:
```
body { background: radial-gradient(circle at top, #1f2b56, #0d1226 55%, #070b17); color:#e8ecff; font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif; }
.wrap { max-width:1020px; margin:0 auto; padding: clamp(10px, 2vw, 20px); min-height:100dvh; display:flex; flex-direction:column; gap: clamp(10px, 2vw, 16px); }
header { display:flex; flex-direction:column; gap:4px; }
h1 { margin:0; font-size:clamp(1.4rem, 3.5vw, 2.2rem); letter-spacing:.02em; text-shadow:0 2px 12px rgba(0,0,0,.35); }
.subtitle { margin:0; color:#a9b6d7; font-size:clamp(.85rem, 2vw, 1rem); }
.hud { display:grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap:8px; }
.stat { background:linear-gradient(180deg,#1b2748,#141d36); border:1px solid #2d4170; border-radius:12px; padding:8px 10px; box-shadow: inset 0 1px 0 rgba(255,255,255,.06); }
.stat span { display:block; font-size:.65rem; text-transform:uppercase; letter-spacing:.08em; color:#8ca0cc; margin-bottom:2px; }
.stat strong { font-size:clamp(1rem,2.5vw,1.3rem); color:#fff; }
.board-wrap { position:relative; width:100%; aspect-ratio:4/3; border-radius:18px; overflow:hidden; background:#0d1226; box-shadow:0 20px 50px rgba(0,0,0,.45), inset 0 0 0 1px #334875; touch-action:none; cursor:pointer; }
canvas { width:100%; height:100%; display:block; }
.overlay { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; flex-direction:column; gap:10px; padding:20px; text-align:center; background:rgba(7,11,23,.58); color:#fff; pointer-events:none; transition:opacity .25s; }
.overlay.hidden { opacity:0; visibility:hidden; }
.overlay .big { font-size:clamp(1.25rem, 4vw, 2.1rem); font-weight:900; text-shadow:0 4px 16px rgba(0,0,0,.4); }
.overlay .small { font-size:clamp(.85rem, 2.2vw, 1.05rem); color:#cbd5ff; max-width:560px; }
.controls { display:flex; justify-content:center; gap:10px; flex-wrap:wrap; }
.dirbtn { width:clamp(46px, 14vw, 64px); height:clamp(46px,14vw,64px); border-radius:14px; border:1px solid #3b5591; background:linear-gradient(180deg,#23325c,#18233f); color:#e8ecff; font-size:1.4rem; line-height:1; display:grid; place-items:center; touch-action:manipulation; cursor:pointer; user-select:none; -webkit-tap-highlight-color:transparent; }
.dirbtn:active { transform:translateY(1px) scale(.98); background:#314a85; }
footer { color:#8ca0cc; font-size:.82rem; text-align:center; }
kbd { background:#23325c; border:1px solid #3b5591; border-radius:6px; padding:1px 5px; font-size:.8em; }
@media (max-width:520px) { .hud { grid-template-columns:repeat(2,1fr); } }
```
Use visibility hidden for overlay hidden with transition? `display:none` can't transition. Fine. But pointer-events none. Could use `.hidden { opacity:0; pointer-events:none; }` but we need not. Good.

Need if overlay hidden, still visible? Use `visibility:hidden`. Good.

Need D-pad layout perhaps arrows in row: left, right, up, down? Could do `left right up down`. Better grid D-pad? Use 4 buttons in row. Fine.

Could hide controls on desktop? If visible maybe takes space. Could show always. Maybe `@media(min-width:700px){ .controls{ display:none; } }` to give more board? But if screenshot on 1280, no buttons. Keyboard works. On mobile, show. Let's do `@media (min-width: 860px) { .controls { display:none; } }` but then mobile buttons. Need if desktop with touch? okay. But instructions mention swipe/tap. For side-by-side, board larger without controls. Use hide on wide. But if user on desktop with no keyboard? no. Good.

Need board aspect maybe 4/3; if hide controls on 1280, more space. Good.

Potential issue: `min-height:100dvh` may not supported? Chrome supports. Use `min-height:100vh`. To avoid horizontal scrollbar, body overflow-x hidden. Good.

Let's refine JS with exact code.

Detailed functions:
```
function same(a,b){ return a.x===b.x && a.y===b.y; }
function wrapAdd(p,d){ return {x:(p.x+d.x+COLS)%COLS, y:(p.y+d.y+ROWS)%ROWS}; }
```
Need for `bfsDistance` queue with index pointer; no `shift` performance. Use arrays.
```
function bfsDistance(start, target, occ) {
 if (same(start,target)) return 0;
 const seen=new Uint8Array(COLS*ROWS);
 const dist=new Int16Array(COLS*ROWS);
 const queue=[idxOf(start)]; seen[queue[0]]=1; dist[queue[0]]=0;
 for (let qh=0; qh<queue.length; qh++) {
   const curId=queue[qh]; const cur = {x:curId%COLS,y:Math.floor(curId/COLS)};
   const dcur=dist[curId];
   for(const d of DIRS) {
     const nx=(cur.x+d.x+COLS)%COLS, ny=(cur.y+d.y+ROWS)%ROWS; const nid=ny*COLS+nx;
     if (nx===target.x && ny===target.y) return dcur+1;
     if (seen[nid]) continue;
     if (occ[nid]) continue;
     seen[nid]=1; dist[nid]=dcur+1; queue.push(nid);
   }
 }
 return -1;
}
```
Need target could be occupied and neighbor target check before occ, good. But if target == start handled. If target occupied and only reachable through target? It returns. Good.
Potential if start occ and target adjacent but occ target? returns. Good.
Need if target is blocked but we want not allow entering? For food not blocked. For tail target, okay.

Flood:
```
function floodCount(start, occ) {
 const seen=new Uint8Array(COLS*ROWS);
 let count=0;
 const queue=[];
 const add=(id)=>{ if(seen[id]||occ[id]) return; seen[id]=1; queue.push(id); };
 for(const d of DIRS) { const nx=(start.x+d.x+COLS)%COLS, ny=(start.y+d.y+ROWS)%ROWS; add(ny*COLS+nx); }
 for(let qh=0;qh<queue.length;qh++){ const id=queue[qh]; count++; const x=id%COLS,y=Math.floor(id/COLS); for(d) ... }
 return count;
}
```
Need occ marks blocked, not start. If adjacent target food passable. Good.

`occupancyAfterMove`:
```
function occupancyAfterMove(newHead, grow) {
 const occ = new Uint8Array(COLS*ROWS);
 if (grow) { for(let i=0;i<snake.length;i++) occ[idxOf(snake[i])]=1; }
 else { for(let i=0;i<snake.length-1;i++) occ[idxOf(snake[i])]=1; }
 occ[idxOf(newHead)]=1;
 return occ;
}
```
For grow, old snake still exists plus new head. Good. For non-grow, remove old tail but if newHead equals old tail, we mark old snake except tail plus newHead (same as tail) so okay. But in the loop for i=0; i<snake.length-1, old tail excluded. Good.

`isSafeActualMove(newHead, grow)`:
```
function isSafeActualMove(p, grow) {
 const tail = snake[snake.length-1];
 for (let i=0;i<snake.length;i++) {
  if (p.x === snake[i].x && p.y === snake[i].y) {
    if (i === snake.length-1 && !grow) return true;
    return false;
  }
 }
 return true;
}
```
If p==tail and !grow safe. But if p==tail and snake length 1? tail=head; but movement into own cell? impossible because reverse excluded and dir wrap? Could move from x to itself if dir? no. If p==head? only if dx/dy zero. Not.

`spawnFood`:
```
function spawnFood(preferAhead=false) {
 const occupied = new Uint8Array(COLS*ROWS); for(const s of snake) occupied[idxOf(s)]=1;
 if (preferAhead) { let p=snake[0]; for(step=1; step<=12; step++){ p=wrapAdd(p, dir); if(!occupied[idxOf(p)]) { food=p; passengerColor=pickPersonColor(); passengerColor2=pickPersonColor(); return true; } } }
 const empty=[]; for(let y=0;y<ROWS;y++) for(let x=0;x<COLS;x++){ const p={x,y}; if(!occupied[idxOf(p)]) empty.push(p); }
 if(empty.length === 0) return false;
 food = empty[(Math.random()*empty.length)|0]; passengerColor = PERSON_COLORS[(Math.random()*PERSON_COLORS.length)|0]; passengerColor2 = PERSON_COLORS[(Math.random()*PERSON_COLORS.length)|0]; return true;
}
```
Need `PERSON_COLORS` pick function. Use `const pickColor = () => PERSON_COLORS[(Math.random()*PERSON_COLORS.length)|0];`
Initial reset calls `spawnFood(false)` and then manually set food ahead? Use:
```
snake = [{x:3,y:mid},...]; dir=right; food={x:8,y:mid}; colors...;
```
Need ensure not occupied. If COLS=20 mid=7. Food x=8. Good. For initial demo/player. For subsequent spawn random.

Could implement `setInitialFood`:
```
const initialFoodX = Math.min(COLS-3, snake[0].x+6); food={x:initialFoodX,y:snake[0].y}; colors.
```
But after wrap if snake[0] near edge? reset at 3. Good.

Reset:
```
function resetGame(mode) {
 const midY = Math.floor(ROWS/2);
 snake = [{x:3,y:midY},{x:2,y:midY},{x:1,y:midY}];
 dir = RIGHT; queued=[];
 score=0; stepInterval=BASE; accumulator=0;
 food = {x:9,y:midY}; passengerColor=PERSON_COLORS[0]; passengerColor2=PERSON_COLORS[1];
 state=mode; gameOverAt=0; updateHUD(); updateOverlay();
}
```
But if `mode='DEMO'` and user hasn't started, initial food ahead. If demo picks up food, spawn random. If user presses right, immediate start with same food ahead, okay. Could random colors initial maybe. Use random.

Need define DIRS constants:
```
const RIGHT={x:1,y:0}, LEFT={x:-1,y:0}, DOWN={x:0,y:1}, UP={x:0,y:-1}; const DIRS=[RIGHT,DOWN,LEFT,UP];
```
But if using same object references in `queued`, okay. `requestDir` compares values. Good.

`chooseAI` with DIRS order.
```
function chooseAI() {
 let bestDir = dir, bestScore = -Infinity;
 for (const d of DIRS) {
   if (d.x === -dir.x && d.y === -dir.y) continue;
   const h = wrapAdd(snake[0], d);
   const grow = same(h, food);
   if (!isSafeActualMove(h, grow)) continue;
   const occ = occupancyAfterMove(h, grow);
   const path = grow ? 0 : bfsDistance(h, food, occ);
   const free = floodCount(h, occ);
   let sc = free * 4;
   if (path >= 0) sc += 300 - Math.min(path, 250);
   else sc -= 1000;
   if (grow) {
     sc += 260;
     if (free < 18) sc -= (18 - free) * 7;
   }
   if (d === dir) sc += 6;
   // maybe if d.x/d.y toward center? not.
   if (sc > bestScore) { bestScore=sc; bestDir=d; }
 }
 if (bestScore === -Infinity) { // no safe candidate; try ignoring reverse
   for (const d of DIRS) { const h=wrapAdd(snake[0],d); const grow=same(h,food); if(isSafeActualMove(h,grow)){ return d; } }
   return dir;
 }
 return bestDir;
}
```
Need `Math.min(path,250)` but path can be >300? not. If path=-1 handled. Good.

Potential bug: `d === dir` object reference; `dir` might be set to candidate from chooseAI which returns one of DIRS constants, so yes. If dir from input queue? queued dirs are constants from DIRS/keyMap. Good. If `dir` assigned to `nd` from keyMap (same constants), yes.

`step`:
```
function step() {
 if (state === 'DEMO') { dir = chooseAI(); }
 else {
  while (queued.length) { const q=queued.shift(); if (!(q.x === -dir.x && q.y === -dir.y)) { dir=q; break; } }
 }
 const newHead = wrapAdd(snake[0], dir);
 const grow = same(newHead, food);
 if (!isSafeActualMove(newHead, grow)) { die(); return; }
 snake.unshift(newHead);
 if (!grow) snake.pop();
 else { score++; if(score>best) best=score; stepInterval = Math.max(70, 112 - score*1.5); updateHUD(); if (!spawnFood(false)) winGame(); }
 updateHUD();
}
```
Need if state DEMO, updateHUD includes score. Good.
Potential if state PLAYING and queued has a direction that becomes invalid due to previous queued? We shift all, if opposite skip; if queue has multiple, may take second if first invalid. Good.

`spawnFood(false)`: Need avoid placing on snake. Use occupied. If no empty win. If random. Could occasionally set food ahead? no.

`winGame`:
```
function winGame() { state='GAME_OVER'; gameOverAt=performance.now(); overlayTitle.textContent='🎉 All passengers picked!'; updateOverlay(); }
```
But updateOverlay uses state GAME_OVER and overwrites title to 'Game over'. Need add final message variable. Simpler: in updateOverlay if score==snake.length-1? Hmm. Use `let finalTitle='';` In die finalTitle='💥 Game over — passengers: '+score; in win finalTitle='🎉 Station cleared!'. updateOverlay if GAME_OVER uses finalTitle. Let's implement.
```
let finalTitle = '';
function die(){ finalTitle='💥 Game over — passengers: '+score; ... }
function winGame(){ finalTitle='🎉 Station cleared! passengers: '+score; ... }
function updateOverlay(){ if(state==='DEMO') title = demo; else if(state==='GAME_OVER') title=finalTitle; }
```
After auto reset demo, finalTitle not used.

Need `updateHUD`:
```
function updateHUD() {
 if(!scoreEl) return;
 scoreEl.textContent=score; lengthEl.textContent=snake?snake.length:0; bestEl.textContent=best; speedEl.textContent=(112/stepInterval).toFixed(1)+'x'; statusEl.textContent = state==='DEMO'?'Demo train' : state==='PLAYING'?'Driving' : 'Stopped';
}
```
If stepInterval maybe 112? At reset. Good.

`updateOverlay` uses finalTitle and score for game over.
```
function updateOverlay() {
 if (state==='PLAYING') overlay.classList.add('hidden'); else overlay.classList.remove('hidden');
 if(state==='DEMO') { overlayTitle.textContent='🚂 Click or press a key to start'; overlaySub.textContent='Driverless demo train is already running. Arrows / WASD, swipe, or tap a direction to take the controls.'; }
 else if(state==='GAME_OVER') { overlayTitle.textContent=finalTitle || '💥 Game over'; overlaySub.textContent='Click or press a key to restart. The demo train returns in a moment.'; }
}
```
Need if state DEMO after game over but finalTitle old, no matter.

`startGame`:
```
function startGame(nd) { resetGame('PLAYING'); if(nd) requestDir(nd); canvas.focus(); overlay.classList.add('hidden'); }
```
Canvas focus not necessary; but if tabindex. Could `canvas.focus()` but if canvas tabindex 0. Good. If focus called without click maybe okay. `canvas.focus()` can cause scroll? It has tabindex. Not. Could skip.

Pointer handlers: Need if `boardWrap` contains overlay and canvas. Use pointerdown on `boardWrap`:
```
let swipe=null;
boardWrap.addEventListener('pointerdown', e => { if (state !== 'PLAYING') { e.preventDefault(); startGame(); return; } swipe={x:e.clientX,y:e.clientY}; });
window.addEventListener('pointermove', e => { if(!swipe) return; const dx=e.clientX-swipe.x, dy=e.clientY-swipe.y; if(Math.hypot(dx,dy)>30){ requestDir(dx>0&&abs(dx)>=abs(dy)?RIGHT:...); swipe=null; } });
window.addEventListener('pointerup', e => { if(!swipe) return; const dx=e.clientX-swipe.x, dy=e.clientY-swipe.y; if(Math.abs(dx)>18 || Math.abs(dy)>18) { requestDir(...); } else { startGame(); } swipe=null; });
```
If state PLAYING and swipe threshold reached during move, request and null. If release short, maybe not. But if user taps while playing? no need. If state changed due to death while swipe? maybe ignore.
Need in pointerup if state !== PLAYING? If died, ignore? Could reset swipe. Good.
But if state DEMO pointerdown starts game; then pointerup would maybe start? state PLAYING, swipe not set because pointerdown return. Good.

D-pad buttons:
```
document.querySelectorAll('.dirbtn').forEach(btn => { btn.addEventListener('pointerdown', e => { e.preventDefault(); const nd = dirBtnMap[btn.dataset.dir]; if(state!=='PLAYING') startGame(nd); else requestDir(nd); }); });
```
Need `dirBtnMap` uses constants. If startGame with direction opposite initial right, ignored. Good.

Keyboard handler: If state !== PLAYING: if key in map or space/enter start. But for space/enter we don't prevent default for space? Need prevent scroll. Add if `key === ' '` e.preventDefault. Good.
```
window.addEventListener('keydown', e => {
 if (e.ctrlKey || e.metaKey || e.altKey) return;
 const k=e.key.toLowerCase(); const nd=KEY_DIR[k];
 if (state !== 'PLAYING') { if (nd || k===' ' || k==='enter') { e.preventDefault(); startGame(nd); } return; }
 if (nd) { e.preventDefault(); requestDir(nd); }
});
```
Need `KEY_DIR` uses constants.

Potential issue: `window.addEventListener('keydown', ...)` in sandbox maybe receives after click. Good.

Need `resize` also if `boardWrap` height changes due to controls hidden? rAF loop maybe not continuously check. Use window resize and orientationchange. Also maybe call `resize` once before first draw. If container size changes due to font load? no. Could use ResizeObserver for boardWrap. Supported. Use `new ResizeObserver(() => resize()).observe(boardWrap)` to adapt. But if not, okay. Add ResizeObserver? Could cause multiple. Use window resize. For mobile URL bar, resize. Good. Could use `window.addEventListener('orientationchange', resize);` plus maybe `setTimeout(resize, 100)`. Simpler.

Potential performance: renderBackground on resize only. Good.

Need maybe use `dpr` in roundRect? not.

Let's write `renderBackground` with `roundRect` and `dashed` maybe. But if bctx transform, dashed line width logical. Good.
```
function renderBackground() {
 if (!viewW || !viewH) return;
 bgCanvas = document.createElement('canvas'); bgCanvas.width=pixelW; bgCanvas.height=pixelH; const b=bgCanvas.getContext('2d'); b.setTransform(dpr,0,0,dpr,0,0);
 const g=b.createLinearGradient(0,0,viewW,viewH); g.addColorStop(0,'#0d152e'); g.addColorStop(0.5,'#101b38'); g.addColorStop(1,'#0a1124'); b.fillStyle=g; b.fillRect(0,0,viewW,viewH);
 // grid cells
 for (let y=0; y<ROWS; y++) for (let x=0; x<COLS; x++) { const px=offX+x*cell, py=offY+y*cell; const inset=Math.max(1,cell*0.06); b.fillStyle = (x+y)%2? '#142246':'#111b39'; roundRect(b,px+inset,py+inset,cell-inset*2,cell-inset*2,cell*0.16); b.fill(); }
 // rails
 b.save(); b.setLineDash([Math.max(2,cell*0.1), Math.max(3,cell*0.18)]); b.strokeStyle='rgba(170,200,255,0.08)'; b.lineWidth=Math.max(1,cell*0.045);
 for(let y=0; y<ROWS; y++) { const cy=offY+(y+0.5)*cell; b.beginPath(); b.moveTo(offX+cell*0.1,cy); b.lineTo(offX+cell*(COLS-0.1),cy); b.stroke(); }
 for(let x=0; x<COLS; x++) { const cx=offX+(x+0.5)*cell; b.beginPath(); b.moveTo(cx,offY+cell*0.1); b.lineTo(cx,offY+cell*(ROWS-0.1)); b.stroke(); }
 b.restore();
 // outer rounded track frame
 b.strokeStyle='rgba(255,255,255,0.14)'; b.lineWidth=Math.max(2,cell*0.07); roundRect(b,offX-cell*0.08,offY-cell*0.08,cell*COLS+cell*0.16,cell*ROWS+cell*0.16,cell*0.35); b.stroke();
}
```
Need `roundRect` with r maybe negative? cell positive. Good.

Potential issue: if cell=0 (viewW=0) renderBackground loops. Guard.

Draw background: if bgCanvas missing, fallback fill. In `draw`, if `bgCanvas` null, fill dark. Good.

Need drawTrain with connectors and shadows. Let's implement helpers:
```
function carColor(i) { return i === 0 ? LOCO_COLOR : WAGON_COLORS[(i-1)%WAGON_COLORS.length]; }
function drawCar(pos, orient, i, isHead) { ... }
```
But if `dir` is a constant, orientationAt head may be one of constants. For head, if state just died, dir last. Good.

Implementation of `drawCar`:
```
function drawCar(pos, orient, index, isHead) {
 const cx = offX + (pos.x + 0.5) * cell;
 const cy = offY + (pos.y + 0.5) * cell;
 const angle = orient.x === 1 ? 0 : orient.x === -1 ? Math.PI : orient.y === 1 ? Math.PI/2 : -Math.PI/2;
 ctx.save(); ctx.translate(cx, cy); ctx.rotate(angle);
 const len=cell*0.86, wid=cell*0.66;
 // shadow under car
 ctx.fillStyle='rgba(0,0,0,.28)'; roundRect(ctx,-len/2+cell*0.02, -wid/2+cell*0.04, len, wid, cell*0.16); ctx.fill();
 // body
 ctx.fillStyle = isHead ? LOCO_COLOR : carColor(index); roundRect(ctx,-len/2, -wid/2, len, wid, cell*0.16); ctx.fill();
 // border
 ctx.strokeStyle='rgba(0,0,0,.35)'; ctx.lineWidth=Math.max(1, cell*0.04); roundRect(ctx,-len/2, -wid/2, len, wid, cell*0.16); ctx.stroke();
 // top highlight
 ctx.fillStyle='rgba(255,255,255,.14)'; roundRect(ctx,-len*0.36, -wid*0.32, len*0.72, wid*0.34, cell*0.08); ctx.fill();
 // windows / cab
 if (isHead) {
   // cab roof
   ctx.fillStyle='rgba(0,0,0,.24)'; roundRect(ctx,-len*0.48,-wid*0.42,len*0.28,wid*0.84,cell*0.08); ctx.fill();
   ctx.fillStyle='rgba(255,240,180,.85)'; roundRect(ctx,-len*0.42,-wid*0.24,len*0.16,wid*0.48,cell*0.05); ctx.fill();
   // front boiler
   ctx.fillStyle='rgba(255,255,255,.18)'; roundRect(ctx,-len*0.06,-wid*0.25,len*0.38,wid*0.5,cell*0.06); ctx.fill();
   // chimney
   ctx.fillStyle='#101520'; roundRect(ctx,len*0.06,-cell*0.08,cell*0.16,cell*0.16,cell*0.04); ctx.fill();
   ctx.fillStyle='#2b3545'; circle(ctx,len*0.14,0,cell*0.05);
   // headlight
   ctx.fillStyle='#ffd166'; circle(ctx,len*0.45,0,cell*0.07);
 } else {
   // dark side stripe (cargo)
   ctx.fillStyle='rgba(0,0,0,.22)'; roundRect(ctx,-len*0.4,-wid*0.12,len*0.8,wid*0.24,cell*0.05); ctx.fill();
   ctx.fillStyle='rgba(255,255,255,.18)'; roundRect(ctx,-len*0.4,-wid*0.12,len*0.8,wid*0.1,cell*0.04); ctx.fill();
   ctx.fillStyle='#fff3b0';
   const ws=cell*0.13;
   for (let k=-1; k<=1; k++) { roundRect(ctx, k*len*0.24 - ws/2, -ws*0.4, ws, ws*0.8, cell*0.035); ctx.fill(); }
 }
 // wheels
 ctx.fillStyle='#0a1120';
 const wr=cell*0.075;
 for (const side of [-1,1]) { for (const xx of [-len*0.28, len*0.28]) { ctx.beginPath(); ctx.ellipse(xx, side*wid*0.56, wr, wr*0.6, 0, 0, Math.PI*2); ctx.fill(); } }
 // coupling? already connector
 if (isHead) drawSmoke();
 ctx.restore();
}
```
Need `circle(ctx,x,y,r)` uses fill current.
`drawSmoke` within save: after body/wheels, use local coords. It may use globalAlpha and reset. Add small smoke behind front? Need smoke after head so not clipped? Not clipped. 
```
function drawSmoke() {
 const t = time;
 for (let i=0;i<3;i++) {
  const tt = (t*1.6 - i*0.32) % 1;
  if (tt < 0) tt += 1;
  ctx.globalAlpha = 0.28 * (1 - tt);
  ctx.fillStyle = '#e8ecff';
  ctx.beginPath(); ctx.arc(cell*0.52 + tt*cell*0.45, Math.sin((t*3+i)*2.2)*cell*0.07, cell*0.1*(0.7+tt*0.8), 0, Math.PI*2); ctx.fill();
 }
 ctx.globalAlpha=1;
}
```
Need after head car, smoke drawn inside transform; front local +x. Good.

Potential issue: `drawCar` uses `carColor(index)` but for head index 0. For wagons, index 1 first wagon. Good.

`drawTrain`:
```
function drawTrain() {
 // connectors
 for (let i=snake.length-1; i>0; i--) {
  const a=snake[i], b=snake[i-1]; if(!adjacent(a,b)) continue;
  const c1=center(a), c2=center(b); ctx.fillStyle='rgba(10,18,35,.85)';
  const minW=cell*0.2;
  if (a.y===b.y) { const x=Math.min(c1.x,c2.x), y=c1.y; roundRect(ctx,x, y-minW/2, Math.abs(x2-x1), minW, minW*0.25); ctx.fill(); }
  else { const y=Math.min(c1.y,c2.y); roundRect(ctx,c1.x-minW/2,y, minW, Math.abs(c2.y-c1.y), minW*0.25); ctx.fill(); }
 }
 for(let i=snake.length-1;i>=0;i--){ drawCar(snake[i], orientationAt(i), i, i===0); }
}
```
Need `center` returns `{x: offX+(pos.x+0.5)*cell, y: ...}`.
Connector if vertical/horizontal. If width height zero? Adjacent cell centers diff cell. Good. `roundRect` r `minW*0.25`.
Potential if connector under cars drawn before; okay.

Need `adjacent`: if positions are adjacent with no wrap. But if snake wraps, old adjacent across edge not drawn. Good. However if `dx=19` and `dy=0` due wrap, `adjacent` false. Car still appears. Good.

Potential if orientationAt for body segment after wrap: delta mod computed. For segment i=1 (body) at x=COLS-1, prev head at x=0; dx = prev.x-cur.x = 1? Wait prev=0, cur=19, dx=-19; if dx<-1 add COLS -> 1, so orientation east. Good. For body segment i=2 at x=18, prev x=19 dx=-1? no -1? prev=19-cur=1 -> 18? Actually dx=19-18=1 -> east? Path from segment to head via increasing x? Hmm. Good.

Need maybe if head orientation from `dir` but head just moved through wrap, dir east. Good.

Potential bug: In drawCar, `ctx.strokeStyle` set for border, then `roundRect` and `ctx.stroke()`. Good. `roundRect` helper only path.

Potential bug: `ctx.fillStyle` after roundRect not set? yes.

Need `roundRect` handles w/h less than 0? no.

Potential if cell small, `roundRect` radius too large but helper caps. Good.

Need `drawFood` with pulse. If food cell maybe under train? no. Draw before train; if train overlaps due movement? head on food? after step head moved onto food but then spawn new, so no. In game over maybe? okay.
```
function drawFood() {
 if (!food) return;
 const p=center(food);
 ctx.save(); ctx.translate(p.x,p.y);
 const pulse=0.94+Math.sin(time*4)*0.05;
 const glow=ctx.createRadialGradient(0,0,0,0,0,cell*0.8*pulse); glow.addColorStop(0,'rgba(255,230,160,.24)'); glow.addColorStop(1,'rgba(255,230,160,0)'); ctx.fillStyle=glow; ctx.beginPath(); ctx.arc(0,0,cell*0.8*pulse,0,Math.PI*2); ctx.fill();
 // platform
 ctx.fillStyle='#1f4e6b'; roundRect(ctx,-cell*0.45*pulse,-cell*0.45*pulse,cell*0.9*pulse,cell*0.9*pulse,cell*0.17); ctx.fill();
 ctx.strokeStyle='#ffd166'; ctx.lineWidth=Math.max(1.5,cell*0.055); roundRect(...); ctx.stroke();
 // inner platform
 ctx.fillStyle='#2c6b8f'; roundRect(ctx,-cell*0.32*pulse,-cell*0.32*pulse,cell*0.64*pulse,cell*0.64*pulse,cell*0.1); ctx.fill();
 // sign ticket? Draw small ticket: white rounded rect with lines.
 const s=cell*0.14*pulse;
 ctx.fillStyle='rgba(255,255,255,.9)'; roundRect(ctx,-s*1.4, -cell*0.35*pulse, s*2.8, s*1.0, s*0.25); ctx.fill(); ctx.fillStyle='#111'; roundRect(ctx,-s*1.1, -cell*0.28*pulse, s*1.0, s*0.28, s*0.1); ctx.fill(); ctx.fillStyle='#ffd166'; ctx.fillRect(s*0.1, -cell*0.28*pulse, s*0.45, s*0.28);
 // passengers
 drawPerson(-cell*0.11*pulse, cell*0.04*pulse, passengerColor, cell*0.095*pulse);
 drawPerson(cell*0.12*pulse, cell*0.06*pulse, passengerColor2, cell*0.08*pulse);
 ctx.restore();
}
```
Need check coordinate. Good enough. Could simplify.

`drawPerson`:
```
function drawPerson(px,py,shirt,s) {
 ctx.fillStyle='#ffd6a5'; circle(ctx,px,py-s*1.5,s*0.65);
 ctx.fillStyle=shirt; roundRect(ctx,px-s*0.75,py-s*0.55,s*1.5,s*1.25,s*0.35); ctx.fill();
 ctx.strokeStyle=shirt; ctx.lineWidth=s*0.22; ctx.lineCap='round'; ctx.beginPath(); ctx.moveTo(px-s*0.8, py-s*0.25); ctx.lineTo(px-s*1.05, py+s*0.35); ctx.moveTo(px+s*0.8, py-s*0.25); ctx.lineTo(px+s*1.05, py+s*0.35); ctx.stroke();
 ctx.strokeStyle='#203047'; ctx.lineWidth=s*0.25; ctx.beginPath(); ctx.moveTo(px-s*0.3, py+s*0.7); ctx.lineTo(px-s*0.3, py+s*1.15); ctx.moveTo(px+s*0.3, py+s*0.7); ctx.lineTo(px+s*0.3, py+s*1.15); ctx.stroke();
}
```
Need if `s` small. Fine.

Potential issue: `ctx.lineCap='round'` persists; reset after? In drawPerson, then drawFood restore resets? Since drawFood has ctx.save() at start and restore at end; lineCap changes inside restore. Good.

Need draw train after food; train may cover platform? okay.

Need maybe draw a small "station" sign? Not.

Let's consider if initial food at x=9 and platform drawing overlaps grid. Good.

Need if demo after start overlay hidden? no.

Potential issue: `overlay.hidden` class: I used `.hidden { visibility:hidden; opacity:0 }` with pointer-events none. If state PLAYING, overlay hidden. Good.

Need if game over auto demo: resetGame('DEMO') updates overlay visible. Good.

Potential bug: `resize` creates bgCanvas after initial reset? init: `canvas=...; ctx=...; resize(); resetGame('DEMO'); requestAnimationFrame(frame);`. Good. But renderBackground uses `cell` from resize; if boardWrap not sized? It is. Good.

Potential bug: ResizeObserver maybe if not defined? Modern. Could skip. If not, window resize.

Need if `canvas` has `width` CSS 100% and `display:block`. Good.

Let's write final code with no syntax errors. Need be careful with const/let.

Full script skeleton:
```
<script>
(() => {
 const COLS = 20, ROWS = 15;
 const RIGHT={x:1,y:0}, LEFT={x:-1,y:0}, DOWN={x:0,y:1}, UP={x:0,y:-1};
 const DIRS=[RIGHT,DOWN,LEFT,UP];
 const KEY_DIR = { arrowright:RIGHT, d:RIGHT, arrowleft:LEFT, a:LEFT, arrowdown:DOWN, s:DOWN, arrowup:UP, w:UP };
 const DIR_BTN = { right:RIGHT, left:LEFT, down:DOWN, up:UP };
 const LOCO_COLOR='#d62828';
 const WAGON_COLORS=['#ffd166','#06d6a0','#118ab2','#ef476f','#8338ec','#fb8500','#00b4d8','#9d4edd'];
 const PERSON_COLORS=['#ef476f','#ffd166','#06d6a0','#118ab2','#8338ec','#fb5607','#ff006e','#48cae4'];
 const BASE_INTERVAL=112;
 let canvas, ctx, boardWrap, overlay, overlayTitle, overlaySub;
 let scoreEl, lengthEl, speedEl, bestEl, statusEl;
 let viewW=0, viewH=0, pixelW=0, pixelH=0, dpr=1, cell=0, offX=0, offY=0;
 let bgCanvas=null;
 let snake=[], dir=RIGHT, queued=[], food={x:8,y:7}, passengerColor=PERSON_COLORS[0], passengerColor2=PERSON_COLORS[1];
 let score=0, best=0, stepInterval=BASE_INTERVAL, accumulator=0, lastTime=0, time=0, state='DEMO', gameOverAt=0, finalTitle='';
 // helpers...
 function init(){...}
 // start loop
})();
</script>
```
Need `food` used before reset? okay.

`roundRect` global:
```
function roundRect(c,x,y,w,h,r){ r=Math.min(r,Math.abs(w)/2,Math.abs(h)/2); if(r<0)r=0; 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 circle(c,x,y,r){ c.beginPath(); c.arc(x,y,Math.max(0.1,r),0,Math.PI*2); c.fill(); }
```
`Math.abs(w)/2` maybe w negative? Use positive dimensions. okay.

`center`:
```
function center(p){ return {x: offX + (p.x+0.5)*cell, y: offY + (p.y+0.5)*cell}; }
```

`idxOf`:
```
function idxOf(p){ return p.y*COLS + p.x; }
```

`pickColor`:
```
function pickColor(){ return PERSON_COLORS[(Math.random()*PERSON_COLORS.length)|0]; }
```

`renderBackground` needs roundRect. Need if `cell <=0` return.

Potential issue: `b.fillStyle='rgba(170,200,255,0.08)'` for line dash; roundRect? okay.

`draw`:
```
function draw() {
 if (!ctx) return;
 ctx.save(); ctx.setTransform(1,0,0,1,0,0);
 if (bgCanvas) ctx.drawImage(bgCanvas,0,0); else { ctx.fillStyle='#0d1226'; ctx.fillRect(0,0,canvas.width,canvas.height); }
 ctx.restore();
 // ensure transform dpr? ctx.save restore didn't change because save before transform? Wait in draw: ctx.save(); ctx.setTransform(1,...); ctx.drawImage; ctx.restore(); restore returns transform to previous dpr. Good.
 drawFood(); drawTrain();
}
```
After ctx initialized with setTransform dpr in resize. But in draw we call ctx.save, setTransform identity, draw bg, restore -> transform back to dpr. Good. Then drawFood/Train use dpr.

Potential if `canvas.width` changed after draw? resize resets ctx transform. Good.

`init` events. Need `resize` before loop. Also maybe on load call resize again after DOM stable. Use `requestAnimationFrame(() => resize())`? But loop resize once. If layout shifts? Maybe ResizeObserver. Let's include:
```
if (window.ResizeObserver) { const ro=new ResizeObserver(()=>resize()); ro.observe(boardWrap); }
window.addEventListener('resize', resize);
window.addEventListener('orientationchange', ()=>setTimeout(resize,50));
```
ResizeObserver may call resize immediately; good. Need avoid multiple renderBackground. Fine.

Potential issue: ResizeObserver callback during init before elements? okay.

`pointermove` and `pointerup` window. Use `e.clientX`. If multiple pointers? Could set `swipe=null` after. Fine.

`window.addEventListener('pointerup', ...)` might fire for mouse after start. If state PLAYING and short tap, currently pointerup if swipe and movement < threshold: startGame? We don't want tap while playing to restart. So in pointerup, if state PLAYING and no swipe direction, just swipe=null, not start. But if state DEMO pointerdown start and swipe null, pointerup no action. Good. If state changes to GAME_OVER between pointerdown and pointerup? swipe may exist; pointerup if state !== PLAYING -> swipe=null. Good.
```
function handlePointerUp(e) { if(!swipe) return; const dx=..., dy=...; if(state==='PLAYING' && (Math.abs(dx)>18||Math.abs(dy)>18)) requestDir(...); swipe=null; }
```
No tap restart while playing. But if user clicks boardWrap while playing, no start. Good.

Need `pointerdown`: if state != PLAYING start. But if user clicks a D-pad button, not boardWrap. Good.

Potential if state DEMO and overlay pointer-events none, click works. Good.

`window.addEventListener('keydown', handleKeyDown, false);` Need if focus not body? It bubbles to window. Good.

Potential if `e.key` undefined? Modern. Use `(e.key || '').toLowerCase()`.

Let's code `handleKeyDown` carefully:
```
function handleKeyDown(e){
 if(e.ctrlKey||e.metaKey||e.altKey) return;
 const k=(e.key||'').toLowerCase();
 const nd=KEY_DIR[k];
 if(state!=='PLAYING'){
  if(nd||k===' '||k==='enter'){ e.preventDefault(); startGame(nd); }
  return;
 }
 if(nd){ e.preventDefault(); requestDir(nd); }
}
```
Need `KEY_DIR` has arrowleft etc. For uppercase W? lower. Good.

`startGame` uses `canvas.focus();` If canvas not focusable? It is. But `canvas.focus()` can scroll into view. okay. Maybe skip to avoid issues. We can set `boardWrap.focus()`? not. Skip focus.

`requestDir`: If `queued.length` and last, compare with `dir` or last. Good.

Potential bug: In `step` for player, if queued contains a direction that is reverse of current, skip; if contains same direction, skip? Our `while` if q.x===-dir and reverse continue; if q equals current, we set dir (same) and break. Good.

Potential bug: In `chooseAI`, uses `same(h, food)` but if food object maybe mutated? no.

Potential bug: `isSafeActualMove` loops snake. For not grow and newHead equals tail, returns true when finds tail; if there is another segment same position? snake unique. Good. For length 1, tail=head, but newHead cannot equal head if direction valid? In wrap grid 1 maybe. Not.

Potential bug: If `spawnFood` random picks cell that is occupied by snake if using `occupied` array and `empty` includes tail? It includes current snake after step (including new head). For growth, before spawn, snake includes new head and tail remains. Good.

Potential bug: `spawnFood(preferAhead)` uses `dir` to find ahead, but not used for random. okay.

Need `winGame`: if no empty after pickup. `spawnFood` false. `winGame` state GAME_OVER. Good.

Potential issue: `best` update when win/die. For demo, best could increase automatically if user hasn't clicked? Should best reflect player? It might. But okay. Could update only in player? Not necessary. But if side-by-side best shows high demo after auto? Not visible maybe. Could set best only when state PLAYING? But if demo scores before user starts, then best changes. To avoid weird, update best only in PLAYING? But demo can be considered score? HUD best perhaps. Let's update best when state === 'PLAYING'? Actually if user presses start and dies, best. Demo running before start shouldn't affect best? It could be a self-playing demo but not user. But no rule. To avoid confusion, in step if grow and state==='PLAYING' update best. But if state DEMO, score still counts as demo? Could display. Best maybe remains 0 until player scores. Implement:
```
if (score > best) best = score;
```
But if demo after game over resets, score 0, best from demo? Hmm. Could do always; simple. If user hasn't played, best may increase; okay.

Potential issue: HUD status in DEMO says Demo train, not Game over. If demo dies and auto returns after 1.5s, during game over overlay says game over. But if user hasn't clicked, seeing game over might be weird. To avoid demo death during first 3 seconds, AI robust. If demo eventually dies, okay. Could make demo not die by using wrap and no self? But maybe. Good.

Need maybe self-collision with wrap can occur; AI avoids.

Let's test mentally for initial demo if user clicks: startGame resets state PLAYING, score 0, food ahead. If user presses left, requestDir ignored because opposite. If user doesn't press, train continues to food. Good.

Could make initial demo visible action before first 3 seconds: The loop starts, after 1 second, train moves ~9 cells? step interval 112 -> 9 cells at 1s. Initial food 6 cells ahead, picked. New random passenger maybe. Good.

Potential rendering of passenger: platform maybe too complex with small cell. But okay.

Let's ensure no invalid CSS: `aspect-ratio` supported. `min-height:100dvh` not used? Use `100vh`. `visibility:hidden`. Good.

Need no horizontal scrollbar: body `overflow-x:hidden`; wrapper max-width; padding. Good.

Potential issue: canvas `width:100%; height:100%` inside boardWrap with border-radius and overflow hidden. Good.

Potential issue: `.dirbtn` uses unicode arrows; okay.

Need maybe title says "Station Snake" with train. Good.

Let's consider whether `roundRect` helper uses `c.arcTo` with radius 0, okay. If `w` or `h` negative? Dimensions positive. For connector, if `diff=0`? adjacent so diff=cell. Good. For `drawFood` roundRect with width `cell*0.9*pulse`, positive. Good.

Need maybe use `ctx.lineCap` reset? In drawPerson within save. Good.

Potential issue: In `drawTrain` connector, after setting `ctx.fillStyle`, roundRect and fill. If vertical/horizontal `x2-x1` etc. Need variable:
```
const c1 = center(a), c2 = center(b);
if (a.y === b.y) { const left = Math.min(c1.x,c2.x); const width = Math.abs(c1.x-c2.x); const h=Math.max(2,cell*0.22); roundRect(ctx,left,c1.y-h/2,width,h,h*0.25); ctx.fill(); }
```
Good.

Potential issue: orientationAt returns {x:-1,y:0}; drawCar angle: orient.x === -1 -> Math.PI; orient.y=== -1 -> -Math.PI/2; else if orient.y===1 -> Math.PI/2; else if orient.x===1 ->0. If orient is {0,0} somehow, angle=0. Could fallback.

Potential issue: In drawCar smoke, after body/wheels, call `drawSmoke`; it uses current globalAlpha; restore resets. Good.

Potential issue: In renderBackground dashed lines, if `cell*0.045` less than 1, still min 1. Good.

Potential issue: `resize` called by ResizeObserver before `ctx` assigned? init assigns then observer. Good.

Need maybe `document.addEventListener('visibilitychange')`? not.

Let's write final code. Need one fenced code block only.

Double-check syntax:
- Use template literals? okay.
- Avoid semicolon mistakes.
- Use constants.

Potential issue: `state` initial declared `let state='DEMO';`, but before `init` resetGame called? okay.

Let's write functions in order: helpers, state functions, drawing, events, init.

Detailed code:
```
function updateHUD(){ if (!scoreEl) return; scoreEl.textContent=score; lengthEl.textContent=snake.length; bestEl.textContent=best; speedEl.textContent=(BASE_INTERVAL/stepInterval).toFixed(1)+'x'; statusEl.textContent=state==='DEMO'?'Demo train':state==='PLAYING'?'Driving':'Stopped'; }
```
`BASE_INTERVAL` used for speed display. If stepInterval < base, speed >1. Good.

Potential issue: `updateHUD` called in resetGame before `stepInterval`? yes. If `snake` empty? set.

`die`:
```
function die() { if (state==='GAME_OVER') return; state='GAME_OVER'; gameOverAt=performance.now(); finalTitle='💥 Game over — passengers: '+score; updateHUD(); updateOverlay(); }
```
`winGame` similar.

`startGame`:
```
function startGame(nd) { resetGame('PLAYING'); if(nd) requestDir(nd); updateOverlay(); }
```
`resetGame` already updateOverlay; state PLAYING hides overlay. If nd, request. Good.

Potential issue: If user clicks boardWrap, pointerdown calls startGame. If then keyup? okay.

`handlePointerDown`: If state PLAYING? set swipe. If state not PLAYING? startGame. Good. But if state PLAYING and user taps (short) no action. If user wants to restart? no. Game over handled by overlay. Good.

`handlePointerMove`: We could avoid moving threshold until pointerup? Current move if distance >25 request and null, so swipe can register during move, more responsive. Need prevent multiple swipes. If requestDir called once, swipe=null. Good.
```
if (!swipe || state!=='PLAYING') return; dx=..., dist=...; if(dist>22){ if(Math.abs(dx)>Math.abs(dy)) requestDir(dx>0?RIGHT:LEFT); else requestDir(dy>0?DOWN:UP); swipe=null; }
```
On pointerup if swipe remains and state PLAYING maybe tap no move, null.

Need add event listeners with options.

Potential issue: `boardWrap.addEventListener('pointerdown', handlePointerDown, {passive:false})`; if touch, `e.preventDefault()` to avoid scrolling? But boardWrap touch-action none already. If we preventDefault, may stop click? no. Good.

`controls` buttons: if hidden desktop? On wide hidden. On mobile visible. Add pointerdown.

Potential issue: If D-pad buttons hidden by CSS, event still attached but not visible. okay.

Let's ensure no undefined functions: `pickColor` used in spawnFood; `PERSON_COLORS` exists.

`spawnFood` implementation:
```
function spawnFood(preferAhead = false) {
 const occupied = new Uint8Array(COLS*ROWS);
 for(const s of snake) occupied[idxOf(s)] = 1;
 if (preferAhead) { let p=snake[0]; for(let i=1;i<=Math.max(6,COLS/2);i++){ p=wrapAdd(p,dir); if(!occupied[idxOf(p)]) { setFood(p); return true; } } }
 const empty=[]; for(let y=0; y<ROWS; y++) for(let x=0;x<COLS;x++){ if(!occupied[y*COLS+x]) empty.push({x,y}); }
 if(empty.length===0) return false;
 setFood(empty[(Math.random()*empty.length)|0]); return true;
}
function setFood(p){ food = {x:p.x,y:p.y}; passengerColor=pickColor(); passengerColor2=pickColor(); }
```
Need `idxOf(p)` expects object. Good.

Initial reset setFood manually with initial food ahead. Could call `setFood({x:9,y:midY})` after colors. Good.

Potential bug: `occupied[idxOf(s)]` works; `idxOf` uses p.y*COLS+p.x. Good.

`chooseAI`: if `snake.length === 0`? not. Need `food` object. Good.

Could maybe use `floodCount` every candidate, `bfsDistance`. Need `Uint8Array` and `Int16Array` allocations each tick; okay but 300 cells. Could optimize but fine. 4 candidates, 4 allocations. At speed 60? step ~100ms -> 10/sec, okay.

Potential performance: drawing 300 cells? background pre-rendered. Train drawing segments length maybe grows; each with many shapes. Still okay. If snake grows long >100, drawing shapes heavy but rare. Fine.

Potential issue: `roundRect` helper called many times; okay.

Need maybe if cell is 0, drawing car dimensions zero. Guard? If board not visible? cell positive. Could in draw if `cell<=0` return. Add.

Potential issue: `bgCanvas` if renderBackground not called before draw? resize calls. But if pixelW=0? skip. In draw, if `cell <= 0 || !bgCanvas` fallback fill; still draw train maybe zero. Add `if (cell <=0) return` after background? Actually if view zero, no. Could:
```
if (!ctx || cell <= 0) return;
```
But first paint maybe boardWrap aspect ensures. Good.

Need final output code only. Ensure HTML doctype.

Let's craft code carefully.

Check `drawCar` uses `carColor(index)`: For index 0 head; index >0 wagons. `WAGON_COLORS[(index - 1) % WAGON_COLORS.length]`. Good.

Check `drawCar` head: front headlight at x=len*0.45; if len 0.86cell, x=0.387cell, body edge 0.43? Wait len/2=0.43cell, headlight at 0.387 inside front. Good. Smoke starts at cell*0.52 just outside body. Good.
Chimney `roundRect(ctx, len*0.06, -cell*0.08, cell*0.16, cell*0.16, cell*0.04)` x=0.052cell to 0.212cell, inside front. Good.
Head light circle r=cell*0.07, center x=0.387cell, edge front? okay.
Cab roof from x=-0.41 to -0.15? len=0.86 -> -len*0.48=-0.413, width 0.24 -> -0.173. inside rear. Good.
Windows cab at -0.36 to -0.20? okay.
Wheels at side wid*0.56 =0.37cell; rx=0.075. Slightly outside body edge (wid/2=0.33). Good.

For wagons: windows at k=-1,0,1 x offset k*len*0.24 (0.206cell spacing). ws=0.13cell, positions: -0.336,0,0.336; edges -0.401 and 0.401, body half len 0.43, okay.

`drawTrain` connectors: color `rgba(10,18,35,.85)`. Good.

`drawFood`: platform colors maybe. Ticket sign: `roundRect(ctx, -s*1.4, -cell*0.35*pulse, s*2.8, s*1.0, s*0.25)` with s=cell*0.14 -> sign y from -0.35cell to -0.21cell, within platform. Inner text bars maybe. Good. Passengers y=cell*0.04 and head y-s*1.5 => about -0.145cell, legs to 0.15cell, within. Good.

Need `circle` function sets path and fill; if `c` parameter used in `drawCar` with `ctx`. Good.

Potential bug: In `renderBackground`, use `roundRect(b, ...)` where b has `setTransform`, okay.

Potential bug: In `draw`, `ctx.save(); ctx.setTransform(1,0,0,1,0,0); ctx.drawImage(bgCanvas,0,0); ctx.restore();` But before draw, `ctx` transform may be dpr. `save` stores. `setTransform` identity. `drawImage` uses pixel coordinates. Restore returns dpr. Good.

Potential bug: In `resize`, after setting `canvas.width`, context transform lost; call `ctx.setTransform(dpr,0,0,dpr,0,0)`. Good.

Potential bug: If `ResizeObserver` fires before `canvas` assigned? init assigns elements first. Good.

Potential issue: `canvas.focus()` skipped. Keyboard events on window. Good.

Potential issue: In `resetGame`, `accumulator=0; lastTime=performance.now();` But `frame` uses `lastTime` global; if we set to 0, dt huge on next? Use `lastTime = performance.now()`? But `frame` timestamp similar. If set `lastTime=0`, dt on first frame after reset maybe large causing multiple steps. So reset `accumulator=0`, but `lastTime` remains global. If startGame called, lastTime current, dt normal. If set lastTime=0, on next frame dt=performance.now() -> many steps. Avoid. Do not set lastTime. Or set `lastTime = performance.now()`. Since performance.now accessible. Use `lastTime = performance.now();`. But in reset, if called inside frame, performance.now maybe slightly different than frame ts but okay. Could just leave. `accumulator=0`. Good.
Need if game auto reset after game over, lastTime from previous frame, okay. Good.

Potential issue: In `frame`, `const dt = lastTime ? ts - lastTime : 0; lastTime = ts;` If `lastTime` set to performance.now in init before first frame, first dt maybe small. If 0 initially, first dt 0. Good. If startGame sets lastTime=performance.now, okay.

Need `gameOverAt` use performance.now in die. Good.

Potential issue: If state GAME_OVER and auto reset after 1.8s, `resetGame('DEMO')` sets accumulator 0. Good.

Potential issue: If user presses key while state GAME_OVER before auto, startGame. Good.

Potential issue: If `chooseAI` returns dir but no safe candidates, step will die; game over overlay then auto. Good.

Could maybe make demo mode safer by not using self collision? no.

Let's final code. Need maybe no comments? Fine. Comments okay.

Double-check HTML for elements IDs:
```
<div class="board-wrap" id="boardWrap">
 <canvas id="game"></canvas>
 <div class="overlay" id="overlay">... spans with ids</div>
</div>
<div class="controls">
 <button type="button" class="dirbtn" data-dir="left">◀</button>
 <button ... up>▲</button>
 <button ... down>▼</button>
 <button ... right>▶</button>
</div>
```
Maybe order left/up/down/right? For row, `left, up, down, right` okay. On mobile, left/right/up/down? Use row `left, up, down, right`. Fine.

Need `statusVal` etc.

Potential issue: `boardWrap` `cursor:pointer`; if controls hidden on desktop, okay.

Potential issue: `overlay` `hidden` class initial? It should be visible. In HTML not hidden. JS update hides if playing. Good.

Need no `console.log`.

Let's think of possible hidden errors:
- In `roundRect`, if radius negative due min, use `if (r < 0) r = 0;`. okay.
- In `bfsDistance`, target could be food; if target id occupied, neighbor target check before occ. But if start target? handled. If target not adjacent but path through target? We return when found. Good. If target unreachable because blocked and target itself not checked as passable? We don't push target because we return when neighbor. If start target? return. Good.
- If target occupied and start adjacent but `seen`? no. Good.
- If `floodCount`, it doesn't count start, only adjacent empty cells. For open area around head, if snake length 1, count cells reachable adjacent not occupied. Good.
- `chooseAI`: For grow immediate, occ includes newHead; floodCount starts from newHead and counts adjacent empty. If all neighbors occupied, free=0. Score immediate: path>=0 (0) => +300, grow +260, free 0 ->560. If another candidate no path but free large e.g. free 100 => -1000+400 = -600; immediate chosen. If immediate leads to death next, maybe unavoidable if no space. But if free <18 penalty: if free 0, sc 560-126=434; still high. Could choose a non-eat with free 200 -> 800, safer. If free 50 ->200. Non-eat with path 10 and free 50 ->290+4=294? immediate better. Could be okay. If immediate free 0 but alternative free 110 ->440 -1000? = -560? no path worse. Need no path penalty too high; if no path free 110 -> -560, immediate free0 ->434, choose immediate maybe death. But if no path but actually safe to wander? Static path -1 due body, but moving away may open. Our penalty too high; maybe choose immediate with no free. Could adjust no path penalty to -200 instead of -1000. Let's make better:
```
if (path >= 0) sc += 300 - path; else sc -= 500;
```
Then no path free 110 -> -500+440=-60, immediate 434 still. Hmm. Could add if grow and free==0 then -1000. `if(grow && free<3) sc -= 1000;` So avoid if trapped. But if immediate only candidate? choose. Good.
Set no path penalty -120; free*4 still. Candidate with path 30 free 20 -> 270+80=350; immediate free 5 -> 560+20-126=454; okay. If immediate free0 and alternative no path free80 -> -? with no path -120+320=200; immediate 560, still immediate. Add `if (grow && free < 6) sc -= 300;` Then immediate free0 =260; alternative 200 maybe less but not safe. Could set if grow && free < 6 && some no path candidate exists? Hard.
Simplify AI: prioritize survival free area over immediate pickup if no safe after eating. Compute after eating candidate flood free and also path to tail? Hmm.
Maybe use candidate validity after eating: if grow, simulate a few random future steps? Too complex.
But typical snake won't eat into no space due small grid and length low. Fine.

Potential path score: If path length > 300? no.
Add `sc += Math.min(free, 100)*3`. Good.

Potential tie: use first candidate DIRS order. With smooth bonus, okay.

Need maybe if `chooseAI` candidate immediate food but `path=0`, grow score +260. Good.

Let's adjust in code: no path penalty -220. If grow and free < 5 subtract 400.

```
if (path >= 0) sc += 300 - Math.min(path, 300); else sc -= 220;
if (grow) { sc += 250; if (free < 5) sc -= 400; else if (free < 16) sc -= (16-free)*8; }
```
This avoids eating into very cramped. Good.

Could include "distance from food" fallback for candidates that don't reach via BFS but generally move toward food? Not necessary. BFS static might say unreachable due body but still moving toward may open. Could use Manhattan distance if path -1: `sc += 40 - Math.min(80, manhattan(h,food));` This guides toward food even if BFS unreachable. Add:
```
const md = Math.min(Math.abs(h.x-food.x)+... , COLS+ROWS); if (path<0) sc += 60 - Math.min(60, md);
```
Because no path may still want toward food. But free matters. Add.

`chooseAI` final:
```
if (path >= 0) sc += 300 - path;
else { sc -= 220; sc += 60 - Math.min(60, Math.abs(h.x - food.x) + Math.abs(h.y - food.y)); }
```
Good.

Need if food behind wrap, Manhattan not true distance, but okay. Could use wrap distance:
```
function wrapDist(a,b){ let dx=Math.abs(a.x-b.x); if(dx>COLS/2) dx=COLS-dx; let dy=...; return dx+dy; }
```
Use. Add helper.

`chooseAI`: compute `md = wrapDist(h,food)` for path -1.

Good.

Potential issue: AI with wrap and no self-collision may wander. Fine.

Let's include wrapDist helper.

Now, maybe add "platform" around board? Not necessary.

Potential problem with self-collision: With wrap, if snake length is 2 and moving into tail (old tail) not grow allowed. But visual orientation for head and body: if new head equals old tail, after move, positions [oldTail, oldHead]. oldHead and oldTail adjacent? If oldHead to oldTail maybe reverse? Example snake [3,4] dir left? head 3, tail 4. Move left to 2? not tail. Move right? reverse invalid. If wrap? okay. If moving into tail in a U shape? Safe. But orientationAt for body uses previous head; fine.

Potential bug: `isSafeActualMove` if `newHead` equals tail and !grow, returns true. But if `newHead` equals tail and also equals another segment? impossible. Good.

Potential bug: occupancyAfterMove for non-grow when newHead equals old tail: loop snake[0..len-2] includes old head and others except old tail. occ[newHead]=1; if newHead equals old tail, occ old tail set by loop if index <= len-2? For length 2: loop i=0 only oldHead. newHead oldTail. Good. For length 3 moving into tail? old tail index 2, loop i=0,1 includes oldHead and oldBody, not tail. newHead tail set. Good. It doesn't include oldTail from loop, okay. For length 1 moving? loop i<0 none; newHead set. Good.

Potential bug: `isSafeActualMove` loops through snake; for non-grow if p equals tail, return true before checking p equals other? Since if p equals tail, no need continue. If p equals oldHead? It returns false when i=0, not tail unless length1. For length1 p could equal oldHead? move zero. no.

Good.

Let's think if game over overlay pointer-events none, user clicks anywhere starts because pointerdown on boardWrap. If user clicks D-pad while game over, starts. If user presses key while iframe not focused? Need user click first; overlay message says. Keyboard events only after click. If user clicks outside boardWrap but within body? window pointerdown? We attach only boardWrap and D-pad buttons. The instruction says click or press a key; if user clicks header or body, no. But within iframe, if click on body outside boardWrap? Could start if we attach document pointerdown? Maybe better attach pointerdown on `document` or `window` to start. But if user clicks D-pad, we don't want double? Could add document listener but if state PLAYING, it sets swipe? Need careful. Simpler: attach to `boardWrap` and `.controls` and `window.addEventListener('pointerdown')`? If state != PLAYING, any click starts. If state PLAYING, if pointer target is boardWrap? swipe; if D-pad? handled. But document pointerdown could start if game over anywhere. Good. Implement maybe:
```
document.addEventListener('pointerdown', e => { if (state !== 'PLAYING') { // don't start if click inside button? Button handler already, but okay double start? It will reset. Could return if target.closest('.dirbtn')? } else { if (boardWrap.contains(e.target)) start swipe; } });
```
But simpler with boardWrap plus controls. Instruction says click anywhere? We can make body click start by attaching to `window`:
```
window.addEventListener('pointerdown', (e) => {
 if (state !== 'PLAYING') { e.preventDefault(); startGame(); }
 else if (e.target === canvas || e.target === boardWrap || boardWrap.contains(e.target)) { swipe=...; }
}, {passive:false});
```
If user clicks a button while state DEMO, window starts, and button pointerdown starts again? Two resets. Could avoid by checking target closest('.dirbtn') and return. But button while game over should start; could let window start but no double if prevent default? It would reset twice if both handlers fire. We can attach start only to `document` and remove boardWrap start. For playing swipe, attach boardWrap. For buttons, attach. But window pointerdown for game over might reset twice with button? If button handler calls startGame, then window pointerdown also if state not PLAYING (because button handler runs before window? Both capture/bubble order? Add window listener on document bubble after button; button sets state PLAYING, then window sees state PLAYING and tries swipe if target boardWrap? Button not boardWrap, maybe if we only swipe when boardWrap contains target; button not, so no reset. But if window handler starts on all non-PLAYING clicks, then if button state DEMO, button handler starts before window? Actually event dispatch: capture then target then bubble. If window listener on document bubbling, target button's own listener runs first if attached to button, then document. Button sets state PLAYING, document sees PLAYING; if target button not boardWrap, no swipe. Good. If attached window before? order by target then bubble; button listener executes before window if on document? Yes, if button listener added on button, it runs. Good. But if user clicks header, window starts. Good.

Could use window pointerdown for all start and swipe on boardWrap. Need be careful with preventDefault: if window preventDefault on pointerdown while playing? We don't. Only if state != PLAYING. If user clicks header while playing? We might preventDefault but do nothing; could block text selection. Fine? Better not preventDefault unless state != PLAYING. For buttons in demo, button handler start and document handler sees PLAYING. Good.

But if window handler starts game when user clicks D-pad in demo, and button handler also starts? If button handler runs first and state DEMO -> startGame -> state PLAYING, then window handler state PLAYING, but if target button not boardWrap, no swipe. No double. If button handler not set because pointerdown maybe? It is. Good.

Let's design:
- `window.addEventListener('pointerdown', (e) => { if (state !== 'PLAYING') { if (e.target.closest && e.target.closest('.dirbtn')) return; e.preventDefault(); startGame(); } else { if (boardWrap.contains(e.target)) swipe={...}; } });`
But if game over and user clicks D-pad, button handler start, then window sees state PLAYING; if boardWrap contains button? No, controls outside, so no swipe. Good.
- Keep button handler for when state PLAYING and for if window didn't start due target closest? If state DEMO, button starts. Good.
- For boardWrap swipe while PLAYING. Good.
This allows click anywhere start. Good. Need if user presses mouse on overlay? pointer-events none, event target maybe canvas? window start. Good.
- Need if state GAME_OVER and user clicks, start. Good.
- Need if `e.target.closest` not exist? For SVG? no. Use `const t=e.target; if (t.closest && t.closest('.dirbtn')) return;`
- If state !== PLAYING and target is a link? none.
- PreventDefault on window pointerdown may block button focus? If state != PLAYING, we return before for dirbtn to let button handler. If state DEMO and click boardWrap, preventDefault okay.
- If state PLAYING and pointerdown on canvas, don't preventDefault, set swipe. Good.
- Remove boardWrap pointerdown listeners? Could still use window. Simpler use window only.

Need `window.addEventListener('pointerup', ...)` for swipe. If state != PLAYING, swipe null. Good.

Potential issue: `window` pointerdown with state != PLAYING could start when user clicks scrollbar? no. If user clicks body and drags text selection? no selection. Good.

Need if user clicks D-pad while state PLAYING, button handler requests direction; window handler: state PLAYING, if boardWrap.contains(button)? false, so no swipe. Good.

Could attach button handler with `pointerdown`; if state != PLAYING startGame; else requestDir. But window handler may also start before button if listener order? We use `e.target.closest('.dirbtn') return` for state != PLAYING, so window won't start; button starts. Good.

Need if target is overlay child and overlay pointer-events none, target likely canvas? window start. Good.

Let's adjust listeners accordingly.

Keyboard still works if user clicks iframe. Good.

Potential issue: If user clicks body while state DEMO, start; then keydown works. Good.

Now, `handlePointerUp`: if swipe and state PLAYING and dist threshold request. If pointerup outside boardWrap? If drag from canvas to outside, clientX maybe, okay.

Need if multiple pointers? Could ignore.

Let's write final code accordingly.

Double-check `e.target.closest` in window pointerdown: if state != PLAYING and target is dirbtn, return to let button handler start. But if button handler somehow not fire? It will. Good. If target is inside controls but not button? no.

Potential issue: `e.preventDefault()` on window pointerdown when state != PLAYING could prevent focus of buttons? Not if target button returned. If target header/canvas, okay. Could prevent text selection? no.

Need maybe if state GAME_OVER and auto demo after 1.8s, user clicks after 1.8s -> state DEMO, start. Good.

Now, final code:

Let's assemble and review.

```
<script>
(() => {
...
function init() {
 canvas = document.getElementById('game'); ctx = canvas.getContext('2d'); boardWrap=...
 ...
 resize();
 resetGame('DEMO');
 window.addEventListener('resize', resize);
 if (window.ResizeObserver) { ... }
 window.addEventListener('pointerdown', onPointerDown);
 window.addEventListener('pointermove', onPointerMove);
 window.addEventListener('pointerup', onPointerUp);
 window.addEventListener('keydown', onKeyDown);
 document.querySelectorAll('.dirbtn').forEach(btn => btn.addEventListener('pointerdown', onDirButton, {passive:false}));
 requestAnimationFrame(frame);
}
init();
})();
```

Need `onDirButton` if target pointerdown, call `e.preventDefault()` maybe. It starts or requests. If state != PLAYING, start; else request. Good. But window pointerdown also fires; for dirbtn, state not PLAYING -> return before start; button handler start. For state PLAYING, button handler request; window pointerdown: state PLAYING, boardWrap.contains(button) false, no swipe. Good.
Potential order: If button listener on target fires before window bubble? yes. If we add button listener after init, same. If button pointerdown with passive false; e.preventDefault maybe stop click? okay.

`onPointerDown`: For state PLAYING and target boardWrap/canvas: set swipe. Use `if (boardWrap.contains(e.target)) swipe=...`. Since canvas child boardWrap. Good. For state != PLAYING and target dirbtn return; else startGame. But if user clicks a text area? none. Good. Need if target is button in controls? return. If target is overlay? overlay pointer-events none so target maybe canvas/boardWrap, start. Good.

Potential issue: If user clicks body outside wrap, state != PLAYING start. That's fine. If body scroll? no.

`onPointerMove`: if swipe and state PLAYING. Good.

`onPointerUp`: if swipe, state PLAYING and dist threshold request. If state changed due death while moving, swipe remains? In onPointerUp, if state != PLAYING, swipe=null. Good.

Need add `touch-action: none` to body? For swipe, boardWrap only. If pointerdown on canvas while playing, prevent scroll? `touch-action:none` on boardWrap. Good.

Potential issue: If state != PLAYING and pointerdown on body, `e.preventDefault()` can prevent click event but okay. It could prevent button click? We returned for dirbtn. Good.

Now, code style.

Let's manually compile some snippets:
`function onPointerDown(e) {
  const target = e.target;
  if (state !== 'PLAYING') {
    if (target.closest && target.closest('.dirbtn')) return;
    e.preventDefault();
    startGame();
    return;
  }
  if (boardWrap.contains(target)) swipe = {x:e.clientX,y:e.clientY};
}`
Need if `e.button`? For right click maybe start; maybe use `if(e.button !== undefined && e.button !== 0 && e.pointerType==='mouse') return;` Not necessary. But if user right-click while game over? Could start; okay.

`function onDirButton(e){ e.preventDefault(); const nd=DIR_BTN[e.currentTarget.dataset.dir]; if(!nd) return; if(state!=='PLAYING') startGame(nd); else requestDir(nd); }`

`function onKeyDown(e){ const k=(e.key||'').toLowerCase(); const nd=KEY_DIR[k]; if(state!=='PLAYING'){ if(nd||k===' '||k==='enter'){ e.preventDefault(); startGame(nd);} return;} if(nd){e.preventDefault(); requestDir(nd);} }`

Potential issue: If user clicks canvas to focus, then presses arrow, state maybe DEMO if not started? The first keydown starts game with direction; if arrow up, startGame(UP), then requestDir(UP) inside start? But initial dir right, request UP works. Good. If arrow left, request ignored; start with left not possible. That's okay.

Need if user presses 'Enter' while on D-pad button? Keydown state PLAYING, 'enter' not in KEY_DIR, does nothing. Good. If state GAME_OVER, Enter starts. But focus maybe button, Enter may click button? no. Fine.

Potential issue: If `startGame(nd)` with nd=RIGHT, reset then requestDir(RIGHT). queued push same as current? requestDir compares last=dir=RIGHT, same => not push. okay.

Need maybe `startGame` should set overlay hidden immediately. resetGame does.

Let's inspect `resetGame`: `stepInterval=BASE_INTERVAL; score=0; state=mode; finalTitle='';` Then update. If mode PLAYING, `state='PLAYING'`. Good.

`food` initial: If `midY` 7, initial head 3. food x=9. If user takes UP at start, food still at y=7; AI/player need move. Good.

`spawnFood` after growth random may spawn on food? no.

Potential issue: In `drawFood`, if food null? not. Could guard.

Potential issue: In `drawTrain`, if snake length 0? no.

Potential issue: `orientationAt` for body uses `dir` if snake length maybe? okay.

Let's check `bfsDistance` using `new Int16Array` default zeros; not used except dist[cur]. Good.
`seen` Uint8Array default 0. Good.

Potential issue: `Int16Array` distance can be negative? default 0, we set for visited. Good.

Potential issue: `floodCount` uses `occ` array. If start is surrounded, queue empty, count 0. Good.

Potential issue: If `grow` true, `path = 0`. Good.

Potential issue: If `chooseAI` no candidates, returns first safe ignoring reverse? We do:
```
if (bestDir === null || bestScore === -Infinity) { for d if safe return d; return dir; }
```
Need initialize bestDir null. Then return dir. But if return dir, step will probably die if no safe due reverse? If no safe, dir maybe safe? It was filtered; if no safe, return dir. Good.

Potential issue: In `chooseAI`, if `d === dir` object reference but `dir` might be changed to an input direction object from KEY_DIR. KEY_DIR values constants DIRS, so reference. If startGame(nd) with nd constant. Good.

Potential issue: In `step`, if state DEMO and chooseAI returns dir same as current, okay. If demo mode, `dir` may be set to a candidate object not from DIRS? chooseAI returns d from DIRS or dir (constant). Good.

Potential issue: If user input queue contains direction, state PLAYING, but if death due self while queued? step die; game over. queued cleared in reset.

Potential issue: If `boardWrap` aspect-ratio, if viewport height small, board may extend below? Body min-height, but maybe no scroll? If height 800, max width 1020, header+board+hud+controls maybe >800? Let's estimate: max-width 1020, board 4/3 => 765 height. Header 70, hud 70, padding 40 -> 945, exceeds 800. But wrap max-width 1020, body will scroll vertical (not horizontal). That's okay? Screenshot viewport 1280x800; board might be too large causing bottom controls out of view. Need ensure something visible first paint and no horizontal, vertical scroll okay? It can be visible? They likely view top. Board height 765, header/hud push board below ~170, only top 630 of board visible, okay. But better set `max-height` or aspect adapt. Need fit within 800 ideally. Use board-wrap with `max-height: 62vh`? Or `height: clamp(...)`? Could set `.board-wrap { width:100%; max-width: 900px; margin:auto; aspect-ratio:4/3; max-height: 64vh; }` But if max-height and width, aspect conflict: need width adjust. Use `height: min(64vh, calc(100vw * 0.75 - padding))`? Simpler: `.board-wrap { position:relative; width:100%; max-width: 920px; margin: 0 auto; aspect-ratio: 4/3; max-height: 66vh; }` But aspect-ratio with max-height? The height would be constrained by max-height, width from aspect? Actually width:100%, aspect-ratio gives height = width*3/4; if max-height applies, height may be smaller, but then width not adjusted causing aspect break? Canvas uses clientWidth/clientHeight; aspect may be not 4:3, cell = min(width/cols, height/rows). If board-wrap width 920, max-height 528 (66vh of 800), aspect 1.74, cell limited by height -> 35, grid width 700, offset horizontal. Board still fits. Good. Need `max-width` maybe 980. Use `max-height: min(70vh, 560px)`? For 1280x800, board height <=560? header/hud ~120 -> 680 + controls maybe. Good. But if max-height too low, board less wide. Fine.

Let's set `.board-wrap { width:100%; max-width: 960px; margin:0 auto; aspect-ratio: 4 / 3; max-height: 62vh; min-height: 220px; }` For 1280x800, 62vh=496, board height 496, width 662? Actually with max-height, browser may keep width 960 and height 496? `aspect-ratio` is preferred not mandatory if max-height? I think height may be limited to 496, width remains 960, aspect not preserved; canvas cell min(width/20=48, height/15=33) =33, grid 660x495 centered horizontally. Good. If CSS doesn't reduce width, still okay. But boardWrap height 496. On mobile 375x667, max-height 414, aspect width 375 height 281, min? height 281 <=414. Good.

Need no horizontal scrollbar; max-width ensures. Use `min-height:260px` maybe. If height small? okay.

HUD grid, controls. For 1280, hide controls; header+hud+board+footer = maybe 100+70+496+50=716, fits.

Need if `.board-wrap` width max 960 centered; canvas grid not full width but offset. Good.

CSS update:
```
.board-wrap { ... width: min(100%, 960px); max-height: 62vh; min-height: 240px; margin-inline:auto; aspect-ratio:4/3; }
```
If max-height reduces height, aspect ratio might stretch? Canvas handles. Good.

Need ensure boardWrap has defined height: `aspect-ratio:4/3` and width. If max-height constrains, height maybe? For Chrome, aspect-ratio computes height from width; max-height can override height, leaving width unchanged. Good.

Could also set `height: clamp(240px, 62vh, 560px);` and `width: min(100%, 960px);` instead of aspect, but then if width small (375) height 240 -> aspect 1.56, cell min 18 vs 16 -> 16, ok. But aspect ratio nice. Use aspect-ratio with max-height.

Potential issue: On 1280x800, max-height 496, cell height limited, board drawn grid centered; okay.

Need `.wrap` min-height 100vh; if content taller, scroll vertical. Good.

Let's adjust CSS accordingly.

Potential issue: ResizeObserver with max-height, okay.

Now final code. Need ensure output only one fenced html block. The system asks reply with exactly ONE fenced code block. We need final include ```html ... ```. No extra text.

Double-check if using `document.createElement('canvas')` allowed (no network). Yes.

Let's write final code in final. Ensure syntax. I'll craft carefully:

```
function drawTrain() {
 const minW = Math.max(2, cell*0.2);
 for (let i=snake.length-1; i>0; i--) { ... }
 for ...
}
```
Need `roundRect` with `minW*0.25` radius. Good.

In `drawCar`, `ctx.strokeStyle` after roundRect for border, then top highlight fill; no issue.

In `drawCar`, wheels after windows; if body color bright, dark wheels. Good.

Potential issue: `drawSmoke` uses `time` variable updated in frame. If paused? no. If game over, still drawn; okay.

Potential issue: `draw` called after state game over but before auto; final train frozen? If state GAME_OVER, no step, but draw still with time for smoke? It will animate smoke? Since time updates, yes. Fine.

Potential issue: `updateHUD` uses `BASE_INTERVAL/stepInterval`. stepInterval reset base. Good.

Need `BASE_INTERVAL` const used in start? okay.

Potential issue: If `snake` is long, drawing connectors for all, okay.

Potential issue: `renderBackground` creates `bgCanvas` with `pixelW`, `pixelH`; if dpr not defined? yes. `ctx.setTransform(dpr...)`. In draw, if bgCanvas pixel dims differ from canvas after dpr change? resize both. Good.

Potential issue: ResizeObserver may call resize in loop; renderBackground often. Fine.

Need maybe `init` after DOM. Since script at body end, okay.

Let's think about hidden accessibility: buttons type. Good.

Potential bug: In `spawnFood`, when preferAhead true not used. okay.

Potential bug: In `resetGame`, if initial food x=9 but occupied? snake x=1..3, no. Good.

Potential bug: In `resetGame`, if COLS less than 10? 20. Good.

Potential bug: `state` initial `'DEMO'`, overlay title set after reset. Good.

Potential bug: `startGame` if called with no args: `if(nd)` false. Good.

Potential bug: `onKeyDown`: if state not PLAYING and key is arrow, startGame(nd). If key is space, `nd` undefined; startGame(undefined) okay. If key is `tab`, no start, so focus works. Good.

Potential bug: `window.addEventListener('keydown', ...)` may prevent scroll on space/enter in game over; good.

Potential bug: `e.preventDefault()` in window pointerdown if state != PLAYING and target not dirbtn could prevent context menu? no.

Potential bug: If user touches a D-pad button while state PLAYING, button handler requests, window handler sees state PLAYING, target button not boardWrap. Good. If button handler `e.preventDefault()` stops pointerdown propagation? preventDefault doesn't stop propagation. Good.

Potential bug: If state GAME_OVER and user taps D-pad, button handler starts, window handler sees state PLAYING (because button handler ran first) and target button not boardWrap, no start. Good. If window handler ran first? Event dispatch: for bubbling listeners, target listeners first? The order is capture, target, bubble. Both are bubble? Button listener on target runs before window (bubble) unless window listener added capture. We add no capture. Good.

Potential bug: If state DEMO and user clicks outside but target closest dirbtn undefined? okay.

Potential bug: If `target.closest` returns null for body, okay.

Potential bug: `boardWrap.contains(e.target)` when e.target is body? false.

Potential bug: If user drags swipe while state playing but pointerdown on body not boardWrap, no swipe; controls only on board. okay.

Potential bug: If pointerup event not fired due release outside? swipe remains until next pointermove? Could set `window.addEventListener('pointercancel', ()=>{swipe=null;})`. Add maybe.

Potential bug: If `performance.now` not defined? browser yes.

Potential bug: In `renderBackground`, `b.setLineDash([Math.max(2, cell*0.1), Math.max(3, cell*0.18)]);` after restore? We save/restore. Good.

Potential bug: If `bgCanvas` is null in draw, fallback fillRect with `canvas.width, canvas.height` but ctx transform? We are inside identity after save? In draw: `ctx.save(); ctx.setTransform(1...); if(bgCanvas) ... else fillRect(0,0,pixelW,pixelH); ctx.restore();` Use pixelW. Good. Then if cell <=0 return? We can check at start: if (!ctx || cell <=0 || pixelW <=0 || pixelH<=0) return; But background fill fallback needs pixel. If cell 0, don't draw train. Good.

Potential bug: `roundRect` helper if ctx `b` null? no.

Potential bug: In `drawCar`, if cell small and len=cell*0.86, wid=cell*0.66, radius cell*0.16. Good.

Potential bug: If `index` > WAGON_COLORS length, modulo. Good.

Potential bug: If `state` after reset is 'DEMO' but `gameOverAt=0`; frame checks `state==='GAME_OVER'` only. Good.

Potential bug: If user presses key and `startGame` called while state DEMO; overlay hidden. Good.

Need final code no weird unicode causing issues? emojis in overlay text. UTF-8 meta. okay.

Let's prepare final answer. Use ```html. Ensure no text outside. final oververbosity? Just code.

Double-check code indentation and missing commas.

Let's write final:

Potential syntax issue in CSS: `aspect-ratio: 4 / 3;` okay.
Potential issue: `min-height: 100vh;` and `margin-inline: auto`? Use `margin: 0 auto;` okay.
Potential issue: `.controls { display:none; }` on wide; but event still. okay.

Potential issue: If controls hidden, `window pointerdown` target button? none. Good.

Let's include `aria-label` maybe. Not needed.

Potential issue: If `canvas.getContext('2d')` returns null in some sandbox? Not. Could `if (!ctx) return;` in init. But then loop? Add guard.

Need no `alert` etc.

Let's think of visual first paint: Since `requestAnimationFrame` starts, first frame after init maybe within 16ms. It draws demo. Overlay message visible. Good.

Potential issue: If fonts/emoji not loaded? System emojis. okay.

Potential issue: In `draw`, time used for smoke; if first frame `time=0` (ts/1000 maybe maybe 0? ts ~0.1? okay). Smoke visible? At t=0, tt = -i*0.32 mod 1: for i=1 tt=0.68; i=2 tt=0.36; visible. For head i=0 tt=0. okay. Good.

Potential issue: `drawSmoke` after body uses `cell*0.52 + tt*cell*0.45`; for tt=0, x=0.52cell just front. Body half 0.43, smoke outside. Good.

Potential issue: `circle(ctx, len*0.14,0,cell*0.05)` after chimney fill, current fillStyle #2b3545. Good.

Potential issue: `drawCar` after border uses top highlight fill; okay.

Potential issue: `ctx.globalAlpha` set in drawSmoke but after function returns to drawCar then restore; globalAlpha restored to value before save? Since we didn't save after setting inside same ctx.save? Actually drawCar has `ctx.save()` at start, then `drawSmoke()` sets alpha, then `ctx.restore()` resets alpha. Good.

Potential issue: `drawFood` save/restore, lineCap changes in drawPerson restored. Good.

Potential issue: If `snake.length` large, drawTrain connectors loop O(n), cars O(n), okay.

Potential issue: AI may be slow if snake length near max due flood and BFS. Grid 300, n small. okay.

Potential issue: `occupied[idxOf(p)]` in spawnFood with `idxOf` expects p object; `s` object. good.

Potential issue: `const empty=[]; for(let y=0; y<ROWS; y++) for(let x=0; x<COLS; x++) { const id=y*COLS+x; if(!occupied[id]) empty.push({x,y}); }` good.

Potential issue: `setFood(p)` uses `food = { x: p.x, y: p.y }` to copy. Good.

Potential issue: In `chooseAI`, uses `food` object, if null? reset set. Good.

Potential issue: `wrapDist`:
```
function wrapDist(a,b){ let dx=Math.abs(a.x-b.x); if(dx>COLS/2) dx=COLS-dx; let dy=...; return dx+dy; }
```
COLS int. okay.

Potential issue: `if (d === dir)` with dir maybe one of KEY_DIR? KEY_DIR values are constants. Good.

Potential issue: `queued` stores references; `requestDir` with nd constant. Good.

Potential issue: If user holds arrow key, keydown repeats, request pushes same as last? It ignores same. Good.

Potential issue: If queued length >3, `queued.shift()`, okay.

Potential issue: If step uses queue after AI? state PLAYING only. Good.

Potential issue: If `state === 'DEMO'`, step sets dir=chooseAI, but `queued` maybe from previous? reset clears. Good.

Potential issue: `resetGame('DEMO')` called on game over auto after user played; overlay DEMO. Good.

Potential issue: If demo dies and auto resets while user had started? state GAME_OVER, overlay; if user presses key before auto, start player. Good.

Potential issue: `best` if auto demo after game over keeps previous best. Good.

Potential issue: In `updateHUD`, speed `(BASE_INTERVAL/stepInterval).toFixed(1) + 'x'` at base 112/112=1.0. Good.

Potential issue: In `frame`, after `step`, if state GAME_OVER break while; but accumulator maybe still > stepInterval? break. Good.
```
while(accumulator >= stepInterval) { accumulator -= stepInterval; step(); if(state === 'GAME_OVER') break; }
```
If game over, accumulator leftover may cause next frame state GAME_OVER skip. Good.

Potential issue: If dt large due tab inactive, accumulator huge, while could many steps. But rAF paused when hidden, dt maybe large on resume. Could clamp dt to 100 to prevent many steps. Add `const dt = lastTime ? Math.min(100, ts-lastTime) : 0;` This helps. Good.

Potential issue: If `accumulator` after reset set 0 but dt may large; okay.

Potential issue: If `stepInterval` changes during while, `accumulator` may remain negative? no.

Potential issue: On game over, finalTitle. Good.

Let's add clamp.

Potential issue: If `canvas.width` set every resize; if `pixelW` 0? guard.

Potential issue: `bgCanvas.getContext('2d')` can be null? no.

Potential issue: `renderBackground` loops cells; if cell <=0 skip. Good.

Potential issue: If cell changes due resize, draw train uses new cell; good.

Let's final code now. Use no extra text.