fuzzyworld

explodorama / commit

Power-ups, Run and Detonate, the 50-level ending, mid-run save (M6 part 1)

27f24fed3fa587d68ea61d19ebbd7698eab37bbb · Pavel Safronov · 2026-10-20 10:42:25 -0700 · parents 82adee1813a8

Claude Code usage: this commit ~80.2K out / ~22.0M cached · cost unknown (no per-model trailer) · session 1 tally 790 in / 475.3K out / 95.1M cached · usage by commit and session

Power-ups, Run and Detonate, the 50-level ending, mid-run save (M6 part 1)

One power-up per level under a crate (never the exit's): Bomb+, Fire+,
Fuse+ (gems), Skates (Run, guaranteed on level 1 or 2), Detonator
(Detonate), Wall / Bomb / Flame Pass, Star, Heart, Coin bag. Flaming an
uncovered one destroys it and lets out enemies. A death loses the passes,
Detonator and Star. Clearing level 50 wins the run. The run record is
saved after every action and replayed on load; test URLs don't touch it.
SIM_VERSION 4.

Usage: 790 in / 475.3K out / 95.1M cached (session tally)
rtk:   1.7M saved, 25.5% over 2779 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 PLAN.md                           |  19 +++-
 README.md                         |  48 ++++++--
 assets/kenney/bush.png            | Bin 0 -> 2145 bytes
 assets/kenney/ladder_top.png      | Bin 0 -> 589 bytes
 assets/kenney/lever.png           | Bin 0 -> 1803 bytes
 assets/kenney/torch_off.png       | Bin 0 -> 1331 bytes
 decisions.md                      |  20 ++++
 docs/screenshots/auto-move.png    | Bin 239199 -> 243724 bytes
 docs/screenshots/bomb-picked.png  | Bin 238931 -> 243572 bytes
 docs/screenshots/charges.png      | Bin 269004 -> 273479 bytes
 docs/screenshots/countdown.png    | Bin 244028 -> 248462 bytes
 docs/screenshots/death.png        | Bin 199288 -> 202732 bytes
 docs/screenshots/desktop.png      | Bin 585942 -> 589981 bytes
 docs/screenshots/enemies.png      | Bin 250386 -> 254864 bytes
 docs/screenshots/lingering.png    | Bin 248179 -> 252768 bytes
 docs/screenshots/long-press.png   | Bin 242461 -> 246984 bytes
 docs/screenshots/pierce-decoy.png | Bin 277755 -> 282258 bytes
 docs/screenshots/power-up.png     | Bin 0 -> 260047 bytes
 docs/screenshots/run.png          | Bin 0 -> 243745 bytes
 docs/screenshots/scrolled.png     | Bin 253258 -> 257350 bytes
 docs/screenshots/start.png        | Bin 237840 -> 242525 bytes
 src/main.ts                       |  49 +++++++-
 src/render/board.ts               |  12 +-
 src/render/sprites.ts             |  28 ++++-
 src/sim/actions.ts                |  35 +++++-
 src/sim/levelgen.ts               |  37 +++++-
 src/sim/path.ts                   |  11 +-
 src/sim/record.ts                 |   2 +-
 src/sim/state.ts                  |  43 +++++--
 src/sim/turn.ts                   |  98 ++++++++++++----
 src/sim/types.ts                  |  42 ++++++-
 src/style.css                     |   6 +
 src/ui/controls.ts                |  43 +++++--
 test/helpers.ts                   |   5 +-
 test/powerups.test.ts             | 230 ++++++++++++++++++++++++++++++++++++++
 tools/screenshots.mjs             |  11 +-
 36 files changed, 664 insertions(+), 75 deletions(-)

diff --git a/PLAN.md b/PLAN.md
index f7fae95..9543248 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -3,7 +3,7 @@
 A turn-based, mobile-first, roguelike take on the 1985 NES *Bomberman*: 50 levels and an ending.
 Built with **LittleJS + TypeScript + Vite**, tested with **Vitest**, deployed to **GitLab Pages**.
 
-Status: **M0–M5 done** (see §18); M6 next. This document is the design and the hand-off
+Status: **M0–M5 done, M6 under way** (see §18). This document is the design and the hand-off
 for whoever continues the implementation. Read it fully before writing code, especially §1 (the
 turn), §15 (notes for the implementer) and §18 (milestones). Reasons for decisions are in
 `decisions.md`.
@@ -194,6 +194,12 @@ NES rules kept (they create the classic tension):
 - **Flaming the exit door** spawns a group of enemies from it.
 - **The exit opens only when all enemies are dead.**
 - **Skates are guaranteed on level 1 or 2** so every player learns Run early.
+- *Built (2026-10-20):* the pickup is under any crate except the exit's. Draw weights (to tune):
+  Bomb+ 20, Fire+ 20, Fuse+ 6, Coin bag 12 (10 coins), Heart 4, Wall Pass 8, Bomb Pass 8,
+  Flame Pass 5, Detonator 9, Star 8 (8 turns). Skates are not in the draw; which of level 1 or 2
+  gets them comes from the seed. Sprites: gems for Bomb+ (blue), Fire+ (red), Fuse+ (yellow),
+  Skates (green), §13. Until Keep / Fresh exists, a death keeps stats and Skates (as "Keep"
+  would). A Run collects what is on the tile it passes through.
 
 ---
 
@@ -350,7 +356,9 @@ contact. Intent shown every turn. Adapted from the NES eight:
 - **Ending** after level 50: an ending screen, then the **victory certificate**.
 - **Mid-run save is required**, not optional: a 50-level run won't happen in one sitting on a
   phone. The save is just the run record (seed + Workshop loadout + action log, §12), replayed on
-  load — or a JSON snapshot of the state plus the log so far.
+  load — or a JSON snapshot of the state plus the log so far. *Built (2026-10-20):* the record,
+  in `localStorage`, after every action, replayed on load; only for the same `SIM_VERSION`, and
+  never for test URLs (`?seed`, `?level`, `?charges`). Finished runs aren't resumed.
 
 ### Victory certificate
 
@@ -431,7 +439,7 @@ Sprite assignments (✅ = drawn now; the rest are copied and waiting for their f
 | ✅ decoy | `character_green_front`, faded |
 | ✅ auto-move flag | `flag_green_a` |
 | ✅ intent arrows | drawn red lines with a head, not sprites |
-| pickups (power-ups) | gems: `gem_red` Fire +1, `gem_blue` Bomb +1, `gem_yellow` Fuse +1, `gem_green` Skates (suggested; final mapping when M6 is built); `heart` Heart; `star` Star |
+| ✅ power-ups | `gem_blue` Bomb+, `gem_red` Fire+, `gem_yellow` Fuse+, `gem_green` Skates (and the Run button), `lever` Detonator (and Detonate), `bush` Wall Pass, `ladder_top` Bomb Pass, `torch_off` Flame Pass, `star` Star (and a gold ring), `heart` Heart, `hud_coin` Coin bag; on a yellow glow. HUD: `torch_on_a` fuse, small pass icons. |
 | ✅ Blob | `slime_normal_rest` (nowhere to go), `_walk_a` / `_walk_b` alternating; mirrored when heading right. `_flat` spare for a death animation. |
 | ✅ Seeker | `fly_rest`, `fly_a`, `fly_b`, as the Blob |
 | ✅ Drifter | `saw_rest`, `saw_a`, `saw_b`; spins while moving |
@@ -588,8 +596,9 @@ tools/                 icons.mjs (PWA icons), screenshots.mjs (Playwright)
    charge drops, coins, charge inventory (buttons with counts), and all 11 charge actions
    (2026-10-20). Touch shortcuts from the user's playtest: tap self = Wait, hold = Bomb,
    tap far = auto-move (§3.1).
-7. **M6 – Run loop:** NES pickups, coins, shop after each level, Keep/Fresh death choice, curses
-   from level 2, 50-level progression and ending, mid-run save.
+7. **M6 – Run loop:** ✅ NES pickups (all 11, one hidden per level, Skates on level 1 or 2),
+   Run and Detonate, the passes and Star, 50-level progression and ending, mid-run save
+   (2026-10-20). ⏳ Shop after each level, Keep/Fresh death choice, curses from level 2.
 8. **M7 – Look & feel:** animations, sounds (Kenney pack, §13), PWA icons, remaining
    enemies and actions, screenshots tool.
 9. **M8 – Permanence:** Workshop, save export/import, victory certificate + QR, replay file and
diff --git a/README.md b/README.md
index 002899c..150e9f1 100644
--- a/README.md
+++ b/README.md
@@ -4,9 +4,10 @@ A turn-based, mobile-first, roguelike take on the 1985 NES bomb-laying classic.
 counts down with *your* moves, so nothing happens until you act — and you can see exactly
 what will burn before you commit.
 
-Status: **early development.** The board, moving, bombs, chain reactions, lives, the first three
-enemy types, coins, all eleven action charges and the exit to the next level work. Power-ups, the shop,
-more enemies and the 50-level ending are planned — see [PLAN.md](PLAN.md).
+Status: **early development.** The board, bombs, lives, the first three enemy types, coins, all
+eleven action charges, hidden power-ups, the exit, the 50-level run with its ending, and saving
+mid-run work. The shop, the Keep / Fresh choice on death, curses and more enemies are planned —
+see [PLAN.md](PLAN.md).
 
 ![The board on a phone, with a bomb counting down](docs/screenshots/countdown.png)
 
@@ -121,15 +122,47 @@ open, so pick them up soon. Coins will buy upgrades in the shop (not built yet).
 - **Par:** the top bar shows `turns taken / par`. Finish within par for a bonus of **5 coins**.
   Par is 50 turns plus 10 per enemy on the level.
 
+### Power-ups
+
+Every level hides **one power-up** under a crate (never the exit's). Burn the crate to reveal it
+— it sits on a yellow glow — then walk onto it. **Don't blast it once it's uncovered:** flame on
+it destroys it and lets out 3 enemies, just like the exit. **Skates are always on level 1 or 2.**
+
+| Power-up | Looks like | Effect | Lost when you die? |
+|---|---|---|---|
+| **Bomb +1** | blue gem | One more bomb on the board at a time. | no |
+| **Fire +1** | red gem | Blast reaches one tile further. | no |
+| **Fuse +1** | yellow gem | Your bombs start one higher (more time to get away). | no |
+| **Skates** | green gem | The **Run** action: 2 tiles in a straight line in one turn (**R**, then a direction). Both tiles must be empty; an enemy on the tile in between is run into. Anything on that tile is picked up. | no |
+| **Detonator** | lever | The **Detonate** action: pick one of your bombs (**X**, then tap it); it goes off this turn. | yes |
+| **Wall Pass** | bush | Walk through crates. | yes |
+| **Bomb Pass** | ladder | Walk over bombs. | yes |
+| **Flame Pass** | unlit torch | Flame doesn't hurt you (enemies still do). | yes |
+| **Star** | star | Nothing hurts you for 8 turns (a gold ring; the top bar counts down). | yes |
+| **Heart** | heart | One more life. | — |
+| **Coin bag** | coin | 10 coins. | — |
+
+Run and Detonate appear as buttons in the second row once you have them. The top bar shows
+your fuse and a small icon for each pass you hold.
+
 ### Lives
 
 You have **3 lives**. Caught in a blast or touched by an enemy, you lose one and the level starts
-again with the same layout and enemies. You keep your coins and charges. With none left, the run
-is over and the seed is shown, so the same board can be replayed.
+again with the same layout and enemies. You keep your coins, charges, stats and Skates, but lose
+Wall Pass, Bomb Pass, Flame Pass, the Detonator and a Star (as in the original); the banner says
+what you lost. With none left, the run is over and the seed is shown, so the same board can be
+replayed.
+
+### The run
+
+**50 levels.** Clearing level 50 ends the run with a victory banner: total turns, lives lost
+and the seed. The game **saves after every move** (in this browser), so closing the tab and
+coming back continues the run where you left it.
 
 ### The top bar
 
-Lives (hearts), level (flag), enemies left, coins, turn / par, and your bomb limit and blast size.
+Lives (hearts), level (flag), enemies left, coins, turn / par, your bomb limit, blast size and
+fuse, then the passes you hold and a Star's countdown.
 
 ### The board
 
@@ -149,7 +182,8 @@ npm run screenshots  # build, then photograph the game into docs/screenshots/ (P
 
 URL parameters: `?seed=123` plays a fixed board; `?level=4` starts at a later level (level 13
 has all three enemy types); `?charges=2` starts with 2 of every action charge, for trying them
-out.
+out. A URL with any of these parameters plays a test board: it doesn't load or overwrite your
+saved run.
 
 Deployed to GitLab Pages by `.gitlab-ci.yml` on every push to the default branch.
 
diff --git a/assets/kenney/bush.png b/assets/kenney/bush.png
new file mode 100644
index 0000000..62fed53
Binary files /dev/null and b/assets/kenney/bush.png differ
diff --git a/assets/kenney/ladder_top.png b/assets/kenney/ladder_top.png
new file mode 100644
index 0000000..c9fd03a
Binary files /dev/null and b/assets/kenney/ladder_top.png differ
diff --git a/assets/kenney/lever.png b/assets/kenney/lever.png
new file mode 100644
index 0000000..f28552d
Binary files /dev/null and b/assets/kenney/lever.png differ
diff --git a/assets/kenney/torch_off.png b/assets/kenney/torch_off.png
new file mode 100644
index 0000000..8c54d19
Binary files /dev/null and b/assets/kenney/torch_off.png differ
diff --git a/decisions.md b/decisions.md
index a81d06a..bc4ef2d 100644
--- a/decisions.md
+++ b/decisions.md
@@ -156,3 +156,23 @@ fix, two parts:
 - Auto-move's safety check uses each enemy's next planned move.
 - Starting fire stays at 1, so Fire+ pickups (M6) still have room to matter.
 
+### 2026-10-20 — M6 part 1: power-ups, the ending, mid-run save
+
+- **Interim death rule until Keep / Fresh:** a death keeps stats, Skates, coins and charges and
+  loses Wall Pass, Bomb Pass, Flame Pass, the Detonator and a Star — the NES rule plus "Keep".
+  Keep / Fresh (PLAN.md §9) will replace the stats/Skates part.
+- **The power-up is under any crate but the exit's**, including near the start, so an early
+  Bomb+ or Fire+ is possible. **Skates are on level 1 or 2**, chosen by the seed, and not in the
+  random draw anywhere else (so there is never a useless second pair).
+- **A flamed power-up spawns the same punishment as a flamed exit** (3 of the level's toughest
+  type, no drops). Lingering flame doesn't destroy it, like drops.
+- **Run collects what is on the tile it passes through** (coins, drops, the power-up), since
+  running past a coin without taking it would feel like a bug. Running into an enemy there kills.
+- **Star and Flame Pass:** Star blocks every hit for 8 turns (counting the one it is taken in);
+  Flame Pass blocks flame only. Neither kills enemies on contact.
+- **Save = the run record** in `localStorage`, replayed on load. It is a few KB even for a long
+  run, and replay is exact because the sim is deterministic. Test URLs neither load nor save.
+- **Ending:** clearing level 50 sets status `won`; the banner shows total turns, lives lost and
+  the seed. The victory certificate is M8.
+- `SIM_VERSION` is now 4: older saved runs are not resumed (they would replay differently).
+
diff --git a/docs/screenshots/auto-move.png b/docs/screenshots/auto-move.png
index dd0735b..a61e0b0 100644
Binary files a/docs/screenshots/auto-move.png and b/docs/screenshots/auto-move.png differ
diff --git a/docs/screenshots/bomb-picked.png b/docs/screenshots/bomb-picked.png
index a760034..76cc335 100644
Binary files a/docs/screenshots/bomb-picked.png and b/docs/screenshots/bomb-picked.png differ
diff --git a/docs/screenshots/charges.png b/docs/screenshots/charges.png
index 4ef5d94..ba0bdc6 100644
Binary files a/docs/screenshots/charges.png and b/docs/screenshots/charges.png differ
diff --git a/docs/screenshots/countdown.png b/docs/screenshots/countdown.png
index 8e78746..0077429 100644
Binary files a/docs/screenshots/countdown.png and b/docs/screenshots/countdown.png differ
diff --git a/docs/screenshots/death.png b/docs/screenshots/death.png
index d4adf48..44cc235 100644
Binary files a/docs/screenshots/death.png and b/docs/screenshots/death.png differ
diff --git a/docs/screenshots/desktop.png b/docs/screenshots/desktop.png
index ebcea15..3a81306 100644
Binary files a/docs/screenshots/desktop.png and b/docs/screenshots/desktop.png differ
diff --git a/docs/screenshots/enemies.png b/docs/screenshots/enemies.png
index 7eba8cf..a2587b4 100644
Binary files a/docs/screenshots/enemies.png and b/docs/screenshots/enemies.png differ
diff --git a/docs/screenshots/lingering.png b/docs/screenshots/lingering.png
index 9e1053e..4a9396d 100644
Binary files a/docs/screenshots/lingering.png and b/docs/screenshots/lingering.png differ
diff --git a/docs/screenshots/long-press.png b/docs/screenshots/long-press.png
index 443c5c0..3d9c8f0 100644
Binary files a/docs/screenshots/long-press.png and b/docs/screenshots/long-press.png differ
diff --git a/docs/screenshots/pierce-decoy.png b/docs/screenshots/pierce-decoy.png
index 4ce5d41..8b03aaf 100644
Binary files a/docs/screenshots/pierce-decoy.png and b/docs/screenshots/pierce-decoy.png differ
diff --git a/docs/screenshots/power-up.png b/docs/screenshots/power-up.png
new file mode 100644
index 0000000..c3c4fcf
Binary files /dev/null and b/docs/screenshots/power-up.png differ
diff --git a/docs/screenshots/run.png b/docs/screenshots/run.png
new file mode 100644
index 0000000..51a6d45
Binary files /dev/null and b/docs/screenshots/run.png differ
diff --git a/docs/screenshots/scrolled.png b/docs/screenshots/scrolled.png
index b3585c8..01a738b 100644
Binary files a/docs/screenshots/scrolled.png and b/docs/screenshots/scrolled.png differ
diff --git a/docs/screenshots/start.png b/docs/screenshots/start.png
index 68bac0c..bbab4ec 100644
Binary files a/docs/screenshots/start.png and b/docs/screenshots/start.png differ
diff --git a/src/main.ts b/src/main.ts
index 7b368ce..9f80ba6 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -17,7 +17,7 @@ import { isAvailable, isValidTarget, ACTIONS } from './sim/actions'
 import { enemyAt } from './sim/enemies'
 import { playerPath } from './sim/path'
 import { predictDanger, type Danger } from './sim/danger'
-import { newRecord, type RunRecord } from './sim/record'
+import { newRecord, replay, SIM_VERSION, type RunRecord } from './sim/record'
 import { eq, idx, inBounds, newRun } from './sim/state'
 import { step } from './sim/turn'
 import { DIRS, type Action, type ActionId, type Dir, type GameState, type Vec } from './sim/types'
@@ -50,9 +50,45 @@ const LONG_PRESS_MS = 450
 function startRun(seed: number): void {
   record = newRecord(seed, startLevel, startCharges)
   state = newRun(seed, startLevel, startCharges)
+  save()
   afterChange()
 }
 
+/**
+ * Mid-run save (PLAN.md §11): the run record goes to localStorage after every action, and is
+ * replayed on the next visit. A URL that sets up a test board (`?seed`, `?level`, `?charges`)
+ * neither loads nor saves, so trying something never replaces the real run. Storage can be
+ * missing or full; then the game simply doesn't save.
+ */
+const SAVE_KEY = 'explodorama.run'
+const testing = params.has('seed') || params.has('level') || params.has('charges')
+
+function save(): void {
+  if (testing) return
+  try {
+    localStorage.setItem(SAVE_KEY, JSON.stringify(record))
+  } catch {
+    // No storage: play on without saving.
+  }
+}
+
+/** The saved run, if there is one from this version of the rules that is still going. */
+function resumeSaved(): boolean {
+  if (testing) return false
+  try {
+    const saved = JSON.parse(localStorage.getItem(SAVE_KEY) ?? 'null') as RunRecord | null
+    if (!saved || saved.simVersion !== SIM_VERSION) return false
+    const s = replay(saved)
+    if (s.status === 'gameOver' || s.status === 'won') return false
+    record = saved
+    state = s
+    afterChange()
+    return true
+  } catch {
+    return false
+  }
+}
+
 function randomSeed(): number {
   return Math.floor(Math.random() * 2 ** 31)
 }
@@ -68,6 +104,7 @@ function act(action: Action): void {
     if (e.type === 'moved' && e.to.x !== e.from.x) facingLeft = e.to.x < e.from.x
   }
   selected = null
+  save()
   afterChange()
 }
 
@@ -137,12 +174,12 @@ function pick(id: ActionId): void {
 
 /**
  * A direction key: uses the picked action that way (a bomb or kick next to the player, a throw
- * 2 tiles away), or moves if nothing is picked.
+ * or run 2 tiles away), or moves if nothing is picked.
  */
 function direction(d: Dir): void {
   if (state.status !== 'playing') return
   const id: ActionId = selected && ACTIONS[selected].needsTarget ? selected : 'move'
-  const reach = id === 'throw' ? 2 : 1
+  const reach = id === 'throw' || id === 'run' ? 2 : 1
   const p = { x: state.player.x + reach * DIRS[d].x, y: state.player.y + reach * DIRS[d].y }
   if (isValidTarget(state, id, p)) act({ id, target: p })
 }
@@ -151,7 +188,7 @@ const controls = createControls({
   pick,
   banner() {
     if (state.status === 'dead' || state.status === 'cleared') act({ id: 'continue' })
-    else if (state.status === 'gameOver') startRun(randomSeed())
+    else if (state.status === 'gameOver' || state.status === 'won') startRun(randomSeed())
   },
 })
 
@@ -163,7 +200,7 @@ const KEY_DIRS: Record<string, Dir> = {
 }
 
 const ACTION_KEYS: Record<string, ActionId> = {
-  KeyB: 'bomb', KeyK: 'kick', KeyT: 'throw', KeyH: 'shield', KeyF: 'freeze', Equal: 'fuseUp', Minus: 'fuseDown',
+  KeyB: 'bomb', KeyR: 'run', KeyX: 'detonate', KeyK: 'kick', KeyT: 'throw', KeyH: 'shield', KeyF: 'freeze', Equal: 'fuseUp', Minus: 'fuseDown',
   KeyG: 'diagonal', KeyP: 'teleport', KeyI: 'pierce', KeyO: 'power', KeyL: 'line', KeyY: 'decoy',
 }
 
@@ -205,7 +242,7 @@ function tileUnder(e: PointerEvent): Vec | null {
   return inBounds(state, p) ? p : null
 }
 
-startRun(Number(params.get('seed')) || randomSeed())
+if (!resumeSaved()) startRun(Number(params.get('seed')) || randomSeed())
 
 // Sharp on high-density screens, capped so phones don't pay for 3×.
 setCanvasPixelRatio(Math.min(devicePixelRatio, 2))
diff --git a/src/render/board.ts b/src/render/board.ts
index d51af79..b00ca2f 100644
--- a/src/render/board.ts
+++ b/src/render/board.ts
@@ -5,7 +5,7 @@ import { blastTiles } from '../sim/explosions'
 import { dirBetween } from '../sim/enemies'
 import { cellAt, cloneState, eq, exitOpen, idx } from '../sim/state'
 import type { ActionId, Bomb, Enemy, GameState, Vec } from '../sim/types'
-import { CHARGE_ICONS, coinSprite, digitSprite, ENEMY_SPRITES, sprite, type BoardSprite } from './sprites'
+import { CHARGE_ICONS, coinSprite, digitSprite, ENEMY_SPRITES, POWER_UP_ICONS, sprite, type BoardSprite } from './sprites'
 
 /**
  * The one place where sim tiles meet LittleJS world space. The sim's y grows downward
@@ -62,6 +62,8 @@ const C = {
   clear: rgb(0, 0, 0, 0),
   pathDot: rgb(0.25, 0.62, 0.87, 0.7),
   burning: rgb(1, 0.55, 0.1, 0.5),
+  powerUpGlow: rgb(1, 0.95, 0.55, 0.8),
+  star: rgb(1, 0.85, 0.2, 0.9),
   blocked: rgb(0.85, 0.2, 0.2, 0.35),
 }
 
@@ -90,6 +92,12 @@ export function drawBoard(view: View): void {
     }
   }
   if (cellAt(s, s.exit) === 'floor') drawExit(toWorld(s, s.exit), exitOpen(s))
+  // The level's power-up, once its crate is gone: on a soft glow, so it stands out from drops.
+  if (s.powerUp && cellAt(s, s.powerUp.pos) === 'floor') {
+    const pos = toWorld(s, s.powerUp.pos)
+    drawCircle(pos, 0.86, C.powerUpGlow)
+    drawTile(pos.add(vec2(0, 0.04 * Math.sin(time * 3))), vec2(0.7), sprite(POWER_UP_ICONS[s.powerUp.kind]))
+  }
 
   for (const i of danger.next) if (!danger.now.has(i)) drawRect(toWorld(s, tileOf(s, i)), vec2(0.94), C.dangerNext)
   for (const i of danger.now) drawRect(toWorld(s, tileOf(s, i)), vec2(0.94), C.dangerNow)
@@ -153,6 +161,8 @@ export function drawBoard(view: View): void {
   for (const e of s.enemies) drawEnemy(s, e)
 
   if (s.shield) drawCircle(toWorld(s, s.player), 1.08, C.clear, 0.07, C.shield)
+  // A Star: a pulsing gold ring for as long as it lasts.
+  if (s.star > 0) drawCircle(toWorld(s, s.player), 1.0 + 0.06 * Math.sin(time * 8), C.clear, 0.08, C.star)
   if (after && !eq(after.player, s.player)) {
     drawTile(toWorld(s, after.player), tileSize, sprite('character_green_idle'), C.ghost, 0, view.facingLeft)
   }
diff --git a/src/render/sprites.ts b/src/render/sprites.ts
index 5c474e8..52c3939 100644
--- a/src/render/sprites.ts
+++ b/src/render/sprites.ts
@@ -1,5 +1,5 @@
 import { tile, vec2, type TileInfo } from 'littlejsengine'
-import type { ChargeId, EnemyKind } from '../sim/types'
+import type { ChargeId, EnemyKind, PowerUpKind } from '../sim/types'
 
 /**
  * Art from Kenney's New Platformer Pack 1.1 (CC0), copied into `assets/kenney/` under the pack's
@@ -57,6 +57,17 @@ const BOARD = [
   'bridge_logs',
   'hud_player_helmet_green',
   'character_green_front',
+  'gem_blue',
+  'gem_red',
+  'gem_yellow',
+  'gem_green',
+  'heart',
+  'star',
+  'hud_coin',
+  'bush',
+  'ladder_top',
+  'torch_off',
+  'lever',
   'slime_normal_rest',
   'slime_normal_walk_a',
   'slime_normal_walk_b',
@@ -116,3 +127,18 @@ export const ENEMY_SPRITES: Record<EnemyKind, { rest: BoardSprite; a: BoardSprit
 export function coinSprite(amount: number): BoardSprite {
   return amount >= 5 ? 'coin_gold' : amount >= 2 ? 'coin_silver' : 'coin_bronze'
 }
+
+/** Each power-up's sprite (PLAN.md §13): gems for the stats and Skates, the rest by meaning. */
+export const POWER_UP_ICONS: Record<PowerUpKind, BoardSprite> = {
+  bombPlus: 'gem_blue',
+  firePlus: 'gem_red',
+  fusePlus: 'gem_yellow',
+  skates: 'gem_green',
+  wallPass: 'bush',
+  bombPass: 'ladder_top',
+  flamePass: 'torch_off',
+  detonator: 'lever',
+  star: 'star',
+  coinBag: 'hud_coin',
+  heart: 'heart',
+}
diff --git a/src/sim/actions.ts b/src/sim/actions.ts
index 9dfad12..2cd8af5 100644
--- a/src/sim/actions.ts
+++ b/src/sim/actions.ts
@@ -1,6 +1,6 @@
 import { enemyAt } from './enemies'
 import { add, bombAt, eq, isBurning, isFree, playerCanEnter } from './state'
-import { DIRS, DIR_LIST, type ActionId, type Bomb, type ChargeId, type GameEvent, type GameState, type Vec } from './types'
+import { DIRS, DIR_LIST, type ActionId, type Bomb, type ChargeId, type GameEvent, type GameState, type Powers, type Vec } from './types'
 
 /**
  * The board actions (PLAN.md §3). Adding one = an entry here, its tests, and a button.
@@ -13,6 +13,8 @@ export interface ActionDef {
   needsTarget: boolean
   /** The action charge it spends (PLAN.md §3.3); only offered while the player holds one. */
   charge?: ChargeId
+  /** The power-up ability it needs (PLAN.md §3.2); only offered once the player has it. */
+  ability?: keyof Powers
   validTargets(s: GameState): Vec[]
   apply(s: GameState, target: Vec | undefined, events: GameEvent[]): void
 }
@@ -89,6 +91,33 @@ export const ACTIONS: Record<ActionId, ActionDef> = {
       events.push({ type: 'waited' })
     },
   },
+  run: {
+    id: 'run',
+    label: 'Run',
+    needsTarget: true,
+    ability: 'run',
+    // 2 tiles in a straight line; both must be free, so nothing is jumped over. An enemy on the
+    // first tile is run into (step() checks it).
+    validTargets: (s) =>
+      DIR_LIST.map((d) => DIRS[d]).flatMap((d) => {
+        const mid = add(s.player, d)
+        const land = add(mid, d)
+        return playerCanEnter(s, mid) && playerCanEnter(s, land) ? [land] : []
+      }),
+    apply: (s, target, events) => walkTo(s, target!, events),
+  },
+  detonate: {
+    id: 'detonate',
+    label: 'Detonate',
+    needsTarget: true,
+    ability: 'detonate',
+    // Any of your bombs: it explodes this turn, in phase 3, with everything it sets off.
+    validTargets: (s) => s.bombs.filter((b) => b.timer > 0).map((b) => b.pos),
+    apply(s, target, events) {
+      bombAt(s, target!)!.timer = 0
+      events.push({ type: 'detonated', pos: { ...target! } })
+    },
+  },
   kick: {
     id: 'kick',
     label: 'Kick',
@@ -238,7 +267,7 @@ function changeFuse(s: GameState, at: Vec, delta: number, events: GameEvent[]):
 }
 
 export const ACTION_ORDER: readonly ActionId[] = [
-  'move', 'bomb', 'wait', 'kick', 'throw', 'shield', 'freeze', 'fuseUp', 'fuseDown', 'diagonal', 'teleport',
+  'move', 'bomb', 'wait', 'run', 'detonate', 'kick', 'throw', 'shield', 'freeze', 'fuseUp', 'fuseDown', 'diagonal', 'teleport',
   'pierce', 'power', 'line', 'decoy',
 ]
 
@@ -254,6 +283,8 @@ export function isValidTarget(s: GameState, id: ActionId, target: Vec): boolean
 /** Whether the action can be picked right now. */
 export function isAvailable(s: GameState, id: ActionId): boolean {
   if (s.status !== 'playing') return false
+  const ability = ACTIONS[id].ability
+  if (ability && !s.powers[ability]) return false
   const charge = chargeOf(id)
   if (charge) {
     if (s.charges[charge] <= 0) return false
diff --git a/src/sim/levelgen.ts b/src/sim/levelgen.ts
index 3c48cdf..79c6d4f 100644
--- a/src/sim/levelgen.ts
+++ b/src/sim/levelgen.ts
@@ -1,5 +1,5 @@
-import { stream } from './rng'
-import { DIR_LIST, type Cell, type Dir, type EnemyKind, type Vec } from './types'
+import { hashSeed, stream, type Rng } from './rng'
+import { DIR_LIST, type Cell, type Dir, type EnemyKind, type PowerUp, type PowerUpKind, type Vec } from './types'
 
 /** The NES layout: 31 × 13 with a wall all round and a pillar at every even (x, y). */
 export const LEVEL_WIDTH = 31
@@ -29,6 +29,30 @@ export function parFor(enemies: number): number {
   return 50 + 10 * enemies
 }
 
+/** The run's last level; clearing it wins (PLAN.md §11). */
+export const LAST_LEVEL = 50
+
+/**
+ * How likely each power-up is to be a level's hidden one. Skates aren't in the draw: they are
+ * guaranteed on level 1 or 2 instead (which one, from the seed), so every run gets Run early.
+ * To tune by playtesting.
+ */
+const POWER_UP_WEIGHTS: [PowerUpKind, number][] = [
+  ['bombPlus', 20], ['firePlus', 20], ['fusePlus', 6], ['coinBag', 12], ['heart', 4],
+  ['wallPass', 8], ['bombPass', 8], ['flamePass', 5], ['detonator', 9], ['star', 8],
+]
+
+export function skatesLevel(seed: number): number {
+  return 1 + (hashSeed(seed, 'skates') & 1)
+}
+
+function pickPowerUp(seed: number, level: number, rng: Rng): PowerUpKind {
+  if (level === skatesLevel(seed)) return 'skates'
+  let r = rng.next() * POWER_UP_WEIGHTS.reduce((sum, [, w]) => sum + w, 0)
+  for (const [kind, w] of POWER_UP_WEIGHTS) if ((r -= w) < 0) return kind
+  return POWER_UP_WEIGHTS[0]![0]
+}
+
 export interface EnemyStart {
   kind: EnemyKind
   pos: Vec
@@ -42,6 +66,8 @@ export interface Layout {
   start: Vec
   /** Always under a soft block. */
   exit: Vec
+  /** Under a different soft block. */
+  powerUp: PowerUp
   enemies: EnemyStart[]
   par: number
 }
@@ -71,7 +97,7 @@ export function generateLevel(seed: number, level: number): Layout {
   const far = (i: number) => Math.abs((i % width) - START.x) + Math.abs(Math.floor(i / width) - START.y) > SAFE_DISTANCE
   const at = (i: number): Vec => ({ x: i % width, y: Math.floor(i / width) })
   const blocks = cells.flatMap((c, i) => (c === 'block' && far(i) ? [i] : []))
-  const exit = at(blocks[rng.int(blocks.length)]!)
+  const exit = at(blocks.splice(rng.int(blocks.length), 1)[0]!)
 
   const open = cells.flatMap((c, i) => (c === 'floor' && far(i) ? [i] : []))
   const kinds = enemyKinds(level)
@@ -80,7 +106,10 @@ export function generateLevel(seed: number, level: number): Layout {
     const i = open.splice(rng.int(open.length), 1)[0]!
     enemies.push({ kind: kinds[rng.int(kinds.length)]!, pos: at(i), dir: DIR_LIST[rng.int(4)]! })
   }
-  return { width, height, cells, start: { ...START }, exit, enemies, par: parFor(enemies.length) }
+  // Any other block, near the start or not, so an early power-up is possible.
+  const others = cells.flatMap((c, i) => (c === 'block' && !(i % width === exit.x && Math.floor(i / width) === exit.y) ? [i] : []))
+  const powerUp = { pos: at(others[rng.int(others.length)]!), kind: pickPowerUp(seed, level, rng) }
+  return { width, height, cells, start: { ...START }, exit, powerUp, enemies, par: parFor(enemies.length) }
 }
 
 function key(p: Vec): string {
diff --git a/src/sim/path.ts b/src/sim/path.ts
index ad3f456..fbc1854 100644
--- a/src/sim/path.ts
+++ b/src/sim/path.ts
@@ -1,14 +1,15 @@
-import { add, eq, isFree } from './state'
+import { add, eq, playerCanPass } from './state'
 import { DIRS, DIR_LIST, type GameState, type Vec } from './types'
 
 /**
- * The shortest walk for the player to `to` over free tiles (floor, no bomb), for auto-move. The
- * result leaves out the player's own tile and ends at `to`; null if `to` can't be reached.
+ * The shortest walk for the player to `to`, for auto-move, over tiles the player can pass (free
+ * floor, plus crates or bombs with Wall or Bomb Pass). The result leaves out the player's own
+ * tile and ends at `to`; null if `to` can't be reached.
  * Enemies don't block the path, because they will have moved by the time the player gets there;
  * the UI refuses a step onto an enemy instead.
  */
 export function playerPath(s: GameState, to: Vec): Vec[] | null {
-  if (eq(s.player, to) || !isFree(s, to)) return null
+  if (eq(s.player, to) || !playerCanPass(s, to)) return null
   const key = (p: Vec) => p.y * s.width + p.x
   const prev = new Map<number, Vec>([[key(s.player), s.player]])
   const queue = [s.player]
@@ -16,7 +17,7 @@ export function playerPath(s: GameState, to: Vec): Vec[] | null {
     const p = queue[i]!
     for (const d of DIR_LIST) {
       const q = add(p, DIRS[d])
-      if (prev.has(key(q)) || !isFree(s, q)) continue
+      if (prev.has(key(q)) || !playerCanPass(s, q)) continue
       prev.set(key(q), p)
       if (eq(q, to)) {
         const path = [q]
diff --git a/src/sim/record.ts b/src/sim/record.ts
index dc582a5..53faefc 100644
--- a/src/sim/record.ts
+++ b/src/sim/record.ts
@@ -6,7 +6,7 @@ import type { Action, GameState } from './types'
  * Bumped whenever a rule change would make an old record replay differently. Records from
  * another version can't be trusted to replay (PLAN.md §11).
  */
-export const SIM_VERSION = 3
+export const SIM_VERSION = 4
 
 /** Everything needed to reproduce a run exactly: mid-run saves, replays, certificates. */
 export interface RunRecord {
diff --git a/src/sim/state.ts b/src/sim/state.ts
index 87b7d7e..08a3150 100644
--- a/src/sim/state.ts
+++ b/src/sim/state.ts
@@ -1,7 +1,7 @@
 import { planIntents } from './enemies'
 import { generateLevel } from './levelgen'
 import { hashSeed } from './rng'
-import { CHARGE_IDS, type Bomb, type Cell, type ChargeId, type GameState, type Stats, type Vec } from './types'
+import { CHARGE_IDS, type Bomb, type Cell, type ChargeId, type GameState, type Powers, type Stats, type Vec } from './types'
 
 export const START_LIVES = 3
 export const START_STATS: Stats = { capacity: 1, fire: 1, fuse: 3 }
@@ -15,6 +15,14 @@ export interface Carry {
   stats: Stats
   coins: number
   charges: Record<ChargeId, number>
+  powers: Powers
+  star: number
+  runTurns: number
+  deaths: number
+}
+
+export function noPowers(): Powers {
+  return { run: false, detonate: false, wallPass: false, bombPass: false, flamePass: false }
 }
 
 /** `n` of every action charge. */
@@ -28,15 +36,18 @@ export function noCharges(): Record<ChargeId, number> {
 
 /** `n`: start with this many of every action charge (a testing aid, `?charges=` in the URL). */
 export function newRun(seed: number, level = 1, n = 0): GameState {
-  return createLevel(seed, level, { lives: START_LIVES, stats: { ...START_STATS }, coins: 0, charges: charges(n) })
+  return createLevel(seed, level, {
+    lives: START_LIVES, stats: { ...START_STATS }, coins: 0, charges: charges(n), powers: noPowers(), star: 0,
+    runTurns: 0, deaths: 0,
+  })
 }
 
 /**
  * A fresh attempt at a level: the layout and enemies come from the seed, so a retry after a death
- * is the same board. Lives, stats, coins and charges come with the player.
+ * is the same board. What the player has comes with them (`carryOf` decides what a death costs).
  */
 export function createLevel(seed: number, level: number, carry: Carry): GameState {
-  const { width, height, cells, start, exit, enemies, par } = generateLevel(seed, level)
+  const { width, height, cells, start, exit, powerUp, enemies, par } = generateLevel(seed, level)
   const s: GameState = {
     seed, level, turn: 0, width, height, cells, player: start,
     lives: carry.lives, stats: { ...carry.stats }, coins: carry.coins, charges: { ...carry.charges },
@@ -44,14 +55,24 @@ export function createLevel(seed: number, level: number, carry: Carry): GameStat
     enemies: enemies.map((e, id) => ({ id, kind: e.kind, pos: e.pos, dir: e.dir, plan: [], spawned: false })),
     nextEnemyId: enemies.length,
     drops: [], exit, par, shield: false, frozen: false, decoy: null, burning: [],
+    powers: { ...carry.powers }, star: carry.star, powerUp, runTurns: carry.runTurns, deaths: carry.deaths,
     aiRng: hashSeed(seed, 'ai', level), dropRng: hashSeed(seed, 'drops', level),
   }
   planIntents(s)
   return s
 }
 
-export function carryOf(s: GameState): Carry {
-  return { lives: s.lives, stats: s.stats, coins: s.coins, charges: s.charges }
+/**
+ * What the player takes on. After a death, the NES rule: Wall Pass, Bomb Pass, Flame Pass,
+ * Detonator and Star are lost; stats, Skates (Run), coins and charges are kept. (The Keep / Fresh
+ * choice of PLAN.md §9 will decide about the stats and Skates.)
+ */
+export function carryOf(s: GameState, died = false): Carry {
+  const powers = died ? { ...noPowers(), run: s.powers.run } : s.powers
+  return {
+    lives: s.lives, stats: s.stats, coins: s.coins, charges: s.charges, powers, star: died ? 0 : s.star,
+    runTurns: s.runTurns, deaths: s.deaths,
+  }
 }
 
 /** The exit can be used once its block is gone and every enemy is dead. */
@@ -98,7 +119,13 @@ export function isBurning(s: GameState, p: Vec): boolean {
   return inBounds(s, p) && s.burning.includes(idx(s, p))
 }
 
-/** Where the player can step or land: free and not on fire. */
+/** Where the player's body can go, fire aside: floor, or a soft block with Wall Pass; a bomb only with Bomb Pass. */
+export function playerCanPass(s: GameState, p: Vec): boolean {
+  const cell = cellAt(s, p)
+  return (cell === 'floor' || (cell === 'block' && s.powers.wallPass)) && (!bombAt(s, p) || s.powers.bombPass)
+}
+
+/** Where the player can step or land now: passable, and not on fire (unless Flame Pass). */
 export function playerCanEnter(s: GameState, p: Vec): boolean {
-  return isFree(s, p) && !isBurning(s, p)
+  return playerCanPass(s, p) && (!isBurning(s, p) || s.powers.flamePass)
 }
diff --git a/src/sim/turn.ts b/src/sim/turn.ts
index de845cc..8551e3c 100644
--- a/src/sim/turn.ts
+++ b/src/sim/turn.ts
@@ -1,17 +1,21 @@
 import { ACTIONS, chargeOf, isAvailable, isValidTarget } from './actions'
 import { enemyAt, moveEnemies, planIntents, rollDrops } from './enemies'
-import { enemyKinds } from './levelgen'
+import { enemyKinds, LAST_LEVEL } from './levelgen'
 import { detonate, tickBombs } from './explosions'
-import { carryOf, cellAt, cloneState, createLevel, eq, exitOpen, idx, PAR_BONUS } from './state'
-import type { Action, GameEvent, GameState } from './types'
+import { add, carryOf, cellAt, cloneState, createLevel, eq, exitOpen, idx, PAR_BONUS } from './state'
+import type { Action, GameEvent, GameState, PowerUpKind, Vec } from './types'
 
 export interface StepResult {
   state: GameState
   events: GameEvent[]
 }
 
-/** How many enemies come out of an exit caught in a flame. */
+/** How many enemies come out of an exit or power-up caught in a flame. */
 export const EXIT_SPAWN = 3
+/** Turns of invincibility from a Star. */
+export const STAR_TURNS = 8
+/** Coins in a Coin bag. */
+export const COIN_BAG = 10
 
 /**
  * One turn (PLAN.md §1). Never mutates `prev`. Throws on an action that isn't allowed right
@@ -24,7 +28,7 @@ export function step(prev: GameState, action: Action): StepResult {
   if (action.id === 'continue') {
     if (s.status === 'dead') {
       events.push({ type: 'levelRestarted' })
-      return { state: createLevel(s.seed, s.level, carryOf(s)), events }
+      return { state: createLevel(s.seed, s.level, carryOf(s, true)), events }
     }
     if (s.status === 'cleared') {
       events.push({ type: 'levelStarted', level: s.level + 1 })
@@ -44,21 +48,30 @@ export function step(prev: GameState, action: Action): StepResult {
     s.charges[charge]--
     events.push({ type: 'chargeUsed', id: charge })
   }
+  const from = s.player
   def.apply(s, action.target, events)
-  pickUp(s, events)
+  // A run passes through the tile in between: anything there is picked up, or run into.
+  const passed: Vec | null = action.id === 'run' ? add(from, { x: (s.player.x - from.x) / 2, y: (s.player.y - from.y) / 2 }) : null
+  if (passed) pickUp(s, passed, events)
+  pickUp(s, s.player, events)
   if (exitOpen(s) && eq(s.player, s.exit)) {
     const parBonus = s.turn + 1 <= s.par ? PAR_BONUS : 0
     s.coins += parBonus
     s.turn++
-    s.status = 'cleared'
+    s.runTurns++
     events.push({ type: 'levelCleared', level: s.level, turns: s.turn, parBonus })
+    if (s.level >= LAST_LEVEL) {
+      s.status = 'won'
+      events.push({ type: 'runWon', turns: s.runTurns, deaths: s.deaths })
+    } else s.status = 'cleared'
     return { state: s, events }
   }
 
-  // A shield absorbs every hit for the rest of the turn it breaks in.
+  // A shield absorbs every hit for the rest of the turn it breaks in. A Star stops every hit;
+  // Flame Pass stops flame.
   let shielded = false
-  const hurt = (): void => {
-    if (shielded) return
+  const hurt = (cause: 'flame' | 'enemy'): void => {
+    if (shielded || s.star > 0 || (cause === 'flame' && s.powers.flamePass)) return
     if (s.shield) {
       s.shield = false
       shielded = true
@@ -66,22 +79,24 @@ export function step(prev: GameState, action: Action): StepResult {
       return
     }
     s.lives--
+    s.deaths++
     s.status = s.lives > 0 ? 'dead' : 'gameOver'
     events.push({ type: 'playerDied', pos: s.player, livesLeft: s.lives })
   }
 
-  // Contact, first check: the player walked into an enemy (or swapped places with one).
-  if (enemyAt(s, s.player)) hurt()
+  // Contact, first check: the player walked (or ran) into an enemy, or swapped places with one.
+  if (enemyAt(s, s.player) || (passed && enemyAt(s, passed))) hurt('enemy')
 
   // Phase 2: enemies carry out their announced intents. Second contact check.
   if (s.status === 'playing') {
     moveEnemies(s, events)
-    if (enemyAt(s, s.player)) hurt()
+    if (enemyAt(s, s.player)) hurt('enemy')
   }
 
   // Phase 3: bombs tick; those at 0 explode, in chains. Last turn's flame is still burning too.
   const lingering = new Set(s.burning)
   const exitWasOpen = cellAt(s, s.exit) === 'floor'
+  const powerUpWasOpen = !!s.powerUp && cellAt(s, s.powerUp.pos) === 'floor'
   const enemiesBefore = s.enemies.length
   tickBombs(s, events)
   const burned = detonate(s, events)
@@ -90,7 +105,7 @@ export function step(prev: GameState, action: Action): StepResult {
   // the decoy. Only new flame burns drops lying in the open; slain enemies then leave their own
   // drops, so those survive the flame that made them (and its lingering).
   const fire = new Set([...burned, ...lingering])
-  if (s.status === 'playing' && fire.has(idx(s, s.player))) hurt()
+  if (s.status === 'playing' && fire.has(idx(s, s.player))) hurt('flame')
   for (const d of s.drops.filter((d) => burned.has(idx(s, d.pos)))) {
     s.drops.splice(s.drops.indexOf(d), 1)
     events.push({ type: 'dropBurned', drop: d })
@@ -109,35 +124,72 @@ export function step(prev: GameState, action: Action): StepResult {
   }
   if (!exitWasOpen && cellAt(s, s.exit) === 'floor') events.push({ type: 'exitRevealed', pos: s.exit })
   // Flaming an uncovered exit lets out a group of the level's toughest type (NES rule, PLAN.md §5).
-  if (exitWasOpen && burned.has(idx(s, s.exit))) {
-    const kinds = enemyKinds(s.level)
-    for (let n = 0; n < EXIT_SPAWN; n++) {
-      s.enemies.push({ id: s.nextEnemyId++, kind: kinds[kinds.length - 1]!, pos: { ...s.exit }, dir: 'up', plan: [], spawned: true })
+  if (exitWasOpen && burned.has(idx(s, s.exit))) spawnPunishment(s, s.exit, events)
+  // The same for the level's power-up: flame on it once uncovered destroys it and lets enemies out.
+  if (s.powerUp) {
+    const at = idx(s, s.powerUp.pos)
+    if (powerUpWasOpen && burned.has(at)) {
+      events.push({ type: 'powerUpBurned', powerUp: s.powerUp })
+      spawnPunishment(s, s.powerUp.pos, events)
+      s.powerUp = null
+    } else if (!powerUpWasOpen && cellAt(s, s.powerUp.pos) === 'floor') {
+      events.push({ type: 'powerUpRevealed', powerUp: s.powerUp })
     }
-    events.push({ type: 'enemiesSpawned', pos: s.exit, count: EXIT_SPAWN })
   }
   if (enemiesBefore > 0 && s.enemies.length === 0) events.push({ type: 'exitOpened', pos: s.exit })
 
   // This turn's flame lingers through the next turn; last turn's goes out.
   s.burning = [...burned]
 
-  // Phase 5: enemies announce their next intents. A decoy lasts a fixed number of these.
+  // Phase 5: enemies announce their next intents. A decoy lasts a fixed number of these; so does a Star.
   planIntents(s)
+  if (s.star > 0) s.star--
   if (s.decoy && --s.decoy.turns <= 0) {
     events.push({ type: 'decoyGone', pos: s.decoy.pos, burned: false })
     s.decoy = null
   }
 
   s.turn++
+  s.runTurns++
   return { state: s, events }
 }
 
-/** Collect every drop on the player's tile. */
-function pickUp(s: GameState, events: GameEvent[]): void {
-  for (const d of s.drops.filter((d) => eq(d.pos, s.player))) {
+/** A group of the level's toughest enemy type comes out at `pos` (NES rule, PLAN.md §5). They drop nothing. */
+function spawnPunishment(s: GameState, pos: Vec, events: GameEvent[]): void {
+  const kinds = enemyKinds(s.level)
+  for (let n = 0; n < EXIT_SPAWN; n++) {
+    s.enemies.push({ id: s.nextEnemyId++, kind: kinds[kinds.length - 1]!, pos: { ...pos }, dir: 'up', plan: [], spawned: true })
+  }
+  events.push({ type: 'enemiesSpawned', pos: { ...pos }, count: EXIT_SPAWN })
+}
+
+/** Collect every drop at `p`, and the level's power-up if it lies there uncovered. */
+function pickUp(s: GameState, p: Vec, events: GameEvent[]): void {
+  for (const d of s.drops.filter((d) => eq(d.pos, p))) {
     s.drops.splice(s.drops.indexOf(d), 1)
     if (d.kind === 'coins') s.coins += d.amount
     else s.charges[d.kind]++
     events.push({ type: 'pickedUp', drop: d })
   }
+  if (s.powerUp && eq(s.powerUp.pos, p) && cellAt(s, p) === 'floor') {
+    applyPowerUp(s, s.powerUp.kind)
+    events.push({ type: 'powerUpTaken', powerUp: s.powerUp })
+    s.powerUp = null
+  }
+}
+
+function applyPowerUp(s: GameState, kind: PowerUpKind): void {
+  switch (kind) {
+    case 'bombPlus': s.stats.capacity++; break
+    case 'firePlus': s.stats.fire++; break
+    case 'fusePlus': s.stats.fuse++; break
+    case 'skates': s.powers.run = true; break
+    case 'detonator': s.powers.detonate = true; break
+    case 'wallPass': s.powers.wallPass = true; break
+    case 'bombPass': s.powers.bombPass = true; break
+    case 'flamePass': s.powers.flamePass = true; break
+    case 'star': s.star = STAR_TURNS; break
+    case 'coinBag': s.coins += COIN_BAG; break
+    case 'heart': s.lives++; break
+  }
 }
diff --git a/src/sim/types.ts b/src/sim/types.ts
index c6012b1..ebe23bf 100644
--- a/src/sim/types.ts
+++ b/src/sim/types.ts
@@ -46,8 +46,33 @@ export interface Stats {
 /**
  * `dead`: lost a life and waiting for `continue`. `gameOver`: no lives left.
  * `cleared`: stepped onto the open exit; `continue` goes to the next level.
+ * `won`: cleared the last level (PLAN.md §11); the run is over.
  */
-export type Status = 'playing' | 'dead' | 'gameOver' | 'cleared'
+export type Status = 'playing' | 'dead' | 'gameOver' | 'cleared' | 'won'
+
+/** The power-ups hidden one per level under a soft block (PLAN.md §5, after the NES eight). */
+export type PowerUpKind =
+  | 'bombPlus' | 'firePlus' | 'fusePlus' | 'skates' | 'wallPass' | 'bombPass' | 'flamePass' | 'detonator'
+  | 'star' | 'coinBag' | 'heart'
+
+export interface PowerUp {
+  pos: Vec
+  kind: PowerUpKind
+}
+
+/** Abilities from power-ups. Run and Detonate are actions; the passes change where the player can go. */
+export interface Powers {
+  /** Skates: the Run action, 2 tiles in a straight line. Kept on death. */
+  run: boolean
+  /** Detonator: the Detonate action, set off one of your bombs now. Lost on death. */
+  detonate: boolean
+  /** Walk through soft blocks. Lost on death. */
+  wallPass: boolean
+  /** Walk over bombs. Lost on death. */
+  bombPass: boolean
+  /** Flame doesn't hurt. Lost on death. */
+  flamePass: boolean
+}
 
 /** The enemy types so far (PLAN.md §10). */
 export type EnemyKind = 'blob' | 'seeker' | 'drifter'
@@ -116,6 +141,14 @@ export interface GameState {
   shield: boolean
   /** Enemies skip their next move (Freeze). */
   frozen: boolean
+  powers: Powers
+  /** Turns played and lives lost over the whole run, for the ending. */
+  runTurns: number
+  deaths: number
+  /** Turns of Star invincibility left. */
+  star: number
+  /** This level's power-up: hidden while its block stands, then lying in the open until taken. Null once taken or burned. */
+  powerUp: PowerUp | null
   /**
    * Tile indices still on fire from last turn's explosions. Flame lingers one extra turn: it
    * kills creatures that are in it or walk into it this turn, and the player can't step or put
@@ -129,7 +162,7 @@ export interface GameState {
 }
 
 /** The actions the player can pick on the board. */
-export type ActionId = 'move' | 'bomb' | 'wait' | ChargeActionId
+export type ActionId = 'move' | 'bomb' | 'wait' | 'run' | 'detonate' | ChargeActionId
 
 export type Action = { id: ActionId; target?: Vec } | { id: 'continue' }
 
@@ -159,3 +192,8 @@ export type GameEvent =
   | { type: 'fuseChanged'; pos: Vec; timer: number }
   | { type: 'decoyPlaced'; pos: Vec }
   | { type: 'decoyGone'; pos: Vec; burned: boolean }
+  | { type: 'powerUpRevealed'; powerUp: PowerUp }
+  | { type: 'powerUpBurned'; powerUp: PowerUp }
+  | { type: 'powerUpTaken'; powerUp: PowerUp }
+  | { type: 'detonated'; pos: Vec }
+  | { type: 'runWon'; turns: number; deaths: number }
diff --git a/src/style.css b/src/style.css
index 98005ad..2879045 100644
--- a/src/style.css
+++ b/src/style.css
@@ -65,6 +65,12 @@ img {
   filter: drop-shadow(0 0 1.5px rgb(255 255 255 / 0.7));
 }
 
+#hud .icon.small {
+  width: 22px;
+  height: 22px;
+  margin-left: 6px;
+}
+
 #hud .digit {
   width: 20px;
   height: 20px;
diff --git a/src/ui/controls.ts b/src/ui/controls.ts
index a040d03..1f1f3a8 100644
--- a/src/ui/controls.ts
+++ b/src/ui/controls.ts
@@ -1,25 +1,37 @@
 import { ACTIONS, ACTION_ORDER, chargeOf, isAvailable } from '../sim/actions'
 import { PAR_BONUS, START_LIVES } from '../sim/state'
-import type { ActionId, GameState } from '../sim/types'
-import { CHARGE_ICONS, spriteUrl } from '../render/sprites'
+import type { ActionId, GameState, PowerUpKind, Powers } from '../sim/types'
+import { CHARGE_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites'
 
 /** The HTML around the board: HUD, action bar, and the banner shown between attempts and levels. */
 export interface Handlers {
   /** An action button was pressed. */
   pick(id: ActionId): void
-  /** The banner's button: continue after losing a life or clearing a level, or a new run after game over. */
+  /** The banner's button: continue after losing a life or clearing a level, or a new run after game over or the ending. */
   banner(): void
 }
 
 export const KEYS: Record<ActionId, string> = {
-  move: 'WASD', bomb: 'B', wait: 'Space', kick: 'K', throw: 'T', shield: 'H', freeze: 'F', fuseUp: '=', fuseDown: '−',
+  move: 'WASD', bomb: 'B', wait: 'Space', run: 'R', detonate: 'X', kick: 'K', throw: 'T', shield: 'H', freeze: 'F', fuseUp: '=', fuseDown: '−',
   diagonal: 'G', teleport: 'P', pierce: 'I', power: 'O', line: 'L', decoy: 'Y',
 }
 const ICONS: Record<ActionId, string> = {
   move: 'character_green_walk_a', bomb: 'bomb', wait: 'character_green_duck', ...CHARGE_ICONS,
+  run: POWER_UP_ICONS.skates, detonate: POWER_UP_ICONS.detonator,
   fuseUp: CHARGE_ICONS.fuse, fuseDown: CHARGE_ICONS.fuse,
 }
 
+/** The passes shown in the HUD, with their power-up and name. */
+const PASSES: [keyof Powers, PowerUpKind, string][] = [
+  ['run', 'skates', 'Skates'],
+  ['detonate', 'detonator', 'Detonator'],
+  ['wallPass', 'wallPass', 'Wall Pass'],
+  ['bombPass', 'bombPass', 'Bomb Pass'],
+  ['flamePass', 'flamePass', 'Flame Pass'],
+]
+/** What a death takes away (the NES rule; Skates are kept). */
+const LOST_ON_DEATH = PASSES.filter(([p]) => p !== 'run')
+
 function icon(name: string, className = 'icon'): string {
   return `<img class="${className}" src="${spriteUrl(name)}" alt="" draggable="false">`
 }
@@ -36,7 +48,8 @@ export function createControls(handlers: Handlers) {
     const b = document.createElement('button')
     b.type = 'button'
     b.dataset.action = id
-    if (chargeOf(id)) b.classList.add('charge')
+    // Charges and power-up abilities share the smaller second row.
+    if (chargeOf(id) || ACTIONS[id].ability) b.classList.add('charge')
     b.innerHTML = `${icon(ICONS[id])}<span class="label">${ACTIONS[id].label}</span><kbd>${KEYS[id]}</kbd>`
     if (chargeOf(id)) b.insertAdjacentHTML('beforeend', '<span class="count"></span>')
     b.addEventListener('click', () => handlers.pick(id))
@@ -70,6 +83,9 @@ export function createControls(handlers: Handlers) {
           b.querySelector('.icon')!.setAttribute('src', spriteUrl(autoMove ? 'flag_green_a' : ICONS.move))
           b.classList.toggle('auto', autoMove)
         }
+        // Abilities only get a button once the player has them.
+        const ability = ACTIONS[id].ability
+        if (ability) b.hidden = !s.powers[ability]
         // Charges only get a button while the player holds some; a badge shows how many.
         const charge = chargeOf(id)
         if (charge) {
@@ -88,12 +104,21 @@ export function createControls(handlers: Handlers) {
       // Turns taken, and the par to beat for the bonus.
       hud.turn.innerHTML =
         `<small aria-hidden="true">Turn</small>` + number(s.turn, 'Turn ') + `<small class="par">/${s.par}</small>`
+      // Bomb limit, blast size and fuse, then a small icon for each pass held and the Star's turns.
+      const passes = PASSES.filter(([p]) => s.powers[p])
+        .map(([, kind, name]) => `<span class="sr-only">, ${name}</span>${icon(POWER_UP_ICONS[kind], 'icon small')}`)
+        .join('')
+      const star = s.star > 0 ? icon('star', 'icon small') + number(s.star, ', Star for ') : ''
       hud.stats.innerHTML =
-        icon('bomb') + number(s.stats.capacity, 'Bombs ') + icon('fireball') + number(s.stats.fire, ', fire ')
+        icon('bomb') + number(s.stats.capacity, 'Bombs ') + icon('fireball') + number(s.stats.fire, ', fire ') +
+        icon('torch_on_a') + number(s.stats.fuse, ', fuse ') + passes + star
 
       banner.hidden = s.status === 'playing'
       if (s.status === 'dead') {
-        bannerText.textContent = `Ouch! ${s.lives} ${s.lives === 1 ? 'life' : 'lives'} left.`
+        const lost = LOST_ON_DEATH.filter(([p]) => s.powers[p]).map(([, , name]) => name)
+        if (s.star > 0) lost.push('Star')
+        bannerText.textContent =
+          `Ouch! ${s.lives} ${s.lives === 1 ? 'life' : 'lives'} left.` + (lost.length ? ` Lost: ${lost.join(', ')}.` : '')
         bannerButton.textContent = 'Try again'
         bannerIcon.src = spriteUrl('character_green_hit')
       } else if (s.status === 'gameOver') {
@@ -105,6 +130,10 @@ export function createControls(handlers: Handlers) {
         bannerText.textContent = `Level ${s.level} cleared in ${s.turn} turns.${bonus}`
         bannerButton.textContent = 'Next level'
         bannerIcon.src = spriteUrl('flag_green_a')
+      } else if (s.status === 'won') {
+        bannerText.textContent = `You cleared all ${s.level} levels! ${s.runTurns} turns, ${s.deaths} ${s.deaths === 1 ? 'life' : 'lives'} lost. Seed ${s.seed}.`
+        bannerButton.textContent = 'New run'
+        bannerIcon.src = spriteUrl('star')
       }
     },
     /** True if the banner is showing; Enter presses its button. */
diff --git a/test/helpers.ts b/test/helpers.ts
index 178debc..b2d2783 100644
--- a/test/helpers.ts
+++ b/test/helpers.ts
@@ -1,4 +1,4 @@
-import { noCharges, START_LIVES, START_STATS } from '../src/sim/state'
+import { noCharges, noPowers, START_LIVES, START_STATS } from '../src/sim/state'
 import type { Cell, EnemyKind, GameState, Stats, Vec } from '../src/sim/types'
 
 const ENEMIES: Record<string, EnemyKind> = { B: 'blob', S: 'seeker', D: 'drifter' }
@@ -20,7 +20,8 @@ export function board(picture: string, stats: Partial<Stats> = {}): GameState {
     seed: 1, level: 1, turn: 5, width, height, cells, player: { x: 0, y: 0 },
     lives: START_LIVES, stats: { ...START_STATS, ...stats }, bombs: [], status: 'playing',
     enemies: [], nextEnemyId: 0, drops: [], exit: { x: -1, y: -1 }, par: 100, coins: 0,
-    charges: noCharges(), shield: false, frozen: false, decoy: null, burning: [], aiRng: 1, dropRng: 1,
+    charges: noCharges(), shield: false, frozen: false, decoy: null, burning: [],
+    powers: noPowers(), star: 0, powerUp: null, runTurns: 0, deaths: 0, aiRng: 1, dropRng: 1,
   }
   rows.forEach((row, y) => {
     if (row.length !== width) throw new Error(`row ${y} is ${row.length} wide, not ${width}`)
diff --git a/test/powerups.test.ts b/test/powerups.test.ts
new file mode 100644
index 0000000..1598aea
--- /dev/null
+++ b/test/powerups.test.ts
@@ -0,0 +1,230 @@
+import { describe, expect, it } from 'vitest'
+import { ACTIONS, isAvailable } from '../src/sim/actions'
+import { generateLevel, LAST_LEVEL, skatesLevel } from '../src/sim/levelgen'
+import { newRun } from '../src/sim/state'
+import { COIN_BAG, EXIT_SPAWN, STAR_TURNS, step } from '../src/sim/turn'
+import type { GameState, PowerUpKind } from '../src/sim/types'
+import { board, v } from './helpers'
+
+describe('levelgen: the hidden power-up', () => {
+  for (let seed = 1; seed <= 100; seed++) {
+    it(`seed ${seed}: under a soft block that isn't the exit's`, () => {
+      const g = generateLevel(seed, 1 + (seed % 50))
+      expect(g.cells[g.powerUp.pos.y * g.width + g.powerUp.pos.x]).toBe('block')
+      expect(g.powerUp.pos).not.toEqual(g.exit)
+    })
+  }
+
+  it('Skates come on level 1 or 2, and only there', () => {
+    for (let seed = 1; seed <= 50; seed++) {
+      const at = skatesLevel(seed)
+      expect([1, 2]).toContain(at)
+      expect(generateLevel(seed, at).powerUp.kind).toBe('skates')
+      for (let level = 1; level <= 12; level++) if (level !== at) expect(generateLevel(seed, level).powerUp.kind).not.toBe('skates')
+    }
+  })
+})
+
+/** A board with the power-up `kind` lying uncovered at (2, 1). */
+function withPowerUp(kind: PowerUpKind, picture = `
+  ######
+  #P...#
+  ######`): GameState {
+  const s = board(picture)
+  s.powerUp = { pos: v(2, 1), kind }
+  return s
+}
+
+describe('picking up power-ups', () => {
+  const take = (kind: PowerUpKind) => step(withPowerUp(kind), { id: 'move', target: v(2, 1) })
+
+  it('stats: Bomb+, Fire+, Fuse+', () => {
+    expect(take('bombPlus').state.stats.capacity).toBe(2)
+    expect(take('firePlus').state.stats.fire).toBe(2)
+    expect(take('fusePlus').state.stats.fuse).toBe(4)
+  })
+
+  it('Heart, Coin bag, Star', () => {
+    expect(take('heart').state.lives).toBe(4)
+    expect(take('coinBag').state.coins).toBe(COIN_BAG)
+    // Taken this turn, and this turn's end already counts one off.
+    expect(take('star').state.star).toBe(STAR_TURNS - 1)
+  })
+
+  it('abilities, and the event; the power-up is gone afterwards', () => {
+    const r = take('skates')
+    expect(r.state.powers.run).toBe(true)
+    expect(r.state.powerUp).toBeNull()
+    expect(r.events).toContainEqual({ type: 'powerUpTaken', powerUp: { pos: v(2, 1), kind: 'skates' } })
+    expect(take('detonator').state.powers.detonate).toBe(true)
+  })
+
+  it('a power-up still under its block cannot be taken', () => {
+    const s = withPowerUp('heart', `
+      ######
+      #P+..#
+      ######`)
+    s.powers.wallPass = true
+    expect(step(s, { id: 'move', target: v(2, 1) }).state.lives).toBe(3)
+  })
+
+  it('burning its block reveals it', () => {
+    const s = withPowerUp('heart', `
+      ######
+      #P.1+#
+      ######`)
+    s.powerUp!.pos = v(4, 1)
+    const { state, events } = step(s, { id: 'wait' })
+    expect(events).toContainEqual({ type: 'powerUpRevealed', powerUp: { pos: v(4, 1), kind: 'heart' } })
+    expect(state.powerUp).not.toBeNull()
+  })
+
+  it('flame on it once uncovered destroys it and lets enemies out', () => {
+    const s = withPowerUp('heart', `
+      ######
+      #P..1#
+      ######`)
+    s.powerUp!.pos = v(3, 1)
+    const { state, events } = step(s, { id: 'wait' })
+    expect(state.powerUp).toBeNull()
+    expect(events.map((e) => e.type)).toContain('powerUpBurned')
+    expect(state.enemies).toHaveLength(EXIT_SPAWN)
+  })
+})
+
+describe('Run (Skates)', () => {
+  it('goes 2 tiles in a straight line, only over free tiles', () => {
+    const s = board(`
+      #######
+      #P...3#
+      #.#####
+      #+#####
+      #######`)
+    expect(isAvailable(s, 'run')).toBe(false)
+    s.powers.run = true
+    expect(ACTIONS.run.validTargets(s)).toEqual([v(3, 1)])
+    expect(step(s, { id: 'run', target: v(3, 1) }).state.player).toEqual(v(3, 1))
+  })
+
+  it('collects what is on the tile it runs through', () => {
+    const s = board(`
+      ######
+      #P$..#
+      ######`)
+    s.powers.run = true
+    expect(step(s, { id: 'run', target: v(3, 1) }).state.coins).toBe(1)
+  })
+
+  it('running into an enemy on the tile in between is death', () => {
+    const s = board(`
+      ######
+      #PB..#
+      ######`)
+    s.powers.run = true
+    expect(ACTIONS.run.validTargets(s)).toEqual([v(3, 1)])
+    expect(step(s, { id: 'run', target: v(3, 1) }).state.status).toBe('dead')
+  })
+})
+
+describe('Detonate', () => {
+  it('sets one of your bombs off this turn', () => {
+    const s = board(`
+      #######
+      #P..3+#
+      #######`)
+    s.powers.detonate = true
+    const { state, events } = step(s, { id: 'detonate', target: v(4, 1) })
+    expect(events).toContainEqual({ type: 'detonated', pos: v(4, 1) })
+    expect(state.bombs).toEqual([])
+    expect(state.cells[7 + 5]).toBe('floor')
+  })
+})
+
+describe('passes', () => {
+  it('Wall Pass walks through soft blocks', () => {
+    const s = board(`
+      #####
+      #P+.#
+      #####`)
+    expect(ACTIONS.move.validTargets(s)).toEqual([])
+    s.powers.wallPass = true
+    expect(ACTIONS.move.validTargets(s)).toEqual([v(2, 1)])
+  })
+
+  it('Bomb Pass walks over bombs', () => {
+    const s = board(`
+      #####
+      #P3.#
+      #####`)
+    s.powers.bombPass = true
+    expect(ACTIONS.move.validTargets(s)).toEqual([v(2, 1)])
+  })
+
+  it('Flame Pass: flame does not hurt; enemies still do', () => {
+    const s = board(`
+      #####
+      #P1.#
+      #####`)
+    s.powers.flamePass = true
+    expect(step(s, { id: 'wait' }).state.status).toBe('playing')
+    const e = board(`
+      #####
+      #PB.#
+      #####`)
+    e.powers.flamePass = true
+    expect(step(e, { id: 'move', target: v(2, 1) }).state.status).toBe('dead')
+  })
+
+  it('Star: nothing hurts while it lasts', () => {
+    const s = board(`
+      #####
+      #PB1#
+      #####`)
+    s.star = 3
+    expect(step(s, { id: 'move', target: v(2, 1) }).state.status).toBe('playing')
+  })
+
+  it('a death loses the passes, Detonator and Star, but keeps Skates and stats', () => {
+    const s = { ...board(`
+      #####
+      #P1.#
+      #####`, { capacity: 2 }), star: 0 }
+    s.powers = { run: true, detonate: true, wallPass: true, bombPass: true, flamePass: false }
+    const dead = step(s, { id: 'wait' }).state
+    expect(dead.deaths).toBe(1)
+    const again = step(dead, { id: 'continue' }).state
+    expect(again.powers).toEqual({ run: true, detonate: false, wallPass: false, bombPass: false, flamePass: false })
+    expect(again.stats.capacity).toBe(2)
+  })
+
+  it('clearing a level keeps everything', () => {
+    const s = { ...board(`
+      ####
+      #PE#
+      ####`), star: 4 }
+    s.powers.wallPass = true
+    const next = step(step(s, { id: 'move', target: v(2, 1) }).state, { id: 'continue' }).state
+    expect(next.powers.wallPass).toBe(true)
+    expect(next.star).toBe(4)
+  })
+})
+
+describe('the ending', () => {
+  it(`clearing level ${LAST_LEVEL} wins the run`, () => {
+    const s = { ...board(`
+      ####
+      #PE#
+      ####`), level: LAST_LEVEL, runTurns: 1234, deaths: 2 }
+    const { state, events } = step(s, { id: 'move', target: v(2, 1) })
+    expect(state.status).toBe('won')
+    expect(events.at(-1)).toEqual({ type: 'runWon', turns: 1235, deaths: 2 })
+    expect(() => step(state, { id: 'continue' })).toThrow()
+  })
+
+  it('counts turns across the whole run', () => {
+    let s = newRun(3)
+    s = step(s, { id: 'wait' }).state
+    s = step(s, { id: 'wait' }).state
+    expect(s.runTurns).toBe(2)
+  })
+})
diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs
index 56f2232..60a99ce 100644
--- a/tools/screenshots.mjs
+++ b/tools/screenshots.mjs
@@ -63,6 +63,15 @@ const SHOTS = [
   },
   // A Pierce bomb (spikes badge) placed to the right, then a Decoy (pale copy of the player) below.
   { name: 'pierce-decoy', query: 'charges=2', act: press('KeyI', 'KeyD', 'KeyY', 'KeyS') },
+  // Seed 55 hides Skates (a green gem) under the crate two tiles below the start: bomb it open
+  // and step back once the flame has died down...
+  { name: 'power-up', query: 'seed=55', act: press('KeyB', 'KeyS', 'KeyD', 'KeyD', 'Space', 'KeyA') },
+  // ...then walk back and take it: Run appears, with its 2-tile targets picked.
+  {
+    name: 'run',
+    query: 'seed=55',
+    act: press('KeyB', 'KeyS', 'KeyD', 'KeyD', 'Space', 'KeyA', 'KeyA', 'KeyS', 'KeyS', 'KeyR'),
+  },
   // Holding action charges: their buttons appear; Throw picked shows where a bomb can land.
   { name: 'charges', query: 'charges=2', act: async (page) => { await page.click('[data-action=throw]') } },
 ]
@@ -73,7 +82,7 @@ const browser = await chromium.launch()
 try {
   for (const { name, viewport = PHONE, query = '', act } of SHOTS) {
     const page = await browser.newPage({ viewport, deviceScaleFactor: 2, hasTouch: viewport === PHONE })
-    await page.goto(`http://localhost:4179/?seed=1&${query}`)
+    await page.goto(`http://localhost:4179/?${query.includes('seed=') ? '' : 'seed=1&'}${query}`)
     await page.waitForSelector('canvas')
     await page.waitForTimeout(300)
     await act(page)