fuzzyworld

explodorama / commit

Autopilot stays out of bomb blasts (Q101); dev replay page for bug reports (Q103)

351348d116dafa630bdaf6f3ab9e8275831cb17a · Pavel Safronov · 2026-11-03 20:46:01 -0800 · parents 9ed991c9907c

Claude Code usage: this commit 122.8K out / 118.6M cached · cost unknown (no per-model trailer) · session 14 tally 408 in / 155.9K out / 121.6M cached · usage by commit and session

Autopilot stays out of bomb blasts (Q101); dev replay page for bug reports (Q103)

Q101 (bug mail 2026-11-03): with Bomb Pass the autopilot walked through its
own ticking bomb to fetch a power-up and was caught in the blast. It now keeps
off every tile the danger overlay says will burn (not spikes, which stay
timed by safeStep), waits while that shuts the way, and walks out of a blast
zone first. Rules version unchanged (43).

Q103 (dev only, nothing in a build): npm run report-replay turns a bug mail
into replays/<name>.json and prints a page address. ?replay=<name> loops the
report's last turns with two players, red (recorded) and blue (today's
autopilot); &rules=<commit> plays the whole run under an older commit's rules
(red) and today's (blue). The page takes no input. Tests with the Q101 report
as a fixture.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

FW-Tokens-In: 408
FW-Tokens-Out: 155918
FW-Tokens-Cached: 121605329
FW-Session: 5fd260d6-fb44-4767-8699-cda9f628d999
FW-Rtk-Saved: 4453720
FW-Rtk-Cmds: 13523

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
 .gitignore                        |   6 ++
 CHANGELOG.md                      |   8 ++
 README.md                         |  21 +++++
 package.json                      |   1 +
 src/changelog.ts                  |  10 ++-
 src/main.ts                       | 180 +++++++++++++++++++++++++++++++++++++-
 src/render/board.ts               |  30 +++++--
 src/sim/autopilot.ts              |  51 ++++++++++-
 src/sim/replayCompare.ts          | 114 ++++++++++++++++++++++++
 src/sim/ruleCompare.ts            |  99 +++++++++++++++++++++
 test/autopilot.test.ts            |  27 ++++++
 test/fixtures/q101-bomb-pass.json |   1 +
 test/replayCompare.test.ts        |  32 +++++++
 test/reportReplay.test.ts         |  51 +++++++++++
 test/ruleCompare.test.ts          |  51 +++++++++++
 tools/report-replay.mjs           |  78 +++++++++++++++++
 vite.config.ts                    |  19 +++-
 17 files changed, 762 insertions(+), 17 deletions(-)

diff --git a/.gitignore b/.gitignore
index f87fd02..9bdc062 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,9 @@ dist/
 
 # Private progress view (local tooling output, never committed)
 STATUS.md
+
+# Run records of bug reports for the replay page (tools/report-replay.mjs, Q103); private.
+replays/
+
+# `src/sim` of older commits, for the replay page's rules comparison (tools/report-replay.mjs, Q103).
+rules-versions/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 93614ca..b91eb27 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,14 @@ saved before the change is dropped and the next visit starts a new run.
 
 ## [Unreleased]
 
+### Added
+
+- **Replay page for bug reports** (Q103, user request, 2026-11-03; dev only, nothing in a build): `npm run report-replay -- <mail>` turns a report's run record into `replays/<name>.json` and prints `http://localhost:5173/?replay=<name>`. The page loops the last level's last turns with two players on one board, a little off centre: red (left) plays the report's recorded turns, blue (right) the same until the autopilot took over, then today's autopilot (`rival` in `render/board.ts`); the label tells how long they are in step and when they split, and how each ends. New `sim/replayCompare.ts` (`buildReplay`), `tools/report-replay.mjs` (reads quoted-printable mail), a dev-server plugin in `vite.config.ts`, `?replay` counts as a test URL (no save, no scores). Test: `test/replayCompare.test.ts` with the Q101 report as a fixture. **The page takes no input** (window-level capture handlers stop pointer, key, touch and wheel events; the pad is not polled), and has a second mode, `&rules=<commit>`: the whole run played by the rules of an older commit (red; its `src/sim` made by `git archive` into git-ignored `rules-versions/<commit>/` by `tools/report-replay.mjs`, which takes the commit from the report's "commit …" line) and by today's (blue), `sim/ruleCompare.ts` `compareRules`, with the quiet stretch jumped through and the first difference named in the label. Test: `test/ruleCompare.test.ts`.
+
+### Fixed
+
+- **The autopilot no longer walks into a bomb's blast** (Q101, user bug report, 2026-11-03, seed 1464004408, level 4): with Bomb Pass, a power-up behind the player's own ticking bomb counted as reachable, and the autopilot stepped onto the bomb's tile and back, inside the blast, and lost a life. `safeStep` and `isDeadly` only look at this turn. `autopilotPlan` (`sim/autopilot.ts`) now keeps off every tile in the danger overlay (`now`, `next` and `later`: what will burn while the player waits), waits (phase `bombs`) when the way is shut by it, and when standing in such a zone first walks out by the shortest way (`fleeAction`). Spikes are left out of that zone (they are always up in one turn of two, and `safeStep` already times the crossing), and the `bombs` wait only applies while a bomb, flame, smouldering bush or ceiling is on the board, so the stall limit still catches a wait that leads nowhere. Flame Pass-style `flameSafe` runs are not held back. Test: `test/autopilot.test.ts` (Bomb Pass scene, fails without the fix). Autopilot moves are ordinary `move` actions, so **SIM_VERSION is unchanged** (43).
+
 ## [0.2.0] - 2026-11-02
 
 ### Added
diff --git a/README.md b/README.md
index f9bd946..6f15735 100644
--- a/README.md
+++ b/README.md
@@ -650,8 +650,29 @@ npm run build        # type-check and build into dist/
 npm run preview      # serve dist/ (also on the network, with a QR code), as it will be deployed
 npm run screenshots  # build, then photograph the game into docs/screenshots/ (Playwright)
 npm run balance -- help   # the balance lab: bots play many games to measure the rules (below)
+npm run report-replay -- <mail.eml>   # a bug report's run as a replay URL (below)
 ```
 
+**Replaying a bug report** (Q103, dev only): `npm run report-replay -- "<the report mail>.eml"` finds the
+report's run record (a bare record or any text holding a `Run record: {…}` line works too), stores it in
+`replays/` (git-ignored, private) and prints an address such as
+`http://localhost:5173/?replay=seed1464004408-v43-290` for `npm run dev`. The page loops the last
+level's last turns with **two players on one board**, a little off the tile's centre: **red** (left)
+plays the report's recorded turns (the replay), **blue** (right) plays the same turns until the autopilot
+took over and then **today's autopilot** (the simulation). Both take a turn at a time; the label says
+how many turns they stay on the same tile and when they split, and at the end how each finished (alive,
+dead, lives left). The board shown is the replay's; the simulation's bombs are outlined in blue. The page takes **no input** (pointer, keys, touch and pad are all stopped; it plays alone).
+**Two versions of the rules over a whole run:** the script also prints
+`...?replay=<name>&rules=<commit>` for the commit the report says it was made on (or `--rules <commit>`).
+It takes that commit's `src/sim` out of git into `rules-versions/<commit>/` (git-ignored) and plays the
+report's whole run through both engines, red under the old rules and blue under today's, the same
+recorded turns each. Where the games agree it jumps through; from six turns before the first turn where
+they differ (player, status, lives, level, enemies, bombs, coins) it plays each turn, until 15 turns after it, and the
+label names the difference ("5 vs 6 lives"). A turn one version's rules refuse stops that side there. If
+they never differ, it says so and plays the last 8 turns. The logic is `sim/ruleCompare.ts`
+(`compareRules`, tested with stand-in engines). The autopilot comparison above is `sim/replayCompare.ts` (tested with the Q101 report in `test/fixtures/`);
+the page is in `main.ts`, served by a dev-server plugin in `vite.config.ts`, and is not in a build.
+
 **The balance lab** (`tools/balance/`, PLAN.md §19.1) measures what a rule or a power-up does to
 the game. Bots play the real rules in Node, on all CPU cores, at three skill levels: **careful**
 (checks every turn that it can survive what the enemies and bombs will do next; wins about 90% of
diff --git a/package.json b/package.json
index 9902b37..5a5e19f 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,7 @@
     "test:watch": "vitest",
     "screenshots": "npm run build && node tools/screenshots.mjs",
     "balance": "node tools/balance/run.mjs",
+    "report-replay": "node tools/report-replay.mjs",
     "deploy": "fw deploy --ref $(git describe --tags --abbrev=0)",
     "deploy:next": "fw deploy --as explodorama-next"
   },
diff --git a/src/changelog.ts b/src/changelog.ts
index 3387fa7..28f7516 100644
--- a/src/changelog.ts
+++ b/src/changelog.ts
@@ -176,7 +176,15 @@ export const RELEASES: Release[] = [
 ]
 
 /** Finished since the latest release, not yet part of one. Moved into RELEASES when one is tagged. */
-export const UNRELEASED: Change[] = []
+export const UNRELEASED: Change[] = [
+  {
+    kind: 'changed',
+    icon: 'bomb',
+    title: 'The autopilot keeps out of bomb blasts',
+    text: ['With Bomb Pass, the autopilot could walk through your own ticking bomb to fetch something and be caught in the blast. It now waits for bombs to go off, and walks out of a blast zone first.'],
+    date: '2026-11-03',
+  },
+]
 
 /** The version being played. */
 export const VERSION = RELEASES[0]!.version
diff --git a/src/main.ts b/src/main.ts
index 1f70885..f765668 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -71,6 +71,8 @@ import { openScores, recordScore } from './ui/scores'
 import { countsForScores, scoreEntry } from './sim/scores'
 import { device, recentErrors, reportError } from './devlog'
 import { hashSeed } from './sim/rng'
+import { buildReplay, type ReplayScript } from './sim/replayCompare'
+import { compareRules, type RuleComparison } from './sim/ruleCompare'
 import { TEST_KINDS, TEST_SCENES, type TestKind } from './sim/testScenes'
 import { CONTROL_KINDS, DYING_KINDS, LIVING_KINDS, SCENES, type Gesture, type Scene, type SceneKind } from './sim/demo'
 import { spriteUrl } from './render/sprites'
@@ -89,6 +91,14 @@ import zoomIcon from '../assets/kenney-icons/zoom.png'
 // seed — chosen here, never inside the sim.
 const params = new URLSearchParams(location.search)
 const startLevel = Number(params.get('level')) || 1
+// The replay page (Q103) takes no input: a replay and a simulation play alone. Everything the user does
+// (pointer, keys, touch, wheel) is stopped before it reaches the game; the pad is not polled.
+const replayOn = import.meta.env.DEV && params.has('replay')
+if (replayOn) {
+  for (const type of ['pointerdown', 'pointermove', 'pointerup', 'pointerleave', 'mousedown', 'mouseup', 'click', 'dblclick', 'touchstart', 'touchmove', 'touchend', 'wheel', 'contextmenu', 'keydown', 'keyup']) {
+    window.addEventListener(type, (e) => e.stopImmediatePropagation(), { capture: true, passive: false })
+  }
+}
 // `?charge=nuke` starts carrying that charge (state.ts `newRun`), to try it.
 const startCharge = (CHARGE_IDS as readonly string[]).includes(params.get('charge') ?? '') ? (params.get('charge') as ChargeId) : null
 
@@ -229,7 +239,7 @@ const SAVE_KEY = storageKey('run')
 function urlOptions(): RunOptions {
   return { dev: params.has('dev'), immortal: params.has('immortal') }
 }
-const testing = ['seed', 'level', 'charge', 'dev', 'immortal', 'shop'].some((p) => params.has(p))
+const testing = ['seed', 'level', 'charge', 'dev', 'immortal', 'shop', 'replay'].some((p) => params.has(p))
 
 function save(): void {
   if (testing) return
@@ -399,7 +409,7 @@ let choiceMissingSince = 0
  * timers open the dialog) it opens whichever is missing.
  */
 function reopenPendingChoice(): void {
-  if (demo || !(state.offer || state.chargeOffer) || dialogOpen()) {
+  if (demo || replayOn || !(state.offer || state.chargeOffer) || dialogOpen()) {
     choiceMissingSince = 0
     return
   }
@@ -856,6 +866,8 @@ function stopAutopilot(toast = false): void {
 
 /** One frame of the autopilot: starts it on the level's last kill, then takes a turn every step, unless a dialog is open or a choice waits. */
 function autopilotTick(): void {
+  // The replay page plays alone (Q103).
+  if (replayOn) return
   if (demo || state.status !== 'playing') return stopAutopilot()
   if (state.enemies.length > 0) {
     autopilotArmed = true
@@ -1716,6 +1728,166 @@ setGamepadsEnable(false)
 
 if (!resumeSaved()) startRun(Number(params.get('seed')) || randomSeed(), testing ? urlOptions() : preferredOptions())
 
+/**
+ * The replay page (Q103, dev only): `?replay=<name>` plays a bug report's run (made by
+ * `npm run report-replay`) in a loop, with two players on the one board, side by side: the red
+ * one (left) plays the report's recorded turns (the replay), the blue one (right) the same turns
+ * until the autopilot took over and then today's autopilot (the simulation). Each turn both take
+ * one. The label says how long they stay in step and when they split. The board shown is the
+ * replay's; the simulation's bombs are outlined in blue.
+ */
+const REPLAY_STEP_SECONDS = 0.55
+const REPLAY_HOLD_SECONDS = 3
+let replayScript: ReplayScript | null = null
+interface ReplayRun {
+  i: number
+  at: number
+  /** The blue player's state and animation (the red one's are `state` and `anim`). */
+  blue: GameState
+  blueAnim: ReturnType<typeof newAnimation>
+  red: Action[]
+  sim: Action[]
+  /** The first turn (counted from the autopilot taking over) at which they were not on the same tile, or null. */
+  split: number | null
+  held: boolean
+}
+let replayRun: ReplayRun | null = null
+let replayLabel: HTMLElement | null = null
+let rulesCompare: RuleComparison | null = null
+let rulesRef = ''
+if (replayOn) {
+  replayLabel = document.createElement('div')
+  replayLabel.style.cssText = 'position:fixed;left:50%;transform:translateX(-50%);bottom:12px;z-index:50;padding:6px 14px;border-radius:8px;font:600 17px system-ui;color:#fff;background:#444;text-align:center;pointer-events:none;white-space:pre-line'
+  document.body.append(replayLabel)
+  // Whatever goes wrong here (no such replay, a record the rules refuse, rules not extracted) is told on the page, not left as a blank one.
+  try {
+    const response = await fetch(`/__replay/${encodeURIComponent(params.get('replay')!)}.json`)
+    if (!response.ok) throw new Error(`no such replay: ${params.get('replay')}`)
+    const loaded = (await response.json()) as RunRecord
+    const ref = params.get('rules') ?? ''
+    if (/^[0-9a-f]{7,40}$/.test(ref)) {
+      // The whole run under the rules of an older commit (red) and today's (blue): `rules-versions/<ref>/src/sim`, from tools/report-replay.mjs.
+      rulesRef = ref
+      let oldState, oldTurn
+      try {
+        ;[oldState, oldTurn] = await Promise.all([import(/* @vite-ignore */ `/rules-versions/${ref}/src/sim/state.ts`), import(/* @vite-ignore */ `/rules-versions/${ref}/src/sim/turn.ts`)])
+      } catch {
+        throw new Error(`the rules of ${ref} are not extracted: run  npm run report-replay -- <mail> --rules ${ref}`)
+      }
+      rulesCompare = compareRules({ newRun: oldState.newRun, step: oldTurn.step }, { newRun, step }, loaded)
+    } else replayScript = buildReplay(loaded)
+  } catch (error) {
+    console.error('replay:', error)
+    replayLabel.textContent = String(error instanceof Error ? error.message : error)
+    replayLabel.style.background = '#b3261e'
+  }
+}
+
+/** Starts a loop: both players on the room as it was, side by side. */
+function replayStart(): void {
+  const r = replayScript!
+  resetView()
+  state = r.start
+  afterChange()
+  replayRun = { i: 0, at: time + 1.2, blue: r.start, blueAnim: newAnimation(), red: [...r.prefix, ...r.old], sim: [...r.prefix, ...r.neu], split: null, held: false }
+}
+
+const lifeText = (s: GameState): string => `${s.status === 'playing' ? 'alive' : s.status}, ${s.lives} ${s.lives === 1 ? 'life' : 'lives'}`
+
+/** How the rules comparison plays: the quiet stretch where the games agree is jumped through, then each turn near the split (or the end) is shown. */
+const RULES_LEAD_TURNS = 6
+const RULES_TAIL_TURNS = 15
+interface RulesRun {
+  i: number
+  end: number
+  at: number
+  blue: GameState
+  blueAnim: ReturnType<typeof newAnimation>
+  held: boolean
+}
+let rulesRun: RulesRun | null = null
+
+/** The state of one side after turn `k` (-1: the start); a side that stopped keeps its last. */
+const sideState = (side: RuleComparison['red'], k: number): GameState => (k < 0 ? side.start : side.results[Math.min(k, side.results.length - 1)]?.state ?? side.start)
+
+function rulesStart(): void {
+  const c = rulesCompare!
+  const total = c.actions.length
+  const from = c.split === null ? Math.max(0, total - 8) : Math.max(0, c.split - RULES_LEAD_TURNS)
+  resetView()
+  state = sideState(c.red, from - 1)
+  afterChange()
+  replayLabel!.textContent = `Rules of ${rulesRef} (red) against today's (blue), the whole run: ${c.split === null ? 'no difference' : `they split at turn ${c.split + 1}`}`
+  rulesRun = { i: from, end: c.split === null ? total : Math.min(total, c.split + RULES_TAIL_TURNS), at: time + 1.2, blue: sideState(c.blue, from - 1), blueAnim: newAnimation(), held: false }
+}
+
+function rulesTick(): void {
+  const c = rulesCompare!
+  if (!rulesRun) return rulesStart()
+  const run = rulesRun
+  if (time < run.at) return
+  const total = c.actions.length
+  const names = `Red: rules of ${rulesRef}   Blue: today's rules`
+  if (run.i >= run.end) {
+    if (run.held) return rulesStart()
+    run.held = true
+    run.at = time + REPLAY_HOLD_SECONDS
+    const status = (s: GameState) => `${s.status === 'playing' ? 'alive' : s.status}, ${s.lives} ${s.lives === 1 ? 'life' : 'lives'}, level ${s.level}`
+    replayLabel!.textContent = `${c.split === null ? 'The two versions agree on the whole run' : `Split at turn ${c.split + 1} of ${total}: ${c.why}`}\nRed: ${status(state)}   Blue: ${status(run.blue)}`
+    return
+  }
+  const action = c.actions[run.i]!
+  const red = c.red.results[run.i]
+  if (red) animateTurn(red, action)
+  const blue = c.blue.results[run.i]
+  if (blue) {
+    animateBoard(run.blueAnim, run.blue, blue, action, time)
+    run.blue = blue.state
+  }
+  danger = predictDanger(state)
+  run.i++
+  const split = c.split !== null && run.i > c.split
+  replayLabel!.textContent = `Turn ${run.i} of ${total}, level ${state.level}: ${split ? `split since turn ${c.split! + 1} (${c.why})` : 'in step'}\n${names}`
+  replayLabel!.style.background = split ? '#7b3a8a' : '#555'
+  run.at = time + REPLAY_STEP_SECONDS
+}
+
+function replayTick(): void {
+  if (rulesCompare) return rulesTick()
+  if (!replayScript) return
+  const r = replayScript
+  if (!replayRun) return replayStart()
+  const run = replayRun
+  if (time < run.at) return
+  const turns = Math.max(run.red.length, run.sim.length)
+  // Done: held a moment with how each ended, then round again.
+  if (run.i >= turns) {
+    if (run.held) return replayStart()
+    run.held = true
+    run.at = time + REPLAY_HOLD_SECONDS
+    return
+  }
+  const fromFork = run.i - r.prefix.length + 1
+  const redAction = run.red[run.i]
+  if (redAction && state.status === 'playing') animateTurn(step(state, redAction), redAction)
+  const blueAction = run.sim[run.i]
+  if (blueAction && run.blue.status === 'playing') {
+    const result = step(run.blue, blueAction)
+    animateBoard(run.blueAnim, run.blue, result, blueAction, time)
+    run.blue = result.state
+  }
+  danger = predictDanger(state)
+  run.i++
+  const together = eq(state.player, run.blue.player) && state.status === run.blue.status
+  if (!together && run.split === null) run.split = Math.max(1, fromFork)
+  const done = run.i >= turns
+  replayLabel!.textContent =
+    (run.i <= r.prefix.length ? `Turn ${run.i}: same recorded turns, in step` : run.split === null ? `${fromFork} turns after the autopilot took over: in step` : `${fromFork} turns after the autopilot took over: split since turn ${run.split}`) +
+    `\nRed (replay): ${done ? lifeText(state) : 'recorded turns'}   Blue (simulation): ${done ? lifeText(run.blue) : "today's autopilot"}`
+  replayLabel!.style.background = run.split === null ? '#555' : '#7b3a8a'
+  run.at = time + REPLAY_STEP_SECONDS
+}
+
 /**
  * The demo, before the game starts (user request, 2026-10-23: some players never played the
  * original). Scenes made up on the spot (sim/demo.ts) are replayed view-only, each with a caption,
@@ -2832,7 +3004,7 @@ await engineInit(
     mainCanvas.addEventListener('contextmenu', (e) => e.preventDefault())
   },
   timed(guarded('update', () => {
-    pad.poll(performance.now())
+    if (!replayOn) pad.poll(performance.now())
     demoTick()
     reopenPendingChoice()
     // In the demo the board and the action bar under it fit above the caption panel, with room
@@ -2946,6 +3118,7 @@ await engineInit(
     pruneAnimation(anim, time)
     autopilotTick()
     autoStep()
+    replayTick()
   })),
   () => {},
   timed(guarded('draw', () => {
@@ -2962,6 +3135,7 @@ await engineInit(
       // A deadly next move shows as a blocked one: the first tile red.
       route: destination && !deadlyAutoMove ? autoRoute(state, destination, safeStep) : null,
       pathBlocked: (!!path?.[0] && !safeStep(path[0])) || deadlyAutoMove,
+      rival: replayRun || rulesRun ? ((r) => ({ state: r.blue, move: r.blueAnim.playerMove, pose: r.blueAnim.playerPose, facingLeft: r.blueAnim.facingLeft }))((replayRun ?? rulesRun)!) : undefined,
     })
     // What is out of sight on a board bigger than the view: arrows at its edge.
     if (!demo) drawIndicators(state, viewRect())
diff --git a/src/render/board.ts b/src/render/board.ts
index e31d9e8..ce23704 100644
--- a/src/render/board.ts
+++ b/src/render/board.ts
@@ -56,6 +56,8 @@ export interface View {
   deadlyCursor?: boolean
   /** An action and tile to preview as if hovered (the faded bomb, blast or player), with nothing picked: the controller's cursor. */
   ghost?: { id: ActionId; target: Vec } | null
+  /** The replay page's second player (blue; the board's own is then red, a little to the left): the simulation's state and how it is animated. */
+  rival?: { state: GameState; move: PlayerMove | null; pose: PlayerPose; facingLeft: boolean }
   /** The tile under the pointer or finger, for the commit preview. */
   hover: Vec | null
   flames: Flame[]
@@ -100,6 +102,9 @@ const C = {
   sky: rgb(0.765, 0.89, 1),
   floor: rgb(0.95, 0.97, 0.99),
   floorAlt: rgb(0.9, 0.94, 0.98),
+  /** The replay page's two players: tints, and how far each stands off the tile's centre. */
+  playerRed: rgb(1, 0.5, 0.5),
+  playerBlue: rgb(0.5, 0.65, 1),
   dangerNow: rgb(0.9, 0.15, 0.1, 0.45),
   dangerNext: rgb(1, 0.35, 0.2, 0.18),
   dangerLater: rgb(0.92, 0.25, 0.15, 0.65),
@@ -374,7 +379,12 @@ export function drawBoard(view: View): void {
   if (after && !eq(after.player, s.player)) {
     drawTile(toWorld(s, after.player).add(PLAYER_LIFT), tileSize, sprite('character_green_idle'), C.ghost, 0, view.facingLeft)
   }
-  drawPlayer(s, view.playerMove, view.playerPose, view.facingLeft)
+  const rival = view.rival
+  drawPlayer(s, view.playerMove, view.playerPose, view.facingLeft, rival ? C.playerRed : C.white, rival ? DUO_OFFSET.scale(-1) : vec2(0))
+  if (rival) {
+    for (const b of rival.state.bombs) drawOutline(toWorld(s, b.pos), 0.92, 0.07, C.playerBlue)
+    drawPlayer(rival.state, rival.move, rival.pose, rival.facingLeft, C.playerBlue, DUO_OFFSET)
+  }
   // Hidden in a bush: leaves over the player too, see-through, so they still know where they are.
   if (s.bushes.includes(idx(s, s.player)) && at.distance(toWorld(s, s.player)) < 0.01) drawTile(toWorld(s, s.player).add(vec2(0, 0.04)), vec2(1.02), sprite(bushSprite(idx(s, s.player))), C.white.scale(1, 0.55))
   // The controller's target, over everything, breathing a little so it is easy to find.
@@ -397,30 +407,34 @@ export function playerPos(s: GameState, move: PlayerMove | null): Vector2 {
  */
 const PLAYER_LIFT = vec2(0, 0.07)
 
+/** The replay page: each of the two players stands this far off the tile's centre (red left, blue right). */
+const DUO_OFFSET = vec2(0.2, 0)
+
 /**
  * The player: first their move this turn — a hop from tile to tile with walking frames, or for a
  * teleport shrinking away and growing back — then, once it is done, their pose: the hit pose when
  * caught, a jump when the level is cleared, a crouch after placing a bomb; otherwise an idle bob
  * with a glance at the camera now and then. Nothing can skip the move (user request, 2026-10-23).
  */
-function drawPlayer(s: GameState, move: PlayerMove | null, pose: PlayerPose, facingLeft: boolean): void {
-  const to = toWorld(s, s.player).add(PLAYER_LIFT)
+function drawPlayer(s: GameState, move: PlayerMove | null, pose: PlayerPose, facingLeft: boolean, tint: Color = C.white, offset: Vector2 = vec2(0)): void {
+  const lift = PLAYER_LIFT.add(offset)
+  const to = toWorld(s, s.player).add(lift)
   const t = move ? time - move.at : Infinity
   if (move && t < moveSeconds(move)) {
     if (move.teleport) {
       const out = t < TELEPORT_SECONDS
       const k = out ? 1 - t / TELEPORT_SECONDS : (t - TELEPORT_SECONDS) / TELEPORT_SECONDS
-      drawTile(out ? toWorld(s, move.from).add(PLAYER_LIFT) : to, vec2(k), sprite('character_green_front'), C.white, (1 - k) * 2, facingLeft)
+      drawTile(out ? toWorld(s, move.from).add(lift) : to, vec2(k), sprite('character_green_front'), tint, (1 - k) * 2, facingLeft)
       return
     }
     const k = moveProgress(move, t)
     // A slide glides crouched, with no hop; a step hops on walking frames.
     if (isSlide(move)) {
-      drawTile(toWorld(s, move.from).add(PLAYER_LIFT).lerp(to, k), vec2(1), sprite('character_green_duck'), C.white, 0, facingLeft)
+      drawTile(toWorld(s, move.from).add(lift).lerp(to, k), vec2(1), sprite('character_green_duck'), tint, 0, facingLeft)
       return
     }
-    const pos = toWorld(s, move.from).add(PLAYER_LIFT).lerp(to, k).add(vec2(0, 0.08 * Math.sin(k * Math.PI)))
-    drawTile(pos, vec2(1), sprite(k < 0.5 ? 'character_green_walk_a' : 'character_green_walk_b'), C.white, 0, facingLeft)
+    const pos = toWorld(s, move.from).add(lift).lerp(to, k).add(vec2(0, 0.08 * Math.sin(k * Math.PI)))
+    drawTile(pos, vec2(1), sprite(k < 0.5 ? 'character_green_walk_a' : 'character_green_walk_b'), tint, 0, facingLeft)
     return
   }
   // The pose starts once the move is done.
@@ -440,7 +454,7 @@ function drawPlayer(s: GameState, move: PlayerMove | null, pose: PlayerPose, fac
     pos = to.add(vec2(0, 0.025 * Math.sin(time * 3)))
     if (time % 4 > 3.4) frame = 'character_green_front'
   }
-  drawTile(pos, vec2(1), sprite(frame), C.white, 0, facingLeft)
+  drawTile(pos, vec2(1), sprite(frame), tint, 0, facingLeft)
 }
 
 /** The exit: only the door's arched top piece, a short doorway that fills its tile (user request, 2026-10-23). */
diff --git a/src/sim/autopilot.ts b/src/sim/autopilot.ts
index e9648da..60ffa00 100644
--- a/src/sim/autopilot.ts
+++ b/src/sim/autopilot.ts
@@ -1,7 +1,8 @@
 import { isDeadly, type Danger } from './danger'
 import { autoMoveAction, playerPath, safeStep } from './path'
-import { cellAt, eq, idx } from './state'
-import type { Action, GameState, Vec } from './types'
+import { isValidTarget } from './actions'
+import { cellAt, eq, flameSafe, idx, playerCanPass } from './state'
+import { DIRS, DIR_LIST, type Action, type GameState, type Vec } from './types'
 
 /** Up to this many targets the cheapest order is searched exhaustively; beyond, nearest-first. */
 const SEARCH_MAX = 10
@@ -111,13 +112,44 @@ function nextTarget(s: GameState, targets: Vec[]): Vec {
   return targets[best]!
 }
 
+/**
+ * The way out of a blast zone (Q101): the first step of the shortest walk to a tile that will not
+ * burn, over tiles the player can pass (not the open exit, which would end the level), the step
+ * itself one that `safeStep` and `isDeadly` allow. Null if there is none.
+ */
+function fleeAction(s: GameState, danger: Danger, zone: Set<number>): Action | null {
+  const key = (p: Vec) => idx(s, p)
+  const seen = new Set<number>([key(s.player)])
+  const queue: { at: Vec; first: Action }[] = []
+  const around = (p: Vec) => DIR_LIST.map((d) => ({ x: p.x + DIRS[d].x, y: p.y + DIRS[d].y }))
+  const open = (q: Vec) => !seen.has(key(q)) && playerCanPass(s, q) && !(s.status === 'playing' && eq(q, s.exit))
+  for (const q of around(s.player)) {
+    if (!open(q)) continue
+    seen.add(key(q))
+    const a: Action = { id: 'move', target: q }
+    if (isValidTarget(s, 'move', q) && safeStep(s, danger, q) && !isDeadly(s, a)) queue.push({ at: q, first: a })
+  }
+  for (let i = 0; i < queue.length; i++) {
+    const { at, first } = queue[i]!
+    if (!zone.has(key(at))) return first
+    for (const q of around(at)) {
+      if (!open(q)) continue
+      seen.add(key(q))
+      queue.push({ at: q, first })
+    }
+  }
+  return null
+}
+
 /**
  * The autopilot's turn (Q73): once the last enemy is gone, walk to every coin, charge and gem
  * lying about, then to the exit, one ordinary turn at a time, planned afresh each turn from the
  * state at hand (flame, bombs and blocked tiles change the next step). Targets with no way to them
  * are skipped. While a gem is still under a crate that is crumbling, or the way to the exit is
  * shut by crates that are, it waits; so it does while every target is cut off by a bomb still
- * ticking or flame still burning (both stop the walk until they are gone). The step is auto-move's (`autoMoveAction`: a Run when owned,
+ * ticking or flame still burning (both stop the walk until they are gone). It never steps onto a tile
+ * a bomb (or a falling ceiling) will burn before it is gone, even where Bomb Pass lets it walk
+ * through the bomb (Q101, bug mail 2026-11-03); standing in such a zone, it first walks out. Spikes are not part of the zone; they are crossed by timing. The step is auto-move's (`autoMoveAction`: a Run when owned,
  * never onto the open exit early, only onto `safeStep` tiles). If there is none, it waits, unless
  * waiting kills too: then `action` is null.
  */
@@ -136,7 +168,18 @@ export function autopilotPlan(s: GameState, danger: Danger): AutopilotPlan {
   }
   const gathering = reachable.length > 0
   const goal = gathering ? nextTarget(s, reachable) : s.exit
-  const action = autoMoveAction(s, goal, (p) => safeStep(s, danger, p))
+  // Everything that will burn if the player waits; harmless to a player who can't be hurt by flame.
+  // Spikes are left out: they are always up in one turn of two, so they stay in `now` or `next` for
+  // ever, and their timing is already `safeStep`'s (`danger.now`).
+  const spikeCells = new Set(s.spikes.map((sp) => sp.cell))
+  const zone = flameSafe(s) ? new Set<number>() : new Set([...danger.now, ...danger.next, ...danger.later].filter((i) => !spikeCells.has(i)))
+  if (zone.has(idx(s, s.player))) {
+    const out = fleeAction(s, danger, zone)
+    if (out) return { phase: 'bombs', goal: null, action: out }
+  }
+  const action = autoMoveAction(s, goal, (p) => safeStep(s, danger, p) && !zone.has(idx(s, p)))
   const usable = action && !isDeadly(s, action) ? action : waitOrNull()
+  const temporary = s.bombs.length > 0 || s.burning.length > 0 || s.smoldering.length > 0 || s.collapsing.length > 0
+  if (!action && zone.size > 0 && temporary) return { phase: 'bombs', goal: null, action: usable }
   return { phase: gathering ? 'gather' : 'exit', goal, action: usable }
 }
diff --git a/src/sim/replayCompare.ts b/src/sim/replayCompare.ts
new file mode 100644
index 0000000..e82e09c
--- /dev/null
+++ b/src/sim/replayCompare.ts
@@ -0,0 +1,114 @@
+import { autopilotPlan } from './autopilot'
+import { predictDanger } from './danger'
+import { newRun } from './state'
+import { step } from './turn'
+import { SIM_VERSION, type RunRecord } from './record'
+import type { Action, GameState } from './types'
+
+/**
+ * A bug report's run, set against what today's autopilot does from the same place (Q103, user
+ * request, 2026-11-03): the dev replay page (`?replay=` in `main.ts`) plays two players side by
+ * side, one turn each at a time, in a loop: red plays `prefix` then `old` (the report), blue plays
+ * `prefix` then `neu` (today's autopilot).
+ */
+export interface ReplayScript {
+  /** The room as it stood `lead` turns before the autopilot took over. */
+  start: GameState
+  /** Turns both share: from `start` to the moment the last enemy died (the autopilot took over). */
+  prefix: Action[]
+  /** What the report did from there on, and a few waits after it to show how it ends. */
+  old: Action[]
+  /** What today's autopilot does from there on, to as long as the report went, a few turns more. */
+  neu: Action[]
+  /** How each fork ends: the status and lives left. */
+  oldEnd: { status: GameState['status']; lives: number }
+  newEnd: { status: GameState['status']; lives: number }
+  /** The record's rules version differs from today's: the replay may not match the report. */
+  versionMismatch: boolean
+  level: number
+}
+
+export interface ReplayOptions {
+  /** Turns shown before the autopilot took over. */
+  lead?: number
+  /** Turns past the report's last that are still shown. */
+  extra?: number
+}
+
+/** Plays `actions` from `from`, keeping those the rules take while the game is on; the states on the way. */
+function play(from: GameState, actions: Action[]): { kept: Action[]; states: GameState[] } {
+  const kept: Action[] = []
+  const states: GameState[] = []
+  let s = from
+  for (const a of actions) {
+    if (s.status !== 'playing') break
+    try {
+      s = step(s, a).state
+    } catch {
+      break
+    }
+    kept.push(a)
+    states.push(s)
+  }
+  return { kept, states }
+}
+
+/** Today's autopilot from `from` (answering offers with the first choice), for at most `limit` turns. */
+function autopilotRun(from: GameState, limit: number): { actions: Action[]; states: GameState[] } {
+  const actions: Action[] = []
+  const states: GameState[] = []
+  let s = from
+  for (let i = 0; i < limit && s.status === 'playing'; i++) {
+    const action: Action | null = s.offer ? { id: 'choose', kind: s.offer[0]! } : s.chargeOffer ? { id: 'sellCharge' } : autopilotPlan(s, predictDanger(s)).action
+    if (!action) break
+    s = step(s, action).state
+    actions.push(action)
+    states.push(s)
+  }
+  return { actions, states }
+}
+
+export function buildReplay(record: RunRecord, options: ReplayOptions = {}): ReplayScript {
+  const { lead = 6, extra = 4 } = options
+  // The state after each recorded action; `after(-1)` is the start of the run.
+  const first = newRun(record.seed, record.startLevel, record.startCharge, record.options)
+  const states: GameState[] = []
+  let s = first
+  for (const a of record.actions) {
+    s = step(s, a).state
+    states.push(s)
+  }
+  const after = (i: number): GameState => (i < 0 ? first : states[i]!)
+  const last = after(record.actions.length - 1)
+  // The level the report ends in, from the turn that opened it.
+  let levelStart = -1
+  for (let i = 0; i < states.length; i++) if (states[i]!.level === last.level) { levelStart = i; break }
+  if (levelStart < 0) levelStart = record.actions.length - 1
+  // The autopilot takes over when the last enemy is gone: the first such turn on this level.
+  let takeover = record.actions.length - 1
+  for (let i = levelStart; i < states.length; i++) {
+    if (states[i]!.level === last.level && states[i]!.enemies.length === 0 && states[i]!.status === 'playing') { takeover = i; break }
+  }
+  const startIdx = Math.max(levelStart, takeover - lead)
+  const start = after(startIdx)
+  const prefix = record.actions.slice(startIdx + 1, takeover + 1)
+  const fork = after(takeover)
+  const recorded = record.actions.slice(takeover + 1)
+  const waits: Action[] = Array.from({ length: extra }, () => ({ id: 'wait' }))
+  const old = play(fork, [...recorded, ...waits])
+  const neu = autopilotRun(fork, recorded.length + extra)
+  const end = (from: GameState, st: GameState[]) => {
+    const e = st.at(-1) ?? from
+    return { status: e.status, lives: e.lives }
+  }
+  return {
+    start,
+    prefix,
+    old: old.kept,
+    neu: neu.actions,
+    oldEnd: end(fork, old.states),
+    newEnd: end(fork, neu.states),
+    versionMismatch: record.simVersion !== SIM_VERSION,
+    level: last.level,
+  }
+}
diff --git a/src/sim/ruleCompare.ts b/src/sim/ruleCompare.ts
new file mode 100644
index 0000000..b55cda7
--- /dev/null
+++ b/src/sim/ruleCompare.ts
@@ -0,0 +1,99 @@
+import type { RunRecord } from './record'
+import type { StepResult } from './turn'
+import type { Action, GameState, RunOptions, ChargeId } from './types'
+
+/**
+ * Two versions of the rules played over one whole run (Q103, user request, 2026-11-03): the
+ * replay page's `?rules=<commit>` mode. An engine is a version's `newRun` and `step`; the older
+ * one is loaded from `rules-versions/<commit>/` (tools/report-replay.mjs makes it with
+ * `git archive`), the newer one is today's. Both are given the same recorded actions; this finds
+ * where their games stop agreeing.
+ */
+export interface Engine {
+  newRun(seed: number, level: number, charge: ChargeId | null, options: RunOptions): GameState
+  step(prev: GameState, action: Action): StepResult
+}
+
+/** One side's game: the start and the result of each turn it could play. */
+export interface Side {
+  start: GameState
+  results: StepResult[]
+  /** The turn (0-based) this side's rules refused, with the reason; its game stops there. */
+  refused: { at: number; why: string } | null
+}
+
+export interface RuleComparison {
+  actions: Action[]
+  red: Side
+  blue: Side
+  /** The first turn (0-based, into `actions`) after which the games differ, or null if they agree to the end. */
+  split: number | null
+  /** What differs there, in words. */
+  why: string
+}
+
+/** What two games must agree on to count as the same game: the board's life, not every field (a version may add some). */
+function digest(s: GameState): string {
+  return JSON.stringify([
+    s.level, s.status, s.lives, s.player, s.coins, s.turn, !!s.offer,
+    s.enemies.map((e) => [e.kind, e.pos]),
+    s.bombs.map((b) => [b.pos, b.timer]),
+    // (An older version may lack a field.)
+    s.burning?.length, s.drops?.length, s.gems?.length,
+  ])
+}
+
+/** The first difference between two games, in words ('' if none). */
+export function describeDifference(a: GameState, b: GameState): string {
+  const at = (p: { x: number; y: number }) => `(${p.x},${p.y})`
+  if (a.level !== b.level) return `level ${a.level} vs ${b.level}`
+  if (a.status !== b.status) return `${a.status} vs ${b.status}`
+  if (a.lives !== b.lives) return `${a.lives} vs ${b.lives} lives`
+  if (a.player.x !== b.player.x || a.player.y !== b.player.y) return `player at ${at(a.player)} vs ${at(b.player)}`
+  if (a.enemies.length !== b.enemies.length) return `${a.enemies.length} vs ${b.enemies.length} enemies`
+  if (a.coins !== b.coins) return `${a.coins} vs ${b.coins} coins`
+  if (a.bombs.length !== b.bombs.length) return `${a.bombs.length} vs ${b.bombs.length} bombs`
+  return digest(a) === digest(b) ? '' : 'the boards differ'
+}
+
+function playSide(engine: Engine, record: RunRecord): Side {
+  const start = engine.newRun(record.seed, record.startLevel, record.startCharge, record.options)
+  const results: StepResult[] = []
+  let s = start
+  let refused: Side['refused'] = null
+  for (let i = 0; i < record.actions.length; i++) {
+    try {
+      const r = engine.step(s, record.actions[i]!)
+      results.push(r)
+      s = r.state
+    } catch (error) {
+      refused = { at: i, why: String(error instanceof Error ? error.message : error) }
+      break
+    }
+  }
+  return { start, results, refused }
+}
+
+export function compareRules(red: Engine, blue: Engine, record: RunRecord): RuleComparison {
+  const a = playSide(red, record)
+  const b = playSide(blue, record)
+  const stateAfter = (side: Side, i: number): GameState => (i < 0 ? side.start : side.results[Math.min(i, side.results.length - 1)]?.state ?? side.start)
+  let split: number | null = null
+  let why = ''
+  const turns = Math.max(a.results.length + (a.refused ? 1 : 0), b.results.length + (b.refused ? 1 : 0))
+  for (let i = 0; i < turns; i++) {
+    const refusedHere = [a, b].filter((x) => x.refused?.at === i)
+    if (refusedHere.length) {
+      split = i
+      why = refusedHere.length === 2 ? 'both refuse this turn' : `${refusedHere[0] === a ? 'red' : 'blue'}'s rules refuse it: ${refusedHere[0]!.refused!.why}`
+      break
+    }
+    const d = describeDifference(stateAfter(a, i), stateAfter(b, i))
+    if (d) {
+      split = i
+      why = d
+      break
+    }
+  }
+  return { actions: record.actions, red: a, blue: b, split, why }
+}
diff --git a/test/autopilot.test.ts b/test/autopilot.test.ts
index 5bb111a..a85a527 100644
--- a/test/autopilot.test.ts
+++ b/test/autopilot.test.ts
@@ -127,6 +127,33 @@ describe('the autopilot (Q73)', () => {
     expect(turns.at(-1)!.drops).toHaveLength(0)
   })
 
+  it('does not walk through its own bomb into the blast with Bomb Pass, and gets out of a blast zone (Q101)', () => {
+    const s = board(`
+      #############
+      #E..P.4..$..#
+      #############`)
+    s.powers.bombPass = true
+    expect(s.bombs).toHaveLength(1)
+    const p = plan(s)
+    expect(p.phase).toBe('bombs')
+    expect(p.action).not.toEqual({ id: 'move', target: v(5, 1) })
+    const turns = play(s)
+    expect(turns.every((t) => t.lives === s.lives)).toBe(true)
+    expect(turns.at(-1)!.status).toBe('cleared')
+    expect(turns.at(-1)!.drops).toHaveLength(0)
+  })
+
+  it('still crosses a spike that blocks the only way to the exit (no endless wait, Q101)', () => {
+    const s = board(`
+      #######
+      #P.v.E#
+      #######`)
+    expect(s.spikes).toHaveLength(1)
+    const turns = play(s)
+    expect(turns.at(-1)!.status).toBe('cleared')
+    expect(turns.every((t) => t.lives === s.lives)).toBe(true)
+  })
+
   it('waits for crates that shut the way to the exit, but not when nothing crumbles', () => {
     const s = board(`
       #######
diff --git a/test/fixtures/q101-bomb-pass.json b/test/fixtures/q101-bomb-pass.json
new file mode 100644
index 0000000..1096cb1
--- /dev/null
+++ b/test/fixtures/q101-bomb-pass.json
@@ -0,0 +1 @@
+{"simVersion":43,"seed":1464004408,"startLevel":1,"startCharge":null,"options":{"dev":false,"immortal":false},"actions":[{"id":"move","target":{"x":1,"y":2}},{"id":"move","target":{"x":1,"y":3}},{"id":"move","target":{"x":1,"y":4}},{"id":"bomb","target":{"x":1,"y":5}},{"id":"move","target":{"x":1,"y":3}},{"id":"move","target":{"x":2,"y":3}},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":1,"y":3}},{"id":"move","target":{"x":1,"y":4}},{"id":"move","target":{"x":1,"y":5}},{"id":"move","target":{"x":1,"y":6}},{"id":"choose","kind":"kick"},{"id":"move","target":{"x":1,"y":5}},{"id":"move","target":{"x":2,"y":5}},{"id":"bomb","target":{"x":3,"y":5}},{"id":"move","target":{"x":1,"y":5}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":2,"y":5}},{"id":"move","target":{"x":3,"y":5}},{"id":"move","target":{"x":4,"y":5}},{"id":"bomb","target":{"x":5,"y":5}},{"id":"kick","target":{"x":5,"y":5}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"bomb","target":{"x":5,"y":5}},{"id":"move","target":{"x":3,"y":5}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":4,"y":5}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"bomb","target":{"x":5,"y":5}},{"id":"move","target":{"x":3,"y":5}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":4,"y":5}},{"id":"move","target":{"x":5,"y":5}},{"id":"move","target":{"x":5,"y":6}},{"id":"move","target":{"x":5,"y":7}},{"id":"move","target":{"x":5,"y":8}},{"id":"move","target":{"x":5,"y":9}},{"id":"continue"},{"id":"bomb","target":{"x":2,"y":1}},{"id":"kick","target":{"x":2,"y":1}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"bomb","target":{"x":1,"y":2}},{"id":"kick","target":{"x":1,"y":2}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"bomb","target":{"x":1,"y":2}},{"id":"kick","target":{"x":1,"y":2}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"bomb","target":{"x":1,"y":2}},{"id":"kick","target":{"x":1,"y":2}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":1,"y":2}},{"id":"move","target":{"x":1,"y":3}},{"id":"move","target":{"x":1,"y":4}},{"id":"move","target":{"x":1,"y":5}},{"id":"move","target":{"x":1,"y":6}},{"id":"move","target":{"x":1,"y":7}},{"id":"move","target":{"x":2,"y":7}},{"id":"move","target":{"x":3,"y":7}},{"id":"move","target":{"x":4,"y":7}},{"id":"move","target":{"x":5,"y":7}},{"id":"move","target":{"x":5,"y":6}},{"id":"move","target":{"x":5,"y":5}},{"id":"move","target":{"x":5,"y":4}},{"id":"move","target":{"x":5,"y":3}},{"id":"move","target":{"x":6,"y":3}},{"id":"move","target":{"x":7,"y":3}},{"id":"bomb","target":{"x":7,"y":4}},{"id":"kick","target":{"x":7,"y":4}},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":6,"y":3}},{"id":"move","target":{"x":5,"y":3}},{"id":"bomb","target":{"x":5,"y":4}},{"id":"kick","target":{"x":5,"y":4}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":5,"y":2}},{"id":"wait"},{"id":"move","target":{"x":5,"y":3}},{"id":"move","target":{"x":5,"y":4}},{"id":"move","target":{"x":5,"y":5}},{"id":"move","target":{"x":5,"y":6}},{"id":"move","target":{"x":5,"y":7}},{"id":"move","target":{"x":4,"y":7}},{"id":"move","target":{"x":3,"y":7}},{"id":"move","target":{"x":2,"y":7}},{"id":"move","target":{"x":1,"y":7}},{"id":"bomb","target":{"x":1,"y":6}},{"id":"kick","target":{"x":1,"y":6}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":2,"y":7}},{"id":"move","target":{"x":3,"y":7}},{"id":"move","target":{"x":4,"y":7}},{"id":"move","target":{"x":5,"y":7}},{"id":"move","target":{"x":6,"y":7}},{"id":"move","target":{"x":7,"y":7}},{"id":"bomb","target":{"x":7,"y":6}},{"id":"kick","target":{"x":7,"y":6}},{"id":"wait"},{"id":"move","target":{"x":7,"y":6}},{"id":"wait"},{"id":"bomb","target":{"x":7,"y":5}},{"id":"kick","target":{"x":7,"y":5}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":7,"y":5}},{"id":"bomb","target":{"x":7,"y":4}},{"id":"move","target":{"x":7,"y":6}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":7,"y":5}},{"id":"bomb","target":{"x":7,"y":4}},{"id":"wait"},{"id":"kick","target":{"x":7,"y":4}},{"id":"wait"},{"id":"move","target":{"x":7,"y":4}},{"id":"move","target":{"x":7,"y":3}},{"id":"move","target":{"x":7,"y":2}},{"id":"move","target":{"x":7,"y":3}},{"id":"move","target":{"x":6,"y":3}},{"id":"move","target":{"x":5,"y":3}},{"id":"move","target":{"x":4,"y":3}},{"id":"choose","kind":"tnt"},{"id":"move","target":{"x":5,"y":3}},{"id":"move","target":{"x":5,"y":4}},{"id":"move","target":{"x":5,"y":5}},{"id":"move","target":{"x":5,"y":6}},{"id":"move","target":{"x":5,"y":7}},{"id":"move","target":{"x":6,"y":7}},{"id":"move","target":{"x":7,"y":7}},{"id":"continue"},{"id":"move","target":{"x":1,"y":2}},{"id":"bomb","target":{"x":1,"y":3}},{"id":"kick","target":{"x":1,"y":3}},{"id":"move","target":{"x":1,"y":1}},{"id":"wait"},{"id":"wait"},{"id":"bomb","target":{"x":1,"y":2}},{"id":"kick","target":{"x":1,"y":2}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":1,"y":2}},{"id":"move","target":{"x":1,"y":3}},{"id":"move","target":{"x":1,"y":4}},{"id":"move","target":{"x":1,"y":5}},{"id":"move","target":{"x":1,"y":6}},{"id":"move","target":{"x":1,"y":7}},{"id":"move","target":{"x":1,"y":8}},{"id":"move","target":{"x":1,"y":9}},{"id":"bomb","target":{"x":2,"y":9}},{"id":"kick","target":{"x":2,"y":9}},{"id":"move","target":{"x":1,"y":8}},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":1,"y":9}},{"id":"move","target":{"x":2,"y":9}},{"id":"move","target":{"x":3,"y":9}},{"id":"move","target":{"x":4,"y":9}},{"id":"sellCharge"},{"id":"move","target":{"x":5,"y":9}},{"id":"move","target":{"x":4,"y":9}},{"id":"move","target":{"x":3,"y":9}},{"id":"bomb","target":{"x":3,"y":8}},{"id":"move","target":{"x":2,"y":9}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":3,"y":9}},{"id":"move","target":{"x":3,"y":8}},{"id":"move","target":{"x":3,"y":7}},{"id":"move","target":{"x":4,"y":7}},{"id":"move","target":{"x":5,"y":7}},{"id":"bomb","target":{"x":5,"y":6}},{"id":"kick","target":{"x":5,"y":6}},{"id":"move","target":{"x":4,"y":7}},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":5,"y":7}},{"id":"move","target":{"x":5,"y":6}},{"id":"move","target":{"x":5,"y":5}},{"id":"move","target":{"x":5,"y":4}},{"id":"choose","kind":"diagonal"},{"id":"move","target":{"x":5,"y":5}},{"id":"move","target":{"x":5,"y":6}},{"id":"diagonal","target":{"x":6,"y":7}},{"id":"diagonal","target":{"x":7,"y":8}},{"id":"continue"},{"id":"buy","kind":"bombPass"},{"id":"buy","kind":"fusePlus"},{"id":"continue"},{"id":"move","target":{"x":1,"y":2}},{"id":"diagonal","target":{"x":2,"y":3}},{"id":"diagonal","target":{"x":3,"y":4}},{"id":"move","target":{"x":3,"y":5}},{"id":"move","target":{"x":2,"y":5}},{"id":"move","target":{"x":1,"y":5}},{"id":"bomb","target":{"x":1,"y":6}},{"id":"move","target":{"x":2,"y":5}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"move","target":{"x":3,"y":5}},{"id":"move","target":{"x":4,"y":5}},{"id":"move","target":{"x":5,"y":5}},{"id":"bomb","target":{"x":6,"y":5}},{"id":"move","target":{"x":5,"y":6}},{"id":"move","target":{"x":5,"y":7}},{"id":"move","target":{"x":6,"y":7}},{"id":"move","target":{"x":7,"y":7}},{"id":"move","target":{"x":7,"y":6}},{"id":"move","target":{"x":7,"y":5}},{"id":"move","target":{"x":8,"y":5}},{"id":"move","target":{"x":7,"y":5}},{"id":"move","target":{"x":6,"y":5}},{"id":"diagonal","target":{"x":5,"y":4}},{"id":"diagonal","target":{"x":6,"y":3}},{"id":"bomb","target":{"x":7,"y":3}},{"id":"diagonal","target":{"x":5,"y":4}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"diagonal","target":{"x":4,"y":3}},{"id":"move","target":{"x":3,"y":3}},{"id":"move","target":{"x":2,"y":3}},{"id":"diagonal","target":{"x":1,"y":2}},{"id":"bomb","target":{"x":1,"y":1}},{"id":"diagonal","target":{"x":2,"y":3}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"diagonal","target":{"x":1,"y":2}},{"id":"diagonal","target":{"x":2,"y":1}},{"id":"move","target":{"x":3,"y":1}},{"id":"move","target":{"x":2,"y":1}},{"id":"diagonal","target":{"x":1,"y":2}},{"id":"diagonal","target":{"x":2,"y":3}},{"id":"diagonal","target":{"x":3,"y":4}},{"id":"diagonal","target":{"x":4,"y":5}},{"id":"diagonal","target":{"x":5,"y":6}},{"id":"diagonal","target":{"x":6,"y":7}},{"id":"diagonal","target":{"x":5,"y":6}},{"id":"move","target":{"x":5,"y":5}},{"id":"move","target":{"x":5,"y":4}},{"id":"move","target":{"x":5,"y":3}},{"id":"bomb","target":{"x":5,"y":4}},{"id":"kick","target":{"x":5,"y":4}},{"id":"wait"},{"id":"wait"},{"id":"wait"},{"id":"bomb","target":{"x":5,"y":4}},{"id":"move","target":{"x":5,"y":4}},{"id":"move","target":{"x":5,"y":5}},{"id":"move","target":{"x":5,"y":4}}]}
\ No newline at end of file
diff --git a/test/replayCompare.test.ts b/test/replayCompare.test.ts
new file mode 100644
index 0000000..699e64b
--- /dev/null
+++ b/test/replayCompare.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest'
+import { readFileSync } from 'node:fs'
+import { buildReplay } from '../src/sim/replayCompare'
+import { replay } from '../src/sim/record'
+import type { RunRecord } from '../src/sim/record'
+import { step } from '../src/sim/turn'
+
+/** The report of Q101 (seed 1464004408, level 4): the autopilot, with Bomb Pass, walked into its own bomb's blast. */
+const record = JSON.parse(readFileSync(new URL('./fixtures/q101-bomb-pass.json', import.meta.url), 'utf8')) as RunRecord
+
+describe('the report replay (Q103)', () => {
+  const script = buildReplay(record)
+
+  it('starts a few turns before the autopilot took over, on the report\'s level', () => {
+    expect(script.level).toBe(4)
+    expect(script.prefix.length).toBe(6)
+    let s = script.start
+    for (const a of script.prefix) s = step(s, a).state
+    expect(s.enemies).toHaveLength(0)
+    // The report went on 3 turns past that moment, then waits.
+    expect(script.old.slice(0, 3)).toEqual(record.actions.slice(-3))
+  })
+
+  it('shows today\'s autopilot keeping its life where the report lost one', () => {
+    expect(script.oldEnd.lives).toBeLessThan(script.newEnd.lives)
+    expect(script.newEnd.lives).toBe(replay({ ...record, actions: record.actions.slice(0, 287) }).lives)
+  })
+
+  it('runs with the rules of today', () => {
+    expect(script.versionMismatch).toBe(false)
+  })
+})
diff --git a/test/reportReplay.test.ts b/test/reportReplay.test.ts
new file mode 100644
index 0000000..cb8a3fc
--- /dev/null
+++ b/test/reportReplay.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest'
+import { spawnSync } from 'node:child_process'
+import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+const script = new URL('../tools/report-replay.mjs', import.meta.url).pathname
+const mail = (record: object, qp = false) => {
+  const line = `Run record: ${JSON.stringify(record)}`
+  // Quoted-printable: = as =3D, a soft line break inside the record.
+  return qp ? `Content-Transfer-Encoding: quoted-printable\n\n${line.replace(/=/g, '=3D').replace(/(.{40})/g, '$1=\n')}\n` : `${line}\n`
+}
+const run = (body: string) => {
+  const dir = mkdtempSync(join(tmpdir(), 'report-replay-'))
+  const file = join(dir, 'mail.txt')
+  writeFileSync(file, body)
+  const r = spawnSync('node', [script, file], { encoding: 'utf8' })
+  rmSync(dir, { recursive: true })
+  return r
+}
+
+describe('tools/report-replay.mjs (Q103)', () => {
+  it('reads a record out of quoted-printable mail text and prints the replay address', () => {
+    const r = run(mail({ simVersion: 43, seed: 123456789, startLevel: 1, startCharge: null, options: { dev: false, immortal: false }, actions: [{ id: 'wait' }, { id: 'wait' }] }, true))
+    try {
+      expect(r.status).toBe(0)
+      expect(r.stdout).toContain('?replay=seed123456789-v43-2')
+    } finally {
+      const made = new URL('../replays/seed123456789-v43-2.json', import.meta.url)
+      if (existsSync(made)) rmSync(made)
+    }
+  })
+
+  it('refuses a record whose simVersion could steer the file name out of replays/', () => {
+    const r = run(mail({ simVersion: '/../../../../tmp/evil', seed: 1, actions: [] }))
+    expect(r.status).not.toBe(0)
+    expect(existsSync('/tmp/evil-0.json')).toBe(false)
+    expect(r.stderr).toContain('not a run record')
+  })
+
+  it('refuses --rules that is not a commit hash', () => {
+    const dir = mkdtempSync(join(tmpdir(), 'report-replay-'))
+    const file = join(dir, 'mail.txt')
+    writeFileSync(file, mail({ simVersion: 43, seed: 7, actions: [] }))
+    const r = spawnSync('node', [script, '--rules', '../../x', file], { encoding: 'utf8' })
+    rmSync(dir, { recursive: true })
+    expect(r.status).not.toBe(0)
+    expect(r.stderr).toContain('commit hash')
+    rmSync(new URL('../replays/seed7-v43-0.json', import.meta.url), { force: true })
+  })
+})
diff --git a/test/ruleCompare.test.ts b/test/ruleCompare.test.ts
new file mode 100644
index 0000000..99fd26a
--- /dev/null
+++ b/test/ruleCompare.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest'
+import { readFileSync } from 'node:fs'
+import { compareRules, type Engine } from '../src/sim/ruleCompare'
+import type { RunRecord } from '../src/sim/record'
+import { newRun } from '../src/sim/state'
+import { step } from '../src/sim/turn'
+
+const record = JSON.parse(readFileSync(new URL('./fixtures/q101-bomb-pass.json', import.meta.url), 'utf8')) as RunRecord
+const today: Engine = { newRun, step }
+
+describe('comparing two versions of the rules (Q103)', () => {
+  it('finds no split when both versions are the same', () => {
+    const c = compareRules(today, today, record)
+    expect(c.split).toBeNull()
+    expect(c.red.results).toHaveLength(record.actions.length)
+    expect(c.blue.refused).toBeNull()
+  })
+
+  it('finds the first turn where one version plays differently', () => {
+    // A version that refuses the 60th turn of the run stands in for a rules change.
+    const strict: Engine = {
+      newRun,
+      step: (s, a) => {
+        if (s.runTurns === 60 && a.id !== 'choose' && a.id !== 'continue') throw new Error('not allowed in this version')
+        return step(s, a)
+      },
+    }
+    const c = compareRules(today, strict, record)
+    expect(c.split).not.toBeNull()
+    expect(c.why).toContain("blue's rules refuse it")
+    expect(c.blue.refused?.at).toBe(c.split)
+    expect(c.red.refused).toBeNull()
+  })
+
+  it('finds a split in the game itself when a rule changes what happens', () => {
+    // A version in which a Wait also costs a coin.
+    const taxed: Engine = {
+      newRun,
+      step: (s, a) => {
+        const r = step(s, a)
+        if (a.id === 'wait' && r.state.coins > 0) r.state.coins--
+        return r
+      },
+    }
+    const c = compareRules(today, taxed, record)
+    expect(c.split).not.toBeNull()
+    expect(c.why).toMatch(/coins/)
+    // Before that turn the games agree: replaying up to it gives the same states.
+    expect(c.red.results[c.split! - 1]!.state.coins).toBe(c.blue.results[c.split! - 1]!.state.coins)
+  })
+})
diff --git a/tools/report-replay.mjs b/tools/report-replay.mjs
new file mode 100644
index 0000000..c5cca27
--- /dev/null
+++ b/tools/report-replay.mjs
@@ -0,0 +1,78 @@
+#!/usr/bin/env node
+// Turns a bug report into a replay URL (Q103): node tools/report-replay.mjs <mail.eml | report.txt | record.json>
+// Finds the "Run record: {...}" line (the mail's quoted-printable text is decoded first), or a bare
+// record, writes replays/<name>.json (git-ignored; served by `npm run dev` as /__replay/<name>.json)
+// and prints the addresses of the pages that play it (the page has two players, red and blue):
+//   ?replay=<name>               the report's last turns (red) against today's autopilot (blue);
+//   ?replay=<name>&rules=<commit> the whole run played by the rules of that commit (red) and by today's (blue).
+// The commit is the one the report says it was made on (or `--rules <commit>`); its `src/sim` is
+// taken out of git into rules-versions/<commit>/ (git-ignored) for the page to load.
+import { execFileSync } from 'node:child_process'
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
+import { basename } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const args = process.argv.slice(2)
+const flag = args.indexOf('--rules')
+const wanted = flag >= 0 ? args.splice(flag, 2)[1] : null
+const file = args[0]
+if (!file) {
+  console.error('usage: node tools/report-replay.mjs <bug report mail or text file>')
+  process.exit(1)
+}
+const raw = readFileSync(file, 'latin1')
+
+/** Quoted-printable to text: soft line breaks removed, =XX bytes read as UTF-8. */
+function decodeQp(text) {
+  const joined = text.replace(/=\r?\n/g, '')
+  const bytes = []
+  for (let i = 0; i < joined.length; i++) {
+    if (joined[i] === '=' && /^[0-9A-Fa-f]{2}$/.test(joined.slice(i + 1, i + 3))) {
+      bytes.push(parseInt(joined.slice(i + 1, i + 3), 16))
+      i += 2
+    } else bytes.push(...Buffer.from(joined[i], 'latin1'))
+  }
+  return Buffer.from(bytes).toString('utf8')
+}
+
+const text = /quoted-printable/i.test(raw) ? decodeQp(raw) : raw
+const found = text.match(/Run record: (\{.*\})\s*$/m)
+let record
+try {
+  record = JSON.parse(found ? found[1] : text.trim())
+} catch {
+  console.error('No run record found in', file)
+  process.exit(1)
+}
+// The record comes from a mail: nothing in it may end up in a path unchecked.
+if (!Array.isArray(record.actions) || !Number.isInteger(record.seed) || !Number.isInteger(record.simVersion)) {
+  console.error('That is not a run record (it needs an integer seed and simVersion, and actions).')
+  process.exit(1)
+}
+const name = `seed${record.seed}-v${record.simVersion}-${record.actions.length}`
+mkdirSync(new URL('../replays/', import.meta.url), { recursive: true })
+writeFileSync(new URL(`../replays/${name}.json`, import.meta.url), JSON.stringify(record))
+console.log(`Record: ${record.actions.length} actions, seed ${record.seed}, rules ${record.simVersion} (from ${basename(file)})`)
+console.log(`Replay (with \`npm run dev\` running): http://localhost:5173/?replay=${name}`)
+
+// The rules the report was made on: its "commit abc1234" line, or --rules.
+const commit = wanted ?? text.match(/commit ([0-9a-f]{7,40})/)?.[1]
+if (commit && !/^[0-9a-f]{7,40}$/.test(commit)) {
+  console.error('--rules takes a commit hash (7 to 40 hex digits), not a name like a tag or HEAD~1.')
+  process.exit(1)
+}
+if (commit) {
+  const root = fileURLToPath(new URL('..', import.meta.url))
+  const dir = new URL(`../rules-versions/${commit}/`, import.meta.url)
+  try {
+    const full = execFileSync('git', ['rev-parse', '--verify', `${commit}^{commit}`], { cwd: root, encoding: 'utf8' }).trim()
+    if (!existsSync(new URL('src/sim/turn.ts', dir))) {
+      mkdirSync(dir, { recursive: true })
+      const tar = execFileSync('git', ['archive', full, 'src/sim'], { cwd: root, maxBuffer: 1 << 28 })
+      execFileSync('tar', ['-x', '-C', fileURLToPath(dir)], { input: tar })
+    }
+    console.log(`Rules of ${commit} against today's, the whole run: http://localhost:5173/?replay=${name}&rules=${commit}`)
+  } catch {
+    console.log(`(No rules page: commit ${commit} is not in this repository.)`)
+  }
+}
diff --git a/vite.config.ts b/vite.config.ts
index 44eabee..b612847 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,7 +1,7 @@
 import { defineConfig } from 'vitest/config'
 import { qrcode } from 'vite-plugin-qrcode'
 import { execSync } from 'node:child_process'
-import { readFileSync } from 'node:fs'
+import { existsSync, readFileSync } from 'node:fs'
 import fuzzyworld from 'fuzzyworld-tools/vite'
 import { UNRELEASED, VERSION } from './src/changelog.ts'
 
@@ -43,6 +43,23 @@ export default defineConfig({
   plugins: [
     fuzzyworld(),
     qrcode(),
+    // Dev only (Q103): `replays/<name>.json`, made by `npm run report-replay`, served as /__replay/<name>.json
+    // for the replay page (`?replay=<name>`). Nothing in a build.
+    {
+      name: 'report-replays',
+      configureServer(server) {
+        server.middlewares.use('/__replay', (req, res) => {
+          const name = (req.url ?? '').replace(/^\//, '').replace(/\.json$/, '')
+          const file = new URL(`./replays/${name}.json`, import.meta.url)
+          if (!/^[\w.-]+$/.test(name) || !existsSync(file)) {
+            res.statusCode = 404
+            return res.end('no such replay')
+          }
+          res.setHeader('content-type', 'application/json')
+          res.end(readFileSync(file))
+        })
+      },
+    },
     // The next copy tells the released one what is coming, for its "Try it early" teaser
     // (src/ui/whatsnew.ts): next.json beside the game.
     {