fuzzyworld

explodorama / commit

Gems, charges on pickup, a demo, installable, and a day of playtest fixes

52d68b59af37f577f9aee7fd961e4cdcf17e8e54 · Pavel Safronov · 2026-10-23 21:46:50 -0700 · parents 0b61e800472d

Claude Code usage: this commit ~827.7K out / ~324.3M cached · cost unknown (no per-model trailer) · session 3 tally 1.9K in / 922.0K out / 331.2M cached · usage by commit and session

Gems, charges on pickup, a demo, installable, and a day of playtest fixes

Gems: each level hides a gem; taking it offers 5 power-ups to choose from
(weighted, none already held). Diagonal is a power-up; a death keeps every
power-up. Charges work when picked up (Pierce, Power, the new Nuke, Shield,
Freeze, Decoy; Line removed) and show in the top bar; the last enemy drops
only lasting ones. Enemies spread out instead of stacking, and enemies let
out by a blasted exit or gem survive the flame that freed them.

Play: no explanation strip (charges need no buttons); auto-move highlights
its way in blue, takes the first step at once and never crosses the open
exit; the top bar has fixed rows so the board never moves; a short arched
exit, walked into before the jump; the Shield ring follows the player.

A demo before the game: scenes generated from the real rules (a kill, a
narrow escape, a chain, Freeze, the Detonator), view-only, until a tap.
Installable as an app with offline play. Fixed a crash from a Freeze drop
(a 100 px icon); frames and turns are guarded, and under npm run dev the
game's logs and errors from any device show in the terminal. Developer mode
is just a gem next to the player. SIM_VERSION 15.

Usage: 1.9K in / 922.0K out / 331.2M cached (session tally)
rtk:   1.8M saved, 40.5% over 3272 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 CHANGELOG.md                                       |  57 +++-
 CONTRIBUTING.md                                    |   7 +
 PLAN.md                                            | 143 ++++++---
 README.md                                          | 211 +++++++------
 assets/composed/charge_decoy.png                   | Bin 0 -> 1338 bytes
 assets/composed/charge_freeze.png                  | Bin 0 -> 3849 bytes
 assets/composed/charge_nuke.png                    | Bin 0 -> 6579 bytes
 assets/composed/charge_shield.png                  | Bin 0 -> 3092 bytes
 assets/composed/powerup_diagonal.png               | Bin 0 -> 2484 bytes
 assets/kenney-board-icons/arrow_diagonal_cross.png | Bin 0 -> 787 bytes
 assets/kenney-board-icons/pawns.png                | Bin 0 -> 1108 bytes
 assets/kenney-board-icons/skull.png                | Bin 0 -> 935 bytes
 assets/kenney-board-icons/timer_0.png              | Bin 0 -> 1271 bytes
 assets/kenney-icons/White/locked.png               | Bin 0 -> 15262 bytes
 assets/kenney/door_closed.png                      | Bin 659 -> 0 bytes
 assets/kenney/door_open.png                        | Bin 141 -> 0 bytes
 assets/kenney/gem_blue.png                         | Bin 0 -> 1436 bytes
 assets/kenney/gem_green.png                        | Bin 0 -> 1421 bytes
 assets/kenney/gem_red.png                          | Bin 0 -> 1434 bytes
 assets/kenney/gem_yellow.png                       | Bin 0 -> 1394 bytes
 decisions.md                                       | 325 ++++++++++++++++++++-
 docs/screenshots/almanac-charges.png               | Bin 363678 -> 369548 bytes
 docs/screenshots/almanac-enemies.png               | Bin 300151 -> 309080 bytes
 docs/screenshots/almanac.png                       | Bin 432463 -> 429437 bytes
 docs/screenshots/auto-move.png                     | Bin 247153 -> 244640 bytes
 docs/screenshots/bomb-picked.png                   | Bin 263510 -> 243597 bytes
 docs/screenshots/charges.png                       | Bin 287254 -> 256646 bytes
 docs/screenshots/countdown.png                     | Bin 249116 -> 248348 bytes
 docs/screenshots/death.png                         | Bin 193047 -> 192554 bytes
 docs/screenshots/demo.png                          | Bin 0 -> 104147 bytes
 docs/screenshots/desktop.png                       | Bin 583475 -> 583108 bytes
 docs/screenshots/dev.png                           | Bin 262006 -> 245790 bytes
 docs/screenshots/enemies.png                       | Bin 247378 -> 238019 bytes
 docs/screenshots/gem-offer.png                     | Bin 0 -> 196925 bytes
 docs/screenshots/gem.png                           | Bin 0 -> 244285 bytes
 docs/screenshots/info-enemy.png                    | Bin 183045 -> 181667 bytes
 docs/screenshots/info-hud.png                      | Bin 173449 -> 172465 bytes
 docs/screenshots/info-mode.png                     | Bin 243316 -> 242881 bytes
 docs/screenshots/lingering.png                     | Bin 249196 -> 248622 bytes
 docs/screenshots/long-press.png                    | Bin 247665 -> 247145 bytes
 docs/screenshots/menu.png                          | Bin 173505 -> 170784 bytes
 docs/screenshots/move-picked.png                   | Bin 306483 -> 243624 bytes
 docs/screenshots/new-run.png                       | Bin 184871 -> 179557 bytes
 docs/screenshots/nuke.png                          | Bin 0 -> 264697 bytes
 docs/screenshots/pierce-decoy.png                  | Bin 265585 -> 0 bytes
 docs/screenshots/power-up.png                      | Bin 251463 -> 0 bytes
 docs/screenshots/run.png                           | Bin 168533 -> 0 bytes
 docs/screenshots/scrolled.png                      | Bin 252804 -> 246848 bytes
 docs/screenshots/small-desktop.png                 | Bin 271844 -> 271684 bytes
 docs/screenshots/small.png                         | Bin 186025 -> 186731 bytes
 docs/screenshots/start.png                         | Bin 243519 -> 243146 bytes
 index.html                                         |  29 +-
 package.json                                       |   2 +-
 src/devlog.ts                                      |  18 ++
 src/main.ts                                        | 255 +++++++++++++---
 src/render/board.ts                                |  60 ++--
 src/render/effects.ts                              |  11 +-
 src/render/sprites.ts                              |  63 ++--
 src/sim/actions.ts                                 | 132 +++------
 src/sim/demo.ts                                    | 162 ++++++++++
 src/sim/enemies.ts                                 |  65 ++++-
 src/sim/explosions.ts                              |  17 +-
 src/sim/levelgen.ts                                |  32 +-
 src/sim/path.ts                                    |   7 +-
 src/sim/record.ts                                  |   2 +-
 src/sim/state.ts                                   |  62 ++--
 src/sim/turn.ts                                    |  85 +++++-
 src/sim/types.ts                                   |  94 ++++--
 src/style.css                                      | 268 ++++++++++-------
 src/ui/almanac.ts                                  |   7 +-
 src/ui/controls.ts                                 | 107 ++-----
 src/ui/dialog.ts                                   |  15 +-
 src/ui/info.ts                                     | 145 +++++----
 src/ui/offer.ts                                    |  36 +++
 static/icons/apple-touch-icon.png                  | Bin 0 -> 25301 bytes
 static/icons/icon-192.png                          | Bin 0 -> 28779 bytes
 static/icons/icon-512.png                          | Bin 0 -> 162267 bytes
 static/icons/icon-maskable-512.png                 | Bin 0 -> 101318 bytes
 static/manifest.webmanifest                        |  16 +
 static/sw.js                                       |  26 ++
 test/charges.test.ts                               | 199 +++++++------
 test/demo.test.ts                                  |  43 +++
 test/enemies.test.ts                               | 141 +++++++--
 test/helpers.ts                                    |   8 +-
 test/options.test.ts                               |  40 ++-
 test/path.test.ts                                  |  29 ++
 test/picks.test.ts                                 |  10 +-
 test/powerups.test.ts                              | 167 ++++++++---
 test/sprites.test.ts                               |  23 ++
 tools/compose-icons.sh                             |  29 +-
 tools/screenshots.mjs                              |  31 +-
 vite.config.ts                                     |   8 +-
 92 files changed, 2286 insertions(+), 901 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7167777..858bd41 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,11 +26,16 @@ saved before the change is dropped and the next visit starts a new run.
   out 3 enemies. A par turn count, with a 5-coin bonus for beating it.
 - **Enemies:** Blob, Seeker and Drifter. Each commits to 3 moves ahead, shown as a fading column
   of arrows. Coin and charge drops; drops never stack on one tile.
-- **Action charges:** Shield, Freeze, Diagonal, Pierce, Power, Line and Decoy, with count
-  badges on their icons.
-- **Power-ups**, one per level under a crate: Bomb+, Fire+, Fuse+, Run, Detonator, Wall Pass,
-  Bomb Pass, Flame Pass, Star, Heart, Coin bag, Kick, Throw and Teleport. A death loses the
-  passes, the Detonator and a Star.
+- **Charges**, dropped by enemies, that work as soon as you pick them up: Pierce, Power and the
+  new Nuke change your next 3 bombs; Shield blocks the next hit; Freeze freezes every enemy for
+  3 turns; Decoy leaves a copy of you.
+  The top bar shows what each still has to give.
+- **Power-ups:** Bomb+, Fire+, Fuse+, Run, Detonator, Wall Pass, Bomb Pass, Flame Pass, Star,
+  Heart, Coin bag, Kick, Throw, Teleport and Diagonal. A death costs only the life: every power-up
+  is kept.
+- **A gem on every level**, hidden under a crate. Taking it offers 5 different power-ups, and
+  you choose one (no turn passes). Common power-ups are offered more often than rare ones, and
+  ones you already hold aren't offered.
 - **The 50-level run** with a victory banner at the end.
 - **Mid-run saving** after every action, in the browser. URLs with test parameters (`?seed`,
   `?level`, `?charges`, `?small`, `?dev`, `?god`, `?crumble`) never load or overwrite the save.
@@ -44,31 +49,63 @@ saved before the change is dropped and the next visit starts a new run.
   things you can walk to offer **Walk there**.
 - **New run options:** a Normal board (31 × 13, 50 levels) or a Small one (9 × 11, 20 levels),
   plus Developer, God and Crumbling crates modes.
-- **Pickup dialogs:** taking a power-up opens a dialog saying what it does; so does the first
-  pickup of each kind of action charge in a run.
+- **Pickup dialogs:** the first pickup of each kind of action charge in a run opens a dialog
+  saying what it does.
 - **Animation** for the player, enemies, bombs, crates, flame, drops, power-ups, top-bar numbers
   and dialogs.
 - **Art** from Kenney's New Platformer Pack, UI Pack, UI Pack: RPG Expansion, Game Icons and
   Board Game Icons (all CC0). Power-ups that improve something get a green modifier badge.
+- **A demo before the game:** scenes made up on the spot from the real rules (an enemy killed, a
+  narrow escape, a chain reaction, Freeze, the Detonator), each with a caption (and the icon of
+  the charge or power-up it's about), until you tap.
+- **Installable** as an app (Chrome, Edge, Android, iPhone / iPad), and playable offline once
+  loaded.
 - **Deployment** to GitLab Pages on every push to the default branch, after the tests and the
   build pass.
 
 ### Changed
 
 - The placeholder shapes were replaced with Kenney sprites.
-- The action bar is now only Move, Bomb and Wait, plus Shield, Freeze and Decoy while you hold
-  them. Upgrades make Move and Bomb do more instead of adding buttons. Picking an action shows a
-  strip that explains what it does right now.
+- The action bar is now only Move, Bomb and Wait. Upgrades make Move and Bomb do more instead of
+  adding buttons, and charges have no buttons at all.
 - Kick, Throw and Teleport became power-ups kept for the whole run (they were charges).
 - Freeze now holds enemies for this turn and 3 more.
 - The Skates power-up is now called Run.
 - The outer wall is drawn as a bordered stone frame.
+- The exit is a short, one-tile arched doorway instead of a squeezed two-tile door.
+- Run is no longer guaranteed on level 1 or 2; it can be offered by any gem.
+- Auto-move's way is shown as blue tiles instead of a flag and dots, and the Go button keeps
+  the walk icon. Setting the way takes the first step at once.
+- The top bar has a fixed number of rows, so the board no longer moves as things are picked up.
+- The Shield's ring is smaller and moves with the player.
+- Stepping onto the exit walks into the doorway before the jump (it used to happen instantly).
+- Saved runs from before the gems can't be resumed (`SIM_VERSION` 15).
+- Developer mode now only puts every level's gem uncovered next to the player; it no longer
+  gives every ability, extra stats or unlimited charges.
 
 ### Removed
 
+- The Line charge, and charges as actions with their own buttons and keys.
+- The explanation strip above the action buttons. Nothing covers or resizes the board during
+  play; **?** then a button explains it.
 - The Fuse +1 / −1 charges.
 
 ### Fixed
 
 - Moving off the auto-move path now clears the flag.
+- Enemies let out by flaming the exit or the gem are no longer killed at once by that flame,
+  which lingers a turn; a new explosion still kills them.
+- The last enemy's charge is always one that lasts (Pierce, Power, Nuke or Shield), so it isn't
+  wasted.
+- Enemies on the same tile no longer move as one: each plans around the others, so they spread out.
+- A Freeze drop on the board crashed the game (its icon was the wrong size).
+- An error while drawing a frame or playing a turn no longer freezes the game; it is logged and
+  play goes on. Under `npm run dev`, the game's logs and errors, from any device, show in the
+  terminal.
+- The run's mode tags moved to the top bar's third row, so they can't push the level, enemies
+  and coins out of sight.
+- Auto-move no longer walks over the open exit on the way somewhere else (which ended the
+  level early).
 - Thin light seams no longer run through the stone outer wall.
+- Top-bar items (hearts, level, enemies and so on) no longer pulse on every update after they
+  have changed once; they pulse only when their value changes.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f24274b..67ae61c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -21,9 +21,16 @@ npm install
 npm run dev          # http://localhost:5173, plus a QR code for a phone on the same Wi-Fi
 npm test             # Vitest
 npm run build        # type-check and build into dist/
+npm run preview      # serve the build, with a QR code for a phone on the same Wi-Fi
 npm run screenshots  # build, then photograph the game into docs/screenshots/ (Playwright)
 ```
 
+**Seeing what went wrong:** under `npm run dev`, everything the game writes to the browser
+console — and every error, caught or not — is printed in the terminal running `npm run dev`,
+marked `(client)`, from any device, including a phone on the Wi-Fi. An error while drawing a
+frame or playing a turn is caught, logged with its stack and the device (`src/devlog.ts`), and
+the game carries on instead of freezing.
+
 The URL parameters listed in the README (`?seed`, `?level`, `?charges`, `?small`, `?dev`,
 `?god`, `?crumble`) are the quickest way to reach the situation you are working on.
 
diff --git a/PLAN.md b/PLAN.md
index c47b107..84a1a05 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -80,7 +80,7 @@ Rules that fall out of this:
 | Fire (blast radius) | 1 | Tiles in each of 4 directions. |
 | Fuse | 3 | Starting timer of the player's bombs. |
 | Abilities | none | From pickups or the shop (§3.2, §5). |
-| Action charges | none | Consumable actions dropped by enemies (§3.3). |
+| Charges | none | Temporary changes dropped by enemies, working on pickup (§3.3). |
 | Coins | 0 | Spent in the shop (§8); leftovers go to the Workshop (§7). |
 | Curse | none | At most one active at a time (§6). |
 
@@ -99,16 +99,22 @@ Starting values can be raised by Workshop tiers (§7).
 | **Wait** | Do nothing; the world advances one turn. | Nothing (or a subtle pulse on the player). |
 
 **Few buttons, icons only, explained when picked (user request, 2026-10-22; built).** The bar
-has Move, Bomb and Wait, plus a second row only for what is held: Shield, Freeze, Decoy.
+has Move, Bomb and Wait. *(Until 2026-10-23 a second row held a button per charge held:
+Shield, Freeze, Decoy, then also Pierce, Power and Line. Charges now work on pickup, §3.3, so
+the bar is only Move, Bomb and Wait.)*
 *(Detonate and Fuse had buttons there too until a follow-up the same day: the Detonator now
 works by tapping a bomb, which pulses, and the Fuse ±1 charge is gone.)* Nothing but Wait goes
-off from its button alone: Shield and Freeze wait in the strip for a **Use** tap (or Enter). Buttons show icons, no names. Upgrades don't add buttons, they widen Move and
+off from its button alone (Shield and Freeze took a second tap, or a **Use** tap in the strip,
+while they were actions). Buttons show icons, no names. Upgrades don't add buttons, they widen Move and
 Bomb (`sim/picks.ts`): Move also kicks (Kick: walking into a bomb), runs (Run), teleports
-(Teleport) and steps diagonally (a Diagonal charge); Bomb also throws (Throw) and detonates
+(Teleport) and steps diagonally (while a Diagonal charge lasts); Bomb also throws (Throw) and detonates
 (Detonator; a plain tap on a bomb does too, before a kick). The tile tapped
 decides which, in that order of preference — a kick before walking onto a bomb with Bomb Pass,
-anything free before spending a charge. Pierce, Power and Line are choices in Bomb's strip. Picking an action opens a **strip above the bar** saying what it does now,
-listing the upgrades held (`actionInfo` in `ui/info.ts`). The old keys stay (R, T, P, G, I, O, L pick that one
+anything free before spending a charge. ~~Picking an action opens a **strip above the bar**
+saying what it does now~~ *Removed 2026-10-23 (user request):* first it covered the board's
+bottom row, then a band kept free for it kept resizing the board. There is no explanation text
+during play: power-ups are explained in the gem's choice, charges on their first pickup, and
+"?" then a button explains it (`actionInfo` in `ui/info.ts`). The old keys stay (R, T, P, G, I, O, L pick that one
 action). With an action picked, a direction key uses the tile next to the player, or 2 away
 if that one can't be used (a run, a throw).
 
@@ -157,30 +163,34 @@ they first appear on).
 | **Kick** | Kick power-up (*was a charge until 2026-10-22*) | Push an adjacent bomb by walking into it; it slides until the next tile is not free, keeping its timer. Part of Move. |
 | **Throw** | Throw power-up (*was a charge*) | A bomb **exactly 2 tiles away** in a straight line, over anything. Part of Bomb. |
 | **Teleport** | Teleport power-up, rare (*was a charge*) | Move to any free tile within Manhattan distance 3, over anything. Part of Move. |
+| **Diagonal** | Diagonal power-up (*a charge until 2026-10-23*, user request; weight 8) | Step one tile diagonally; both orthogonal neighbours may be blocked — a squeeze. Part of Move (G, then Q E Z C). |
 | **Detonate** | Detonator pickup (NES "Detonator"), or shop | Explode one chosen own bomb now. *Since 2026-10-22:* tap the bomb (all of them pulse while you have it); no button. |
 
-Run, Kick, Throw and Teleport are kept on death; Detonate is lost (the NES rule).
+*Since 2026-10-23 (user request) every ability and pass is kept through a death.* (Until then Detonate and the passes were lost, the NES rule.)
 
-### 3.3 Action charges (consumable, dropped by slain enemies)
+### 3.3 Charges (dropped by slain enemies; temporary changes since 2026-10-23)
 
-The button shows a count, e.g. "Shield ×2". Using one spends one charge and the turn. *Since
-2026-10-22* Kick, Throw and Teleport are abilities (§3.2), not charges, and Diagonal, Pierce,
-Power and Line have no button of their own (§3.1). *Built (2026-10-22, user request):* the
-first pickup of each kind of charge in a run opens a dialog saying what it does (`chargeInfo`),
-like a power-up's; later pickups of that kind don't.
+*Redesigned 2026-10-23 (user request):* charges are **no longer actions**. Each works the
+moment it is picked up (`applyCharge` in `sim/actions.ts`), as a temporary change; there are no
+charge buttons. What they still give lives in `state.boosts` (and `shield`, `frozen`, `decoy`)
+and shows in the top bar with its count. It is kept from level to level and through a death.
+Picking up more of a kind adds to it. The first pickup of each kind in a run still opens a
+dialog saying what it does (`chargeInfo`). Icons are the user's choices (§13).
 
-| Action | Rule |
+| Charge | Rule |
 |---|---|
-| ~~**Fuse +1 / Fuse −1**~~ | *Removed 2026-10-22 (user request):* changing a bomb's number after placing it isn't from the original and didn't make sense to the user. The Fuse +1 **power-up** (new bombs start higher) stays. |
-| **Shield** | Absorb the next hit (flame or enemy). Shown as a ring around the player. Arming it costs the turn. *Built:* it also absorbs any further hit in the same turn it breaks (e.g. an enemy and then a flame), so one shield never fails halfway through a turn. Confirmed with a **Use** tap (2026-10-22). |
-| **Diagonal step** | Move 1 tile diagonally. Both orthogonal neighbours may be blocked — it's a squeeze. |
-| **Pierce bomb** | Place a bomb whose flame passes **through** soft blocks (destroying all in range). From later Bomberman games. |
-| **Power bomb** | Place a bomb with maximum fire (whole row/column up to hard walls). *Built:* soft blocks still stop it (Pierce is the one for those). |
-| **Line bomb** | Place all remaining bomb capacity in a straight line from the player, in one action. From *Super Bomberman*. |
-| **Freeze** | All enemies skip their moves for **this turn and the next 3** (`FREEZE_TURNS` 4; *changed 2026-10-22, user request:* one skipped move only wasted the turn). Their plans wait; the arrows go faint and icy. Drawn as blue enemies in a block of ice that thaws a step each turn. Confirmed with a **Use** tap. |
-| **Decoy** | Place a decoy on an adjacent tile; chasing enemies target it instead of the player for 3 turns. Flame destroys it. *Built:* it blocks nothing; "3 turns" = 3 enemy plannings; one decoy at a time (a new one replaces it). |
-
-All charge actions can also appear in the shop (§8).
+| **Pierce** | The next `BOMB_BOOST` (3, user's choice) bombs' flame passes **through** soft blocks. Icon: the Wall Pass icon. |
+| **Power** | The next 3 bombs' flame reaches to the walls (soft blocks still stop it). Icon: the Fire+ icon. |
+| **Nuke** | *New.* The next 3 bombs also burn every tile within `NUKE_RANGE` (2, user's choice over 3) steps, Manhattan, through anything; walls stand. Escapable: a fuse of 3 gives 3 steps. Icon: bomb + skull badge. |
+| ~~**Diagonal**~~ | *Became a power-up the same day* (user request, §3.2). |
+| **Shield** | Armed on pickup; absorbs the next hit (and anything else that turn), then is gone. Kept to the next level. Icon: the Move figure in an empty ring. |
+| **Freeze** | Every enemy freezes for `FREEZE_TURNS` = 3 turns, the pickup turn included (was 4, as an action). Icon: a padlock. |
+| **Decoy** | A decoy on the pickup tile (user's choice over "next to you"); chasing enemies target it for 3 turns. Flame destroys it. Icon: three pawns. |
+| ~~**Line**~~ | *Removed 2026-10-23* (not in the user's new list). |
+| ~~**Fuse +1 / Fuse −1**~~ | *Removed 2026-10-22 (user request).* |
+
+A bomb uses one of each bomb boost held, so Pierce, Power and Nuke combine on one bomb; it wears
+their icons as badges. Charges can also appear in the shop (§8).
 
 ---
 
@@ -215,7 +225,7 @@ The NES original had exactly 8 power-ups, one hidden per level under a soft bloc
 | Bombs | **Bomb +1** (capacity) | yes |
 | Flames | **Fire +1** | yes |
 | Speed (roller skates) | **Run** (called Skates until 2026-10-22) | yes |
-| Wallpass | **Wall Pass** — walk through soft blocks | no — always lost on death |
+| Wallpass | **Wall Pass** — walk through soft blocks | no — always lost on death (*kept since 2026-10-23, see §9*) |
 | Detonator | **Detonator → unlocks Detonate** | no |
 | Bombpass | **Bomb Pass** — walk over bombs | no |
 | Flamepass | **Flame Pass** — immune to flame (tune later if too strong); walk through it (with a Star too, since 2026-10-22) | no |
@@ -230,25 +240,37 @@ Additions:
 NES rules kept (they create the classic tension):
 
 - **One pickup per level** under a soft block (more via shop/Workshop upgrades), plus the
-  **exit door** under another soft block.
+  **exit door** under another soft block. *Since 2026-10-23 (user request) the pickup is a
+  **gem** that offers a choice:* taking it offers `OFFER_SIZE` (5) different power-ups, drawn
+  by the weights below without repeats and leaving out abilities and passes already held, and
+  the player picks one (`choose` action, no turn; `ui/offer.ts`, a dialog that must be
+  answered). The gem's colour is random and means nothing. The offer comes from the seed and
+  level (`stream(seed, 'offer', level)`). A death on the pickup turn drops the offer (the retry
+  has the gem back); reaching the exit on the same step still needs the choice first.
 - **Flame destroys an exposed pickup** — and spawns extra enemies, as in the NES.
-- **Flaming the exit door** spawns a group of enemies from it.
+- **Flaming the exit door** spawns a group of enemies from it. *Since 2026-10-23 (user's choice)*
+  they can't be hurt by the flame that let them out, which lingers a turn and used to kill them
+  at once; new explosions still can (`Enemy.sheltered`). The same for a flamed gem.
 - **The exit opens only when all enemies are dead.**
-- **Run is guaranteed on level 1 or 2** so every player learns Run early.
-- *Built (2026-10-22, user request):* picking one up opens a dialog saying what it does
-  (`powerUpInfo`), 0.45 s later, once the player has arrived and the pickup has risen away.
+- ~~**Run is guaranteed on level 1 or 2**~~ *Dropped 2026-10-23 (user's choice, with gems):* Run
+  is in the draw like the rest (weight 10).
+- ~~*Built (2026-10-22, user request):* picking one up opens a dialog saying what it does~~
+  *Replaced 2026-10-23:* the gem's choice dialog opens 0.45 s after the pickup instead, once the
+  player has arrived and the gem has risen away.
 - *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), and since 2026-10-22 Kick 8, Throw 8, Teleport 3.
-  Run is not in the draw; which of level 1 or 2
-  gets them comes from the seed. Sprites: §13 (since 2026-10-22, a bomb or the astronaut with a
-  modifier badge for Bomb+, Fire+, Fuse+, Run, Kick and Throw; gems before). Until Keep / Fresh exists, a death keeps stats and Run (as "Keep"
+  Flame Pass 5, Detonator 9, Star 8 (8 turns), and since 2026-10-22 Kick 8, Throw 8, Teleport 3;
+  since 2026-10-23 Run 10, and they weight the gem's offer (`POWER_UP_WEIGHTS` in `sim/turn.ts`). Sprites: §13 (since 2026-10-22, a bomb or the astronaut with a
+  modifier badge for Bomb+, Fire+, Fuse+, Run, Kick and Throw; gems before). Until Keep / Fresh exists, a death keeps stats, every power-up and pass (since 2026-10-23, user request; before, "as Keep"
   would). A Run collects what is on the tile it passes through.
 
 ---
 
 ## 5b. Exclusive power-ups — a roguelike direction (idea, user, 2026-10-21; *not decided*)
 
+*2026-10-23:* the gem's choice of 1 from 5 (§5) is a first step in this direction — the player
+chooses instead of taking whatever is there — but nothing is exclusive yet: slots are still open.
+
 The user wants to start thinking about making power-ups **exclusive**: picking one up can
 **replace** another, so the player can't simply collect everything, and is pushed to
 **specialise, experiment and choose** — which to keep, which to drop. A sketch to discuss:
@@ -275,8 +297,10 @@ these, not paper over them):
    the start" — they'd become starting slot contents or extra slots ("+1 slot" is a natural
    Workshop track).
 4. **NES rule: passes and Detonator are lost on death (§5, §9)** — with slots, perhaps nothing
-   is lost on death and the slot choice itself is the cost.
-5. **Developer mode (§13b)** gives everything; it would bypass slots (fine for testing).
+   is lost on death and the slot choice itself is the cost. *Settled 2026-10-23 (user request):
+   nothing is lost on death, slots or not.*
+5. ~~**Developer mode (§13b)** gives everything; it would bypass slots (fine for testing).~~
+   *Resolved 2026-10-23:* Developer mode now only uncovers the gem, so it bypasses nothing.
 
 Open questions for the user: how many slots? Do stats take slots? Is a swapped-out power-up
 gone or left on the tile? Should action charges (one-use, from enemies) stay separate from slots
@@ -365,7 +389,7 @@ enemies reset). Before that, a choice screen with two buttons, **both showing ex
 | | **Keep** | **Fresh** |
 |---|---|---|
 | Bomb +, Fire +, Fuse +, Run | kept | **reset to starting values** |
-| Wall Pass, Bomb Pass, Flame Pass, Detonator, Star | lost (NES rule) | lost |
+| Wall Pass, Bomb Pass, Flame Pass, Detonator, Star | lost (NES rule) — ***open:** since 2026-10-23 a death keeps them (user request), so this row, and what Keep vs Fresh costs, needs rethinking before §9 is built* | lost |
 | Coins | kept | kept, **plus a coin payout** |
 | Action charges | kept | kept |
 | Curse | removed | removed |
@@ -408,7 +432,11 @@ contact. Intent shown every turn. Adapted from the NES eight:
   *Built (2026-10-20):* per type — Blob 20% coins / 15% charge, Seeker 50% / 25%, Drifter
   20% / 50%; a coin drop is worth 1 (60%), 2 (30%) or 5 (10%). Counts: 3 enemies on level 1,
   +1 every 3 levels, at most 10. New type every 6 levels: Seeker from 7, Drifter from 13.
-  Enemies may share a tile (they don't block each other). Enemies spawned from a flamed exit
+  Enemies may share a tile (they don't block each other), but *since 2026-10-23 (user request)*
+  each plans around where the others will be, so they spread out and only share when forced
+  (`spreadSteps`). *Since 2026-10-23 (user's choice):* when a turn's kills leave no enemy, their
+  charge drops are only lasting ones (Pierce, Power, Nuke, Shield: `LASTING_CHARGES`), since a
+  Freeze or Decoy from the last enemy had nothing left to work on. Enemies spawned from a flamed exit
   drop nothing, so the exit can't be farmed.
 - Drops land on the enemy's tile and stay until collected or flamed. *Built (2026-10-21, user
   request):* drops never stack — a drop whose tile is taken (by a drop, a bomb or the uncovered
@@ -533,13 +561,13 @@ Sprite assignments (✅ = drawn now; the rest are copied and waiting for their f
 | ✅ HUD | `hud_heart` / `hud_heart_empty` (lives), `flag_green_a` (level), `slime_normal_rest` (enemies left), `hud_coin` (coins), `bomb`, `fireball`, digits |
 | ✅ action buttons | Move `icon_move` (Board Game Icons `character`), Bomb `bomb`, Wait `icon_wait` (`hourglass`; `character_remove` briefly), both recoloured to ink by `tools/compose-icons.sh` and turned white on dark or green (class `mono`); until 2026-10-22 the astronaut's walking and crouching frames, which looked like the player. Icons only since 2026-10-22. "What is that?" (top right): Game Icons `question` |
 | ✅ banners | `character_green_hit` (lost a life), `hud_heart_empty` (game over), `flag_green_a` (level cleared); favicon `bomb` |
-| ✅ exit (locked / open) | `door_closed` + `door_closed_top`, `door_open` + `door_open_top`, squeezed into one tile. (`lock_yellow` spare.) |
+| ✅ exit (locked / open) | `door_closed_top` / `door_open_top` alone, filling the tile: a short arched doorway (user request, 2026-10-23; until then the two-tile door with `door_closed` / `door_open`, squeezed into one tile, which the user found too tall). (`lock_yellow` spare.) |
 | ✅ coins | `coin_bronze` (1), `coin_silver` (2), `coin_gold` (5), bobbing; `hud_coin` in the HUD |
-| ✅ action charges | Shield `switch_blue`, Freeze `chain`, Diagonal `ramp`, Pierce `spikes`, Power `weight`, Line `bridge_logs`, Decoy `hud_player_helmet_green` (drops, buttons; Pierce / Power also as a badge on their bombs) |
+| ✅ charges | *Since 2026-10-23 (user's choices):* Pierce `powerup_wall_pass`, Power `powerup_fire_plus`, Nuke `charge_nuke` (bomb + Board Game Icons `skull` badge), Diagonal `charge_diagonal` (Move figure + `arrow_diagonal_cross` badge), Shield `charge_shield` (Move figure in the empty ring `timer_0`), Freeze `charge_freeze` (Game Icons `locked`), Decoy `charge_decoy` (`pawns`); built by `tools/compose-icons.sh`. On drops, in the top bar, and Pierce / Power / Nuke as badges on their bombs. (Until then: `switch_blue`, `chain`, `ramp`, `spikes`, `weight`, `bridge_logs`, `hud_player_helmet_green`.) |
 | ✅ decoy | `character_green_front`, faded |
-| ✅ auto-move flag | `flag_green_a` |
+| ✅ auto-move | The Go button keeps Move's walk icon, with a green border (until 2026-10-23 it turned into `flag_green_a`). On the board, since 2026-10-23 (user request), the whole way is highlighted blue, destination included, with no flag on the destination and no dots. |
 | ✅ intent arrows | drawn red lines with a head, not sprites |
-| ✅ power-ups | badged (`assets/composed/`): `powerup_bomb_plus` Bomb+ (bomb + plus), `powerup_fire_plus` Fire+ (bomb + larger), `powerup_fuse_plus` Fuse+ (bomb + timer), `powerup_run` Run (the Move figure + fast-forward), `powerup_kick` Kick (bomb + arrow right), `powerup_throw` Throw (bomb + arrow up-right), `powerup_teleport` Teleport (Move figure + outbox), `powerup_detonator` Detonator (bomb + !), `powerup_wall_pass` Wall Pass (crate + ✕), `powerup_bomb_pass` Bomb Pass (bomb + shield), `powerup_flame_pass` Flame Pass (fireball + shield); `star` Star (and a gold ring), `heart` Heart, `hud_coin` Coin bag; on a yellow glow. (Replaced and removed on 2026-10-22: the gems, `spring_out`, `sign_right`, `bush`, `ladder_top`, `torch_off`, `window`.) HUD: `icon_fuse` fuse (the Fuse +1 badge's timer, ink, white on the bar; the torch `torch_on_a` until 2026-10-22), small pass icons. |
+| ✅ power-ups | badged (`assets/composed/`): `powerup_bomb_plus` Bomb+ (bomb + plus), `powerup_fire_plus` Fire+ (bomb + larger), `powerup_fuse_plus` Fuse+ (bomb + timer), `powerup_run` Run (the Move figure + fast-forward), `powerup_kick` Kick (bomb + arrow right), `powerup_throw` Throw (bomb + arrow up-right), `powerup_teleport` Teleport (Move figure + outbox), `powerup_detonator` Detonator (bomb + !), `powerup_wall_pass` Wall Pass (crate + ✕), `powerup_bomb_pass` Bomb Pass (bomb + shield), `powerup_flame_pass` Flame Pass (fireball + shield); `star` Star (and a gold ring), `heart` Heart, `hud_coin` Coin bag; in the gem's choice dialog and the almanac. On the board, since 2026-10-23, every level's pickup is a gem, `gem_blue` / `gem_green` / `gem_red` / `gem_yellow` (random colour), with no glow (the yellow glow was removed on 2026-10-23, user request). (Replaced and removed on 2026-10-22: the gems as fixed power-ups, `spring_out`, `sign_right`, `bush`, `ladder_top`, `torch_off`, `window`.) HUD: `icon_fuse` fuse (the Fuse +1 badge's timer, ink, white on the bar; the torch `torch_on_a` until 2026-10-22), small pass icons. |
 | ✅ Blob | `slime_normal_rest` (nowhere to go), `_walk_a` / `_walk_b` alternating; mirrored when heading right. `_flat` spare (the death animation flips `_rest` over instead, the same for every enemy). |
 | ✅ Seeker | `fly_rest`, `fly_a`, `fly_b`, as the Blob |
 | ✅ Drifter | `saw_rest`, `saw_a`, `saw_b`; spins while moving |
@@ -555,6 +583,24 @@ settings, sound, and most action charges) are still open.
 
 ---
 
+## 13a. The demo (user request, 2026-10-23; built)
+
+Some players never played the original, so the game opens with a demo: generated scenes,
+replayed view-only, a caption under each, until the player taps or presses a key (that input
+does nothing else; then the saved run, or a new one). Scenes are made **at play time**
+(`sim/demo.ts`, pure and seeded): a 9 × 7 room (walls, pillars on every other tile, crates on
+about one tile in six, the player and 1–3 enemies apart), then up to 150 random games on it by
+the real rules — mostly moves, sometimes bombs, never a move that kills the player — until one
+shows the scene's moment; the shortest of those is the scene (a new room if none does).
+Kinds: **kill** (every enemy dead), **escape** (the player steps out of a blast on the turn it
+goes off), **chain** (two bombs go off together and kill), **freeze** (a Freeze picked up, then
+a frozen enemy killed), **detonate** (the Detonator sets a bomb off and it kills). All five are
+found on every seed tried, in under 0.2 s. The UI (`main.ts`: `startDemo`, `demoTick`) plays a
+turn every 0.6 s through the same animation code as play, hides the top bar and buttons, and
+fits the room above the caption panel. A scene about a charge or power-up (Freeze, the Detonator)
+shows its icon beside the caption, the same icon as on the board, in the top bar and the almanac. Ideas: more kinds (the exit, a gem's choice, Kick and
+Throw), and showing it again after the player is idle on the menu.
+
 ## 13b. Run options (user request, 2026-10-21; built)
 
 Chosen in the **New run** dialog (menu, or after game over / victory), remembered in
@@ -565,7 +611,7 @@ and state (`GameState.options`), so replays are exact. Shown as tags in the top
 |---|---|
 | **Board: Normal** | 31 × 13, density 35%, 50 levels, enemies 3 + 1 per 3 levels (max 10), par 50 + 10/enemy. |
 | **Board: Small** | 9 × 11 (fits a phone whole; the camera scales to fit both ways, no scrolling), density 25%, **20 levels**, enemies 2 + 1 per 4 levels (max 5), par 25 + 8/enemy. Same enemy-type schedule and Run rule. Levelgen tops up crates if fewer than 2 are far enough for the exit and power-up. |
-| **Developer** | Every ability and pass, 5 bombs, blast 5, unlimited charges (never spent; badges show ∞). Re-applied every level and after every death. **Killable** (the user split it from God mode). Second-row buttons pack five to a row when there are more than 8. |
+| **Developer** | *Since 2026-10-23 (user request), only this:* every level's gem lies uncovered right of the start, every level and after every death, to try the choice at once. (Until then: every ability and pass, 5 bombs, blast 5, unlimited charges shown as ∞; **killable**, split from God mode by the user.) |
 | **God** | The player can't be hurt at all. |
 | **Crumbling crates** | When the last enemy dies, every crate gets a countdown of 1–3 turns (seeded, `hash(seed, "crumble", level)`), shown by shaking and fading; at 0 it is gone, revealing whatever was under it. It is not flame: no punishment, nothing burned. |
 
@@ -672,7 +718,7 @@ src/
 assets/kenney/         Kenney New Platformer Pack sprites + License.txt (§13)
 static/                Vite publicDir (NOT public/ — see §17)
 test/                  Vitest
-tools/                 icons.mjs (PWA icons), screenshots.mjs (Playwright)
+tools/                 compose-icons.sh (composed sprites and the app icons), screenshots.mjs (Playwright)
 ```
 
 - `step()` **returns events** (`moved`, `bombPlaced`, `bombTicked`, `exploded`, `blockDestroyed`,
@@ -697,7 +743,10 @@ tools/                 icons.mjs (PWA icons), screenshots.mjs (Playwright)
 - `tsconfig.json`: copy nordic-skating's (strict, `noUncheckedIndexedAccess`, …).
 - `vite.config.ts`: `base: process.env.BASE || './'`, `publicDir: 'static'`, QR plugin, PWA
   (display fullscreen, icons, offline cache incl. the sprites). Read nordic-skating's file — its
-  comments explain each choice.
+  comments explain each choice. *Built 2026-10-23 (user request) without a PWA plugin:*
+  `static/manifest.webmanifest` (display fullscreen), icons in `static/icons/` from
+  `tools/compose-icons.sh`, and `static/sw.js`, a network-first service worker that keeps a copy
+  of everything loaded (so the game starts offline) and registers only in the build.
 - `.gitlab-ci.yml`: copy nordic-skating's (test stage, then `pages` with `mv dist public` and the
   "fail loudly" checks; add a check that the sprites were built). Project at
   `gitlab.com/PavelSafronov/explodorama`, served at `https://pavelsafronov.gitlab.io/explodorama/`.
@@ -723,12 +772,12 @@ 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 (all 11, one hidden per level, Run on level 1 or 2),
+7. **M6 – Run loop:** ✅ NES pickups (all 11, one hidden per level, Run on level 1 or 2; since 2026-10-23 a gem offering 5 to choose from, no Run guarantee),
    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 (2026-10-22: everything that appears, moves, changes or
    goes away on the board, the top bar's numbers, the banner, and dialogs opening and closing);
-   ⏳ sounds (Kenney pack, §13), PWA icons, remaining
+   ✅ installable with offline play (2026-10-23); ✅ the demo before the game (2026-10-23, §13a); ⏳ sounds (Kenney pack, §13), remaining
    enemies and actions, screenshots tool.
 9. **M8 – Permanence:** Workshop, save export/import, victory certificate + QR, replay file and
    verify page.
diff --git a/README.md b/README.md
index e773981..ee44415 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@ counts down with *your* moves, so nothing happens until you act — and you can
 what will burn before you commit.
 
 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
+seven charges that work when picked up, a hidden gem on every level that offers a choice of 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).
 
@@ -15,10 +15,19 @@ see [PLAN.md](PLAN.md).
 
 ## How to play
 
+**The demo:** when the game opens, it first plays a demo — short scenes made up on the spot from
+the real rules, each with a caption: bombs counting down and killing enemies, a narrow escape
+from a blast, a chain reaction, Freeze, and the Detonator; a scene about a charge or power-up
+shows its icon beside the caption. Every time it's different. It is
+view-only; **tap anywhere (or press any key) to play**, and that tap does nothing else. A test
+URL (`?seed` and the like) skips it; `?demo` shows it anyway.
+
+<img src="docs/screenshots/demo.png" alt="The demo: a bomb about to go off in a small room, with the caption underneath" width="260">
+
 You are the green astronaut (animated: it bobs while waiting, hops from tile to tile, crouches
-to place a bomb and jumps when a level is cleared; enemies hop between tiles too, and flip over
+to place a bomb, and walks into the doorway and jumps when a level is cleared; enemies hop between tiles too, and flip over
 when they die). Everything else on the board moves too: bombs pop in, swell as they count down,
-slide when kicked and fly when thrown; crates burst; flame fades out; drops and power-ups rise
+slide when kicked and fly when thrown; crates burst; flame fades out; drops and the gem rise
 away when taken. A number in the top bar bumps when it changes, and dialogs pop up and shrink
 away. **Goal of each level:** destroy every enemy, uncover the exit door
 hidden under one of the crates, and step onto it.
@@ -27,48 +36,52 @@ Each turn you pick **one** action; then the world takes its turn, in this order:
 where their arrows pointed, bombs count down and explode, and enemies show their next step.
 
 The action buttons are **icons only** (a game-piece figure = Move, a bomb = Bomb, an hourglass
-= Wait). **Picking one shows a strip above the buttons that explains what it does
-right now** — it lists the upgrades you have, so it grows as you pick them up. Upgrades don't
-add buttons: they make Move and Bomb do more.
+= Wait). Picking one turns it green and highlights the tiles it can use. There is **no
+explanation text during play** (user request): what each power-up and charge does is shown when
+you first get it, and **?** then any button explains that button. Upgrades don't add buttons:
+they make Move and Bomb do more.
 
 | Action | Phone | Keyboard | What it does |
 |---|---|---|---|
-| **Move** | Tap a tile next to you, or tap **Move** and then any highlighted tile | W A S D or arrow keys | Step one tile up, down, left or right onto an empty tile. **With the Run power-up:** or 2 tiles in a straight line. **With Teleport:** or any empty tile up to 3 steps away, over anything. **With Kick:** walking into a bomb kicks it. **With Diagonal charges:** or a diagonal step (uses one). |
-| **Bomb** | Tap **Bomb**, then a highlighted tile — or **press and hold** a tile next to you | **B**, then a direction | Place a bomb on an empty tile next to you. You can have 1 bomb on the board at a time. **With Throw:** or 2 tiles away in a straight line, over anything. **With Pierce / Power / Line charges:** pick one of them in the strip first. |
+| **Move** | Tap a tile next to you, or tap **Move** and then any highlighted tile | W A S D or arrow keys | Step one tile up, down, left or right onto an empty tile. **With the Run power-up:** or 2 tiles in a straight line. **With Teleport:** or any empty tile up to 3 steps away, over anything. **With Kick:** walking into a bomb kicks it. **With Diagonal:** or a diagonal step. |
+| **Bomb** | Tap **Bomb**, then a highlighted tile — or **press and hold** a tile next to you | **B**, then a direction | Place a bomb on an empty tile next to you. You can have 1 bomb on the board at a time. **With Throw:** or 2 tiles away in a straight line, over anything. Pierce, Power and Nuke charges change your next bombs by themselves (see Charges). |
 | **Wait** | Tap **Wait**, or tap **yourself** | Space | Do nothing; the world takes its turn. |
 
 When Move or Bomb can do several things, the tile you tap decides: next to you is a step (or a
 kick, into a bomb), 2 straight is a run, further is a teleport; next to you is a bomb, 2 away a
-throw. A Diagonal charge is only spent when nothing free reaches that tile.
+throw. A diagonal step is only taken when nothing else reaches that tile.
 
 **Auto-move:** tap any tile further away that you can walk to — empty floor, the exit, the
-power-up or a drop. A flag marks it, dots show the way, and the
-**Move** button becomes **Go**: each press of Go — or each tap on the flag itself — takes **one
-step** along the way (one turn, as always — the world still moves between steps). The way is
-worked out again every turn. Tapping another far tile moves the flag there. Auto-move ends when
-you arrive, when you **move somewhere off the dotted way** (you changed your mind), when the flag
-can no longer be reached, when you tap a wall or crate, or on **Esc**.
+gem or a drop. **You take the first step at once**, just as a tap next to you would (if that
+step is safe). Every tile of the rest of the way, including where you're going, is highlighted
+blue, and the **Move** button becomes **Go** (same walk icon, with a green border): each press of
+Go — or each tap on the destination itself — takes **one step** along the way (one turn, as always — the world still
+moves between steps). The way is worked out again every turn. Tapping another far tile sets a
+new way. Auto-move ends when you arrive, when you **move somewhere off the blue way** (you
+changed your mind), when the destination can no longer be reached, when you tap a wall or crate, or on **Esc**. The way **never crosses
+the open exit** (that would end the level on the way); it only steps onto the exit when the
+exit is where you are going. If the only way somewhere is over the open exit, auto-move can't go there.
 It **won't take a deadly step**: onto an enemy, onto a tile an enemy's arrow points at, or into
-flame this turn. The next step then shows red, and Go / the flag wait for you to deal with it
+flame this turn. The next step then shows red, and Go / the destination wait for you to deal with it
 (Go falls back to picking Move, so you can step aside yourself).
-**Once every enemy is gone**, the flag needs no more presses: you walk there by yourself, one
+**Once every enemy is gone**, the way needs no more presses: you walk there by yourself, one
 step a second (still one turn per step). **With the Run power-up**, a step of auto-move where the way goes
 straight on for 2 tiles is a Run instead (one turn for both), if both tiles are safe.
 
-Tapping an enemy, a bomb, burning flame, a wall or a crate does nothing (apart from clearing the
-auto-move flag, for a wall or crate; and with Kick, tapping a bomb next to you kicks it) — a tap
+Tapping an enemy, a bomb, burning flame, a wall or a crate does nothing (apart from ending
+auto-move, for a wall or crate; and with Kick, tapping a bomb next to you kicks it) — a tap
 never walks into an enemy.
 
 **What is that?** Tap the round **?** button at the top right, below the menu on a phone and beside it on a wider screen (or press **?**),
 then tap anything on the board — or any action button — to see what it is and what it does: an **enemy** (with its next moves spelled out), a **bomb**,
-burning flame, a wall or crate, the exit, the power-up, a drop. The **?** turns green while it is
+burning flame, a wall or crate, the exit, the gem, a drop. The **?** turns green while it is
 on; it works for one tap, and tapping it again or **Esc** turns it off. For things you can walk
-to, the dialog has a **Walk there** button that sets the auto-move flag. The items in the **top
+to, the dialog has a **Walk there** button that starts auto-move there. The items in the **top
 bar** (lives, level, enemies left, coins, turn / par, bombs, blast, fuse, passes, Star) explain
 themselves with a plain tap.
 
 **Menu:** the round button at the top right (or **Esc** with nothing picked). It shows the level
-and seed, and has the **Almanac** — every power-up, action charge and enemy type, with its
+and seed, and has the **Almanac** — every power-up, charge and enemy type, with its
 picture and what it does, in three tabs — and **New run**, which asks first, since the current
 run can't be got back.
 
@@ -76,23 +89,24 @@ run can't be got back.
 support it) to arm a bomb there: its blast shows, and letting go places it. Sliding off the
 tile first cancels it.
 
-**Action charges** are one-use actions that enemies sometimes drop; using one spends one and
-takes your turn. Shield, Freeze and Decoy get their own icon button in a second row, with
-a badge showing how many you hold. **Nothing goes off from a button alone** (except Wait):
-Shield and Freeze open the strip, which explains them and has a **Use** button to confirm (or
-press **Enter**); Decoy then wants a tile. Diagonal, Pierce, Power and Line have no button:
-they are part of Move and Bomb. The first time in a run you pick up each kind of charge, a
-dialog says what it does; after that, picking up more of the same kind shows nothing.
-
-| Charge | Icon | Phone | Keyboard | What it does |
-|---|---|---|---|---|
-| **Shield** | blue dome | Tap **Shield**, then **Use Shield** | **H**, then **Enter** | A ring appears round you. The next hit (flame or enemy) is absorbed, and so is anything else that hits you in that same turn. |
-| **Freeze** | chain | Tap **Freeze**, then **Use Freeze** | **F**, then **Enter** | Every enemy freezes in place for **this turn and the next 3**: they turn blue inside a block of ice and thaw a step each turn, then carry on with the moves their (faded, icy) arrows show. |
-| **Decoy** | helmet | Tap **Decoy**, then a highlighted tile | **Y**, then a direction | A pale copy of you. Enemies that chase you (Seekers) chase it instead for 3 turns. Flame destroys it; it doesn't block anyone. |
-| **Diagonal** | ramp | Tap **Move**, then a diagonal tile | **G**, then **Q E Z C** (up-left, up-right, down-left, down-right) | Step one tile diagonally — even squeezing between two blocked tiles. |
-| **Pierce** | spikes | Tap **Bomb**, pick **Pierce** in the strip, then a tile | **I**, then a direction | A bomb whose flame goes **through** crates, burning every one in reach. Marked with a spikes badge. |
-| **Power** | weight | Tap **Bomb**, pick **Power**, then a tile | **O**, then a direction | A bomb whose flame reaches all the way to the walls (crates still stop it). Marked with a weight badge. |
-| **Line** | logs | Tap **Bomb**, pick **Line**, then a tile | **L**, then a direction | Places as many bombs as you're still allowed, in a row starting at that tile, until the row is blocked. |
+**Charges** are things enemies sometimes drop. They aren't actions and have no buttons: **each
+works the moment you pick it up**, as a temporary change (user request, 2026-10-23). The top bar
+shows what each still has to give — bombs or turns left — and tapping it there explains
+it. Picking up more of a kind adds to it. What they still give goes on to the next level and
+through a death. The first time in a run you pick up each kind, a dialog says what it does.
+(Diagonal was a charge until 2026-10-23; it is a power-up now.)
+
+| Charge | Icon | What it does |
+|---|---|---|
+| **Pierce** | the Wall Pass icon (crate with ✕) | Your next **3 bombs'** flame goes **through** crates, burning every one in reach. |
+| **Power** | the Fire +1 icon | Your next **3 bombs'** flame reaches all the way to the walls (crates still stop it). |
+| **Nuke** | bomb with a skull badge | Your next **3 bombs** also burn **every tile within 2 steps** (across plus down), through crates and round corners; walls still stand. It can catch you too: get 3 steps away. |
+| **Shield** | the Move figure in a ring | A ring appears round you: the next hit (flame or enemy) is absorbed, and so is anything else that hits you that turn. Then it's gone. |
+| **Freeze** | a padlock | Every enemy freezes in place for **3 turns**, the pickup turn included: they turn blue inside a block of ice and thaw a step each turn, then carry on with the moves their (faded, icy) arrows show. |
+| **Decoy** | three pawns | A pale copy of you appears where you picked it up. Enemies that chase you (Seekers) chase it instead for 3 turns. Flame destroys it; it doesn't block anyone. |
+
+A bomb that Pierce, Power or Nuke changed wears their icons as small badges, and one bomb can
+have all three: each bomb you place (or throw) uses one of each that you hold.
 
 With an action picked, holding or hovering a highlighted tile previews what it will do: where you
 will end up, a faded bomb with its blast, a kicked bomb's stopping place, a changed number.
@@ -117,6 +131,8 @@ will end up, a faded bomb with its blast, a kicked bomb's stopping place, a chan
   (Flame Pass, a Star), then you can walk through it like floor. It goes out after that turn.
 - Bombs block movement, for you and for enemies.
 - Bombs can't be placed on an enemy.
+- Pierce, Power and Nuke charges change your next bombs (see Charges above); such a bomb wears
+  their icons as badges.
 
 ### Danger
 
@@ -134,70 +150,86 @@ of its tile: the top arrow is its next move (strongest), the ones below are the
 moves (fainter). It makes exactly
 those moves, one per turn, so you can time a bomb for where it will be. No arrows (and closed
 eyes): it has nowhere to go and stays put. If you put a bomb on a tile it planned to walk to,
-it stops there and plans a new route. Touching an enemy costs a life — walking into it, it
-walking into you, or the two of you swapping places. Flame kills enemies.
+it stops there and plans a new route. Enemies keep clear of each other: each plans its moves
+around where the others will be, so a group (like the 3 let out of a blasted exit) splits up
+instead of moving as one; they only share a tile when there's nowhere else to go. Touching an
+enemy costs a life — walking into it, it walking into you, or the two of you swapping places.
+Flame kills enemies.
 
 | Enemy | Looks like | Appears from | Moves |
 |---|---|---|---|
 | **Blob** | purple slime | level 1 | Wanders; usually keeps going the way it was heading. |
 | **Seeker** | fly | level 7 | Half the time takes the shortest way toward you; otherwise wanders like a Blob. |
-| **Drifter** | spinning saw | level 13 | Goes straight until blocked, then turns. |
+| **Drifter** | spinning saw | level 13 | Goes straight until blocked (or another enemy is heading there), then turns. |
 
 Level 1 has 3 enemies, with one more every 3 levels (at most 10). Enemies never start within
 4 tiles of you.
 
 ### Drops and coins
 
-A slain enemy sometimes leaves **coins** (bronze = 1, silver = 2, gold = 5) or an **action
-charge** on its tile. Drops never share a tile: if one is already there, the new one rolls to the
+A slain enemy sometimes leaves **coins** (bronze = 1, silver = 2, gold = 5) or a **charge**
+(which works as soon as you pick it up) on its tile. The last enemy's charge (when no enemies are
+left after it) is always one that lasts into the next level — Pierce, Power, Nuke or Shield —
+never a Freeze or Decoy, which would have nothing to work on. Drops never share a tile: if one is already there, the new one rolls to the
 nearest free floor tile (up to 2 steps away). Walk onto them to collect them. A later flame burns drops lying in the
 open, so pick them up soon. Coins will buy upgrades in the shop (not built yet).
 
 ### The exit
 
-- The exit door is hidden under one of the crates. Burning that crate reveals it.
+- The exit door (a short arched doorway: orange while locked, dark once open) is hidden under
+  one of the crates. Burning that crate reveals it.
 - The door stays **locked** while any enemy is alive (top-bar enemy count). When the last one
   dies, it opens; step onto it to finish the level.
 - **Don't blast the uncovered door:** flame on it lets out 3 more enemies (of the level's
-  toughest type). Those drop nothing.
+  toughest type). Those drop nothing. They can't be hurt by the flame that let them out (it
+  lingers a turn), but a new explosion kills them as usual.
 - **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. **Run is always on level 1 or 2.**
-When you pick one up, a dialog says what it does.
-
-| Power-up | Looks like | Effect | Lost when you die? |
-|---|---|---|---|
-| **Bomb +1** | bomb with a + badge | One more bomb on the board at a time. | no |
-| **Fire +1** | bomb with an expanding-arrows badge | Blast reaches one tile further. | no |
-| **Fuse +1** | bomb with a timer badge | Your bombs start one higher (more time to get away). | no |
-| **Run** | the Move figure with a fast-forward badge | **Move** can run: 2 tiles in a straight line in one turn (or **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 |
-| **Kick** | bomb with a right-arrow badge | Walk into a bomb next to you and it slides away until the next tile is blocked (a wall, crate, bomb or enemy), keeping its number. | no |
-| **Throw** | bomb with an up-right-arrow badge | **Bomb** can also go exactly 2 tiles away in a straight line, over anything in between (or **T**, then a direction). | no |
-| **Teleport** (rare) | the Move figure with an outbox badge | **Move** can take you to any empty tile within 3 steps (counting across and down), over anything (or **P**, then tap a tile). | no |
-| **Detonator** | bomb with a ! badge | Your bombs pulse with a red glow: **tap one** and it goes off this turn (or **X**, then tap it). No button. | yes |
-| **Wall Pass** | crate with an ✕ badge | Walk through crates. | yes |
-| **Bomb Pass** | bomb with a shield badge | Walk over bombs. | yes |
-| **Flame Pass** | flame with a shield badge | Flame doesn't hurt you (enemies still do), and you can walk through it. | yes |
-| **Star** | star | Nothing hurts you for 8 turns (a gold ring; the top bar counts down); you can walk through flame meanwhile. | yes |
-| **Heart** | heart | One more life. | — |
-| **Coin bag** | coin | 10 coins. | — |
-
-Run, Kick, Throw and Teleport (user request, 2026-10-22: they were one-use charges before)
-add no buttons — they make Move and Bomb do more, and the strip says how. The Detonator adds
-none either: your bombs pulse, and a tap on one sets it off. The top bar shows your fuse and a small icon for each
-ability and pass you hold.
+Every level hides **one gem** under a crate (never the exit's). Burn the crate to reveal it — it
+is in a random colour (the colour means nothing) — 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 (and, as there,
+those enemies can't be hurt by the flame that let them out).
+
+**Taking the gem offers 5 different power-ups, and you choose one.** The dialog shows each with
+what it does; it can't be closed without choosing, and choosing takes no turn. The 5 are drawn
+at random, but not all are equally likely: common ones (Bomb +1, Fire +1) come up far more often
+than rare ones (Teleport, Heart). An ability or pass you already hold is never offered. The
+offer is fixed by the seed and level, so a retry after a death offers the same. If you die on the turn you take the gem, there
+is no choice, and the retry has the gem back. If the same step also reaches the exit, you choose
+before moving on.
+
+| Power-up | Looks like | Effect |
+|---|---|---|
+| **Bomb +1** | bomb with a + badge | One more bomb on the board at a time. |
+| **Fire +1** | bomb with an expanding-arrows badge | Blast reaches one tile further. |
+| **Fuse +1** | bomb with a timer badge | Your bombs start one higher (more time to get away). |
+| **Run** | the Move figure with a fast-forward badge | **Move** can run: 2 tiles in a straight line in one turn (or **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. |
+| **Kick** | bomb with a right-arrow badge | Walk into a bomb next to you and it slides away until the next tile is blocked (a wall, crate, bomb or enemy), keeping its number. |
+| **Throw** | bomb with an up-right-arrow badge | **Bomb** can also go exactly 2 tiles away in a straight line, over anything in between (or **T**, then a direction). |
+| **Teleport** (rare) | the Move figure with an outbox badge | **Move** can take you to any empty tile within 3 steps (counting across and down), over anything (or **P**, then tap a tile). |
+| **Diagonal** | the Move figure with a diagonal-arrows badge | **Move** can also step diagonally, even squeezing between two blocked tiles (or **G**, then **Q E Z C**: up-left, up-right, down-left, down-right). A charge until 2026-10-23. |
+| **Detonator** | bomb with a ! badge | Your bombs pulse with a red glow: **tap one** and it goes off this turn (or **X**, then tap it). No button. |
+| **Wall Pass** | crate with an ✕ badge | Walk through crates. |
+| **Bomb Pass** | bomb with a shield badge | Walk over bombs. |
+| **Flame Pass** | flame with a shield badge | Flame doesn't hurt you (enemies still do), and you can walk through it. |
+| **Star** | star | Nothing hurts you for 8 turns (a gold ring; the top bar counts down); you can walk through flame meanwhile. |
+| **Heart** | heart | One more life. |
+| **Coin bag** | coin | 10 coins. |
+
+Run, Kick, Throw, Teleport and Diagonal (they were one-use charges before) add no buttons — they
+make Move and Bomb do more (**?** then the button says how). The Detonator adds none either:
+your bombs pulse, and a tap on one sets it off. The top bar shows a small icon for each ability
+and 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, charges, stats and Run, 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
+again with the same layout and enemies. **You keep everything else**: coins, stats, every
+power-up (abilities and passes included) and what your charges still give. (The original took
+the passes and the Detonator away; since 2026-10-23 a death costs only the life.) With none left, the run is over and the seed is shown, so the same board can be
 replayed.
 
 ### New run: board size and modes
@@ -208,12 +240,12 @@ replayed.
 |---|---|
 | **Normal** board | 31 × 13, scrolls sideways, **50 levels**. |
 | **Small** board | 9 × 11, fits the screen whole (no scrolling), **20 levels**, fewer enemies (2 on level 1, up to 5) and fewer crates. A quicker game. |
-| **Developer** (checkbox) | Every power-up and ability (Run, Detonate, all three passes), **unlimited** action charges (badges show ∞), 5 bombs, blast 5 — for trying everything out. You can still be killed. |
+| **Developer** (checkbox) | **Every level's gem lies uncovered right next to you** at the start (and after a death), to try the power-up choice at once. Nothing else changes. |
 | **God** (checkbox) | Nothing can kill you. |
-| **Crumbling crates** (checkbox) | Once the last enemy is gone, every crate crumbles away over 1–3 turns (shaking first), revealing the exit and the power-up. Not flame: nothing is punished. |
+| **Crumbling crates** (checkbox) | Once the last enemy is gone, every crate crumbles away over 1–3 turns (shaking first), revealing the exit and the gem. Not flame: nothing is punished. |
 
-The choice is remembered for next time, is fixed for the run, and shows as small yellow tags at
-the left of the top bar (tap them for details) and in the menu. Starting a new run while one is
+The choice is remembered for next time, is fixed for the run, and shows as small yellow tags at the start of
+the top bar's last row (tap them for details) and in the menu. Starting a new run while one is
 going warns that it will be lost.
 
 ### The run
@@ -225,7 +257,10 @@ coming back continues the run where you left it.
 ### The top bar
 
 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.
+fuse, then — in their own row — the passes you hold, a Star's countdown, and what your charges
+still give (bombs or turns left, or the Shield while it's up). The bar has a fixed number of
+rows (three on a phone or a narrow screen, one on a wide one), and a row that gets too long
+scrolls sideways instead of growing, so **the board never moves** as you pick things up.
 
 ### The board
 
@@ -237,19 +272,29 @@ camera follows you sideways.
 
 ```sh
 npm install
-npm run dev          # http://localhost:5173 — also prints a QR code to open it on a phone on the same Wi-Fi
+npm run dev          # http://localhost:5173 — also prints a QR code to open it on a phone on the same Wi-Fi,
+                     # and the game's console output and errors, from any device, in this terminal
 npm test             # Vitest
 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)
 ```
 
 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; `?small`, `?dev`, `?god` and `?crumble` set the board size and modes. A URL with any of these parameters plays a test board: it doesn't load or overwrite your
+has all three enemy types); `?charges=2` starts as if 2 of each lasting charge had been picked up
+(6 Pierce, Power and Nuke bombs, and a Shield), for trying them out; `?small`, `?dev`, `?god` and `?crumble` set the board size and modes; `?demo` shows the demo, which test URLs otherwise skip (with `?seed`, the same demo every time). 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.
 
+**Installing it:** the game is an installable web app (`static/manifest.webmanifest`, icons in
+`static/icons/`, built by `tools/compose-icons.sh`). In Chrome or Edge, use the install button in
+the address bar (or the menu's "Install"); on Android, "Add to Home screen" / "Install app"; on an
+iPhone or iPad, Safari's Share → "Add to Home Screen". It then opens in its own window, full
+screen on a phone. A small service worker (`static/sw.js`, network first) keeps a copy of
+everything it has loaded, so once played it also starts **offline**; online, it always loads the
+latest version.
+
 ## How it's built
 
 - **`src/sim/`** — all the game's rules, in plain TypeScript with no rendering. Deterministic:
diff --git a/assets/composed/charge_decoy.png b/assets/composed/charge_decoy.png
new file mode 100644
index 0000000..7faab89
Binary files /dev/null and b/assets/composed/charge_decoy.png differ
diff --git a/assets/composed/charge_freeze.png b/assets/composed/charge_freeze.png
new file mode 100644
index 0000000..72e8b92
Binary files /dev/null and b/assets/composed/charge_freeze.png differ
diff --git a/assets/composed/charge_nuke.png b/assets/composed/charge_nuke.png
new file mode 100644
index 0000000..b7a1031
Binary files /dev/null and b/assets/composed/charge_nuke.png differ
diff --git a/assets/composed/charge_shield.png b/assets/composed/charge_shield.png
new file mode 100644
index 0000000..3562213
Binary files /dev/null and b/assets/composed/charge_shield.png differ
diff --git a/assets/composed/powerup_diagonal.png b/assets/composed/powerup_diagonal.png
new file mode 100644
index 0000000..3135433
Binary files /dev/null and b/assets/composed/powerup_diagonal.png differ
diff --git a/assets/kenney-board-icons/arrow_diagonal_cross.png b/assets/kenney-board-icons/arrow_diagonal_cross.png
new file mode 100644
index 0000000..7f07fe1
Binary files /dev/null and b/assets/kenney-board-icons/arrow_diagonal_cross.png differ
diff --git a/assets/kenney-board-icons/pawns.png b/assets/kenney-board-icons/pawns.png
new file mode 100644
index 0000000..5da1582
Binary files /dev/null and b/assets/kenney-board-icons/pawns.png differ
diff --git a/assets/kenney-board-icons/skull.png b/assets/kenney-board-icons/skull.png
new file mode 100644
index 0000000..4be93c8
Binary files /dev/null and b/assets/kenney-board-icons/skull.png differ
diff --git a/assets/kenney-board-icons/timer_0.png b/assets/kenney-board-icons/timer_0.png
new file mode 100644
index 0000000..a33521d
Binary files /dev/null and b/assets/kenney-board-icons/timer_0.png differ
diff --git a/assets/kenney-icons/White/locked.png b/assets/kenney-icons/White/locked.png
new file mode 100644
index 0000000..22189be
Binary files /dev/null and b/assets/kenney-icons/White/locked.png differ
diff --git a/assets/kenney/door_closed.png b/assets/kenney/door_closed.png
deleted file mode 100644
index 93a02c6..0000000
Binary files a/assets/kenney/door_closed.png and /dev/null differ
diff --git a/assets/kenney/door_open.png b/assets/kenney/door_open.png
deleted file mode 100644
index b9c6f56..0000000
Binary files a/assets/kenney/door_open.png and /dev/null differ
diff --git a/assets/kenney/gem_blue.png b/assets/kenney/gem_blue.png
new file mode 100644
index 0000000..7cd3ec8
Binary files /dev/null and b/assets/kenney/gem_blue.png differ
diff --git a/assets/kenney/gem_green.png b/assets/kenney/gem_green.png
new file mode 100644
index 0000000..7ccce2e
Binary files /dev/null and b/assets/kenney/gem_green.png differ
diff --git a/assets/kenney/gem_red.png b/assets/kenney/gem_red.png
new file mode 100644
index 0000000..953761c
Binary files /dev/null and b/assets/kenney/gem_red.png differ
diff --git a/assets/kenney/gem_yellow.png b/assets/kenney/gem_yellow.png
new file mode 100644
index 0000000..1e7acfd
Binary files /dev/null and b/assets/kenney/gem_yellow.png differ
diff --git a/decisions.md b/decisions.md
index b114970..efc27a3 100644
--- a/decisions.md
+++ b/decisions.md
@@ -322,7 +322,11 @@ bombs teleported), with flame fading out over about half a second.
   top-bar item that changed (a number, a heart, a pass arriving) is replaced and bumps (scales
   to 1.3 and back in 0.3 s); unchanged items are kept as they are, so a bump isn't cut short by
   the next update. Dialogs close with a 0.15 s shrink-and-fade — buttons, the close button, the
-  backdrop and Esc alike — and a button's action runs once it has closed.
+  backdrop and Esc alike — and a button's action runs once it has closed. *Fixed 2026-10-22
+  (user report: hearts, flag and enemies pulsed every turn):* the kept items are re-attached on
+  every update, and one still carrying the `bump` class played it again each time, so anything
+  that had changed once kept pulsing. The class now comes off when the bump ends
+  (`animationend`). The turn counter still bumps every turn, because it changes every turn.
 - **No input lock** (PLAN.md planned `animate.ts` to own one). The game is turn-based and the
   sim has already moved on, so a quick next tap simply starts the next turn's animations over
   the end of the last ones; locking input would only slow play down.
@@ -364,7 +368,8 @@ charges or permanent, kick on a plain tap, how far to fold, where to explain.
   **Fuse +1 and −1 became one Fuse button** with +1 / −1 chips. Shield, Freeze, Decoy and
   Detonate keep icon buttons, in a second row of up to five.
 - **The explanation strip** (user's choice: above the action bar) sits over the board's bottom
-  edge, so the board doesn't move when it appears. It lists only the upgrades held, so picking
+  edge, so the board doesn't move when it appears. *Superseded 2026-10-23: it must not cover the
+  board; see "The strip gets its own band".* It lists only the upgrades held, so picking
   an action teaches what it can do now. Shield and Freeze, which happen at once, show theirs
   for 2.5 s after use; Wait shows none (it is pressed every few turns) — "?" then Wait explains
   it, as "?" then any button does.
@@ -407,6 +412,8 @@ charges or permanent, kick on a plain tap, how far to fold, where to explain.
 
 ### 2026-10-22 — A dialog when a power-up is picked up
 
+*Superseded 2026-10-23 by the gem's choice dialog (see "Gems: choose 1 of 5 power-ups").*
+
 User request. Taking a power-up opens the info dialog for it ("You picked it up!" and what it
 does, from the same texts as "What is that?"). It opens 0.45 s after the step, so the walk and
 the pickup's rise-away animation are seen first; auto-move pauses while it is open, as for any
@@ -506,3 +513,317 @@ screen, in the almanac, the docs, and the code (the power-up's id `skates` → `
 its old key `'skates'`, so every seed still has it on the same level. Earlier entries here keep
 the old name, as they were written.
 
+### 2026-10-23 — Auto-move never crosses the open exit
+
+User report: the dotted way to a power-up went over the open exit, so walking it ended the
+level without the power-up. `playerPath` now treats the open exit as blocked unless it is the
+destination. If the only way somewhere is over it, there is no way (the flag can't be set): ending
+the level by surprise is worse. A locked exit is still crossed, since stepping on it does
+nothing; the way is worked out again every turn, so it goes round the exit as soon as it opens.
+
+### 2026-10-23 — The strip gets its own band
+
+*Superseded the same day: the strip is gone (see "No explanation strip during play").*
+
+User report: with the player on the bottom row, the explanation strip covered that row, so it
+couldn't be tapped. The user's rule: **the strip must not cover any of the board.** Of the two
+ways to do that, the user chose a band that is always kept free (over a board that shrinks only
+while the strip is open). The strip is now part of the action bar, at its top; the board is
+fitted above the whole bar, as before.
+
+How tall: the tallest strip that could open now (every action that can be picked, plus the one
+open), measured off-screen (`reserve` in `ui/controls.ts`). Measured on a phone: 103 px with
+nothing collected, 181 px holding Pierce / Power / Line (Bomb's choices), 264 px in Developer
+mode; on a desktop about 105 px. Reserving the Developer-mode maximum always would have cost a
+phone about a third of the board, so the band follows what the player holds: the board resizes
+when an upgrade or a charge is gained or spent, never when an action is picked. It is measured
+again on a window resize. The fade-in now plays only when the strip's content changes.
+
+### 2026-10-23 — Gems: choose 1 of 5 power-ups
+
+User request: instead of a fixed power-up, every level's pickup is a gem of random colour, and
+taking it shows a choice of power-ups. User's answers on the open points:
+
+- **5 offered** (`OFFER_SIZE`), all different, **drawn with today's rarities**
+  (`POWER_UP_WEIGHTS`, now in `sim/turn.ts`), listed in the almanac's order. Abilities and passes
+  already held are left out (they would change nothing); if fewer than 5 remain, fewer are offered.
+- **No Run guarantee** any more: Run is in the draw with weight 10 (it had none, being
+  guaranteed on level 1 or 2). `runLevel` is gone.
+- **Choosing takes no turn and can't be put off**: the dialog has no close button, and Esc and
+  the backdrop don't close it (`required` in `ui/dialog.ts`). The world doesn't move meanwhile.
+
+How it works: taking the gem sets `state.offer` (the event `powerUpTaken` carries it); while it
+is set, every action is unavailable except `{ id: 'choose', kind }`, which applies the power-up
+and clears the offer without a turn passing. The choice is an action in the run record, so it
+replays; a reload with a choice open shows it again. The offer comes from the seed and level, so
+a retry after a death offers the same, and dying can't reroll it (unless the death cost a pass,
+which may then be offered). A death on the pickup turn drops the offer, and the retry has the gem
+back; reaching the exit on the same step (a Run over the gem) still needs the choice before the
+next level. A Star chosen this way lasts its full 8 turns (taken mid-turn it had lost one).
+The colour is only how it looks: `gem_blue` / `gem_green` / `gem_red` / `gem_yellow` from the New
+Platformer Pack (the gems were power-up sprites until 2026-10-22). The old "You picked it up!"
+dialog is gone; the choice already says what each does. `SIM_VERSION` 8 (9 since the shelter rule below): saved runs from before
+are dropped (the record's meaning changed). Level layouts are unchanged: the colour uses the
+level stream's last draw, where the power-up's kind used to be.
+
+Checked in a browser (a gem placed next to the player through a temporary hook, removed): the
+dialog opens with 5 choices and no close button, stays open on Esc and on a backdrop tap, Wait
+is refused meanwhile, choosing Fire +1 raised the blast from 1 to 2 with the turn unchanged, and
+play went on. All 5 choices fit without scrolling on a 1280 × 720 desktop and a 390 × 844 phone.
+
+### 2026-10-23 — A short exit doorway
+
+User request: the exit (the pack's two-tile door squeezed into one tile) was too tall. It is now
+only the door's arched top piece (`door_closed_top` / `door_open_top`), at full tile size: a
+short doorway, orange while locked, dark once open. The bottom halves are no longer used and
+were removed from `assets/kenney/`.
+
+### 2026-10-23 — No explanation strip during play
+
+User feedback on the band above: "the text box keeps resizing, that's definitely not going to
+work" (the band followed what the player held, so the board changed size as charges came and
+went). Since power-ups are now explained in the gem's choice and charges on their first pickup,
+the user chose to **show no explanation text during play** at all. "?" then a button still
+explains it (`actionInfo`).
+
+The strip also held two controls, which needed a new home (user's choices):
+
+- **Pierce, Power and Line get their own buttons** in the second row, like Shield, Freeze and
+  Decoy, shown while held, with a count: tap one, then a tile. Bomb no longer stands for them.
+- **Shield and Freeze are used with a second tap on their button** (or Enter); the first tap
+  only picks them (green), and anything else cancels. Still never a single accidental tap.
+
+The second row now holds up to six buttons, so its buttons are a sixth wide (they were a fifth):
+all six fit in one row even on a 320 px phone, so a third row never comes and goes. The row
+itself still appears with the first charge held and goes with the last one, as before.
+Checked in a browser with every charge held (390, 360 and 320 px wide): the bar didn't move
+while picking Move, Bomb, Pierce, Line and Decoy or after using charges; one tap on Shield only
+picked it and the second used it; Freeze with Enter; a Pierce bomb placed with its button and
+an arrow key. The charge texts no longer say "a choice under Bomb", and the first-pickup dialog
+and "?" say how each charge is used.
+
+### 2026-10-23 — Auto-move's way is blue tiles, with no flag
+
+User request: no flag on the destination; highlight the way's tiles blue. Every tile of the way,
+the destination included, gets the same blue as a picked action's targets (the dots and the
+flag sprite are gone). The next step still turns red when it isn't safe. Tapping the destination
+still takes a step, as tapping the flag did. The Go button kept its flag icon at first; see the
+next entry.
+
+### 2026-10-23 — Enemies let out by flame are sheltered from it
+
+User report: a bomb on the uncovered (locked) exit let out 3 enemies, and the remaining fire
+killed them at once. Why: they appear on the exit tile at the end of the turn, and the blast
+lingers one more turn (since 2026-10-20), so the next turn they stand or step inside it and die
+— the NES punishment almost never bit. The same held for a flamed gem.
+
+User's choice (of: shelter them from that flame; let them out a turn later; immune to all flame
+for a turn; put them outside the blast): **they can't be hurt by the flame that let them out,
+but a new explosion kills them as usual.** `Enemy.sheltered` is set when they appear; on the next
+turn only new flame counts against them, then it is cleared. Tests check both halves, and that
+the first one fails without the rule. `SIM_VERSION` 9.
+
+### 2026-10-23 — Developer mode starts each level next to the gem
+
+User request. In Developer mode the level's gem lies uncovered right of the start (a tile that is
+always open floor), every level and after every death, to try the choice without hunting for it.
+The crate that hid it stays a crate. Normal runs are unchanged.
+
+### 2026-10-23 — Screenshots regenerated
+
+User request, after today's changes. `power-up` / `run` became `gem` (seed 55's gem uncovered)
+and `gem-offer` (its choice); `charges` now shows the six charge buttons with Pierce picked (Bomb's
+choices in the strip are gone); comments in `tools/screenshots.mjs` updated for the blue way and
+the missing strip. All 26 were taken again with `npm run screenshots`.
+
+### 2026-10-23 — No glow behind the gem
+
+User request: the yellow circle drawn behind an uncovered gem is gone; the gem is drawn on its
+own, still bobbing gently. (The glow dates from when the pickup was a power-up icon that needed
+to stand out from drops; a gem is distinct enough.)
+
+### 2026-10-23 — Developer mode is only the gem
+
+User request: Developer mode no longer gives every ability and pass, 5 bombs, blast 5 and
+unlimited charges; it **only** puts the level's gem uncovered right of the start (see "Developer
+mode starts each level next to the gem"). Gone with it: `DEV_STATS`, `DEV_CHARGES`, charges never
+being spent, and the ∞ badges and "(unlimited)" labels. Its description in the New run dialog,
+the top bar's mode tag and the docs say so. The `move-picked` screenshot, which used Developer
+mode to show every Move upgrade, now shows a plain Move. Open question 5 of PLAN.md §5b (Developer
+mode bypassing slots) no longer applies. `SIM_VERSION` 10, since a run saved with the option on
+would replay differently.
+
+### 2026-10-23 — Go keeps the walk icon
+
+User request: while walking to a destination, the Move button (Go) no longer turns into a green
+flag; it keeps Move's walk icon. It still gets its green border, so it shows that a press walks
+a step, and its name for screen readers and the tooltip is still "Go". `flag_green_a` is now only
+the level's icon (top bar, level-cleared banner).
+
+### 2026-10-23 — Charges work on pickup, as temporary changes
+
+User request: charges are no longer actions but temporary changes, applied the moment they are
+picked up. The new set and icons are the user's; the open points were the user's choices too:
+
+| Charge | Effect | Icon |
+|---|---|---|
+| Pierce | next 3 bombs' flame goes through crates | Wall Pass icon |
+| Power | next 3 bombs reach to the walls | Fire+ icon |
+| Nuke (new) | next 3 bombs also burn every tile within 2 steps (Manhattan), through anything but walls | bomb + Board Game Icons skull badge |
+| Diagonal | diagonal steps for the rest of this level and all of the next ("round" = level) | Move figure + diagonal-arrows badge |
+| Shield | the next hit is absorbed, then it's gone | Move figure in the empty ring `timer_0` |
+| Freeze | every enemy frozen for 3 turns, the pickup turn included | Game Icons `locked` |
+| Decoy | a copy of the player on the pickup tile (user's choice over "next to you") | `pawns` |
+
+Line is gone (not in the list). Choices: 3 bombs per Pierce / Power / Nuke pickup (over 1 or 5);
+Nuke reach 2 (over 3: with a fuse of 3, 2 is escapable on foot, 3 hardly without Run).
+
+How it works: `state.charges` is replaced by `state.boosts` (bombs left of each bomb boost, and
+the last level Diagonal lasts to); Shield, Freeze and Decoy act through the existing `shield`,
+`frozen` and `decoy`. `applyCharge` runs on pickup; picking up more adds to it (3 more bombs,
+one more level of Diagonal). Every bomb placed or thrown uses one of each bomb boost held, so
+they combine (`Bomb.pierce` / `power` / `nuke`, replacing `Bomb.special`). Boosts and an unbroken
+shield go on to the next level and through a death, as charges did. The shield, freeze, decoy,
+pierce, power, line actions and their buttons, keys (H, F, Y, I, O, L) and the Shield / Freeze
+double tap are gone; the bar is Move, Bomb and Wait; Diagonal stays an action of Move (G, then Q
+E Z C) while it lasts. The top bar shows each active charge with its count (bombs, levels or
+turns left; the Shield while it's up), and tapping one explains it. Shield, Freeze and Decoy's
+one-colour icons turn white there (MONO_ICONS); the Diagonal icon, like Run's, stays dark on the
+bar. `?charges=N` now starts as if N of each lasting charge had been picked up (not Freeze or
+Decoy, which act on the spot). The new icons are built by `tools/compose-icons.sh` from source
+icons copied unchanged into `assets/kenney-board-icons/` (`skull`, `timer_0`, `pawns`,
+`arrow_diagonal_cross`) and `assets/kenney-icons/White/` (`locked`). `SIM_VERSION` 11.
+Screenshots: `pierce-decoy` became `nuke` (a bomb with all three badges and the Nuke's reach in
+the danger overlay); `charges` shows the top bar with every lasting charge.
+
+### 2026-10-23 — Diagonal is a power-up; shield, auto-move, top bar, installing
+
+User requests, from one round of playtesting:
+
+- **Diagonal becomes a power-up** ("a nice walk power-up"): an ability like Run, Teleport, Kick
+  and Throw, kept on death, weight 8 in the gem's draw (as Kick and Throw), icon
+  `powerup_diagonal` (the former charge icon). It is no longer a charge: six charges remain.
+- **The Shield's ring walks with the player** (it jumped from the old tile to the new one): the
+  Shield's and a Star's rings are drawn where the player is drawn (`playerPos`), sliding with a
+  step. **It is smaller**: 0.88 of a tile across (was 1.08, which spilled onto the next tiles).
+- **Setting an auto-move way takes the first step at once**, as a tap next to the player does;
+  an unsafe first step isn't taken (it shows red, as before). "Walk there" in a dialog does the
+  same.
+- **The board never moves when the top bar fills up.** The user is fine with the bar resizing,
+  but not the board moving. The bar now has a fixed number of rows, by screen width only: three
+  on a phone or a screen under 900 px (lives, level, enemies, coins / turn, bombs, blast, fuse /
+  passes, Star and charges, a row kept even while empty), one row on a wide screen. Rows never
+  wrap; a row that gets too long scrolls sideways. Checked in a browser at 320, 390, 700, 899 and
+  1280 px wide: the bar's height is the same with nothing, with every charge, and with every mode
+  tag. With all four mode tags on a phone, the first row scrolls, hiding part of the hearts.
+- **Installable**, as PLAN.md §17 planned (display fullscreen, icons, offline cache), but without
+  a PWA plugin: a manifest, icons built by `tools/compose-icons.sh` (the bomb on the board's sky
+  blue; a maskable one with room round it; an Apple touch icon), and a network-first service
+  worker (`static/sw.js`) registered only in the build, so development keeps Vite's live reload.
+  Checked in Chromium against the built game: no manifest or installability errors, the worker
+  active, and the game loads with the network cut.
+
+`SIM_VERSION` 12 (Diagonal moved from the charges to the powers). Also fixed: rewriting the
+README's charge section earlier the same day had dropped the sections between it and "Lives"
+(the preview notes, Bombs, Danger, Enemies, Drops and coins, The exit, Power-ups); they are back,
+with the day's changes in them.
+
+### 2026-10-23 — A death keeps every power-up
+
+User request: power-ups stay after a death. `carryOf` now carries everything to the retry —
+stats, every ability and pass (Detonator, Wall / Bomb / Flame Pass included), coins, charges'
+boosts and an unbroken Shield; a death costs only the life. (A Star can't be running at a death,
+since nothing hurts the player while it lasts.) The "Kept / Lost when you die" endings are gone
+from the power-up texts, the README's "Lost when you die?" column and the death banner's "Lost:
+…" list too. This drops the NES rule PLAN.md §5 and §9 were built on: the planned Keep / Fresh
+choice on death (§9, not built) took the passes away in both options, so what Keep vs Fresh
+costs needs rethinking before it is built — marked open there. §5b's question 4 (what a death
+costs with slots) is settled: nothing. `SIM_VERSION` 13.
+
+### 2026-10-23 — Enemies spread out
+
+User report: enemies stacked on one tile moved in the same direction, taking up one tile
+together — "not fun, they should go their separate ways". Why: enemies plan without looking at
+each other and may share a tile, so enemies on one tile make the same choices (Drifters go
+straight; Seekers take the same shortest path; Blobs keep their heading 3 times in 4), and the 3
+let out of a blasted exit or gem start on one tile facing the same way (and since the shelter rule
+they live).
+
+Now, in `planIntents`, enemies are planned in turn and each keeps clear of where the ones before
+it will be at each of the next 3 moves (`spreadSteps`): a Blob wanders among the clear steps, a
+Drifter turns if straight on is another enemy's, a Seeker's step toward the player is dropped
+for a wander if another enemy takes that tile. A crowded step is used only when no open step is
+clear. Enemies still don't block each other, so each still makes exactly the moves its arrows
+show. Tests: three of a kind on one tile in an open room never share a tile over their 3
+planned moves (they all fail without the rule); they still share when there is no other way.
+`SIM_VERSION` 14.
+
+### 2026-10-23 — Guards against a frozen game, and the game's logs in the dev terminal
+
+User report: on a phone (under `npm run dev`), the game froze with the player missing from the
+board. Why it froze rather than carrying on: LittleJS's development build stops its frame loop at
+the first error in a frame (its release build goes on).
+
+**The cause:** a Freeze drop on the board. LittleJS cuts a 128 × 128 tile out of each board
+texture; every sprite is 128 × 128 except the new Freeze icon (`charge_freeze`), inked from a
+100 × 100 Game Icons file, so its tile was invalid and the development build's check threw while
+drawing the drops — after the coin drawn before it, so the enemies, fading things and the player
+were never drawn. Found by replaying the screenshot's situation (Small, Developer, Crumbling
+crates, the last enemy blasted) on 16 seeds in a browser: seed 13 dropped a Freeze and failed
+with "Sprite charge_freeze (texture 68): Assert failed". Fixed: `tools/compose-icons.sh` makes
+every inked icon 128 × 128; a test (`test/sprites.test.ts`) checks every PNG in `assets/kenney`
+and `assets/composed` is 128 × 128 (it fails with the old file); the same seed then played on to
+turn 20, and the 16 seeds without an error.
+
+- **Guards** (user request): the game's per-frame update and draw are wrapped (`guarded` in
+  main.ts): an error is reported once while it repeats, and the next frame runs as usual. A
+  turn the rules refuse (`step` throws) is reported and nothing happens, instead of an uncaught
+  error. `sprite()` now names an unknown sprite instead of handing LittleJS texture −1.
+- **Logs on the dev desktop** (user request: "a callback to show the logs as npm run dev
+  output"): Vite 8's own `server.forwardConsole` sends the browser's console (log, info, warn,
+  error) and uncaught errors over its dev connection to the terminal running `npm run dev`,
+  marked `(client)`, from any device on the Wi-Fi. A first version had its own plugin for this;
+  Vite's built-in does the same, so it was dropped. Caught errors are logged with their stack and
+  the device ("Android 390×844") by `src/devlog.ts`. Nothing of this is in the build.
+- **The top bar's first row** was full of mode tags in that game (Small, Dev, Crumble), which
+  pushed the level, enemies and coins out of sight. The mode tags now start the third row, so the
+  first row always shows hearts, level, enemies and coins.
+
+### 2026-10-23 — Into the doorway, and a demo before the game
+
+- **The step into the exit is animated** (user report: it happened instantly). The turn reports
+  the step and then the level cleared; the cleared jump replaced the step's walk at once. The
+  jump now remembers where the step began: the player walks into the doorway (0.16 s), then jumps.
+- **A demo before the game** (user request: some people never played the original; scenes
+  generated at play time and shown view-only, like a screensaver, before the player starts).
+  Made up on the spot from the real rules rather than recorded: a small random room, random
+  games on it until one shows the scene (an enemy killed, a narrow escape, a chain reaction,
+  Freeze, the Detonator), replayed with a caption. Details in PLAN.md §13a. Choices made here,
+  since the request left them open: it plays **every time the game opens** (not only the first
+  time), until any tap or key, which then does nothing else (a click on a button that appears
+  under the finger is swallowed too); the scenes cycle through all five kinds in a shuffled
+  order; captions are one plain sentence each; test URLs skip it unless `?demo`, and with `?seed`
+  it is the same every time (for the screenshot). The rooms are 9 × 7, so on a tall phone the
+  room sits in the middle with sky above and below. Tests: every kind is found on 10 seeds,
+  replays by the rules with the player alive throughout, and ends on its moment; the same seed
+  gives the same scene. Checked in a browser: scenes play with captions and no errors; a tap
+  ends the demo and the game starts at turn 0 with nothing else done.
+- LittleJS's development build draws its version and frame rate in the top left; the top bar
+  covered it, the demo doesn't, so it is turned off (`setDebugWatermark(false)`).
+
+### 2026-10-23 — The last enemy drops a charge that lasts; icons in the demo's captions
+
+- **No wasted charges from the last enemy** (user report: a charge dropped by the last enemy,
+  like a Decoy, was often wasted). Freeze and Decoy only work on enemies. Of three options (the
+  last enemy drops only lasting charges; Freeze and Decoy picked up with no enemies are saved for
+  the next level; both), the user chose the first: when a turn's kills leave no enemy on the
+  board, every charge they drop is drawn from Pierce, Power, Nuke and Shield, which go on to the
+  next level. Decided once the turn's kills are all done, so two enemies dying together are both
+  "last". A Freeze or Decoy dropped earlier and picked up after the last kill still does nothing
+  (accepted). Tests: over 2,000 rolls a last drop is never Freeze or Decoy (and all four lasting
+  kinds come up), an ordinary drop still can be. `SIM_VERSION` 15.
+- **The demo's captions show the icon** of the charge or power-up a scene is about (user
+  request: "better visual associations with concepts"): the padlock for Freeze, the Detonator's
+  badged bomb for the Detonator — the same icon as on the board, in the top bar and the almanac.
+  The three scenes about bombs in general have none. The icon sits beside the words, not in the
+  line, so a two-line caption stays evenly spaced.
diff --git a/docs/screenshots/almanac-charges.png b/docs/screenshots/almanac-charges.png
index 259e715..196e99b 100644
Binary files a/docs/screenshots/almanac-charges.png and b/docs/screenshots/almanac-charges.png differ
diff --git a/docs/screenshots/almanac-enemies.png b/docs/screenshots/almanac-enemies.png
index 8a43a8d..da9732b 100644
Binary files a/docs/screenshots/almanac-enemies.png and b/docs/screenshots/almanac-enemies.png differ
diff --git a/docs/screenshots/almanac.png b/docs/screenshots/almanac.png
index 4cbebfc..eee4858 100644
Binary files a/docs/screenshots/almanac.png and b/docs/screenshots/almanac.png differ
diff --git a/docs/screenshots/auto-move.png b/docs/screenshots/auto-move.png
index 046f933..37829e5 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 c6fa923..8146a75 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 5363982..3d2252e 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 0a211cb..0aabfe6 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 0506ab4..0d4e780 100644
Binary files a/docs/screenshots/death.png and b/docs/screenshots/death.png differ
diff --git a/docs/screenshots/demo.png b/docs/screenshots/demo.png
new file mode 100644
index 0000000..0c4ee20
Binary files /dev/null and b/docs/screenshots/demo.png differ
diff --git a/docs/screenshots/desktop.png b/docs/screenshots/desktop.png
index f653240..83428b3 100644
Binary files a/docs/screenshots/desktop.png and b/docs/screenshots/desktop.png differ
diff --git a/docs/screenshots/dev.png b/docs/screenshots/dev.png
index f1185a1..abff8e7 100644
Binary files a/docs/screenshots/dev.png and b/docs/screenshots/dev.png differ
diff --git a/docs/screenshots/enemies.png b/docs/screenshots/enemies.png
index 86100df..b6eadfa 100644
Binary files a/docs/screenshots/enemies.png and b/docs/screenshots/enemies.png differ
diff --git a/docs/screenshots/gem-offer.png b/docs/screenshots/gem-offer.png
new file mode 100644
index 0000000..685532d
Binary files /dev/null and b/docs/screenshots/gem-offer.png differ
diff --git a/docs/screenshots/gem.png b/docs/screenshots/gem.png
new file mode 100644
index 0000000..5d3c9e6
Binary files /dev/null and b/docs/screenshots/gem.png differ
diff --git a/docs/screenshots/info-enemy.png b/docs/screenshots/info-enemy.png
index e982d0d..0a7fb44 100644
Binary files a/docs/screenshots/info-enemy.png and b/docs/screenshots/info-enemy.png differ
diff --git a/docs/screenshots/info-hud.png b/docs/screenshots/info-hud.png
index c65e6df..2ac3c62 100644
Binary files a/docs/screenshots/info-hud.png and b/docs/screenshots/info-hud.png differ
diff --git a/docs/screenshots/info-mode.png b/docs/screenshots/info-mode.png
index eda83be..3ba678f 100644
Binary files a/docs/screenshots/info-mode.png and b/docs/screenshots/info-mode.png differ
diff --git a/docs/screenshots/lingering.png b/docs/screenshots/lingering.png
index 0da84a6..c1a0e4b 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 ac85f03..174ec4e 100644
Binary files a/docs/screenshots/long-press.png and b/docs/screenshots/long-press.png differ
diff --git a/docs/screenshots/menu.png b/docs/screenshots/menu.png
index bfa645a..008af24 100644
Binary files a/docs/screenshots/menu.png and b/docs/screenshots/menu.png differ
diff --git a/docs/screenshots/move-picked.png b/docs/screenshots/move-picked.png
index c7f629d..f26d04a 100644
Binary files a/docs/screenshots/move-picked.png and b/docs/screenshots/move-picked.png differ
diff --git a/docs/screenshots/new-run.png b/docs/screenshots/new-run.png
index 5c84371..d303cff 100644
Binary files a/docs/screenshots/new-run.png and b/docs/screenshots/new-run.png differ
diff --git a/docs/screenshots/nuke.png b/docs/screenshots/nuke.png
new file mode 100644
index 0000000..8beb80e
Binary files /dev/null and b/docs/screenshots/nuke.png differ
diff --git a/docs/screenshots/pierce-decoy.png b/docs/screenshots/pierce-decoy.png
deleted file mode 100644
index 4034fed..0000000
Binary files a/docs/screenshots/pierce-decoy.png and /dev/null differ
diff --git a/docs/screenshots/power-up.png b/docs/screenshots/power-up.png
deleted file mode 100644
index c5fcb5c..0000000
Binary files a/docs/screenshots/power-up.png and /dev/null differ
diff --git a/docs/screenshots/run.png b/docs/screenshots/run.png
deleted file mode 100644
index f67fa56..0000000
Binary files a/docs/screenshots/run.png and /dev/null differ
diff --git a/docs/screenshots/scrolled.png b/docs/screenshots/scrolled.png
index 41d47cc..6b1ede5 100644
Binary files a/docs/screenshots/scrolled.png and b/docs/screenshots/scrolled.png differ
diff --git a/docs/screenshots/small-desktop.png b/docs/screenshots/small-desktop.png
index 85c3b91..35e6590 100644
Binary files a/docs/screenshots/small-desktop.png and b/docs/screenshots/small-desktop.png differ
diff --git a/docs/screenshots/small.png b/docs/screenshots/small.png
index f5ca200..6ae57e2 100644
Binary files a/docs/screenshots/small.png and b/docs/screenshots/small.png differ
diff --git a/docs/screenshots/start.png b/docs/screenshots/start.png
index c53bfc7..03d1e8f 100644
Binary files a/docs/screenshots/start.png and b/docs/screenshots/start.png differ
diff --git a/index.html b/index.html
index 50465d5..645a737 100644
--- a/index.html
+++ b/index.html
@@ -9,18 +9,28 @@
 <link rel="icon" type="image/png" href="/assets/kenney/bomb.png" />
 <meta name="mobile-web-app-capable" content="yes" />
 <meta name="apple-mobile-web-app-capable" content="yes" />
+<meta name="apple-mobile-web-app-title" content="Explodorama" />
+<link rel="manifest" href="/manifest.webmanifest" />
+<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
 <link rel="stylesheet" href="/src/style.css" />
 </head>
 <body>
 <header id="hud" aria-live="polite">
   <div id="hud-items">
-    <span id="hud-modes"></span>
-    <span id="hud-lives"></span>
-    <span id="hud-level"></span>
-    <span id="hud-enemies"></span>
-    <span id="hud-coins"></span>
-    <span id="hud-turn"></span>
-    <span id="hud-stats"></span>
+    <div class="hud-row">
+      <span id="hud-lives"></span>
+      <span id="hud-level"></span>
+      <span id="hud-enemies"></span>
+      <span id="hud-coins"></span>
+    </div>
+    <div class="hud-row">
+      <span id="hud-turn"></span>
+      <span id="hud-stats"></span>
+    </div>
+    <div class="hud-row" id="hud-third">
+      <span id="hud-modes"></span>
+      <span id="hud-extras"></span>
+    </div>
   </div>
   <div id="hud-buttons">
     <button id="info-button" type="button" aria-label="What is that?" aria-pressed="false"></button>
@@ -42,6 +52,11 @@
     <div id="dialog-buttons"></div>
   </div>
 </dialog>
+<div id="demo" hidden aria-live="polite">
+  <h1>Explodorama</h1>
+  <p id="demo-caption"></p>
+  <p class="demo-hint">Tap anywhere to play</p>
+</div>
 <script type="module" src="/src/main.ts"></script>
 </body>
 </html>
diff --git a/package.json b/package.json
index a5c51ae..1c18299 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,7 @@
   "scripts": {
     "dev": "vite --host",
     "build": "tsc --noEmit && vite build",
-    "preview": "vite preview",
+    "preview": "vite preview --host",
     "test": "vitest run",
     "test:watch": "vitest",
     "screenshots": "npm run build && node tools/screenshots.mjs"
diff --git a/src/devlog.ts b/src/devlog.ts
new file mode 100644
index 0000000..e7c4490
--- /dev/null
+++ b/src/devlog.ts
@@ -0,0 +1,18 @@
+/**
+ * Errors the game catches and carries on from (`guarded` frames, refused turns). They go to the
+ * console with their stack and the device, and `npm run dev` forwards the console to its terminal
+ * (`server.forwardConsole` in vite.config.ts), from a phone on the Wi-Fi too (user request,
+ * 2026-10-23: a crash on the phone showed nothing anywhere).
+ */
+
+/** Which device it came from, in a few words: "Android 390×844", "iPhone 390×844", "desktop 1280×720". */
+function device(): string {
+  const ua = navigator.userAgent
+  const kind = /iPad/.test(ua) ? 'iPad' : /iPhone/.test(ua) ? 'iPhone' : /Android/.test(ua) ? 'Android' : 'desktop'
+  return `${kind} ${innerWidth}×${innerHeight}`
+}
+
+export function reportError(where: string, error: unknown): void {
+  const detail = error instanceof Error ? (error.stack ?? `${error.name}: ${error.message}`) : String(error)
+  console.error(`[${device()}] ${where}: ${detail}`)
+}
diff --git a/src/main.ts b/src/main.ts
index cd7a390..b5eeb8b 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -4,6 +4,7 @@ import {
   screenToWorld,
   setCanvasClearColor,
   setCanvasPixelRatio,
+  setDebugWatermark,
   setGLEnable,
   setShowSplashScreen,
   setTilesPixelated,
@@ -13,7 +14,7 @@ import {
 import { BACKGROUND, drawBoard, FLAME_SECONDS, toTile, type Flame, type PlayerAnim } from './render/board'
 import { clearEffects, newEffects, pruneEffects, startEffects } from './render/effects'
 import { updateCamera } from './render/camera'
-import { TEXTURES } from './render/sprites'
+import { CHARGE_ICONS, MONO_ICONS, POWER_UP_ICONS, TEXTURES } from './render/sprites'
 import { isAvailable, isValidTarget, ACTIONS } from './sim/actions'
 import { canPick, resolvePick } from './sim/picks'
 import { enemyAt } from './sim/enemies'
@@ -26,7 +27,10 @@ import { DEFAULT_OPTIONS, DIRS, type Action, type ActionId, type ChargeId, type
 import { openAlmanac } from './ui/almanac'
 import { createControls } from './ui/controls'
 import { dialogOpen, openDialog, type DialogButton } from './ui/dialog'
-import { actionInfo, chargeInfo, hudInfo, modeLines, powerUpInfo, tileInfo, type Info } from './ui/info'
+import { actionInfo, chargeInfo, hudInfo, modeLines, tileInfo, type Info } from './ui/info'
+import { openOffer } from './ui/offer'
+import { reportError } from './devlog'
+import { generateScene, SCENE_CAPTIONS, SCENE_KINDS, type Scene, type SceneKind } from './sim/demo'
 import { spriteUrl } from './render/sprites'
 import playIcon from '../assets/kenney-ui/Extra/icon_play_light.png'
 import newRunIcon from '../assets/kenney-ui/Extra/icon_repeat_light.png'
@@ -35,7 +39,7 @@ import newRunIcon from '../assets/kenney-ui/Extra/icon_repeat_light.png'
 // seed — chosen here, never inside the sim.
 const params = new URLSearchParams(location.search)
 const startLevel = Number(params.get('level')) || 1
-// `?charges=2` starts with 2 of every action charge, to try them without hunting for drops.
+// `?charges=2` starts as if 2 of each lasting charge had been picked up (state.ts `newRun`), to try them.
 const startCharges = Number(params.get('charges')) || 0
 
 let record: RunRecord
@@ -72,8 +76,8 @@ function firstCharge(e: GameEvent): ChargeId | null {
   return e.drop.kind
 }
 
-function startRun(seed: number, options: RunOptions): void {
-  chargesSeen.clear()
+/** A fresh view of the board: no animations under way, nothing picked, the camera snapped. */
+function resetView(): void {
   playerAnim = { kind: 'none', from: { x: 0, y: 0 }, at: 0 }
   enemySteps.clear()
   clearEffects(fx)
@@ -82,6 +86,11 @@ function startRun(seed: number, options: RunOptions): void {
   destination = null
   flames = []
   snapCamera = true
+}
+
+function startRun(seed: number, options: RunOptions): void {
+  chargesSeen.clear()
+  resetView()
   record = newRecord(seed, startLevel, startCharges, options)
   state = newRun(seed, startLevel, startCharges, options)
   save()
@@ -142,6 +151,8 @@ function resumeSaved(): boolean {
     record = saved
     state = s
     afterChange()
+    // Closed with a gem's choice still open: it opens again.
+    showOffer()
     return true
   } catch {
     return false
@@ -156,12 +167,38 @@ function randomSeed(): number {
 const MOVES: ReadonlySet<Action['id']> = new Set(['move', 'run', 'diagonal', 'teleport'])
 
 function act(action: Action): void {
+  // A gem's offer is answered before anything else (its dialog opens a moment after the pickup).
+  if (state.offer && action.id !== 'choose') return
   lastActionAt = time
   if (MOVES.has(action.id) && 'target' in action && !path?.some((t) => eq(t, action.target!))) destination = null
-  const result = step(state, action)
+  // step() throws on an action the rules don't allow; then nothing happens, and it is reported.
+  let result: ReturnType<typeof step>
+  try {
+    result = step(state, action)
+  } catch (error) {
+    reportError(`turn ${state.turn}, ${JSON.stringify(action)}`, error)
+    return
+  }
+  animateTurn(result, action)
+  record.actions.push(action)
+  for (const e of result.events) {
+    // A gem taken: its choice opens once the gem has risen away (render/effects.ts).
+    if (e.type === 'powerUpTaken') window.setTimeout(showOffer, PICKUP_DIALOG_MS)
+    // The first charge of each kind this run says what it does, so a new button is never a
+    // mystery; unless a gem was taken on the same turn (a run over both), whose choice comes first.
+    const charge = firstCharge(e)
+    if (charge) window.setTimeout(() => state.offer || showInfo(chargeInfo(charge)), PICKUP_DIALOG_MS)
+  }
+  selected = null
+  inspecting = false
+  save()
+  afterChange()
+}
+
+/** A turn played: the new state, and everything that animates it (also for the demo). */
+function animateTurn(result: ReturnType<typeof step>, action: Action): void {
   startEffects(fx, state, result.state, result.events, time)
   state = result.state
-  record.actions.push(action)
   for (const e of result.events) {
     if (e.type === 'exploded') flames.push({ tiles: e.tiles, born: time })
     if (e.type === 'levelRestarted' || e.type === 'levelStarted') snapCamera = true
@@ -173,30 +210,25 @@ function act(action: Action): void {
       playerAnim = { kind: far ? 'teleport' : 'walk', from: e.from, at: time }
     }
     if (e.type === 'bombPlaced' && playerAnim.kind !== 'walk') playerAnim = { kind: 'duck', from: state.player, at: time }
-    if (e.type === 'levelCleared') playerAnim = { kind: 'jump', from: state.player, at: time }
+    // Into the doorway first (the step's walk), then the jump (user request, 2026-10-23: the step
+    // onto the exit had no animation).
+    if (e.type === 'levelCleared') playerAnim = { kind: 'jump', from: playerAnim.kind === 'walk' ? playerAnim.from : state.player, at: time }
     if (e.type === 'levelStarted' || e.type === 'levelRestarted') {
       playerAnim = { kind: 'none', from: state.player, at: time }
       enemySteps.clear()
     }
     if (e.type === 'enemyMoved') enemySteps.set(e.id, { from: e.from, at: time })
-    // A power-up taken: say what it does, once it has risen away (render/effects.ts).
-    if (e.type === 'powerUpTaken') {
-      const { kind } = e.powerUp
-      window.setTimeout(() => showInfo(powerUpInfo(kind)), PICKUP_DIALOG_MS)
-    }
-    // The same for the first charge of each kind this run, so a new button is never a mystery.
-    const charge = firstCharge(e)
-    if (charge) window.setTimeout(() => showInfo(chargeInfo(charge)), PICKUP_DIALOG_MS)
   }
-  selected = null
-  inspecting = false
-  save()
-  afterChange()
 }
 
-/** How long after taking a power-up (or a new charge) its dialog opens: the walk there and the rise away first. */
+/** How long after taking a gem (or a new charge) its dialog opens: the walk there and the rise away first. */
 const PICKUP_DIALOG_MS = 450
 
+/** The gem's choice, if one is waiting. Choosing takes no turn. */
+function showOffer(): void {
+  if (state.offer) openOffer(state.offer, (kind) => act({ id: 'choose', kind }))
+}
+
 function afterChange(): void {
   danger = predictDanger(state)
   // The walk is worked out again every turn, since bombs and crates change; reaching the
@@ -213,7 +245,7 @@ function afterChange(): void {
  * - the player: Wait;
  * - the auto-move flag: one step toward it (like Go), with auto-move's safety check;
  * - a tile next to the player: move there;
- * - any other tile you can walk to: set it as the auto-move destination;
+ * - any other tile you can walk to: set it as the auto-move destination, and take the first step;
  * - a wall or crate, or any floor you can't walk to: clears the flag if there is one;
  * - one of the player's bombs, with the Detonator: set it off (user request, 2026-10-22);
  * - else a bomb next to the player, with Kick: kick it (walking into it);
@@ -255,9 +287,14 @@ function inspect(): void {
   afterChange()
 }
 
+/**
+ * Auto-move to `p`, taking the first step at once, as a tap next to the player would (user
+ * request, 2026-10-23: setting the way used to wait for Go). An unsafe first step isn't taken.
+ */
 function setDestination(p: Vec): void {
   destination = p
   afterChange()
+  stepTowardDestination()
 }
 
 /** The info dialog for a tile; with "Walk there" when it is something to walk to. */
@@ -314,7 +351,7 @@ function newRunDialog(): void {
       `<label><input type="radio" name="size" value="normal"${checked(o.size === 'normal')}><span><b>Normal</b> 31 × 13, scrolls sideways, 50 levels.</span></label>` +
       `<label><input type="radio" name="size" value="small"${checked(o.size === 'small')}><span><b>Small</b> 9 × 11, fits the screen, 20 levels, fewer enemies and crates.</span></label>` +
       `</fieldset><fieldset class="choices"><legend>Modes</legend>` +
-      `<label><input type="checkbox" name="dev"${checked(o.dev)}><span><b>Developer</b> Every power-up and ability, unlimited action charges, 5 bombs, blast 5. You can still be killed.</span></label>` +
+      `<label><input type="checkbox" name="dev"${checked(o.dev)}><span><b>Developer</b> Every level’s gem lies uncovered right next to you at the start.</span></label>` +
       `<label><input type="checkbox" name="god"${checked(o.god)}><span><b>God</b> Nothing can kill you.</span></label>` +
       `<label><input type="checkbox" name="crumble"${checked(o.crumble)}><span><b>Crumbling crates</b> Once the last enemy is gone, the crates crumble away over 1–3 turns.</span></label>` +
       `</fieldset>`,
@@ -385,8 +422,7 @@ function pick(id: ActionId, pressed = selected === id): void {
   }
   if (!canPick(state, id)) return
   if (id === 'move' && destination && !selected && stepTowardDestination()) return
-  // Wait happens at once. Everything else is picked first: a tile to aim at, or for Shield and
-  // Freeze a "Use" tap in the strip (user request, 2026-10-22: Shield used to go off at once).
+  // Wait happens at once. Everything else is picked first, then aimed at a tile.
   if (id === 'wait') act({ id })
   else {
     selected = pressed ? null : id
@@ -394,14 +430,6 @@ function pick(id: ActionId, pressed = selected === id): void {
   }
 }
 
-/** A choice in the explanation strip: a charge bomb instead of a plain one, or "Use" (Shield, Freeze). */
-function choose(id: ActionId): void {
-  if (!canPick(state, id)) return
-  if (!ACTIONS[id].needsTarget) return act({ id })
-  selected = id
-  afterChange()
-}
-
 /**
  * A direction key: uses the picked action that way (a bomb or kick next to the player, a throw
  * or run 2 tiles away), or moves if nothing is picked.
@@ -424,7 +452,6 @@ const controls = createControls({
     if (state.status === 'dead' || state.status === 'cleared') act({ id: 'continue' })
     else if (state.status === 'gameOver' || state.status === 'won') newRunDialog()
   },
-  choose,
   info: (item) => showInfo(hudInfo(item, state)),
   inspect,
   menu: showMenu,
@@ -438,8 +465,7 @@ const KEY_DIRS: Record<string, Dir> = {
 }
 
 const ACTION_KEYS: Record<string, ActionId> = {
-  KeyB: 'bomb', KeyR: 'run', KeyX: 'detonate', KeyK: 'kick', KeyT: 'throw', KeyH: 'shield', KeyF: 'freeze',
-  KeyG: 'diagonal', KeyP: 'teleport', KeyI: 'pierce', KeyO: 'power', KeyL: 'line', KeyY: 'decoy',
+  KeyB: 'bomb', KeyR: 'run', KeyX: 'detonate', KeyK: 'kick', KeyT: 'throw', KeyG: 'diagonal', KeyP: 'teleport',
 }
 
 /** With Diagonal picked: Q / E / Z / C step up-left, up-right, down-left, down-right. */
@@ -467,9 +493,8 @@ window.addEventListener('keydown', (e) => {
     }
     afterChange()
   } else if (e.code === 'Enter') {
-    // Confirms Shield or Freeze when picked; otherwise presses the banner's button.
-    if (selected && !ACTIONS[selected].needsTarget) choose(selected)
-    else controls.pressBanner()
+    // Presses the banner's button.
+    controls.pressBanner()
   }
 })
 
@@ -493,6 +518,121 @@ function tileUnder(e: PointerEvent): Vec | null {
 
 if (!resumeSaved()) startRun(Number(params.get('seed')) || randomSeed(), testing ? urlOptions() : preferredOptions())
 
+/**
+ * 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, one turn every
+ * DEMO_STEP_SECONDS, each with a caption, the top bar and buttons hidden; the run waits behind it.
+ * Any tap or key ends it, and does nothing else. Not on a test URL, unless it has `?demo`.
+ */
+const DEMO_STEP_SECONDS = 0.6
+/** How long a scene's last turn stays on screen, and how long before a scene's first move. */
+const DEMO_HOLD_SECONDS = 1.8
+const DEMO_LEAD_SECONDS = 0.8
+let demo: { scene: Scene; next: number; at: number; real: GameState } | null = null
+// The scenes' seeds: from `?seed` for a repeatable demo, otherwise random.
+let demoSeed = Number(params.get('seed')) || randomSeed()
+let demoKinds: SceneKind[] = []
+const demoPanel = document.getElementById('demo')!
+const demoCaption = document.getElementById('demo-caption')!
+/**
+ * The icon beside the caption of a scene about one charge or power-up, the one it has on the board,
+ * in the top bar and in the almanac (user request, 2026-10-23), so the picture and the idea go
+ * together.
+ */
+const SCENE_ICONS: Partial<Record<SceneKind, string>> = { freeze: CHARGE_ICONS.freeze, detonate: POWER_UP_ICONS.detonator }
+
+/** The next scene, cycling through every kind in a shuffled order. */
+function nextScene(): void {
+  if (!demo) return
+  for (let tries = 0; tries < SCENE_KINDS.length; tries++) {
+    // Shuffled; in their usual order with `?seed`, so a test URL shows the same demo every time.
+    if (!demoKinds.length) demoKinds = params.has('seed') ? [...SCENE_KINDS] : [...SCENE_KINDS].sort(() => Math.random() - 0.5)
+    const scene = generateScene(demoKinds.shift()!, demoSeed++)
+    if (!scene) continue
+    resetView()
+    demo.scene = scene
+    demo.next = 0
+    demo.at = time + DEMO_LEAD_SECONDS
+    state = scene.start
+    const icon = SCENE_ICONS[scene.kind]
+    demoCaption.innerHTML =
+      (icon ? `<img class="demo-icon${MONO_ICONS.has(icon) ? ' mono' : ''}" src="${spriteUrl(icon)}" alt="" draggable="false">` : '') +
+      `<span>${SCENE_CAPTIONS[scene.kind]}</span>`
+    afterChange()
+    return
+  }
+}
+
+function startDemo(): void {
+  demo = { scene: null!, next: 0, at: 0, real: state }
+  document.body.classList.add('demo')
+  demoPanel.hidden = false
+  nextScene()
+  if (!demo.scene) endDemo()
+}
+
+/** Called every frame: the scene's next turn when it is due, or the next scene. */
+function demoTick(): void {
+  if (!demo || time < demo.at) return
+  const { scene } = demo
+  if (demo.next >= scene.actions.length) return nextScene()
+  const action = scene.actions[demo.next++]!
+  animateTurn(step(state, action), action)
+  afterChange()
+  demo.at = time + (demo.next < scene.actions.length ? DEMO_STEP_SECONDS : DEMO_HOLD_SECONDS)
+}
+
+function endDemo(): void {
+  if (!demo) return
+  state = demo.real
+  demo = null
+  document.body.classList.remove('demo')
+  demoPanel.hidden = true
+  resetView()
+  afterChange()
+}
+
+// The tap or key that ends the demo goes no further: not to the board, nor to a button that
+// appears under the finger once the demo is gone (its click is swallowed too).
+let swallowClick = 0
+addEventListener(
+  'pointerdown',
+  (e) => {
+    if (!demo) return
+    endDemo()
+    swallowClick = performance.now() + 600
+    e.stopPropagation()
+    e.preventDefault()
+  },
+  true,
+)
+for (const type of ['pointerup', 'click'] as const) {
+  addEventListener(
+    type,
+    (e) => {
+      if (performance.now() > swallowClick) return
+      e.stopPropagation()
+      e.preventDefault()
+    },
+    true,
+  )
+}
+addEventListener(
+  'keydown',
+  (e) => {
+    if (!demo) return
+    endDemo()
+    e.stopPropagation()
+    e.preventDefault()
+  },
+  true,
+)
+
+if (!testing || params.has('demo')) startDemo()
+
+// LittleJS's development build writes its version and frame rate top left; the top bar used to
+// cover it, but the demo hides the bar (only the development build has it at all).
+setDebugWatermark(false)
 // Sharp on high-density screens, capped so phones don't pay for 3×.
 setCanvasPixelRatio(Math.min(devicePixelRatio, 2))
 // Canvas 2D, not WebGL: a board of a few hundred sprites doesn't need it, and headless
@@ -502,6 +642,24 @@ setGLEnable(false)
 // The sprites are smooth vector-style art drawn smaller than their 128 px, not pixel art.
 setTilesPixelated(false)
 
+/**
+ * A frame's work, made safe: an error is reported (once while it repeats) and the game goes on.
+ * LittleJS's development build stops its loop at the first error, which froze the game on a
+ * phone with the player half-drawn (user report, 2026-10-23).
+ */
+function guarded(where: string, frame: () => void): () => void {
+  let last = ''
+  return () => {
+    try {
+      frame()
+      last = ''
+    } catch (error) {
+      if (String(error) !== last) reportError(where, error)
+      last = String(error)
+    }
+  }
+}
+
 await engineInit(
   () => {
     setShowSplashScreen(false)
@@ -534,19 +692,30 @@ await engineInit(
     // A long press shouldn't open the browser's context menu.
     mainCanvas.addEventListener('contextmenu', (e) => e.preventDefault())
   },
-  () => {
-    updateCamera(state, controls.insets(), snapCamera)
+  guarded('update', () => {
+    demoTick()
+    // In the demo the board fits between the top of the screen and its caption panel.
+    updateCamera(state, demo ? { top: 0, bottom: demoPanel.getBoundingClientRect().height } : controls.insets(), snapCamera)
     snapCamera = false
     flames = flames.filter((f) => time - f.born < FLAME_SECONDS)
     pruneEffects(fx, time)
     autoStep()
-  },
+  }),
   () => {},
-  () =>
+  guarded('draw', () =>
     drawBoard({
       state, danger, selected, hover, flames, facingLeft, destination, path, armed, playerAnim, enemySteps, fx,
       pathBlocked: !!path?.[0] && !safeStep(path[0]),
     }),
+  ),
   () => {},
   TEXTURES,
 )
+
+// Installable and playable offline (static/sw.js). Only in the built game: in development it
+// would serve stale copies over Vite's live reloading.
+if (import.meta.env.PROD && 'serviceWorker' in navigator) {
+  navigator.serviceWorker.register('./sw.js').catch(() => {
+    // No service worker (a private window, an old browser): the game still runs, just not offline.
+  })
+}
diff --git a/src/render/board.ts b/src/render/board.ts
index fabb6e3..039d6a8 100644
--- a/src/render/board.ts
+++ b/src/render/board.ts
@@ -7,7 +7,7 @@ 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 { appearScale, dropSprite, frostCrack, frostLevel, GONE_SECONDS, key, progress, pulseScale, type Effects, type Gone } from './effects'
-import { CHARGE_ICONS, digitSprite, ENEMY_SPRITES, POWER_UP_ICONS, sprite, wallSprite, type BoardSprite } from './sprites'
+import { CHARGE_ICONS, digitSprite, ENEMY_SPRITES, GEM_SPRITES, sprite, wallSprite, type BoardSprite } from './sprites'
 
 /**
  * The one place where sim tiles meet LittleJS world space. The sim's y grows downward
@@ -86,9 +86,7 @@ const C = {
   intent: rgb(0.85, 0.2, 0.2),
   shield: rgb(0.25, 0.62, 0.87, 0.9),
   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),
   stoneOutline: rgb(53 / 255, 53 / 255, 65 / 255),
@@ -147,12 +145,11 @@ export function drawBoard(view: View): void {
   if (cellAt(s, s.exit) === 'floor') {
     drawExit(toWorld(s, s.exit), exitOpen(s), appearScale(fx, key.exit, time) * pulseScale(fx, key.exit, time, 0.25))
   }
-  // The level's power-up, once its crate is gone: on a soft glow, so it stands out from drops.
+  // The level's gem, once its crate is gone, bobbing gently (no glow behind it: user request, 2026-10-23).
   if (s.powerUp && cellAt(s, s.powerUp.pos) === 'floor') {
     const pos = toWorld(s, s.powerUp.pos)
     const k = appearScale(fx, key.powerUp, time)
-    drawCircle(pos, 0.86 * k, C.powerUpGlow)
-    drawTile(pos.add(vec2(0, 0.04 * Math.sin(time * 3))), vec2(0.7 * k), sprite(POWER_UP_ICONS[s.powerUp.kind]))
+    drawTile(pos.add(vec2(0, 0.04 * Math.sin(time * 3))), vec2(0.7 * k), sprite(GEM_SPRITES[s.powerUp.color]))
   }
   // A destroyed crate bursts over whatever it uncovered.
   for (const g of fx.gone) if (g.kind === 'crate') drawGone(s, g)
@@ -164,12 +161,10 @@ export function drawBoard(view: View): void {
   const targets = selected ? pickTargets(s, selected) : []
   const hovered = selected && hover && targets.some((t) => eq(t, hover)) ? hover : null
   for (const t of targets) drawRect(toWorld(s, t), vec2(0.94), hovered && eq(t, hovered) ? C.targetHover : C.target)
+  // Auto-move's way: every tile of it highlighted blue, the destination too, with no flag or dots
+  // (user request, 2026-10-23). The next step is red when it isn't safe to take.
   if (view.destination && view.path) {
-    for (const [i, t] of view.path.entries()) {
-      if (i === 0) drawRect(toWorld(s, t), vec2(0.94), view.pathBlocked ? C.blocked : C.target)
-      else drawCircle(toWorld(s, t), 0.16, C.pathDot)
-    }
-    drawTile(toWorld(s, view.destination), vec2(0.8), sprite('flag_green_a'))
+    for (const [i, t] of view.path.entries()) drawRect(toWorld(s, t), vec2(0.94), i === 0 && view.pathBlocked ? C.blocked : C.target)
   }
   // The commit preview: the picked action played on a copy, then whatever it changed drawn faded.
   const after = view.armed
@@ -208,9 +203,9 @@ export function drawBoard(view: View): void {
     const k = key.bomb(b.pos)
     const pos = bombPos(s, fx, b.pos)
     if (detonatable) drawCircle(pos, 0.9 + 0.12 * Math.sin(time * 5), C.armed.scale(1, 0.6 + 0.4 * Math.sin(time * 5)))
-    drawBomb(pos, b.timer, hot ? 'bomb_active' : 'bomb', C.white, b.special, appearScale(fx, k, time) * pulseScale(fx, k, time))
+    drawBomb(pos, b.timer, hot ? 'bomb_active' : 'bomb', C.white, b, appearScale(fx, k, time) * pulseScale(fx, k, time))
   }
-  for (const b of changedBombs) drawBomb(toWorld(s, b.pos), b.timer, 'bomb', C.ghost, b.special)
+  for (const b of changedBombs) drawBomb(toWorld(s, b.pos), b.timer, 'bomb', C.ghost, b)
   if (s.decoy) drawDecoy(toWorld(s, s.decoy.pos), C.white, appearScale(fx, key.decoy, time))
   if (after?.decoy && !s.decoy) drawDecoy(toWorld(s, after.decoy.pos), C.ghost)
 
@@ -223,15 +218,27 @@ export function drawBoard(view: View): void {
   for (const e of s.enemies) drawEnemy(s, e, view.enemySteps.get(e.id), fx)
   for (const g of fx.gone) if (g.kind !== 'crate' && g.kind !== 'flame') drawGone(s, g)
 
-  if (s.shield) drawCircle(toWorld(s, s.player), 1.08 * appearScale(fx, key.shield, time), 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)
+  // The Shield's ring and a Star's pulsing gold ring go where the player is drawn, so they walk
+  // with them (user request, 2026-10-23: they jumped to the new tile). The Shield's stays inside
+  // the player's tile.
+  const at = playerPos(s, view.playerAnim)
+  if (s.shield) drawCircle(at, 0.88 * appearScale(fx, key.shield, time), C.clear, 0.06, C.shield)
+  if (s.star > 0) drawCircle(at, 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)
   }
   drawPlayer(s, view.playerAnim, view.facingLeft)
 }
 
+/** Where the player is on screen right now: sliding between tiles while walking, otherwise on their tile. */
+function playerPos(s: GameState, anim: PlayerAnim): Vector2 {
+  const to = toWorld(s, s.player)
+  const t = time - anim.at
+  if ((anim.kind === 'walk' || anim.kind === 'jump') && t < WALK_SECONDS) return toWorld(s, anim.from).lerp(to, t / WALK_SECONDS)
+  if (anim.kind === 'teleport' && t < TELEPORT_SECONDS) return toWorld(s, anim.from)
+  return to
+}
+
 /**
  * The player, animated like the enemies: an idle bob with a glance at the camera now and then,
  * walking frames while sliding into a new tile, a crouch when placing a bomb, a jump when the
@@ -251,10 +258,11 @@ function drawPlayer(s: GameState, anim: PlayerAnim, facingLeft: boolean): void {
     const k = out ? 1 - t / TELEPORT_SECONDS : (t - TELEPORT_SECONDS) / TELEPORT_SECONDS
     drawTile(out ? toWorld(s, anim.from) : to, vec2(k), sprite('character_green_front'), C.white, (1 - k) * 2, facingLeft)
     return
-  } else if (anim.kind === 'jump') {
+  } else if (anim.kind === 'jump' && (t >= WALK_SECONDS || eq(anim.from, s.player))) {
+    // Cleared: jumping for joy, once the step into the doorway (below) is done.
     frame = 'character_green_jump'
-    pos = to.add(vec2(0, 0.18 * Math.abs(Math.sin(time * 6))))
-  } else if (anim.kind === 'walk' && t < WALK_SECONDS) {
+    pos = to.add(vec2(0, 0.18 * Math.abs(Math.sin((t - WALK_SECONDS) * 6))))
+  } else if ((anim.kind === 'walk' || anim.kind === 'jump') && t < WALK_SECONDS) {
     const k = t / WALK_SECONDS
     const from = toWorld(s, anim.from)
     pos = from.lerp(to, k).add(vec2(0, 0.08 * Math.sin(k * Math.PI)))
@@ -269,11 +277,9 @@ function drawPlayer(s: GameState, anim: PlayerAnim, facingLeft: boolean): void {
   drawTile(pos, vec2(1), sprite(frame), C.white, 0, facingLeft)
 }
 
-/** The pack's door is two tiles tall; it is drawn narrower so both halves fit about one tile. */
+/** The exit: only the door's arched top piece, a short doorway that fills its tile (user request, 2026-10-23). */
 function drawExit(pos: Vector2, open: boolean, scale = 1): void {
-  const half = vec2(0.56 * scale)
-  drawTile(pos.add(vec2(0, -0.22 * scale)), half, sprite(open ? 'door_open' : 'door_closed'))
-  drawTile(pos.add(vec2(0, 0.34 * scale)), half, sprite(open ? 'door_open_top' : 'door_closed_top'))
+  drawTile(pos, vec2(scale), sprite(open ? 'door_open_top' : 'door_closed_top'))
 }
 
 /** Where a bomb is drawn: on its tile, or on its way there (kicked: sliding; thrown: in an arc). */
@@ -407,16 +413,18 @@ function drawDecoy(pos: Vector2, color: Color, scale = 1): void {
 
 /**
  * The bomb sprite with its timer on the body, which sits a little below and right of the
- * tile's centre. A Pierce or Power bomb wears its charge's icon as a badge at the top left.
+ * tile's centre. A Pierce, Power or Nuke bomb wears that charge's icon as a small badge along
+ * the top, one for each it has.
  */
-function drawBomb(pos: Vector2, timer: number, body: BoardSprite, color: Color, special?: Bomb['special'], scale = 1): void {
+function drawBomb(pos: Vector2, timer: number, body: BoardSprite, color: Color, kinds: Pick<Bomb, 'pierce' | 'power' | 'nuke'>, scale = 1): void {
   if (scale <= 0) return
   drawTile(pos, vec2(0.9 * scale), sprite(body), color)
   const digits = String(timer)
   const w = 0.32 * scale
   const start = pos.add(vec2(0.04 * scale - ((digits.length - 1) * w) / 2, -0.06 * scale))
   for (let i = 0; i < digits.length; i++) drawTile(start.add(vec2(i * w, 0)), vec2(0.42 * scale), digitSprite(Number(digits[i])), color)
-  if (special) drawTile(pos.add(vec2(-0.28, 0.28).scale(scale)), vec2(0.4 * scale), sprite(CHARGE_ICONS[special]), color)
+  const badges = (['pierce', 'power', 'nuke'] as const).filter((k) => kinds[k])
+  badges.forEach((k, i) => drawTile(pos.add(vec2(-0.3 + 0.3 * i, 0.32).scale(scale)), vec2(0.36 * scale), sprite(CHARGE_ICONS[k]), color))
 }
 
 function tileOf(s: GameState, i: number): Vec {
diff --git a/src/render/effects.ts b/src/render/effects.ts
index bb4c610..e089d9b 100644
--- a/src/render/effects.ts
+++ b/src/render/effects.ts
@@ -1,4 +1,4 @@
-import { CHARGE_ICONS, coinSprite, POWER_UP_ICONS, type BoardSprite } from './sprites'
+import { CHARGE_ICONS, coinSprite, GEM_SPRITES, type BoardSprite } from './sprites'
 import { FREEZE_TURNS } from '../sim/actions'
 import type { Drop, Enemy, GameEvent, GameState, Vec } from '../sim/types'
 
@@ -131,6 +131,8 @@ export function startEffects(fx: Effects, prev: GameState, next: GameState, even
         break
       case 'pickedUp':
         fx.gone.push({ kind: 'taken', sprite: dropSprite(e.drop), pos: e.drop.pos, size: 0.62, at: now + TAKE_DELAY })
+        // A Shield works at once: its ring grows round the player.
+        if (e.drop.kind === 'shield') fx.appeared.set(key.shield, now)
         break
       case 'dropBurned':
         fx.gone.push({ kind: 'burned', sprite: dropSprite(e.drop), pos: e.drop.pos, size: 0.62, at: now })
@@ -139,10 +141,10 @@ export function startEffects(fx: Effects, prev: GameState, next: GameState, even
         fx.appeared.set(key.powerUp, now)
         break
       case 'powerUpTaken':
-        fx.gone.push({ kind: 'taken', sprite: POWER_UP_ICONS[e.powerUp.kind], pos: e.powerUp.pos, size: 0.7, at: now + TAKE_DELAY })
+        fx.gone.push({ kind: 'taken', sprite: GEM_SPRITES[e.powerUp.color], pos: e.powerUp.pos, size: 0.7, at: now + TAKE_DELAY })
         break
       case 'powerUpBurned':
-        fx.gone.push({ kind: 'burned', sprite: POWER_UP_ICONS[e.powerUp.kind], pos: e.powerUp.pos, size: 0.7, at: now })
+        fx.gone.push({ kind: 'burned', sprite: GEM_SPRITES[e.powerUp.color], pos: e.powerUp.pos, size: 0.7, at: now })
         break
       case 'exitRevealed':
         fx.appeared.set(key.exit, now)
@@ -159,9 +161,6 @@ export function startEffects(fx: Effects, prev: GameState, next: GameState, even
       case 'shieldBroke':
         fx.gone.push({ kind: 'shield', pos: e.pos, at: now })
         break
-      case 'chargeUsed':
-        if (e.id === 'shield') fx.appeared.set(key.shield, now)
-        break
     }
   }
   // Freezing over, or thawing a step.
diff --git a/src/render/sprites.ts b/src/render/sprites.ts
index 8da40fe..667a693 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, PowerUpKind } from '../sim/types'
+import type { ChargeId, EnemyKind, GemColor, PowerUpKind } from '../sim/types'
 
 /**
  * Art from Kenney's New Platformer Pack 1.1 (CC0), copied into `assets/kenney/` under the pack's
@@ -23,7 +23,7 @@ export function spriteUrl(name: string): string {
  * One-colour icons in the UI's ink (built by tools/compose-icons.sh from Board Game Icons), for
  * the HTML only. Drawn with class "mono", so CSS can turn them white on a dark or green background.
  */
-export const MONO_ICONS: ReadonlySet<string> = new Set(['icon_move', 'icon_wait', 'icon_fuse'])
+export const MONO_ICONS: ReadonlySet<string> = new Set(['icon_move', 'icon_wait', 'icon_fuse', 'charge_shield', 'charge_freeze', 'charge_decoy'])
 
 /** The sprites drawn on the canvas. Their order is their LittleJS texture index. */
 const BOARD = [
@@ -54,20 +54,11 @@ const BOARD = [
   'hud_character_8',
   'hud_character_9',
   'flag_green_a',
-  'door_closed',
   'door_closed_top',
-  'door_open',
   'door_open_top',
   'coin_bronze',
   'coin_silver',
   'coin_gold',
-  'switch_blue',
-  'chain',
-  'ramp',
-  'spikes',
-  'weight',
-  'bridge_logs',
-  'hud_player_helmet_green',
   'character_green_front',
   'character_green_walk_a',
   'character_green_walk_b',
@@ -97,6 +88,15 @@ const BOARD = [
   'powerup_wall_pass',
   'powerup_bomb_pass',
   'powerup_flame_pass',
+  'powerup_diagonal',
+  'gem_blue',
+  'gem_green',
+  'gem_red',
+  'gem_yellow',
+  'charge_nuke',
+  'charge_shield',
+  'charge_freeze',
+  'charge_decoy',
 ] as const
 
 export type BoardSprite = (typeof BOARD)[number]
@@ -109,7 +109,17 @@ const tiles = new Map<BoardSprite, TileInfo>()
 /** Only after `engineInit` has loaded the textures. */
 export function sprite(name: BoardSprite): TileInfo {
   let t = tiles.get(name)
-  if (!t) tiles.set(name, (t = tile(0, vec2(128), BOARD.indexOf(name))))
+  if (!t) {
+    // A name from saved or changed data that isn't loaded: say which, rather than LittleJS's
+    // assertion on texture -1 (in its development build) or a blank tile.
+    const index = BOARD.indexOf(name)
+    if (index < 0) throw new Error(`Sprite not loaded: ${String(name)}`)
+    try {
+      tiles.set(name, (t = tile(0, vec2(128), index)))
+    } catch (error) {
+      throw new Error(`Sprite ${name} (texture ${index}): ${String(error)}`, { cause: error })
+    }
+  }
   return t
 }
 
@@ -137,15 +147,19 @@ export function wallSprite(level: number, width: number, height: number, p: { x:
   return top || bottom ? 'terrain_stone_horizontal_middle' : 'terrain_stone_vertical_middle'
 }
 
-/** The icon for each action charge, on the board (as a drop) and on its button. */
+/**
+ * The icon for each charge, on the board (as a drop), in the top bar while it lasts, and as a
+ * bomb's badge (user's choices, 2026-10-23): Pierce is the Wall Pass icon, Power the Fire+ icon;
+ * Nuke a bomb with a skull badge; Shield the
+ * figure in an empty ring; Freeze a locked padlock; Decoy three pawns (tools/compose-icons.sh).
+ */
 export const CHARGE_ICONS: Record<ChargeId, BoardSprite> = {
-  shield: 'switch_blue',
-  freeze: 'chain',
-  diagonal: 'ramp',
-  pierce: 'spikes',
-  power: 'weight',
-  line: 'bridge_logs',
-  decoy: 'hud_player_helmet_green',
+  pierce: 'powerup_wall_pass',
+  power: 'powerup_fire_plus',
+  nuke: 'charge_nuke',
+  shield: 'charge_shield',
+  freeze: 'charge_freeze',
+  decoy: 'charge_decoy',
 }
 
 /**
@@ -164,6 +178,14 @@ export function coinSprite(amount: number): BoardSprite {
   return amount >= 5 ? 'coin_gold' : amount >= 2 ? 'coin_silver' : 'coin_bronze'
 }
 
+/** A level's gem, in its colour (user request, 2026-10-23). */
+export const GEM_SPRITES: Record<GemColor, BoardSprite> = {
+  blue: 'gem_blue',
+  green: 'gem_green',
+  red: 'gem_red',
+  yellow: 'gem_yellow',
+}
+
 /**
  * Each power-up's sprite (PLAN.md §13). Those that change something you already have are that
  * thing with a modifier badge (user request, 2026-10-22): a bomb with +, with expanding arrows
@@ -186,4 +208,5 @@ export const POWER_UP_ICONS: Record<PowerUpKind, BoardSprite> = {
   kick: 'powerup_kick',
   throw: 'powerup_throw',
   teleport: 'powerup_teleport',
+  diagonal: 'powerup_diagonal',
 }
diff --git a/src/sim/actions.ts b/src/sim/actions.ts
index 8e67bd8..84d839b 100644
--- a/src/sim/actions.ts
+++ b/src/sim/actions.ts
@@ -11,8 +11,6 @@ export interface ActionDef {
   label: string
   /** False for actions like Wait that happen as soon as they are picked. */
   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[]
@@ -28,11 +26,16 @@ function canHoldBomb(s: GameState, p: Vec): boolean {
   return isFree(s, p) && !isBurning(s, p) && !enemyAt(s, p)
 }
 
-function placeBomb(s: GameState, pos: Vec, events: GameEvent[], special?: Bomb['special']): void {
+/** A bomb from the player, with every boost they have left (Pierce, Power, Nuke), each using one. */
+function placeBomb(s: GameState, pos: Vec, events: GameEvent[]): void {
+  const bomb: Bomb = { pos: { ...pos }, timer: s.stats.fuse, fire: s.stats.fire, placedTurn: s.turn }
+  for (const kind of BOMB_BOOSTS) {
+    if (s.boosts[kind] <= 0) continue
+    s.boosts[kind]--
+    bomb[kind] = true
+  }
   // A Power bomb's flame reaches as far as the level is wide: in practice, to the walls.
-  const fire = special === 'power' ? Math.max(s.width, s.height) : s.stats.fire
-  const bomb: Bomb = { pos: { ...pos }, timer: s.stats.fuse, fire, placedTurn: s.turn }
-  if (special) bomb.special = special
+  if (bomb.power) bomb.fire = Math.max(s.width, s.height)
   s.bombs.push(bomb)
   events.push({ type: 'bombPlaced', pos: { ...pos }, timer: s.stats.fuse })
 }
@@ -55,12 +58,43 @@ function walkTo(s: GameState, target: Vec, events: GameEvent[]): void {
 /** How far Teleport reaches (Manhattan distance). */
 export const TELEPORT_RANGE = 3
 /**
- * Freeze: the enemy moves skipped — this turn's, and the next 3 (user request, 2026-10-22: one
- * skipped move was only a wasted turn).
+ * Freeze: the enemy moves skipped, the pickup turn's included (user request, 2026-10-23: "frozen
+ * for 3 turns"; it was this turn and the next 3 while Freeze was an action).
  */
-export const FREEZE_TURNS = 4
+export const FREEZE_TURNS = 3
 /** How many enemy plannings a Decoy lasts. */
 export const DECOY_TURNS = 3
+/** How many bombs one Pierce, Power or Nuke charge changes (user's choice, 2026-10-23). */
+export const BOMB_BOOST = 3
+/** How far a Nuke bomb's blast reaches, counted across plus down, through anything (user's choice). */
+export const NUKE_RANGE = 2
+
+const BOMB_BOOSTS = ['pierce', 'power', 'nuke'] as const
+
+/**
+ * A charge picked up works at once (user request, 2026-10-23: they were actions before).
+ * Picking up more of a kind adds to it: BOMB_BOOST more bombs.
+ */
+export function applyCharge(s: GameState, kind: ChargeId, events: GameEvent[]): void {
+  switch (kind) {
+    case 'pierce':
+    case 'power':
+    case 'nuke':
+      s.boosts[kind] += BOMB_BOOST
+      break
+    case 'shield':
+      s.shield = true
+      break
+    case 'freeze':
+      s.frozen = Math.max(s.frozen, FREEZE_TURNS)
+      break
+    case 'decoy':
+      // On the pickup tile: the player steps off it, and chasing enemies go there instead.
+      s.decoy = { pos: { ...s.player }, turns: DECOY_TURNS }
+      events.push({ type: 'decoyPlaced', pos: s.decoy.pos })
+      break
+  }
+}
 
 /** Where a bomb kicked by the player slides to: on, away from them, until the next tile can't hold it. */
 export function kickDestination(s: GameState, from: Vec): Vec {
@@ -151,31 +185,11 @@ export const ACTIONS: Record<ActionId, ActionDef> = {
         : DIR_LIST.map((d) => add(s.player, { x: 2 * DIRS[d].x, y: 2 * DIRS[d].y })).filter((p) => canHoldBomb(s, p)),
     apply: (s, target, events) => placeBomb(s, target!, events),
   },
-  shield: {
-    id: 'shield',
-    label: 'Shield',
-    needsTarget: false,
-    charge: 'shield',
-    validTargets: () => [],
-    apply(s) {
-      s.shield = true
-    },
-  },
-  freeze: {
-    id: 'freeze',
-    label: 'Freeze',
-    needsTarget: false,
-    charge: 'freeze',
-    validTargets: () => [],
-    apply(s) {
-      s.frozen = FREEZE_TURNS
-    },
-  },
   diagonal: {
     id: 'diagonal',
     label: 'Diagonal',
     needsTarget: true,
-    charge: 'diagonal',
+    ability: 'diagonal',
     // A squeeze: the two orthogonal tiles in between may both be blocked.
     validTargets: (s) =>
       [
@@ -205,57 +219,9 @@ export const ACTIONS: Record<ActionId, ActionDef> = {
     },
     apply: (s, target, events) => walkTo(s, target!, events),
   },
-  pierce: {
-    id: 'pierce',
-    label: 'Pierce',
-    needsTarget: true,
-    charge: 'pierce',
-    validTargets: bombTargets,
-    apply: (s, target, events) => placeBomb(s, target!, events, 'pierce'),
-  },
-  power: {
-    id: 'power',
-    label: 'Power',
-    needsTarget: true,
-    charge: 'power',
-    validTargets: bombTargets,
-    apply: (s, target, events) => placeBomb(s, target!, events, 'power'),
-  },
-  line: {
-    id: 'line',
-    label: 'Line',
-    needsTarget: true,
-    charge: 'line',
-    // The tile picked sets the direction: bombs go there and on beyond it, one per free tile,
-    // until capacity runs out or the line is blocked.
-    validTargets: bombTargets,
-    apply(s, target, events) {
-      const d = { x: target!.x - s.player.x, y: target!.y - s.player.y }
-      for (let p = target!; hasCapacity(s) && canHoldBomb(s, p); p = add(p, d)) placeBomb(s, p, events)
-    },
-  },
-  decoy: {
-    id: 'decoy',
-    label: 'Decoy',
-    needsTarget: true,
-    charge: 'decoy',
-    validTargets: (s) => neighbours(s.player).filter((p) => canHoldBomb(s, p)),
-    apply(s, target, events) {
-      s.decoy = { pos: { ...target! }, turns: DECOY_TURNS }
-      events.push({ type: 'decoyPlaced', pos: s.decoy.pos })
-    },
-  },
 }
 
-export const ACTION_ORDER: readonly ActionId[] = [
-  'move', 'bomb', 'wait', 'run', 'detonate', 'kick', 'throw', 'shield', 'freeze', 'diagonal', 'teleport',
-  'pierce', 'power', 'line', 'decoy',
-]
-
-/** The action charge an action spends, if it is a charge action. */
-export function chargeOf(id: ActionId): ChargeId | undefined {
-  return ACTIONS[id].charge
-}
+export const ACTION_ORDER: readonly ActionId[] = ['move', 'bomb', 'wait', 'run', 'detonate', 'kick', 'throw', 'diagonal', 'teleport']
 
 export function isValidTarget(s: GameState, id: ActionId, target: Vec): boolean {
   return ACTIONS[id].validTargets(s).some((p) => eq(p, target))
@@ -263,15 +229,9 @@ 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
+  if (s.status !== 'playing' || s.offer) 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
-    if (id === 'shield' && s.shield) return false
-    if (id === 'freeze' && s.enemies.length === 0) return false
-  }
   const def = ACTIONS[id]
   return !def.needsTarget || def.validTargets(s).length > 0
 }
diff --git a/src/sim/demo.ts b/src/sim/demo.ts
new file mode 100644
index 0000000..efdb080
--- /dev/null
+++ b/src/sim/demo.ts
@@ -0,0 +1,162 @@
+import { ACTIONS, isAvailable } from './actions'
+import { planIntents } from './enemies'
+import { Rng } from './rng'
+import { noBoosts, noPowers, START_LIVES, START_STATS } from './state'
+import { step } from './turn'
+import { DEFAULT_OPTIONS, type Action, type ActionId, type Cell, type EnemyKind, type GameEvent, type GameState, type Vec } from './types'
+
+/**
+ * The demo played before the game starts, for players who never played the original (user
+ * request, 2026-10-23). Each scene is made up on the spot: a small random room, then random play
+ * on it, by the real rules, until a game shows what the scene is about. The scene is that room
+ * and those actions; the UI replays them, view-only. Pure and seeded like the rest of the sim.
+ */
+export type SceneKind = 'kill' | 'escape' | 'chain' | 'freeze' | 'detonate'
+
+export const SCENE_KINDS: readonly SceneKind[] = ['kill', 'escape', 'chain', 'freeze', 'detonate']
+
+/** What each scene shows, in one line under it. */
+export const SCENE_CAPTIONS: Record<SceneKind, string> = {
+  kill: 'Bombs count down with your moves. Their flame kills enemies.',
+  escape: 'Step out of the blast just in time.',
+  chain: 'A blast sets off every bomb it reaches.',
+  freeze: 'Freeze stops every enemy in its tracks. Then bomb them.',
+  detonate: 'With the Detonator, set a bomb off right when an enemy walks in.',
+}
+
+export interface Scene {
+  kind: SceneKind
+  start: GameState
+  actions: Action[]
+}
+
+const WIDTH = 9
+const HEIGHT = 7
+/** How many turns a scene may take, how many random games are tried per room, and how many rooms. */
+const MAX_TURNS = 24
+const TRIES = 150
+const ROOMS = 6
+
+/** What each scene starts with: enemies, and what the player has. */
+const SETUP: Record<SceneKind, { enemies: EnemyKind[]; prepare?: (s: GameState, rng: Rng) => void }> = {
+  kill: { enemies: ['blob', 'seeker'] },
+  escape: { enemies: ['seeker'] },
+  chain: { enemies: ['blob', 'blob'], prepare: (s) => (s.stats.capacity = 2) },
+  // A Freeze charge two steps from the player, to walk over.
+  freeze: { enemies: ['seeker', 'blob', 'blob'], prepare: (s, rng) => dropNear(s, rng, 'freeze') },
+  // A long fuse, so the Detonator is what sets the bomb off.
+  detonate: { enemies: ['blob', 'seeker'], prepare: (s) => ((s.powers.detonate = true), (s.stats.fuse = 6)) },
+}
+
+/** A scene of `kind`, from `seed`; null if no room gave one (rare). */
+export function generateScene(kind: SceneKind, seed: number): Scene | null {
+  const rng = new Rng(seed)
+  for (let room = 0; room < ROOMS; room++) {
+    const start = makeRoom(kind, rng)
+    let best: Action[] | null = null
+    for (let t = 0; t < TRIES; t++) {
+      const actions = rollout(kind, start, rng)
+      if (actions && (!best || actions.length < best.length)) best = actions
+      // A short one is good enough; a few more tries look for a shorter one.
+      if (best && best.length <= 10) break
+    }
+    if (best) return { kind, start, actions: best }
+  }
+  return null
+}
+
+/** A small room: walls round it, pillars on every other tile, a few crates; the player and enemies apart. */
+function makeRoom(kind: SceneKind, rng: Rng): GameState {
+  const cells: Cell[] = []
+  for (let y = 0; y < HEIGHT; y++) {
+    for (let x = 0; x < WIDTH; x++) {
+      const edge = x === 0 || y === 0 || x === WIDTH - 1 || y === HEIGHT - 1
+      cells.push(edge || (x % 2 === 0 && y % 2 === 0) ? 'wall' : 'floor')
+    }
+  }
+  const floor = () => cells.flatMap((c, i) => (c === 'floor' ? [{ x: i % WIDTH, y: Math.floor(i / WIDTH) }] : []))
+  const tiles = floor()
+  const player = tiles[rng.int(tiles.length)]!
+  const far = (p: Vec) => Math.abs(p.x - player.x) + Math.abs(p.y - player.y)
+  // Crates on about one free tile in six, never next to the player (room to place a bomb and go).
+  for (const p of tiles) if (far(p) > 2 && rng.next() < 0.16) cells[p.y * WIDTH + p.x] = 'block'
+  const s: GameState = {
+    seed: rng.int(2 ** 31), options: { ...DEFAULT_OPTIONS, size: 'small' }, level: 1, turn: 0, width: WIDTH, height: HEIGHT,
+    cells, player: { ...player }, lives: START_LIVES, stats: { ...START_STATS }, bombs: [], status: 'playing',
+    enemies: [], nextEnemyId: 0, drops: [], exit: { x: -1, y: -1 }, par: 99, coins: 0,
+    boosts: noBoosts(), shield: false, frozen: 0, decoy: null, burning: [],
+    powers: noPowers(), star: 0, powerUp: null, offer: null, runTurns: 0, deaths: 0, crumbling: [],
+    aiRng: rng.int(2 ** 31), dropRng: rng.int(2 ** 31),
+  }
+  const open = floor().filter((p) => far(p) >= 3)
+  for (const enemy of SETUP[kind].enemies) {
+    if (!open.length) break
+    const pos = open.splice(rng.int(open.length), 1)[0]!
+    s.enemies.push({ id: s.nextEnemyId++, kind: enemy, pos, dir: 'left', plan: [], spawned: false, sheltered: false })
+  }
+  SETUP[kind].prepare?.(s, rng)
+  planIntents(s)
+  return s
+}
+
+/** Put a charge on a free floor tile two steps from the player. */
+function dropNear(s: GameState, rng: Rng, kind: 'freeze'): void {
+  const near = s.cells.flatMap((c, i) => {
+    const p = { x: i % s.width, y: Math.floor(i / s.width) }
+    return c === 'floor' && Math.abs(p.x - s.player.x) + Math.abs(p.y - s.player.y) === 2 && !s.enemies.some((e) => e.pos.x === p.x && e.pos.y === p.y) ? [p] : []
+  })
+  if (near.length) s.drops.push({ pos: near[rng.int(near.length)]!, kind })
+}
+
+/** One random game from `start`; its actions if it shows the scene, otherwise null. */
+function rollout(kind: SceneKind, start: GameState, rng: Rng): Action[] | null {
+  let s = start
+  const actions: Action[] = []
+  const seen = { froze: -1 }
+  for (let turn = 0; turn < MAX_TURNS && s.status === 'playing'; turn++) {
+    // A random action that doesn't get the player killed this turn (a few goes at it).
+    let next: { action: Action; state: GameState; events: GameEvent[] } | null = null
+    for (let go = 0; go < 4 && !next; go++) {
+      const action = randomAction(s, rng)
+      const r = step(s, action)
+      if (r.state.status === 'playing' || r.state.status === 'cleared') next = { action, state: r.state, events: r.events }
+    }
+    if (!next) return null
+    actions.push(next.action)
+    if (shows(kind, s, next.state, next.events, seen, turn)) return actions
+    s = next.state
+  }
+  return null
+}
+
+/** Whether this turn (from `prev` to `next`, with its events) is the moment the scene is about. */
+function shows(kind: SceneKind, prev: GameState, next: GameState, events: GameEvent[], seen: { froze: number }, turn: number): boolean {
+  const killed = events.some((e) => e.type === 'enemyDied')
+  const exploded = events.filter((e) => e.type === 'exploded')
+  switch (kind) {
+    case 'kill':
+      return next.enemies.length === 0
+    case 'escape':
+      // Stood in a blast's reach, stepped out of it on the very turn it went off.
+      return exploded.some((e) => e.type === 'exploded' && e.tiles.some((t) => t.x === prev.player.x && t.y === prev.player.y))
+    case 'chain':
+      return exploded.length >= 2 && killed
+    case 'freeze':
+      if (events.some((e) => e.type === 'pickedUp' && e.drop.kind === 'freeze')) seen.froze = turn
+      return seen.froze >= 0 && killed && prev.frozen > 0
+    case 'detonate':
+      return events.some((e) => e.type === 'detonated') && killed
+  }
+}
+
+/** Weights for a random action: mostly moving, bombing now and then, sometimes waiting. */
+const WEIGHTS: Partial<Record<ActionId, number>> = { move: 5, bomb: 2, wait: 1, detonate: 3 }
+
+function randomAction(s: GameState, rng: Rng): Action {
+  const choices = (Object.keys(WEIGHTS) as ActionId[]).filter((id) => isAvailable(s, id))
+  let r = rng.next() * choices.reduce((sum, id) => sum + WEIGHTS[id]!, 0)
+  const id = choices.find((c) => (r -= WEIGHTS[c]!) < 0) ?? 'wait'
+  if (!ACTIONS[id].needsTarget) return { id }
+  const targets = ACTIONS[id].validTargets(s)
+  return { id, target: targets[rng.int(targets.length)]! }
+}
diff --git a/src/sim/enemies.ts b/src/sim/enemies.ts
index 7cbd149..f152232 100644
--- a/src/sim/enemies.ts
+++ b/src/sim/enemies.ts
@@ -20,6 +20,8 @@ interface Step {
 interface Walker {
   pos: Vec
   dir: Dir
+  /** Whether another enemy, planned before this one, will be on `p` at the move being planned. */
+  crowded(p: Vec): boolean
 }
 
 /** The direction of a one-tile step. */
@@ -30,21 +32,31 @@ export function dirBetween(from: Vec, to: Vec): Dir {
 /** How many moves ahead every enemy plans, and shows (PLAN.md §4). */
 export const PLAN_AHEAD = 3
 
-function openSteps(s: GameState, w: Walker): Step[] {
+function openSteps(s: GameState, w: Pick<Walker, 'pos'>): Step[] {
   return DIR_LIST.map((dir) => ({ dir, to: add(w.pos, DIRS[dir]) })).filter((st) => enemyCanEnter(s, st.to))
 }
 
+/**
+ * The open steps that keep clear of the other enemies (user request, 2026-10-23: enemies on one
+ * tile went everywhere together). Only when every open step is crowded is a crowded one used.
+ */
+function spreadSteps(s: GameState, w: Walker): Step[] {
+  const steps = openSteps(s, w)
+  const clear = steps.filter((st) => !w.crowded(st.to))
+  return clear.length ? clear : steps
+}
+
 /** Blob: carries on the way it was going three times in four, otherwise any open way. */
 function wander(s: GameState, w: Walker, rng: Rng): Step | null {
-  const steps = openSteps(s, w)
+  const steps = spreadSteps(s, w)
   const ahead = steps.find((st) => st.dir === w.dir)
   if (ahead && rng.next() < 0.75) return ahead
   return steps.length ? steps[rng.int(steps.length)]! : null
 }
 
-/** Drifter: straight on until blocked, then any open way. */
+/** Drifter: straight on until blocked (or the way ahead is another enemy's), then any open way. */
 function drift(s: GameState, w: Walker, rng: Rng): Step | null {
-  const steps = openSteps(s, w)
+  const steps = spreadSteps(s, w)
   return steps.find((st) => st.dir === w.dir) ?? (steps.length ? steps[rng.int(steps.length)]! : null)
 }
 
@@ -52,7 +64,7 @@ function drift(s: GameState, w: Walker, rng: Rng): Step | null {
  * The first step of a shortest path over tiles enemies can enter toward what they chase: the
  * decoy while there is one, otherwise the player. Null if there is no way.
  */
-export function stepToward(s: GameState, w: Walker): Step | null {
+export function stepToward(s: GameState, w: Pick<Walker, 'pos'>): Step | null {
   const goal = s.decoy?.pos ?? s.player
   const key = (p: Vec) => p.y * s.width + p.x
   const first = new Map<number, Step>()
@@ -77,8 +89,12 @@ export function stepToward(s: GameState, w: Walker): Step | null {
 
 const PLANNERS: Record<EnemyKind, (s: GameState, w: Walker, rng: Rng) => Step | null> = {
   blob: wander,
-  // Seeker: half the time a step along the shortest path to the player, otherwise a Blob's wander.
-  seeker: (s, w, rng) => (rng.next() < 0.5 ? stepToward(s, w) : null) ?? wander(s, w, rng),
+  // Seeker: half the time a step along the shortest path to the player (unless another enemy
+  // takes that tile then), otherwise a Blob's wander.
+  seeker: (s, w, rng) => {
+    const toward = rng.next() < 0.5 ? stepToward(s, w) : null
+    return toward && !w.crowded(toward.to) ? toward : wander(s, w, rng)
+  },
   drifter: drift,
 }
 
@@ -87,9 +103,14 @@ const PLANNERS: Record<EnemyKind, (s: GameState, w: Walker, rng: Rng) => Step |
  * make. Planned steps the board no longer allows (a bomb now in the way) are dropped first,
  * along with everything after them, and planned again from there. The steps are worked out on
  * the board as it is now, so a later bomb can still block one.
+ * Enemies are planned in turn, each keeping clear of where the ones before it will be at each
+ * of the coming moves (`spreadSteps`), so they spread out instead of moving as one.
  */
 export function planIntents(s: GameState): void {
   const rng = new Rng(s.aiRng)
+  // "t:x,y": an enemy planned so far will be at x,y after t moves (it stays where its plan ends).
+  const taken = new Set<string>()
+  const at = (t: number, p: Vec) => `${t}:${p.x},${p.y}`
   for (const e of s.enemies) {
     let from = e.pos
     const kept: Vec[] = []
@@ -100,7 +121,11 @@ export function planIntents(s: GameState): void {
     }
     // Carry on from the last kept step, heading the way that step went.
     const last = kept.at(-1)
-    const w: Walker = { pos: from, dir: last ? dirBetween(kept.at(-2) ?? e.pos, last) : e.dir }
+    const w: Walker = {
+      pos: from,
+      dir: last ? dirBetween(kept.at(-2) ?? e.pos, last) : e.dir,
+      crowded: (p) => taken.has(at(e.plan.length + 1, p)),
+    }
     e.plan = kept
     while (e.plan.length < PLAN_AHEAD) {
       const st = PLANNERS[e.kind](s, w, rng)
@@ -110,6 +135,7 @@ export function planIntents(s: GameState): void {
       w.dir = st.dir
     }
     e.dir = w.dir
+    for (let t = 1; t <= PLAN_AHEAD; t++) taken.add(at(t, e.plan[t - 1] ?? e.plan.at(-1) ?? e.pos))
   }
   s.aiRng = rng.state
 }
@@ -144,13 +170,24 @@ const DROP_CHANCE: Record<EnemyKind, { coins: number; charge: number }> = {
   drifter: { coins: 0.2, charge: 0.5 },
 }
 
-/** Every charge is equally likely. */
-function pickCharge(rng: Rng): ChargeId {
-  return CHARGE_IDS[Math.floor(rng.next() * CHARGE_IDS.length)]!
+/**
+ * The charges that still help once every enemy is gone: they go on to the next level. Freeze
+ * and Decoy only work on enemies, so dropped by the last one they were wasted (user report,
+ * 2026-10-23; the user's choice of fix).
+ */
+export const LASTING_CHARGES: readonly ChargeId[] = ['pierce', 'power', 'nuke', 'shield']
+
+/** Every charge is equally likely; with `last`, every lasting one. */
+function pickCharge(rng: Rng, last: boolean): ChargeId {
+  const ids = last ? LASTING_CHARGES : CHARGE_IDS
+  return ids[Math.floor(rng.next() * ids.length)]!
 }
 
-/** What a slain enemy leaves on its tile. Coins are worth 1 (60%), 2 (30%) or 5 (10%). */
-export function rollDrops(s: GameState, e: Enemy): Drop[] {
+/**
+ * What a slain enemy leaves on its tile. Coins are worth 1 (60%), 2 (30%) or 5 (10%). `last`:
+ * no enemies are left, so a charge is a lasting one.
+ */
+export function rollDrops(s: GameState, e: Enemy, last = false): Drop[] {
   if (e.spawned) return []
   const rng = new Rng(s.dropRng)
   const drops: Drop[] = []
@@ -160,7 +197,7 @@ export function rollDrops(s: GameState, e: Enemy): Drop[] {
     drops.push({ pos: e.pos, kind: 'coins', amount: r < 0.6 ? 1 : r < 0.9 ? 2 : 5 })
   }
   if (rng.next() < chance.charge) {
-    drops.push({ pos: e.pos, kind: pickCharge(rng) })
+    drops.push({ pos: e.pos, kind: pickCharge(rng, last) })
   }
   s.dropRng = rng.state
   return drops
diff --git a/src/sim/explosions.ts b/src/sim/explosions.ts
index 0f44f06..1f60b85 100644
--- a/src/sim/explosions.ts
+++ b/src/sim/explosions.ts
@@ -1,12 +1,14 @@
+import { NUKE_RANGE } from './actions'
 import { add, bombAt, cellAt, idx } from './state'
 import { DIRS, DIR_LIST, type Bomb, type GameEvent, type GameState, type Vec } from './types'
 
 /**
  * The tiles a bomb's flame reaches: its own tile, then up to `fire` tiles in each direction.
  * Flame stops before a hard wall, and stops *on* a soft block or another bomb (burning it).
- * A Pierce bomb's flame carries on through soft blocks.
+ * A Pierce bomb's flame carries on through soft blocks. A Nuke bomb also burns every tile within
+ * NUKE_RANGE steps (across plus down), through anything, except walls, which still stand.
  */
-export function blastTiles(s: GameState, bomb: Pick<Bomb, 'pos' | 'fire' | 'special'>): Vec[] {
+export function blastTiles(s: GameState, bomb: Pick<Bomb, 'pos' | 'fire' | 'pierce' | 'nuke'>): Vec[] {
   const tiles = [bomb.pos]
   for (const d of DIR_LIST) {
     let p = bomb.pos
@@ -15,7 +17,16 @@ export function blastTiles(s: GameState, bomb: Pick<Bomb, 'pos' | 'fire' | 'spec
       const cell = cellAt(s, p)
       if (cell === 'wall') break
       tiles.push(p)
-      if ((cell === 'block' && bomb.special !== 'pierce') || bombAt(s, p)) break
+      if ((cell === 'block' && !bomb.pierce) || bombAt(s, p)) break
+    }
+  }
+  if (bomb.nuke) {
+    for (let dy = -NUKE_RANGE; dy <= NUKE_RANGE; dy++) {
+      for (let dx = -NUKE_RANGE; dx <= NUKE_RANGE; dx++) {
+        const p = add(bomb.pos, { x: dx, y: dy })
+        if (Math.abs(dx) + Math.abs(dy) > NUKE_RANGE || cellAt(s, p) === 'wall') continue
+        if (!tiles.some((t) => t.x === p.x && t.y === p.y)) tiles.push(p)
+      }
     }
   }
   return tiles
diff --git a/src/sim/levelgen.ts b/src/sim/levelgen.ts
index dbb6bed..c9371e0 100644
--- a/src/sim/levelgen.ts
+++ b/src/sim/levelgen.ts
@@ -1,5 +1,5 @@
-import { hashSeed, stream, type Rng } from './rng'
-import { DIR_LIST, type BoardSize, type Cell, type Dir, type EnemyKind, type PowerUp, type PowerUpKind, type Vec } from './types'
+import { stream } from './rng'
+import { DIR_LIST, type BoardSize, type Cell, type Dir, type EnemyKind, type GemColor, type PowerUp, 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
@@ -62,28 +62,8 @@ export function lastLevel(size: BoardSize): number {
   return SIZES[size].lastLevel
 }
 
-/**
- * How likely each power-up is to be a level's hidden one. Run isn't in the draw: it is
- * 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],
-  ['kick', 8], ['throw', 8], ['teleport', 3],
-]
-
-/** Level 1 or 2, from the seed. (The hash key is still 'skates', its old name, so every seed keeps its level.) */
-export function runLevel(seed: number): number {
-  return 1 + (hashSeed(seed, 'skates') & 1)
-}
-
-function pickPowerUp(seed: number, level: number, rng: Rng): PowerUpKind {
-  if (level === runLevel(seed)) return 'run'
-  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]
-}
+/** A gem's colour is only how it looks. */
+const GEM_COLORS: readonly GemColor[] = ['blue', 'green', 'red', 'yellow']
 
 export interface EnemyStart {
   kind: EnemyKind
@@ -143,9 +123,9 @@ export function generateLevel(seed: number, level: number, size: BoardSize = 'no
     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)]! })
   }
-  // Any other block, near the start or not, so an early power-up is possible.
+  // Any other block, near the start or not, so an early gem 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) }
+  const powerUp = { pos: at(others[rng.int(others.length)]!), color: GEM_COLORS[rng.int(GEM_COLORS.length)]! }
   return { width, height, cells, start: { ...START }, exit, powerUp, enemies, par: parFor(enemies.length, size) }
 }
 
diff --git a/src/sim/path.ts b/src/sim/path.ts
index fbc1854..a914f54 100644
--- a/src/sim/path.ts
+++ b/src/sim/path.ts
@@ -1,4 +1,4 @@
-import { add, eq, playerCanPass } from './state'
+import { add, eq, exitOpen, playerCanPass } from './state'
 import { DIRS, DIR_LIST, type GameState, type Vec } from './types'
 
 /**
@@ -7,17 +7,20 @@ import { DIRS, DIR_LIST, type GameState, type Vec } from './types'
  * 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.
+ * An open exit is only walked onto as the destination: passing over it would end the level on
+ * the way somewhere else (user report, 2026-10-23).
  */
 export function playerPath(s: GameState, to: Vec): Vec[] | 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 avoidExit = exitOpen(s) && !eq(s.exit, to)
   const queue = [s.player]
   for (let i = 0; i < queue.length; i++) {
     const p = queue[i]!
     for (const d of DIR_LIST) {
       const q = add(p, DIRS[d])
-      if (prev.has(key(q)) || !playerCanPass(s, q)) continue
+      if (prev.has(key(q)) || !playerCanPass(s, q) || (avoidExit && eq(q, s.exit))) 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 4a8ae4b..805408d 100644
--- a/src/sim/record.ts
+++ b/src/sim/record.ts
@@ -6,7 +6,7 @@ import { DEFAULT_OPTIONS, type Action, type GameEvent, type GameState, type RunO
  * 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 = 7
+export const SIM_VERSION = 15
 
 /** 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 fd86ed4..8337bab 100644
--- a/src/sim/state.ts
+++ b/src/sim/state.ts
@@ -1,7 +1,8 @@
 import { planIntents } from './enemies'
 import { generateLevel } from './levelgen'
 import { hashSeed } from './rng'
-import { CHARGE_IDS, DEFAULT_OPTIONS, type Bomb, type Cell, type ChargeId, type GameState, type Powers, type RunOptions, type Stats, type Vec } from './types'
+import { BOMB_BOOST } from './actions'
+import { DEFAULT_OPTIONS, type Bomb, type Boosts, type Cell, type GameState, type Powers, type RunOptions, type Stats, type Vec } from './types'
 
 export const START_LIVES = 3
 export const START_STATS: Stats = { capacity: 1, fire: 1, fuse: 3 }
@@ -14,7 +15,9 @@ export interface Carry {
   lives: number
   stats: Stats
   coins: number
-  charges: Record<ChargeId, number>
+  boosts: Boosts
+  /** A shield picked up and not yet broken goes on to the next level. */
+  shield: boolean
   powers: Powers
   star: number
   runTurns: number
@@ -22,26 +25,22 @@ export interface Carry {
 }
 
 export function noPowers(): Powers {
-  return { run: false, kick: false, throw: false, teleport: false, detonate: false, wallPass: false, bombPass: false, flamePass: false }
+  return { run: false, kick: false, throw: false, teleport: false, diagonal: false, detonate: false, wallPass: false, bombPass: false, flamePass: false }
 }
 
-/** `n` of every action charge. */
-export function charges(n: number): Record<ChargeId, number> {
-  return Object.fromEntries(CHARGE_IDS.map((id) => [id, n])) as Record<ChargeId, number>
+export function noBoosts(): Boosts {
+  return { pierce: 0, power: 0, nuke: 0 }
 }
 
-export function noCharges(): Record<ChargeId, number> {
-  return charges(0)
-}
-
-/** Developer mode's stats, and its charges (shown as unlimited; never spent). */
-export const DEV_STATS: Stats = { capacity: 5, fire: 5, fuse: 3 }
-export const DEV_CHARGES = 99
-
-/** `n`: start with this many of every action charge (a testing aid, `?charges=` in the URL). */
+/**
+ * `n`: start as if `n` of each lasting charge had been picked up — Pierce, Power, Nuke (n ×
+ * BOMB_BOOST bombs each) and a Shield; not Freeze or Decoy, which act on the
+ * spot (a testing aid, `?charges=` in the URL).
+ */
 export function newRun(seed: number, level = 1, n = 0, options: RunOptions = DEFAULT_OPTIONS): GameState {
+  const boosts = n > 0 ? { pierce: n * BOMB_BOOST, power: n * BOMB_BOOST, nuke: n * BOMB_BOOST } : noBoosts()
   return createLevel(seed, level, options, {
-    lives: START_LIVES, stats: { ...START_STATS }, coins: 0, charges: charges(n), powers: noPowers(), star: 0,
+    lives: START_LIVES, stats: { ...START_STATS }, coins: 0, boosts, shield: n > 0, powers: noPowers(), star: 0,
     runTurns: 0, deaths: 0,
   })
 }
@@ -54,34 +53,31 @@ export function createLevel(seed: number, level: number, options: RunOptions, ca
   const { width, height, cells, start, exit, powerUp, enemies, par } = generateLevel(seed, level, options.size)
   const s: GameState = {
     seed, options: { ...options }, level, turn: 0, width, height, cells, player: start,
-    lives: carry.lives, stats: { ...carry.stats }, coins: carry.coins, charges: { ...carry.charges },
+    lives: carry.lives, stats: { ...carry.stats }, coins: carry.coins, boosts: { ...carry.boosts },
     bombs: [], status: 'playing',
-    enemies: enemies.map((e, id) => ({ id, kind: e.kind, pos: e.pos, dir: e.dir, plan: [], spawned: false })),
+    enemies: enemies.map((e, id) => ({ id, kind: e.kind, pos: e.pos, dir: e.dir, plan: [], spawned: false, sheltered: false })),
     nextEnemyId: enemies.length,
-    drops: [], exit, par, shield: false, frozen: 0, decoy: null, burning: [],
-    powers: { ...carry.powers }, star: carry.star, powerUp, runTurns: carry.runTurns, deaths: carry.deaths, crumbling: [],
+    drops: [], exit, par, shield: carry.shield, frozen: 0, decoy: null, burning: [],
+    powers: { ...carry.powers }, star: carry.star, powerUp, offer: null, runTurns: carry.runTurns, deaths: carry.deaths, crumbling: [],
     aiRng: hashSeed(seed, 'ai', level), dropRng: hashSeed(seed, 'drops', level),
   }
-  // Developer mode: everything, every level and after every death.
-  if (options.dev) {
-    s.powers = { run: true, kick: true, throw: true, teleport: true, detonate: true, wallPass: true, bombPass: true, flamePass: true }
-    s.stats = { ...DEV_STATS, fuse: Math.max(DEV_STATS.fuse, s.stats.fuse) }
-    s.charges = charges(DEV_CHARGES)
-  }
+  // Developer mode: only this (user request, 2026-10-23; it used to give every ability, stats and
+  // unlimited charges). The level's gem lies uncovered right of the start (always open floor),
+  // every level and after every death, to try the choice at once.
+  if (options.dev) s.powerUp = { pos: { x: start.x + 1, y: start.y }, color: powerUp.color }
   planIntents(s)
   return s
 }
 
 /**
- * What the player takes on. After a death, the NES rule: Wall Pass, Bomb Pass, Flame Pass,
- * Detonator and Star are lost; stats, Run, Kick, Throw, Teleport, coins and charges are
- * kept. (The Keep / Fresh choice of PLAN.md §9 will decide about the stats and Run.)
+ * What the player takes on, to the next level or the retry after a death: everything. Since
+ * 2026-10-23 (user request) a death costs no power-ups; the NES rule lost the passes, Detonator
+ * and Star. (A Star can't be running at a death: nothing hurts the player while it lasts.)
+ * (The Keep / Fresh choice of PLAN.md §9 will decide about the stats and Run.)
  */
-export function carryOf(s: GameState, died = false): Carry {
-  const { run, kick, throw: canThrow, teleport } = s.powers
-  const powers = died ? { ...noPowers(), run, kick, throw: canThrow, teleport } : s.powers
+export function carryOf(s: GameState): Carry {
   return {
-    lives: s.lives, stats: s.stats, coins: s.coins, charges: s.charges, powers, star: died ? 0 : s.star,
+    lives: s.lives, stats: s.stats, coins: s.coins, boosts: s.boosts, shield: s.shield, powers: s.powers, star: s.star,
     runTurns: s.runTurns, deaths: s.deaths,
   }
 }
diff --git a/src/sim/turn.ts b/src/sim/turn.ts
index ec8a83e..f679458 100644
--- a/src/sim/turn.ts
+++ b/src/sim/turn.ts
@@ -1,10 +1,10 @@
-import { ACTIONS, chargeOf, isAvailable, isValidTarget } from './actions'
+import { ACTIONS, applyCharge, isAvailable, isValidTarget } from './actions'
 import { enemyAt, moveEnemies, planIntents, rollDrops } from './enemies'
 import { enemyKinds, lastLevel } from './levelgen'
 import { detonate, tickBombs } from './explosions'
 import { add, bombAt, carryOf, cellAt, cloneState, createLevel, eq, exitOpen, idx, PAR_BONUS } from './state'
 import { stream } from './rng'
-import { DIRS, DIR_LIST, type Action, type GameEvent, type GameState, type PowerUpKind, type Vec } from './types'
+import { DIRS, DIR_LIST, type Action, type GameEvent, type GameState, type Powers, type PowerUpKind, type Vec } from './types'
 
 export interface StepResult {
   state: GameState
@@ -26,10 +26,21 @@ export function step(prev: GameState, action: Action): StepResult {
   const s = cloneState(prev)
   const events: GameEvent[] = []
 
+  // The gem's choice: no turn passes, nothing else moves.
+  if (action.id === 'choose') {
+    if (!s.offer?.includes(action.kind)) throw new Error(`${action.kind} is not on offer`)
+    applyPowerUp(s, action.kind)
+    s.offer = null
+    events.push({ type: 'powerUpChosen', kind: action.kind })
+    return { state: s, events }
+  }
+  // An offer must be answered first, even with the level cleared; a death drops it.
+  if (s.offer && s.status !== 'dead') throw new Error(`${action.id} while a power-up is on offer`)
+
   if (action.id === 'continue') {
     if (s.status === 'dead') {
       events.push({ type: 'levelRestarted' })
-      return { state: createLevel(s.seed, s.level, s.options, carryOf(s, true)), events }
+      return { state: createLevel(s.seed, s.level, s.options, carryOf(s)), events }
     }
     if (s.status === 'cleared') {
       events.push({ type: 'levelStarted', level: s.level + 1 })
@@ -44,11 +55,6 @@ export function step(prev: GameState, action: Action): StepResult {
   if (def.needsTarget && (!action.target || !isValidTarget(s, action.id, action.target))) {
     throw new Error(`invalid target for ${action.id}: ${JSON.stringify(action.target)}`)
   }
-  const charge = chargeOf(action.id)
-  if (charge) {
-    if (!s.options.dev) s.charges[charge]--
-    events.push({ type: 'chargeUsed', id: charge })
-  }
   const from = s.player
   def.apply(s, action.target, events)
   // A run passes through the tile in between: anything there is picked up, or run into.
@@ -112,15 +118,19 @@ export function step(prev: GameState, action: Action): StepResult {
     s.drops.splice(s.drops.indexOf(d), 1)
     events.push({ type: 'dropBurned', drop: d })
   }
-  for (const e of s.enemies.filter((e) => fire.has(idx(s, e.pos)))) {
-    s.enemies.splice(s.enemies.indexOf(e), 1)
+  // Enemies let out by last turn's flame are only hurt by new explosions, not by that flame.
+  const slain = s.enemies.filter((e) => (e.sheltered ? burned : fire).has(idx(s, e.pos)))
+  s.enemies = s.enemies.filter((e) => !slain.includes(e))
+  for (const e of slain) {
     events.push({ type: 'enemyDied', id: e.id, pos: e.pos })
-    for (const drop of rollDrops(s, e)) {
+    // When these were the last, their charges are ones that last past the enemies (user's choice).
+    for (const drop of rollDrops(s, e, s.enemies.length === 0)) {
       drop.pos = dropSpot(s, e.pos)
       s.drops.push(drop)
       events.push({ type: 'dropSpawned', drop })
     }
   }
+  for (const e of s.enemies) e.sheltered = false
   if (s.decoy && fire.has(idx(s, s.decoy.pos))) {
     events.push({ type: 'decoyGone', pos: s.decoy.pos, burned: true })
     s.decoy = null
@@ -155,6 +165,9 @@ export function step(prev: GameState, action: Action): StepResult {
     s.decoy = null
   }
 
+  // Died the turn a gem was taken: no choice. The retry has the gem back.
+  if (s.status === 'dead' || s.status === 'gameOver') s.offer = null
+
   s.turn++
   s.runTurns++
   return { state: s, events }
@@ -218,26 +231,65 @@ function crumble(s: GameState, events: GameEvent[]): void {
 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 })
+    s.enemies.push({ id: s.nextEnemyId++, kind: kinds[kinds.length - 1]!, pos: { ...pos }, dir: 'up', plan: [], spawned: true, sheltered: 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. */
+/** Collect every drop at `p`, and the level's gem if it lies there uncovered (which sets up its offer). */
 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]++
+    else applyCharge(s, d.kind, events)
     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.offer = drawOffer(s)
+    events.push({ type: 'powerUpTaken', powerUp: s.powerUp, offer: s.offer })
     s.powerUp = null
   }
 }
 
+/** How many power-ups a gem offers (user request, 2026-10-23). */
+export const OFFER_SIZE = 5
+
+/**
+ * How likely each power-up is to be offered, in the order an offer lists them (stats,
+ * abilities, passes, the rest). Run is in the draw like the rest; it was guaranteed on level 1
+ * or 2 before gems (the user chose no guarantee). To tune by playtesting.
+ */
+export const POWER_UP_WEIGHTS: [PowerUpKind, number][] = [
+  ['bombPlus', 20], ['firePlus', 20], ['fusePlus', 6], ['run', 10], ['kick', 8], ['throw', 8], ['teleport', 3], ['diagonal', 8],
+  ['detonator', 9], ['wallPass', 8], ['bombPass', 8], ['flamePass', 5], ['star', 8], ['heart', 4], ['coinBag', 12],
+]
+
+/** The power-ups that are an ability or pass: offering one already held would change nothing. */
+const HELD_AS: Partial<Record<PowerUpKind, keyof Powers>> = {
+  run: 'run', kick: 'kick', throw: 'throw', teleport: 'teleport', diagonal: 'diagonal', detonator: 'detonate',
+  wallPass: 'wallPass', bombPass: 'bombPass', flamePass: 'flamePass',
+}
+
+/**
+ * A gem's offer: OFFER_SIZE different power-ups (fewer if fewer would do anything), drawn by
+ * POWER_UP_WEIGHTS and listed in its order. From the seed and level, so a retry after a death
+ * offers the same.
+ */
+export function drawOffer(s: GameState): PowerUpKind[] {
+  const rng = stream(s.seed, 'offer', s.level)
+  const pool = POWER_UP_WEIGHTS.filter(([kind]) => {
+    const held = HELD_AS[kind]
+    return !held || !s.powers[held]
+  })
+  const picked = new Set<PowerUpKind>()
+  while (picked.size < OFFER_SIZE && pool.length) {
+    let r = rng.next() * pool.reduce((sum, [, w]) => sum + w, 0)
+    const i = Math.max(0, pool.findIndex(([, w]) => (r -= w) < 0))
+    picked.add(pool.splice(i, 1)[0]![0])
+  }
+  return POWER_UP_WEIGHTS.map(([kind]) => kind).filter((kind) => picked.has(kind))
+}
+
 function applyPowerUp(s: GameState, kind: PowerUpKind): void {
   switch (kind) {
     case 'bombPlus': s.stats.capacity++; break
@@ -254,5 +306,6 @@ function applyPowerUp(s: GameState, kind: PowerUpKind): void {
     case 'kick': s.powers.kick = true; break
     case 'throw': s.powers.throw = true; break
     case 'teleport': s.powers.teleport = true; break
+    case 'diagonal': s.powers.diagonal = true; break
   }
 }
diff --git a/src/sim/types.ts b/src/sim/types.ts
index b93d3d8..963ba88 100644
--- a/src/sim/types.ts
+++ b/src/sim/types.ts
@@ -31,8 +31,12 @@ export interface Bomb {
   fire: number
   /** The turn it was placed on. A bomb is not ticked on the turn it is placed. */
   placedTurn: number
-  /** Pierce: its flame goes through soft blocks, burning them all. Power: fire to the walls. */
-  special?: 'pierce' | 'power'
+  /** Pierce: its flame goes through soft blocks, burning them all. Set from the player's boosts when placed. */
+  pierce?: boolean
+  /** Power: its flame reaches to the walls. */
+  power?: boolean
+  /** Nuke: it also burns every tile within NUKE_RANGE steps, through anything. */
+  nuke?: boolean
 }
 
 export interface Stats {
@@ -57,7 +61,7 @@ export type BoardSize = 'normal' | 'small'
 export interface RunOptions {
   /** normal: 31 × 13, 50 levels. small: 9 × 11, 20 levels, fewer enemies and crates. */
   size: BoardSize
-  /** Developer mode: every ability and pass, unlimited charges, 5 bombs, blast 5 — for trying things. Still killable. */
+  /** Developer mode: every level's gem lies uncovered right of the start, to try the choice at once. Nothing else. */
   dev: boolean
   /** God mode: nothing can kill the player. */
   god: boolean
@@ -67,36 +71,45 @@ export interface RunOptions {
 
 export const DEFAULT_OPTIONS: RunOptions = { size: 'normal', dev: false, god: false, crumble: false }
 
-/** The power-ups hidden one per level under a soft block (PLAN.md §5, after the NES eight). */
+/** The power-ups (PLAN.md §5, after the NES eight). Taking a level's gem offers a choice of them. */
 export type PowerUpKind =
   | 'bombPlus' | 'firePlus' | 'fusePlus' | 'run' | 'wallPass' | 'bombPass' | 'flamePass' | 'detonator'
-  | 'star' | 'coinBag' | 'heart' | 'kick' | 'throw' | 'teleport'
+  | 'star' | 'coinBag' | 'heart' | 'kick' | 'throw' | 'teleport' | 'diagonal'
 
+/** A gem's colour, from the seed. Only how it looks: every gem offers the same kind of choice. */
+export type GemColor = 'blue' | 'green' | 'red' | 'yellow'
+
+/**
+ * The level's gem, hidden one per level under a soft block. Taking it offers OFFER_SIZE
+ * power-ups to choose one from (user request, 2026-10-23: it used to be one fixed power-up).
+ */
 export interface PowerUp {
   pos: Vec
-  kind: PowerUpKind
+  color: GemColor
 }
 
 /**
- * Abilities from power-ups. Run, Detonate, Kick, Throw and Teleport are actions; the passes
- * change where the player can go.
+ * Abilities from power-ups. Run, Detonate, Kick, Throw, Teleport and Diagonal are actions; the
+ * passes change where the player can go. All are kept through a death (since 2026-10-23).
  */
 export interface Powers {
-  /** The Run power-up (called Skates until 2026-10-22): 2 tiles in a straight line. Kept on death. */
+  /** The Run power-up (called Skates until 2026-10-22): 2 tiles in a straight line. */
   run: boolean
-  /** Kick: walk into a bomb and it slides away. Kept on death (user request, 2026-10-22). */
+  /** Kick: walk into a bomb and it slides away. */
   kick: boolean
-  /** Throw: a bomb 2 tiles away in a straight line, over anything. Kept on death. */
+  /** Throw: a bomb 2 tiles away in a straight line, over anything. */
   throw: boolean
-  /** Teleport: move to any free tile up to TELEPORT_RANGE steps away, over anything. Kept on death. */
+  /** Teleport: move to any free tile up to TELEPORT_RANGE steps away, over anything. */
   teleport: boolean
-  /** Detonator: the Detonate action, set off one of your bombs now. Lost on death. */
+  /** Diagonal: Move can also step diagonally (a charge until 2026-10-23, user request). */
+  diagonal: boolean
+  /** Detonator: the Detonate action, set off one of your bombs now. */
   detonate: boolean
-  /** Walk through soft blocks. Lost on death. */
+  /** Walk through soft blocks. */
   wallPass: boolean
-  /** Walk over bombs. Lost on death. */
+  /** Walk over bombs. */
   bombPass: boolean
-  /** Flame doesn't hurt. Lost on death. */
+  /** Flame doesn't hurt. */
   flamePass: boolean
 }
 
@@ -117,21 +130,32 @@ export interface Enemy {
   plan: Vec[]
   /** Spawned as a punishment (the exit was flamed): drops nothing. */
   spawned: boolean
+  /**
+   * Just let out by a flamed exit or gem: the flame that let it out (still lingering next turn)
+   * can't hurt it; any new explosion can (user's choice, 2026-10-23: they used to die in it at once).
+   */
+  sheltered: boolean
 }
 
 /**
- * The consumable action charges dropped by enemies (PLAN.md §3.3). Kick, Throw and Teleport
- * were charges until 2026-10-22; they are power-up abilities now.
+ * The charges dropped by enemies (PLAN.md §3.3). Since 2026-10-23 (user request) they aren't
+ * actions: each works the moment it is picked up, as a temporary change (`applyCharge`).
+ * Pierce, Power and Nuke change the next BOMB_BOOST bombs; Shield blocks the next hit; Freeze
+ * freezes every enemy; Decoy leaves a copy of the player on the pickup tile. (Line was a charge
+ * until then, and Diagonal, which became a power-up the same day; Kick, Throw and Teleport until
+ * 2026-10-22; Fuse ±1 was removed on 2026-10-22.)
  */
-export type ChargeId = 'shield' | 'freeze' | 'diagonal' | 'pierce' | 'power' | 'line' | 'decoy'
+export type ChargeId = 'pierce' | 'power' | 'nuke' | 'shield' | 'freeze' | 'decoy'
 
-export const CHARGE_IDS: readonly ChargeId[] = ['shield', 'freeze', 'diagonal', 'pierce', 'power', 'line', 'decoy']
+export const CHARGE_IDS: readonly ChargeId[] = ['pierce', 'power', 'nuke', 'shield', 'freeze', 'decoy']
 
-/**
- * The actions charges give, one each. (The Fuse ±1 charge was removed on 2026-10-22, at the
- * user's request: changing a bomb's number after placing it isn't from the original.)
- */
-export type ChargeActionId = ChargeId
+/** What picked-up charges still have to give. Kept from level to level and through deaths. */
+export interface Boosts {
+  /** How many of the player's next bombs pierce, reach the walls, nuke. Each bomb placed uses one of each. */
+  pierce: number
+  power: number
+  nuke: number
+}
 
 /** A stand-in for the player that chasing enemies go after instead, for a few turns. */
 export interface Decoy {
@@ -166,8 +190,8 @@ export interface GameState {
   /** Finishing the level in at most this many turns pays PAR_BONUS coins. */
   par: number
   coins: number
-  charges: Record<ChargeId, number>
-  /** Shield armed: the next hit (flame or enemy) is absorbed. */
+  boosts: Boosts
+  /** Shield armed (picked up): the next hit (flame or enemy) is absorbed. Kept to the next level. */
   shield: boolean
   /** Freeze: how many more enemy moves are skipped (FREEZE_TURNS when used; the turn it is used counts). */
   frozen: number
@@ -177,8 +201,13 @@ export interface GameState {
   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. */
+  /** This level's gem: hidden while its block stands, then lying in the open until taken. Null once taken or burned. */
   powerUp: PowerUp | null
+  /**
+   * The power-ups the gem just taken offers. While it is set, the only action is `choose`; a
+   * death (or game over) in the same turn drops it, and the retry has the gem back.
+   */
+  offer: PowerUpKind[] | 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
@@ -194,9 +223,10 @@ export interface GameState {
 }
 
 /** The actions the player can pick on the board. */
-export type ActionId = 'move' | 'bomb' | 'wait' | 'run' | 'detonate' | 'kick' | 'throw' | 'teleport' | ChargeActionId
+export type ActionId = 'move' | 'bomb' | 'wait' | 'run' | 'detonate' | 'kick' | 'throw' | 'teleport' | 'diagonal'
 
-export type Action = { id: ActionId; target?: Vec } | { id: 'continue' }
+/** `choose`: one of the gem's offer. It takes no turn. */
+export type Action = { id: ActionId; target?: Vec } | { id: 'continue' } | { id: 'choose'; kind: PowerUpKind }
 
 /** What `step()` reports happened, in order, for the renderer to play back. */
 export type GameEvent =
@@ -218,14 +248,14 @@ export type GameEvent =
   | { type: 'exitOpened'; pos: Vec }
   | { type: 'levelCleared'; level: number; turns: number; parBonus: number }
   | { type: 'levelStarted'; level: number }
-  | { type: 'chargeUsed'; id: ChargeId }
   | { type: 'bombKicked'; from: Vec; to: Vec }
   | { type: 'shieldBroke'; pos: Vec }
   | { type: 'decoyPlaced'; pos: Vec }
   | { type: 'decoyGone'; pos: Vec; burned: boolean }
   | { type: 'powerUpRevealed'; powerUp: PowerUp }
   | { type: 'powerUpBurned'; powerUp: PowerUp }
-  | { type: 'powerUpTaken'; powerUp: PowerUp }
+  | { type: 'powerUpTaken'; powerUp: PowerUp; offer: PowerUpKind[] }
+  | { type: 'powerUpChosen'; kind: PowerUpKind }
   | { type: 'detonated'; pos: Vec }
   | { type: 'runWon'; turns: number; deaths: number }
   | { type: 'cratesCrumbling'; count: number }
diff --git a/src/style.css b/src/style.css
index 20dfdc9..25e81dc 100644
--- a/src/style.css
+++ b/src/style.css
@@ -51,16 +51,53 @@ img {
   color: #fff;
 }
 
-/* The items wrap onto as many rows as they need; the round buttons stay on the right, stacked
-   beside the rows on a phone, side by side on a wide screen. */
+/* The items sit in a fixed number of rows that never wrap (user request, 2026-10-23: the bar
+   grew a row as power-ups and charges came, and the board moved down). On a phone or a narrow
+   screen: three rows, the third (passes, Star, charges) kept even while empty. On a wide screen:
+   one row. A row whose content is too wide scrolls sideways instead of growing. The round
+   buttons stay on the right, stacked beside the rows on a phone, side by side on a wide screen. */
 #hud-items {
   flex: 1;
   min-width: 0;
   display: flex;
-  flex-wrap: wrap;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.hud-row {
+  display: flex;
+  flex-wrap: nowrap;
   justify-content: space-between;
   align-items: center;
-  gap: 4px 12px;
+  gap: 12px;
+  min-height: 30px;
+  overflow-x: auto;
+  scrollbar-width: none;
+}
+
+.hud-row::-webkit-scrollbar {
+  display: none;
+}
+
+#hud-third {
+  justify-content: flex-start;
+}
+
+@media (min-width: 900px) {
+  #hud-items {
+    flex-direction: row;
+    align-items: center;
+    gap: 12px;
+  }
+
+  .hud-row {
+    flex: none;
+  }
+
+  #hud-third {
+    flex: 1 1 0;
+    min-width: 0;
+  }
 }
 
 /* On a phone the menu sits above "?" (user request, 2026-10-22); in the HTML "?" comes first,
@@ -78,10 +115,9 @@ img {
   }
 }
 
-#hud-items > span {
+.hud-row > span {
   display: flex;
-  /* A long row (many passes held) wraps instead of pushing the buttons off the screen. */
-  flex-wrap: wrap;
+  flex: none;
   align-items: center;
 }
 
@@ -191,39 +227,6 @@ img {
   display: none;
 }
 
-/* Charges and Detonate: a second, smaller row under Move / Bomb / Wait, up to five of them,
-   each a fifth wide whatever their number, so a single one isn't a full-width bar. */
-#actions > button.charge {
-  flex: 0 0 calc((100% - 32px) / 5);
-  min-height: 44px;
-  margin-top: 6px;
-  padding: 2px 2px 4px;
-  font-size: 11px;
-}
-
-#actions > button.charge .icon {
-  width: 28px;
-  height: 28px;
-}
-
-#actions > button {
-  position: relative;
-}
-
-#actions .count {
-  position: absolute;
-  top: -12px;
-  right: -6px;
-  min-width: 20px;
-  padding: 0 4px;
-  border: 2px solid var(--ink);
-  border-radius: 11px;
-  background: #ffc83d;
-  color: var(--ink);
-  font-size: 12px;
-  font-weight: 800;
-}
-
 /* On a phone there is no keyboard to hint at. */
 @media (max-width: 600px) {
   #actions kbd {
@@ -231,73 +234,6 @@ img {
   }
 }
 
-/* The strip above the action bar: what the picked action does now, and its choices. It sits
-   over the board's bottom edge, so showing it doesn't move the board. */
-#explain {
-  position: absolute;
-  bottom: 100%;
-  left: 0;
-  right: 0;
-  padding: 8px 16px 10px;
-  border-radius: 12px 12px 0 0;
-  background: rgb(53 53 65 / 0.94);
-  color: #fff;
-  font-size: 14px;
-  line-height: 1.35;
-  animation: fade-in 0.15s ease-out;
-}
-
-#explain[hidden] {
-  display: none;
-}
-
-#explain .explain-head {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  font: 15px 'Kenney Future', ui-rounded, system-ui, sans-serif;
-}
-
-#explain .explain-icon {
-  width: 24px;
-  height: 24px;
-}
-
-#explain p {
-  margin: 4px 0 0;
-}
-
-#explain .chips {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 6px;
-  margin-top: 8px;
-}
-
-#explain .chip {
-  display: flex;
-  align-items: center;
-  gap: 4px;
-  padding: 4px 10px 4px 6px;
-  border: 2px solid #fff;
-  border-radius: 16px;
-  background: none;
-  color: #fff;
-  font: inherit;
-  font-weight: 800;
-  cursor: pointer;
-}
-
-#explain .chip[aria-pressed='true'] {
-  border-color: var(--accent);
-  background: var(--accent);
-}
-
-#explain .chip-icon {
-  width: 20px;
-  height: 20px;
-}
-
 #actions > button:active:not(:disabled) {
   transform: translateY(4px);
   box-shadow: none;
@@ -315,8 +251,7 @@ img {
 }
 
 /* One-colour ink icons (Move, Wait) turn white on the green of a picked button. */
-#actions > button[aria-pressed='true'] .mono,
-#explain .mono {
+#actions > button[aria-pressed='true'] .mono {
   filter: brightness(0) invert(1);
 }
 
@@ -603,6 +538,15 @@ img {
   color: inherit;
 }
 
+/* A dialog answered from its body (the gem's choice) has no buttons below: more room for it. */
+#dialog .panel.required #dialog-body {
+  max-height: calc(100vh - 150px);
+}
+
+#dialog-buttons:empty {
+  display: none;
+}
+
 /* The almanac (ui/almanac.ts): tabs, then a picture and the words for each thing. */
 .almanac-tabs {
   position: sticky;
@@ -656,6 +600,25 @@ img {
   font-size: 14px;
 }
 
+/* The gem's choice (ui/offer.ts): each power-up is a card to tap, laid out like an almanac entry. */
+.offer-choice {
+  width: 100%;
+  margin: 0 0 8px;
+  padding: 8px 10px;
+  border: 2px solid var(--ink);
+  border-radius: 12px;
+  background: #fff8e6;
+  box-shadow: 0 3px 0 rgb(53 53 65 / 0.35);
+  color: var(--ink);
+  font: inherit;
+  cursor: pointer;
+}
+
+.offer-choice:active {
+  transform: translateY(2px);
+  box-shadow: none;
+}
+
 /* Keyboard focus in the dialog: a yellow ring, like the count badges. */
 #dialog button:focus-visible {
   outline: 3px solid #ffc83d;
@@ -754,3 +717,90 @@ img {
 #dialog-body::-webkit-scrollbar {
   display: none;
 }
+
+/* The demo before the game (main.ts): the board alone, a caption panel at the bottom. */
+body.demo #hud,
+body.demo #actions {
+  display: none;
+}
+
+#demo {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 2;
+  padding: 12px 16px calc(env(safe-area-inset-bottom) + 16px);
+  background: var(--ink);
+  color: #fff;
+  text-align: center;
+}
+
+#demo[hidden] {
+  display: none;
+}
+
+#demo h1 {
+  margin: 0 0 6px;
+  font: 24px/1.2 'Kenney Future', ui-rounded, system-ui, sans-serif;
+  letter-spacing: 0.04em;
+}
+
+/* The words, with a scene's icon beside them (not in the line, which would space it unevenly). */
+#demo-caption {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 10px;
+  min-height: 3.9em;
+  margin: 0;
+  font-size: 17px;
+  line-height: 1.3;
+}
+
+/* A scene about one charge or power-up shows its icon before the words. */
+#demo .demo-icon {
+  flex: none;
+  width: 34px;
+  height: 34px;
+  /* A light rim, so a dark sprite (a bomb) stands out on the dark panel, as in the top bar. */
+  filter: drop-shadow(0 0 1.5px rgb(255 255 255 / 0.7));
+}
+
+/* One-colour ink icons (the padlock) turn white on the dark panel. */
+#demo .demo-icon.mono {
+  filter: brightness(0) invert(1);
+}
+
+#demo .demo-hint {
+  margin: 10px 0 0;
+  color: #ffc83d;
+  font-weight: 800;
+  animation: demo-hint 1.6s ease-in-out infinite;
+}
+
+@keyframes demo-hint {
+  50% {
+    opacity: 0.45;
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  /* A scene about one charge or power-up shows its icon before the words. */
+#demo .demo-icon {
+  flex: none;
+  width: 34px;
+  height: 34px;
+  /* A light rim, so a dark sprite (a bomb) stands out on the dark panel, as in the top bar. */
+  filter: drop-shadow(0 0 1.5px rgb(255 255 255 / 0.7));
+}
+
+/* One-colour ink icons (the padlock) turn white on the dark panel. */
+#demo .demo-icon.mono {
+  filter: brightness(0) invert(1);
+}
+
+#demo .demo-hint {
+    animation: none;
+  }
+}
diff --git a/src/ui/almanac.ts b/src/ui/almanac.ts
index ca1ab6c..7db9b39 100644
--- a/src/ui/almanac.ts
+++ b/src/ui/almanac.ts
@@ -1,4 +1,5 @@
 import { enemyKinds } from '../sim/levelgen'
+import { OFFER_SIZE } from '../sim/turn'
 import { CHARGE_IDS, type EnemyKind, type PowerUpKind } from '../sim/types'
 import { CHARGE_ICONS, ENEMY_SPRITES, POWER_UP_ICONS, spriteUrl } from '../render/sprites'
 import { openDialog } from './dialog'
@@ -18,7 +19,7 @@ const TABS: [AlmanacTab, string][] = [
 
 /** The power-ups in the order they make sense to read: stats, abilities, passes, the rest. */
 const POWER_UPS: PowerUpKind[] = [
-  'bombPlus', 'firePlus', 'fusePlus', 'run', 'kick', 'throw', 'teleport', 'detonator',
+  'bombPlus', 'firePlus', 'fusePlus', 'run', 'kick', 'throw', 'teleport', 'diagonal', 'detonator',
   'wallPass', 'bombPass', 'flamePass', 'star', 'heart', 'coinBag',
 ]
 
@@ -43,11 +44,11 @@ function body(tab: AlmanacTab): string {
   let list: string
   if (tab === 'powerUps') {
     list =
-      '<p class="almanac-intro">One is hidden under a crate on every level. Burn the crate, then walk onto it.</p>' +
+      `<p class="almanac-intro">Every level hides a gem under a crate. Burn the crate and walk onto the gem to choose one of ${OFFER_SIZE} of these.</p>` +
       POWER_UPS.map((k) => entry(POWER_UP_ICONS[k], POWER_UP_TEXT[k].name, [POWER_UP_TEXT[k].text])).join('')
   } else if (tab === 'charges') {
     list =
-      '<p class="almanac-intro">Enemies sometimes drop these. Each is used once and takes your turn.</p>' +
+      '<p class="almanac-intro">Enemies sometimes drop these. Each works as soon as you pick it up; the top bar shows what it still has to give.</p>' +
       CHARGE_IDS.map((id) => entry(CHARGE_ICONS[id], CHARGE_TEXT[id].name, [CHARGE_TEXT[id].text])).join('')
   } else {
     list =
diff --git a/src/ui/controls.ts b/src/ui/controls.ts
index c1e26b0..33c6c8a 100644
--- a/src/ui/controls.ts
+++ b/src/ui/controls.ts
@@ -1,9 +1,8 @@
-import { ACTIONS } from '../sim/actions'
 import { canPick } from '../sim/picks'
 import { PAR_BONUS, START_LIVES } from '../sim/state'
-import type { ActionId, ChargeId, GameState, PowerUpKind, Powers } from '../sim/types'
+import { CHARGE_IDS, type ActionId, type GameState, type PowerUpKind, type Powers } from '../sim/types'
 import { CHARGE_ICONS, MONO_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites'
-import { actionInfo, type HudItem } from './info'
+import { CHARGE_TEXT, chargeLeft, type HudItem } from './info'
 import questionIcon from '../../assets/kenney-icons/question.png'
 
 /** The HTML around the board: HUD, action bar, and the banner shown between attempts and levels. */
@@ -13,8 +12,6 @@ export interface Handlers {
    * `pressed` says whether one of them is already picked (so the press cancels it).
    */
   pick(id: ActionId, pressed: boolean): void
-  /** One of the choices in the explanation strip: a Pierce bomb instead of a plain one, or "Use" for Shield / Freeze. */
-  choose(id: ActionId): void
   /** 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
   /** A top-bar item was tapped: show what it means. */
@@ -27,9 +24,8 @@ export interface Handlers {
 /**
  * The action bar: as few buttons as possible, icons only (user request, 2026-10-22). Each stands
  * for a group of actions: Move also runs, teleports, kicks and steps diagonally, and Bomb also
- * throws and detonates (sim/picks.ts); Bomb's charge bombs are choices in the strip that explains
- * the picked action, and Shield and Freeze wait there for a "Use" tap. The rest only appear
- * while you have them.
+ * throws and detonates (sim/picks.ts). Charges have no buttons: they work when picked up, and
+ * what they still give shows in the top bar (user request, 2026-10-23).
  */
 interface ButtonDef {
   id: ActionId
@@ -38,37 +34,26 @@ interface ButtonDef {
   key: string
   /** The actions it stands for; the first one that can be picked is picked. */
   group: readonly ActionId[]
-  /** Shown only while the player holds this charge (with a count) or has this ability. */
-  charge?: ChargeId
-  ability?: keyof Powers
 }
 
 const BUTTONS: ButtonDef[] = [
   { id: 'move', label: 'Move', icon: 'icon_move', key: 'WASD', group: ['move', 'run', 'teleport', 'kick', 'diagonal'] },
-  { id: 'bomb', label: 'Bomb', icon: 'bomb', key: 'B', group: ['bomb', 'throw', 'detonate', 'pierce', 'power', 'line'] },
+  { id: 'bomb', label: 'Bomb', icon: 'bomb', key: 'B', group: ['bomb', 'throw', 'detonate'] },
   { id: 'wait', label: 'Wait', icon: 'icon_wait', key: 'Space', group: ['wait'] },
-  { id: 'shield', label: 'Shield', icon: CHARGE_ICONS.shield, key: 'H', group: ['shield'], charge: 'shield' },
-  { id: 'freeze', label: 'Freeze', icon: CHARGE_ICONS.freeze, key: 'F', group: ['freeze'], charge: 'freeze' },
-  { id: 'decoy', label: 'Decoy', icon: CHARGE_ICONS.decoy, key: 'Y', group: ['decoy'], charge: 'decoy' },
 ]
 
-/** Bomb's choices in the strip: a charge bomb instead of a plain one. */
-const BOMB_CHOICES: readonly ActionId[] = ['bomb', 'pierce', 'power', 'line']
-
 /** The passes shown in the HUD, with their power-up and name. */
 const PASSES: [keyof Powers, PowerUpKind, string][] = [
   ['run', 'run', 'Run'],
   ['kick', 'kick', 'Kick'],
   ['throw', 'throw', 'Throw'],
   ['teleport', 'teleport', 'Teleport'],
+  ['diagonal', 'diagonal', 'Diagonal'],
   ['detonate', 'detonator', 'Detonator'],
   ['wallPass', 'wallPass', 'Wall Pass'],
   ['bombPass', 'bombPass', 'Bomb Pass'],
   ['flamePass', 'flamePass', 'Flame Pass'],
 ]
-/** What a death takes away (the NES rule; Run is kept, and so are Kick, Throw and Teleport). */
-const KEPT_ON_DEATH: readonly (keyof Powers)[] = ['run', 'kick', 'throw', 'teleport']
-const LOST_ON_DEATH = PASSES.filter(([p]) => !KEPT_ON_DEATH.includes(p))
 
 /** A top-bar item: a button that opens its info (`data-info`, see ui/info.ts). */
 function item(info: HudItem, html: string): string {
@@ -92,7 +77,12 @@ function show(slot: HTMLElement, html: string): void {
       const kept = old.get(was)
       if (kept) return kept
       ;(c as HTMLElement).dataset.was = was
-      if (!first) c.classList.add('bump')
+      if (!first) {
+        c.classList.add('bump')
+        // Off again once it has played: every later update re-attaches the kept items, which
+        // would otherwise start the bump over each time.
+        c.addEventListener('animationend', () => c.classList.remove('bump'), { once: true })
+      }
       return c
     }),
   )
@@ -117,10 +107,7 @@ export function createControls(handlers: Handlers) {
     b.dataset.action = def.id
     b.title = def.label
     b.setAttribute('aria-label', def.label)
-    // Charges and abilities share the smaller second row, and only show while you have them.
-    if (def.charge || def.ability) b.classList.add('charge')
     b.innerHTML = `${icon(def.icon)}<kbd>${def.key}</kbd>`
-    if (def.charge) b.insertAdjacentHTML('beforeend', '<span class="count"></span>')
     b.addEventListener('click', () => {
       const id = def.group.find((a) => canPick(current!, a)) ?? def.id
       handlers.pick(id, !!currentSelected && def.group.includes(currentSelected))
@@ -129,45 +116,9 @@ export function createControls(handlers: Handlers) {
     buttons.set(def, b)
   }
 
-  // The strip above the bar: what the picked action does now, and its choices.
-  const strip = document.createElement('div')
-  strip.id = 'explain'
-  strip.hidden = true
-  strip.setAttribute('aria-live', 'polite')
-  bar.prepend(strip)
-  strip.addEventListener('click', (e) => {
-    const chip = (e.target as Element).closest<HTMLElement>('[data-choice]')
-    if (chip) handlers.choose(chip.dataset.choice as ActionId)
-  })
   let current: GameState | null = null
   let currentSelected: ActionId | null = null
 
-  function showStrip(s: GameState, selected: ActionId | null): void {
-    const info = selected ? actionInfo(s, selected) : null
-    strip.hidden = !info
-    if (!info) return
-    const chip = (id: ActionId, label: string, sprite: string, on: boolean) =>
-      `<button type="button" class="chip" data-choice="${id}" aria-pressed="${on}">${icon(sprite, 'chip-icon')}${label}</button>`
-    let chips: string[] = []
-    if (selected && !ACTIONS[selected].needsTarget) {
-      // Shield, Freeze: nothing to aim at, so a tap here confirms (user request, 2026-10-22).
-      chips = [chip(selected, `Use ${ACTIONS[selected].label}`, CHARGE_ICONS[selected as ChargeId], true)]
-    } else if (selected && (BOMB_CHOICES.includes(selected) || selected === 'throw' || selected === 'detonate')) {
-      const bombs = BOMB_CHOICES.filter((id) => canPick(s, id))
-      if (bombs.length > 1) {
-        chips = bombs.map((id) => {
-          const label = id === 'bomb' ? 'Bomb' : `${ACTIONS[id].label} ${s.options.dev ? '∞' : `×${s.charges[id as ChargeId]}`}`
-          const on = id === selected || (id === 'bomb' && (selected === 'throw' || selected === 'detonate'))
-          return chip(id, label, id === 'bomb' ? 'bomb' : CHARGE_ICONS[id as ChargeId], on)
-        })
-      }
-    }
-    strip.innerHTML =
-      `<div class="explain-head">${icon(info.icon, 'explain-icon')}<strong>${info.title}</strong></div>` +
-      info.body.map((t) => `<p>${t}</p>`).join('') +
-      (chips.length ? `<div class="chips">${chips.join('')}</div>` : '')
-  }
-
   // "What is that?": a round button next to the menu, top right.
   const inspectButton = document.getElementById('info-button') as HTMLButtonElement
   inspectButton.innerHTML = `<img src="${questionIcon}" alt="" draggable="false">`
@@ -193,6 +144,7 @@ export function createControls(handlers: Handlers) {
     enemies: document.getElementById('hud-enemies')!,
     coins: document.getElementById('hud-coins')!,
     stats: document.getElementById('hud-stats')!,
+    extras: document.getElementById('hud-extras')!,
     modes: document.getElementById('hud-modes')!,
   }
 
@@ -207,23 +159,14 @@ export function createControls(handlers: Handlers) {
         b.disabled = !def.group.some((id) => canPick(s, id))
         b.setAttribute('aria-pressed', String(!!selected && def.group.includes(selected)))
         if (def.id === 'move') {
-          // Auto-move: Move has become Go, one step toward the flag per press.
+          // Auto-move: Move has become Go, one step toward the destination per press. It keeps
+          // the walk icon (user request, 2026-10-23: it used to turn into a flag); a green
+          // border says it will walk.
           b.setAttribute('aria-label', autoMove ? 'Go' : def.label)
           b.title = autoMove ? 'Go' : def.label
-          const img = b.querySelector('.icon')!
-          img.setAttribute('src', spriteUrl(autoMove ? 'flag_green_a' : def.icon))
-          img.classList.toggle('mono', !autoMove)
           b.classList.toggle('auto', autoMove)
         }
-        if (def.ability) b.hidden = !s.powers[def.ability]
-        if (def.charge) {
-          b.hidden = s.charges[def.charge] === 0
-          // Developer mode never spends them.
-          b.querySelector('.count')!.textContent = s.options.dev ? '∞' : `×${s.charges[def.charge]}`
-        }
       }
-      showStrip(s, s.status === 'playing' ? selected : null)
-
       // Lives lost from the starting three show as empty hearts; extra lives add full ones.
       const hearts = Array.from({ length: Math.max(s.lives, START_LIVES) }, (_, i) =>
         icon(i < s.lives ? 'hud_heart' : 'hud_heart_empty'),
@@ -250,21 +193,25 @@ export function createControls(handlers: Handlers) {
         .map(([p, kind, name]) => item(p, `<span class="sr-only">${name}</span>${icon(POWER_UP_ICONS[kind], 'icon small')}`))
         .join('')
       const star = s.star > 0 ? item('star', icon('star', 'icon small') + number(s.star, 'Star for ')) : ''
+      // What picked-up charges still give, each with its count (bombs, levels or turns left).
+      const charges = CHARGE_IDS.map((id) => {
+        const left = chargeLeft(s, id)
+        if (!left) return ''
+        const name = CHARGE_TEXT[id].name
+        return item(id, icon(CHARGE_ICONS[id], 'icon small') + (left === true ? `<span class="sr-only">${name}</span>` : number(left, `${name} `)))
+      }).join('')
       show(
         hud.stats,
         item('bombs', icon('bomb') + number(s.stats.capacity, 'Bombs ')) +
           item('fire', icon('fireball') + number(s.stats.fire, 'Blast ')) +
-          item('fuse', icon('icon_fuse') + number(s.stats.fuse, 'Fuse ')) +
-          passes +
-          star,
+          item('fuse', icon('icon_fuse') + number(s.stats.fuse, 'Fuse ')),
       )
+      // Their own row, which keeps its place even while empty, so the board never moves.
+      show(hud.extras, passes + star + charges)
 
       banner.hidden = s.status === 'playing'
       if (s.status === 'dead') {
-        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(', ')}.` : '')
+        bannerText.textContent = `Ouch! ${s.lives} ${s.lives === 1 ? 'life' : 'lives'} left.`
         bannerButton.textContent = 'Try again'
         bannerIcon.src = spriteUrl('character_green_hit')
       } else if (s.status === 'gameOver') {
diff --git a/src/ui/dialog.ts b/src/ui/dialog.ts
index f37f2af..34f808c 100644
--- a/src/ui/dialog.ts
+++ b/src/ui/dialog.ts
@@ -21,6 +21,8 @@ export interface DialogContent {
   /** Trusted HTML built by the game itself (never from user input). */
   body: string
   buttons: DialogButton[]
+  /** Must be answered from its body: no close button, and neither Esc nor the backdrop closes it. */
+  required?: boolean
 }
 
 const dialog = document.getElementById('dialog') as HTMLDialogElement
@@ -33,9 +35,10 @@ const panel = dialog.querySelector<HTMLElement>('.panel')!
 /** How long the panel takes to shrink away and the backdrop to clear (style.css `.closing`). */
 const CLOSE_MS = 150
 let closing: { timer: number; then?: () => void } | null = null
+let required = false
 
 /** Close with a short shrink-and-fade, then run `then` (a button's action). */
-function closeDialog(then?: () => void): void {
+export function closeDialog(then?: () => void): void {
   if (!dialog.open || closing) return
   if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
     dialog.close()
@@ -56,10 +59,11 @@ function finishClosing(): void {
   then?.()
 }
 
-document.getElementById('dialog-close')!.addEventListener('click', () => closeDialog())
+const closeButton = document.getElementById('dialog-close')!
+closeButton.addEventListener('click', () => closeDialog())
 // A tap on the dimmed backdrop (the dialog element itself, outside its panel) closes it.
 dialog.addEventListener('click', (e) => {
-  if (e.target === dialog) closeDialog()
+  if (e.target === dialog && !required) closeDialog()
 })
 // LittleJS cancels every wheel event on the page (so the game never scrolls it), which also
 // stopped a long dialog, like the almanac, scrolling with a mouse wheel or trackpad. Kept here.
@@ -67,7 +71,7 @@ dialog.addEventListener('wheel', (e) => e.stopPropagation(), { passive: true })
 // Esc closes it too, animated like the rest.
 dialog.addEventListener('cancel', (e) => {
   e.preventDefault()
-  closeDialog()
+  if (!required) closeDialog()
 })
 
 export function openDialog(content: DialogContent): void {
@@ -75,6 +79,9 @@ export function openDialog(content: DialogContent): void {
   finishClosing()
   icon.hidden = !content.icon
   if (content.icon) icon.src = content.icon
+  required = !!content.required
+  closeButton.hidden = required
+  panel.classList.toggle('required', required)
   title.textContent = content.title
   body.innerHTML = content.body
   buttons.replaceChildren(
diff --git a/src/ui/info.ts b/src/ui/info.ts
index 5934f77..c7466ea 100644
--- a/src/ui/info.ts
+++ b/src/ui/info.ts
@@ -1,10 +1,10 @@
-import { DECOY_TURNS, FREEZE_TURNS, TELEPORT_RANGE } from '../sim/actions'
+import { BOMB_BOOST, DECOY_TURNS, FREEZE_TURNS, NUKE_RANGE, TELEPORT_RANGE } from '../sim/actions'
 import { lastLevel } from '../sim/levelgen'
 import { bombAt, cellAt, eq, exitOpen, isBurning, PAR_BONUS } from '../sim/state'
-import { COIN_BAG, EXIT_SPAWN, STAR_TURNS } from '../sim/turn'
+import { COIN_BAG, EXIT_SPAWN, OFFER_SIZE, STAR_TURNS } from '../sim/turn'
 import type { ActionId, ChargeId, Dir, EnemyKind, GameState, Powers, PowerUpKind, Vec } from '../sim/types'
 import { dirBetween } from '../sim/enemies'
-import { CHARGE_ICONS, coinSprite, ENEMY_SPRITES, isOuterWall, POWER_UP_ICONS, spriteUrl, wallSprite } from '../render/sprites'
+import { CHARGE_ICONS, coinSprite, ENEMY_SPRITES, GEM_SPRITES, isOuterWall, POWER_UP_ICONS, spriteUrl, wallSprite } from '../render/sprites'
 
 /**
  * What the game says about each thing on the board and in the top bar, for the info dialog
@@ -21,53 +21,73 @@ export interface Info {
 export const ENEMY_TEXT: Record<EnemyKind, { name: string; moves: string }> = {
   blob: { name: 'Blob', moves: 'Wanders, usually carrying on the way it was going.' },
   seeker: { name: 'Seeker', moves: 'Half the time it takes the shortest way toward you (or your decoy); otherwise it wanders like a Blob.' },
-  drifter: { name: 'Drifter', moves: 'Goes straight until something blocks it, then turns.' },
+  drifter: { name: 'Drifter', moves: 'Goes straight until something blocks it (or another enemy is heading there), then turns.' },
 }
 
 export const POWER_UP_TEXT: Record<PowerUpKind, { name: string; text: string }> = {
   bombPlus: { name: 'Bomb +1', text: 'You can have one more bomb on the board at a time.' },
   firePlus: { name: 'Fire +1', text: 'Your blasts reach one tile further in each direction.' },
   fusePlus: { name: 'Fuse +1', text: 'Your bombs start one higher: one more turn to get away.' },
-  run: { name: 'Run', text: 'Move can also run: 2 tiles in a straight line in one turn. Both tiles must be empty. Kept when you die.' },
-  detonator: { name: 'Detonator', text: 'Your bombs pulse: tap one and it goes off this turn. Lost when you die.' },
-  wallPass: { name: 'Wall Pass', text: 'You can walk through crates. Lost when you die.' },
-  bombPass: { name: 'Bomb Pass', text: 'You can walk over bombs. Lost when you die.' },
-  flamePass: { name: 'Flame Pass', text: 'Flame can’t hurt you (enemies still can). Lost when you die.' },
+  run: { name: 'Run', text: 'Move can also run: 2 tiles in a straight line in one turn. Both tiles must be empty.' },
+  detonator: { name: 'Detonator', text: 'Your bombs pulse: tap one and it goes off this turn.' },
+  wallPass: { name: 'Wall Pass', text: 'You can walk through crates.' },
+  bombPass: { name: 'Bomb Pass', text: 'You can walk over bombs.' },
+  flamePass: { name: 'Flame Pass', text: 'Flame can’t hurt you (enemies still can).' },
   star: { name: 'Star', text: `Nothing can hurt you for ${STAR_TURNS} turns.` },
   coinBag: { name: 'Coin bag', text: `${COIN_BAG} coins.` },
   heart: { name: 'Heart', text: 'One more life.' },
-  kick: { name: 'Kick', text: 'Walk into a bomb next to you and it slides away until something stops it, keeping its number. Kept when you die.' },
-  throw: { name: 'Throw', text: 'Bomb can also put a bomb 2 tiles away in a straight line, over anything in between. Kept when you die.' },
-  teleport: { name: 'Teleport', text: `Move can also take you to any empty tile up to ${TELEPORT_RANGE} steps away, over anything. Kept when you die.` },
+  kick: { name: 'Kick', text: 'Walk into a bomb next to you and it slides away until something stops it, keeping its number.' },
+  throw: { name: 'Throw', text: 'Bomb can also put a bomb 2 tiles away in a straight line, over anything in between.' },
+  teleport: { name: 'Teleport', text: `Move can also take you to any empty tile up to ${TELEPORT_RANGE} steps away, over anything.` },
+  diagonal: { name: 'Diagonal', text: 'Move can also step diagonally, even squeezing between two blocked tiles.' },
 }
 
+/** What each charge does once picked up (user request, 2026-10-23: they work at once, as a temporary change). */
 export const CHARGE_TEXT: Record<ChargeId, { name: string; text: string }> = {
-  shield: { name: 'Shield', text: 'The next hit (flame or enemy) is absorbed, and anything else that hits you that turn.' },
-  freeze: { name: 'Freeze', text: `Every enemy freezes in place: it skips its move this turn and the next ${FREEZE_TURNS - 1}, thawing as it goes.` },
-  diagonal: { name: 'Diagonal', text: 'Move can step one tile diagonally, even between two blocked tiles.' },
-  pierce: { name: 'Pierce', text: 'A choice under Bomb: a bomb whose flame goes through crates, burning every one in reach.' },
-  power: { name: 'Power', text: 'A choice under Bomb: a bomb whose flame reaches all the way to the walls (crates still stop it).' },
-  line: { name: 'Line', text: 'A choice under Bomb: as many bombs as you’re allowed, in a row starting next to you.' },
-  decoy: { name: 'Decoy', text: `A copy of you that Seekers chase instead, for ${DECOY_TURNS} turns. Flame destroys it.` },
+  pierce: { name: 'Pierce', text: `Your next ${BOMB_BOOST} bombs’ flame goes through crates, burning every one in reach.` },
+  power: { name: 'Power', text: `Your next ${BOMB_BOOST} bombs’ flame reaches all the way to the walls (crates still stop it).` },
+  nuke: { name: 'Nuke', text: `Your next ${BOMB_BOOST} bombs also burn every tile within ${NUKE_RANGE} steps, through crates and round corners. Get well away!` },
+  shield: { name: 'Shield', text: 'The next hit (flame or enemy) is absorbed, and anything else that hits you that turn. Then the shield is gone.' },
+  freeze: { name: 'Freeze', text: `Every enemy freezes in place for ${FREEZE_TURNS} turns, this one included, thawing as it goes.` },
+  decoy: { name: 'Decoy', text: `Leaves a copy of you where you picked it up. Seekers chase it instead of you for ${DECOY_TURNS} turns. Flame destroys it.` },
+}
+
+/**
+ * How much a charge still has to give, for the top bar: bombs or turns left, true for one
+ * without a count (Shield), nothing once it is spent.
+ */
+export function chargeLeft(s: GameState, id: ChargeId): number | true | 0 {
+  switch (id) {
+    case 'pierce':
+    case 'power':
+    case 'nuke':
+      return s.boosts[id]
+    case 'shield':
+      return s.shield || 0
+    case 'freeze':
+      return s.frozen
+    case 'decoy':
+      return s.decoy?.turns ?? 0
+  }
 }
 
 const ARROWS: Record<Dir, string> = { up: '↑', down: '↓', left: '←', right: '→' }
 const PUNISH = `Don’t blast it once it’s uncovered: flame on it lets out ${EXIT_SPAWN} more enemies.`
 
 /** The top bar's items. */
-export type HudItem = 'modes' | 'lives' | 'level' | 'enemies' | 'coins' | 'turn' | 'bombs' | 'fire' | 'fuse' | 'star' | keyof Powers
+export type HudItem = 'modes' | 'lives' | 'level' | 'enemies' | 'coins' | 'turn' | 'bombs' | 'fire' | 'fuse' | 'star' | keyof Powers | ChargeId
 
 /** One line per setting of the run that differs from a plain normal run. */
 export function modeLines(s: GameState): string[] {
   const lines = [s.options.size === 'small' ? `Small board: ${s.width} × ${s.height}, ${lastLevel('small')} levels, fewer enemies and crates.` : `Normal board: ${s.width} × ${s.height}, ${lastLevel('normal')} levels.`]
-  if (s.options.dev) lines.push('Developer mode: every power-up and ability, unlimited action charges, 5 bombs, blast 5. You can still be killed.')
+  if (s.options.dev) lines.push('Developer mode: every level’s gem lies uncovered right next to you at the start.')
   if (s.options.god) lines.push('God mode: nothing can kill you.')
   if (s.options.crumble) lines.push('Crumbling crates: once the last enemy is gone, every crate crumbles away over 1–3 turns.')
   return lines
 }
 
 const PASS_KIND: Record<keyof Powers, PowerUpKind> = {
-  run: 'run', kick: 'kick', throw: 'throw', teleport: 'teleport', detonate: 'detonator', wallPass: 'wallPass',
+  run: 'run', kick: 'kick', throw: 'throw', teleport: 'teleport', diagonal: 'diagonal', detonate: 'detonator', wallPass: 'wallPass',
   bombPass: 'bombPass', flamePass: 'flamePass',
 }
 
@@ -128,6 +148,13 @@ export function hudInfo(item: HudItem, s: GameState): Info {
       }
     case 'star':
       return { icon: 'star', title: `Star: ${s.star} turns left`, body: ['Nothing can hurt you until it runs out.'] }
+    case 'pierce':
+    case 'power':
+    case 'nuke':
+    case 'shield':
+    case 'freeze':
+    case 'decoy':
+      return { icon: CHARGE_ICONS[item], title: chargeTitle(s, item), body: [CHARGE_TEXT[item].text] }
     default: {
       const kind = PASS_KIND[item]
       return { icon: POWER_UP_ICONS[kind], title: POWER_UP_TEXT[kind].name, body: [POWER_UP_TEXT[kind].text] }
@@ -159,16 +186,14 @@ export function tileInfo(s: GameState, p: Vec): Info | null {
   }
   const bomb = bombAt(s, p)
   if (bomb) {
-    const kind = bomb.special === 'pierce' ? 'Pierce bomb' : bomb.special === 'power' ? 'Power bomb' : 'Bomb'
-    const reach = bomb.special === 'power' ? 'to the walls' : `${bomb.fire} ${bomb.fire === 1 ? 'tile' : 'tiles'}`
-    return {
-      icon: 'bomb',
-      title: `${kind}: ${bomb.timer}`,
-      body: [
-        `Explodes at the end of your ${bomb.timer === 1 ? 'next action' : `${bomb.timer}th action from now`}.`,
-        `Its blast reaches ${reach} in each direction. Flame stops at walls${bomb.special === 'pierce' ? ' (it goes through crates)' : ' and on the first crate'}, and sets off any bomb it reaches.`,
-      ],
-    }
+    const kinds = (['pierce', 'power', 'nuke'] as const).filter((k) => bomb[k]).map((k) => CHARGE_TEXT[k].name)
+    const reach = bomb.power ? 'to the walls' : `${bomb.fire} ${bomb.fire === 1 ? 'tile' : 'tiles'}`
+    const body = [
+      `Explodes at the end of your ${bomb.timer === 1 ? 'next action' : `${bomb.timer}th action from now`}.`,
+      `Its blast reaches ${reach} in each direction. Flame stops at walls${bomb.pierce ? ' (it goes through crates)' : ' and on the first crate'}, and sets off any bomb it reaches.`,
+    ]
+    if (bomb.nuke) body.push(`Nuke: it also burns every tile within ${NUKE_RANGE} steps, through crates and round corners.`)
+    return { icon: 'bomb', title: `${kinds.length ? `${kinds.join(' + ')} bomb` : 'Bomb'}: ${bomb.timer}`, body }
   }
   if (s.decoy && eq(s.decoy.pos, p)) {
     return { icon: 'character_green_front', title: 'Decoy', body: [`Seekers chase it instead of you for ${s.decoy.turns} more ${s.decoy.turns === 1 ? 'turn' : 'turns'}. Flame destroys it.`] }
@@ -194,18 +219,21 @@ export function tileInfo(s: GameState, p: Vec): Info | null {
     return {
       icon: 'block_planks',
       title: 'Crate',
-      body: ['Flame destroys it (and stops there). On every level one crate hides the exit and another hides a power-up.'],
+      body: ['Flame destroys it (and stops there). On every level one crate hides the exit and another hides a gem.'],
     }
   }
   if (s.powerUp && eq(s.powerUp.pos, p)) {
-    const t = POWER_UP_TEXT[s.powerUp.kind]
-    return { icon: POWER_UP_ICONS[s.powerUp.kind], title: t.name, body: [t.text, 'Walk onto it to take it. ' + PUNISH] }
+    return {
+      icon: GEM_SPRITES[s.powerUp.color],
+      title: 'Gem',
+      body: [`Walk onto it to choose one of ${OFFER_SIZE} power-ups. The colour doesn’t matter.`, PUNISH],
+    }
   }
   if (eq(s.exit, p)) {
     return exitOpen(s)
-      ? { icon: 'door_open', title: 'Exit: open', body: ['Step onto it to finish the level.', PUNISH] }
+      ? { icon: 'door_open_top', title: 'Exit: open', body: ['Step onto it to finish the level.', PUNISH] }
       : {
-          icon: 'door_closed',
+          icon: 'door_closed_top',
           title: 'Exit: locked',
           body: [`It opens when every enemy is gone (${s.enemies.length} left). Then step onto it to finish the level.`, PUNISH],
         }
@@ -214,21 +242,24 @@ export function tileInfo(s: GameState, p: Vec): Info | null {
   if (drop) {
     if (drop.kind === 'coins') return { icon: coinSprite(drop.amount), title: `${drop.amount} ${drop.amount === 1 ? 'coin' : 'coins'}`, body: ['Walk onto it to take it. A blast destroys it.'] }
     const t = CHARGE_TEXT[drop.kind]
-    return { icon: CHARGE_ICONS[drop.kind], title: `${t.name} charge`, body: [t.text, 'Walk onto it to take one. Using it takes your turn. A blast destroys it.'] }
+    return { icon: CHARGE_ICONS[drop.kind], title: `${t.name} charge`, body: [t.text, 'Walk onto it: it works as soon as you pick it up. A blast destroys it.'] }
   }
   return null
 }
 
-/** Shown when a power-up is picked up (user request, 2026-10-22): what it does, from now on. */
-export function powerUpInfo(kind: PowerUpKind): Info {
-  const t = POWER_UP_TEXT[kind]
-  return { icon: POWER_UP_ICONS[kind], title: t.name, body: ['You picked it up!', t.text] }
-}
-
-/** Shown when a kind of action charge is picked up for the first time in a run (user request, 2026-10-22). */
+/** Shown when a kind of charge is picked up for the first time in a run (user request, 2026-10-22). */
 export function chargeInfo(kind: ChargeId): Info {
   const t = CHARGE_TEXT[kind]
-  return { icon: CHARGE_ICONS[kind], title: `${t.name} charge`, body: ['You picked it up!', t.text, 'Each charge is used once, and using it takes your turn.'] }
+  return { icon: CHARGE_ICONS[kind], title: `${t.name} charge`, body: ['You picked it up! It works right away.', t.text, 'The top bar shows what it still has to give.'] }
+}
+
+/** A charge in the top bar, with what it has left. */
+function chargeTitle(s: GameState, id: ChargeId): string {
+  const name = CHARGE_TEXT[id].name
+  const left = chargeLeft(s, id)
+  if (left === true) return name
+  if (id === 'pierce' || id === 'power' || id === 'nuke') return `${name}: next ${left} ${left === 1 ? 'bomb' : 'bombs'}`
+  return `${name}: ${turns(left)} left`
 }
 
 /** `n` turns, in words. */
@@ -236,11 +267,6 @@ function turns(n: number): string {
   return `${n} ${n === 1 ? 'turn' : 'turns'}`
 }
 
-/** "×2" after a charge's name: how many are left (unlimited in Developer mode). */
-function held(s: GameState, id: ChargeId): string {
-  return s.options.dev ? ' (unlimited)' : ` ×${s.charges[id]}`
-}
-
 /**
  * What an action button does, for the strip above the action bar while it is picked (and for
  * "What is that?" on a button). It grows as the player gains upgrades, so picking an action
@@ -258,7 +284,7 @@ export function actionInfo(s: GameState, picked: ActionId): Info {
       if (s.powers.run) body.push('Run: 2 tiles in a straight line, if both are empty.')
       if (s.powers.teleport) body.push(`Teleport: any empty tile up to ${TELEPORT_RANGE} steps away, over anything.`)
       if (s.powers.kick) body.push('Kick: walk into a bomb next to you and it slides away until something stops it.')
-      if (s.charges.diagonal > 0) body.push(`Diagonal${held(s, 'diagonal')}: a diagonal step, even between two blocked tiles. Uses one.`)
+      if (s.powers.diagonal) body.push('Diagonal: a diagonal step, even between two blocked tiles.')
       return { icon: 'icon_move', title: 'Move', body }
     }
     case 'bomb':
@@ -269,22 +295,13 @@ export function actionInfo(s: GameState, picked: ActionId): Info {
       ]
       if (s.powers.throw) body.push('Throw: or 2 tiles away in a straight line, over anything in between.')
       if (s.powers.detonate) body.push('Detonator: tap one of your bombs (they pulse) and it goes off this turn. No need to pick Bomb first.')
+      for (const k of ['pierce', 'power', 'nuke'] as const) {
+        if (s.boosts[k] > 0) body.push(`${CHARGE_TEXT[k].name}: your next ${s.boosts[k]} ${s.boosts[k] === 1 ? 'bomb' : 'bombs'}.`)
+      }
       return { icon: 'bomb', title: 'Bomb', body }
     }
-    case 'pierce':
-    case 'power':
-    case 'line': {
-      const t = CHARGE_TEXT[picked]
-      return { icon: CHARGE_ICONS[picked], title: `${t.name} bomb${held(s, picked)}`, body: [`${t.text.replace('A choice under Bomb: ', '')} Uses one.`] }
-    }
     case 'wait':
       return { icon: 'icon_wait', title: 'Wait', body: ['Do nothing; the world takes its turn. Tapping yourself does the same.'] }
-    case 'shield':
-    case 'freeze':
-    case 'decoy': {
-      const t = CHARGE_TEXT[picked]
-      return { icon: CHARGE_ICONS[picked], title: `${t.name}${held(s, picked)}`, body: [t.text] }
-    }
   }
 }
 
diff --git a/src/ui/offer.ts b/src/ui/offer.ts
new file mode 100644
index 0000000..fc4ae9e
--- /dev/null
+++ b/src/ui/offer.ts
@@ -0,0 +1,36 @@
+import type { PowerUpKind } from '../sim/types'
+import { POWER_UP_ICONS, spriteUrl } from '../render/sprites'
+import { closeDialog, openDialog } from './dialog'
+import { POWER_UP_TEXT } from './info'
+
+/**
+ * The gem's choice (user request, 2026-10-23): its offer of power-ups, each with what it does,
+ * laid out like the almanac. One must be picked; the dialog can't be closed any other way.
+ */
+let chosen: ((kind: PowerUpKind) => void) | null = null
+
+export function openOffer(offer: PowerUpKind[], choose: (kind: PowerUpKind) => void): void {
+  chosen = choose
+  openDialog({
+    title: 'Choose a power-up',
+    body: offer
+      .map(
+        (k) =>
+          `<button type="button" class="almanac-entry offer-choice" data-offer="${k}">` +
+          `<img src="${spriteUrl(POWER_UP_ICONS[k])}" alt="" draggable="false">` +
+          `<div><strong>${POWER_UP_TEXT[k].name}</strong><p>${POWER_UP_TEXT[k].text}</p></div></button>`,
+      )
+      .join(''),
+    buttons: [],
+    required: true,
+  })
+}
+
+const dialogBody = document.getElementById('dialog-body')!
+dialogBody.addEventListener('click', (e) => {
+  const button = (e.target as Element).closest<HTMLElement>('[data-offer]')
+  if (!button || !chosen) return
+  const choose = chosen
+  chosen = null
+  closeDialog(() => choose(button.dataset.offer as PowerUpKind))
+})
diff --git a/static/icons/apple-touch-icon.png b/static/icons/apple-touch-icon.png
new file mode 100644
index 0000000..8fb4eba
Binary files /dev/null and b/static/icons/apple-touch-icon.png differ
diff --git a/static/icons/icon-192.png b/static/icons/icon-192.png
new file mode 100644
index 0000000..23bc00e
Binary files /dev/null and b/static/icons/icon-192.png differ
diff --git a/static/icons/icon-512.png b/static/icons/icon-512.png
new file mode 100644
index 0000000..1b6912f
Binary files /dev/null and b/static/icons/icon-512.png differ
diff --git a/static/icons/icon-maskable-512.png b/static/icons/icon-maskable-512.png
new file mode 100644
index 0000000..5c4ad21
Binary files /dev/null and b/static/icons/icon-maskable-512.png differ
diff --git a/static/manifest.webmanifest b/static/manifest.webmanifest
new file mode 100644
index 0000000..eb91195
--- /dev/null
+++ b/static/manifest.webmanifest
@@ -0,0 +1,16 @@
+{
+  "name": "Explodorama",
+  "short_name": "Explodorama",
+  "description": "A turn-based, mobile-first, roguelike bomb-laying game. Every bomb counts down with your moves.",
+  "start_url": "./",
+  "scope": "./",
+  "display": "fullscreen",
+  "orientation": "any",
+  "background_color": "#c3e3ff",
+  "theme_color": "#353541",
+  "icons": [
+    { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
+    { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
+    { "src": "icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
+  ]
+}
diff --git a/static/sw.js b/static/sw.js
new file mode 100644
index 0000000..79ec3fe
--- /dev/null
+++ b/static/sw.js
@@ -0,0 +1,26 @@
+/**
+ * The service worker that makes the game installable and playable offline (user request,
+ * 2026-10-23). Network first: online, every request goes to the network as usual (so a new
+ * deploy is picked up at once) and a copy is kept; offline, the copy is used. Only this site's
+ * own GET requests are touched. Registered by main.ts in the built game only.
+ */
+const CACHE = 'explodorama'
+
+self.addEventListener('install', () => self.skipWaiting())
+self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()))
+
+self.addEventListener('fetch', (e) => {
+  const request = e.request
+  if (request.method !== 'GET' || new URL(request.url).origin !== self.location.origin) return
+  e.respondWith(
+    fetch(request)
+      .then((response) => {
+        if (response.ok) {
+          const copy = response.clone()
+          e.waitUntil(caches.open(CACHE).then((cache) => cache.put(request, copy)))
+        }
+        return response
+      })
+      .catch(() => caches.match(request).then((cached) => cached ?? Response.error())),
+  )
+})
diff --git a/test/charges.test.ts b/test/charges.test.ts
index 43da85d..38945df 100644
--- a/test/charges.test.ts
+++ b/test/charges.test.ts
@@ -1,20 +1,87 @@
 import { describe, expect, it } from 'vitest'
-import { ACTIONS, DECOY_TURNS, TELEPORT_RANGE } from '../src/sim/actions'
+import { ACTIONS, BOMB_BOOST, DECOY_TURNS, FREEZE_TURNS, isAvailable, NUKE_RANGE, TELEPORT_RANGE } from '../src/sim/actions'
 import { stepToward } from '../src/sim/enemies'
 import { blastTiles } from '../src/sim/explosions'
+import { carryOf, createLevel } from '../src/sim/state'
 import { step } from '../src/sim/turn'
+import type { ChargeId, GameState } from '../src/sim/types'
 import { board, v } from './helpers'
 
+/** The state after walking right onto a charge of `kind` lying next to the player. */
+function pickUp(s: GameState, kind: ChargeId): GameState {
+  const at = v(s.player.x + 1, s.player.y)
+  s.drops.push({ pos: at, kind })
+  return step(s, { id: 'move', target: at }).state
+}
+
+describe('charges work when picked up', () => {
+  it('Pierce, Power and Nuke each give the next bombs that boost', () => {
+    for (const kind of ['pierce', 'power', 'nuke'] as const) {
+      const s = pickUp(board(`
+        ######
+        #P...#
+        ######`), kind)
+      expect(s.boosts[kind]).toBe(BOMB_BOOST)
+      expect(pickUp(s, kind).boosts[kind]).toBe(2 * BOMB_BOOST)
+    }
+  })
+
+  it('Shield is armed at once', () => {
+    expect(pickUp(board(`
+      #####
+      #P..#
+      #####`), 'shield').shield).toBe(true)
+  })
+
+  it(`Freeze freezes every enemy for ${FREEZE_TURNS} turns, the pickup turn included`, () => {
+    const s = board(`
+      #######
+      #P...B#
+      #######`)
+    s.enemies[0]!.plan = [v(4, 1), v(3, 1), v(2, 1)]
+    let after = pickUp(s, 'freeze')
+    expect(after.enemies[0]!.pos).toEqual(v(5, 1))
+    for (let t = 1; t < FREEZE_TURNS; t++) after = step(after, { id: 'wait' }).state
+    expect(after.enemies[0]!.pos).toEqual(v(5, 1))
+    expect(after.frozen).toBe(0)
+  })
+
+  it('Decoy leaves a copy on the pickup tile, which chasing enemies head for', () => {
+    const s = board(`
+      ########
+      #P....S#
+      ########`)
+    const { state, events } = step({ ...s, drops: [{ pos: v(2, 1), kind: 'decoy' }] }, { id: 'move', target: v(2, 1) })
+    expect(events).toContainEqual({ type: 'decoyPlaced', pos: v(2, 1) })
+    expect(state.decoy).toEqual({ pos: v(2, 1), turns: DECOY_TURNS - 1 })
+    const away = step(state, { id: 'move', target: v(1, 1) }).state
+    expect(stepToward(away, away.enemies[0]!)?.to.x).toBeLessThan(away.enemies[0]!.pos.x)
+  })
+
+  it('what they still give goes on to the next level, and through a death', () => {
+    const s = pickUp(pickUp(board(`
+      ######
+      #P...#
+      ######`), 'pierce'), 'shield')
+    const next = createLevel(s.seed, 2, s.options, carryOf(s))
+    expect(next.boosts.pierce).toBe(BOMB_BOOST)
+    expect(next.shield).toBe(true)
+    expect(createLevel(s.seed, 1, s.options, carryOf(s)).boosts.pierce).toBe(BOMB_BOOST)
+  })
+})
+
 describe('Diagonal', () => {
-  it('squeezes between two blocked tiles', () => {
+  it('is a power-up: squeezes between two blocked tiles once gained, kept through a death', () => {
     const s = board(`
       #####
       #P+.#
       #+..#
       #####`)
-    s.charges.diagonal = 1
+    expect(isAvailable(s, 'diagonal')).toBe(false)
+    s.powers.diagonal = true
     expect(ACTIONS.diagonal.validTargets(s)).toEqual([v(2, 2)])
     expect(step(s, { id: 'diagonal', target: v(2, 2) }).state.player).toEqual(v(2, 2))
+    expect(carryOf(s).powers.diagonal).toBe(true)
   })
 })
 
@@ -40,29 +107,42 @@ describe('Teleport', () => {
   })
 })
 
-describe('Pierce and Power bombs', () => {
-  it('Pierce: the flame goes through soft blocks and burns them all', () => {
+describe('boosted bombs', () => {
+  it('each bomb placed uses one of every boost held, until they run out', () => {
+    let s = board(`
+      #########
+      #P......#
+      #########`, { capacity: 5 })
+    s.boosts.pierce = 1
+    s.boosts.nuke = 2
+    s = step(s, { id: 'bomb', target: v(2, 1) }).state
+    expect(s.bombs[0]).toMatchObject({ pierce: true, nuke: true })
+    expect(s.boosts).toMatchObject({ pierce: 0, nuke: 1 })
+    s.player = v(4, 1)
+    s = step(s, { id: 'bomb', target: v(5, 1) }).state
+    expect(s.bombs[1]!.pierce).toBeUndefined()
+    expect(s.bombs[1]!.nuke).toBe(true)
+  })
+
+  it('a thrown bomb uses them too', () => {
     const s = board(`
-      ########
-      #P.++..#
-      ########`, { fire: 3 })
-    const pierce = { pos: v(2, 1), fire: 3, special: 'pierce' as const }
-    expect(blastTiles(s, pierce)).toEqual([v(2, 1), v(1, 1), v(3, 1), v(4, 1), v(5, 1)])
-    expect(blastTiles(s, { pos: v(2, 1), fire: 3 })).toEqual([v(2, 1), v(1, 1), v(3, 1)])
+      ######
+      #P...#
+      ######`)
+    s.powers.throw = true
+    s.boosts.power = 1
+    const { state } = step(s, { id: 'throw', target: v(3, 1) })
+    expect(state.bombs[0]!.power).toBe(true)
+    expect(state.boosts.power).toBe(0)
   })
 
-  it('Pierce: placed like a bomb, marked, and burns every block in reach', () => {
-    let s = board(`
+  it('Pierce: the flame goes through soft blocks and burns them all', () => {
+    const s = board(`
       ########
       #P.++..#
       ########`, { fire: 3 })
-    s.charges.pierce = 1
-    s = step(s, { id: 'pierce', target: v(2, 1) }).state
-    expect(s.bombs[0]!.special).toBe('pierce')
-    s = step(s, { id: 'wait' }).state
-    s = step(s, { id: 'wait' }).state
-    const r = step(s, { id: 'wait' })
-    expect(r.events.filter((e) => e.type === 'blockDestroyed')).toHaveLength(2)
+    expect(blastTiles(s, { pos: v(2, 1), fire: 3, pierce: true })).toEqual([v(2, 1), v(1, 1), v(3, 1), v(4, 1), v(5, 1)])
+    expect(blastTiles(s, { pos: v(2, 1), fire: 3 })).toEqual([v(2, 1), v(1, 1), v(3, 1)])
   })
 
   it('Power: the flame reaches all the way to the walls', () => {
@@ -70,75 +150,26 @@ describe('Pierce and Power bombs', () => {
       ##########
       #P.......#
       ##########`)
-    s.charges.power = 1
-    const { state } = step(s, { id: 'power', target: v(2, 1) })
-    expect(state.bombs[0]!.special).toBe('power')
+    s.boosts.power = 1
+    const { state } = step(s, { id: 'bomb', target: v(2, 1) })
+    expect(state.bombs[0]!.power).toBe(true)
     expect(blastTiles(state, state.bombs[0]!)).toHaveLength(8)
   })
-})
-
-describe('Line', () => {
-  it('places all remaining capacity in a row, starting next to the player', () => {
-    const s = board(`
-      ########
-      #P.....#
-      ########`, { capacity: 3 })
-    s.charges.line = 1
-    const { state } = step(s, { id: 'line', target: v(2, 1) })
-    expect(state.bombs.map((b) => b.pos)).toEqual([v(2, 1), v(3, 1), v(4, 1)])
-  })
-
-  it('stops where the row is blocked', () => {
-    const s = board(`
-      ######
-      #P..+#
-      ######`, { capacity: 5 })
-    s.charges.line = 1
-    expect(step(s, { id: 'line', target: v(2, 1) }).state.bombs).toHaveLength(2)
-  })
-})
-
-describe('Decoy', () => {
-  it('is placed next to the player and starts counting down', () => {
-    const s = board(`
-      #######
-      #P....#
-      #######`)
-    s.charges.decoy = 1
-    const { state, events } = step(s, { id: 'decoy', target: v(2, 1) })
-    expect(events).toContainEqual({ type: 'decoyPlaced', pos: v(2, 1) })
-    expect(state.decoy).toEqual({ pos: v(2, 1), turns: DECOY_TURNS - 1 })
-  })
 
-  it('chasing enemies head for it instead of the player', () => {
+  it(`Nuke: every tile within ${NUKE_RANGE} steps burns, through crates, but walls stand`, () => {
     const s = board(`
       #######
-      #P.S..#
+      #.....#
+      #.+#+.#
+      #..P..#
+      #.+.+.#
+      #.....#
       #######`)
-    expect(stepToward(s, s.enemies[0]!)?.to).toEqual(v(2, 1))
-    s.decoy = { pos: v(5, 1), turns: 2 }
-    expect(stepToward(s, s.enemies[0]!)?.to).toEqual(v(4, 1))
-  })
-
-  it('disappears after its turns, or when flame reaches it', () => {
-    let s = board(`
-      #######
-      #P....#
-      #######`)
-    s.charges.decoy = 1
-    s = step(s, { id: 'decoy', target: v(2, 1) }).state
-    s = step(s, { id: 'wait' }).state
-    const last = step(s, { id: 'wait' })
-    expect(last.state.decoy).toBeNull()
-    expect(last.events).toContainEqual({ type: 'decoyGone', pos: v(2, 1), burned: false })
-
-    const burning = board(`
-      ######
-      #P.1.#
-      ######`)
-    burning.decoy = { pos: v(2, 1), turns: 3 }
-    const r = step(burning, { id: 'wait' })
-    expect(r.state.decoy).toBeNull()
-    expect(r.events).toContainEqual({ type: 'decoyGone', pos: v(2, 1), burned: true })
+    const tiles = blastTiles(s, { pos: v(3, 3), fire: 1, nuke: true })
+    // 13 tiles within 2 steps, less the pillar at (3, 2); the crates are in.
+    expect(tiles).toHaveLength(12)
+    expect(tiles).toContainEqual(v(2, 2))
+    expect(tiles).toContainEqual(v(3, 1))
+    expect(tiles).not.toContainEqual(v(3, 2))
   })
 })
diff --git a/test/demo.test.ts b/test/demo.test.ts
new file mode 100644
index 0000000..c33190a
--- /dev/null
+++ b/test/demo.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest'
+import { generateScene, SCENE_KINDS, type Scene } from '../src/sim/demo'
+import { step } from '../src/sim/turn'
+import type { GameEvent, GameState } from '../src/sim/types'
+
+/** Every turn of a scene, replayed by the rules: the state before it and its events. */
+function replay(scene: Scene): { before: GameState; events: GameEvent[]; after: GameState }[] {
+  let s = scene.start
+  return scene.actions.map((a) => {
+    const r = step(s, a)
+    const turn = { before: s, events: r.events, after: r.state }
+    s = r.state
+    return turn
+  })
+}
+
+describe('demo scenes', () => {
+  for (const kind of SCENE_KINDS) {
+    it(`${kind}: found, replayable, the player alive, and ending on what it shows`, () => {
+      for (let seed = 1; seed <= 10; seed++) {
+        const scene = generateScene(kind, seed)!
+        expect(scene).not.toBeNull()
+        const turns = replay(scene)
+        expect(turns.every((t) => t.after.status === 'playing' || t.after.status === 'cleared')).toBe(true)
+        const last = turns.at(-1)!
+        const types = last.events.map((e) => e.type)
+        if (kind === 'kill') expect(last.after.enemies).toEqual([])
+        if (kind === 'escape') {
+          const p = last.before.player
+          expect(last.events.some((e) => e.type === 'exploded' && e.tiles.some((t) => t.x === p.x && t.y === p.y))).toBe(true)
+        }
+        if (kind === 'chain') expect(types.filter((t) => t === 'exploded').length).toBeGreaterThanOrEqual(2)
+        if (kind === 'freeze') expect(last.before.frozen).toBeGreaterThan(0)
+        if (kind === 'detonate') expect(types).toContain('detonated')
+        if (kind !== 'escape' && kind !== 'kill') expect(types).toContain('enemyDied')
+      }
+    })
+  }
+
+  it('the same seed gives the same scene', () => {
+    expect(generateScene('kill', 7)).toEqual(generateScene('kill', 7))
+  })
+})
diff --git a/test/enemies.test.ts b/test/enemies.test.ts
index f0a66dc..a575600 100644
--- a/test/enemies.test.ts
+++ b/test/enemies.test.ts
@@ -1,6 +1,6 @@
 import { describe, expect, it } from 'vitest'
 import { ACTIONS, FREEZE_TURNS, isAvailable } from '../src/sim/actions'
-import { PLAN_AHEAD, planIntents, stepToward } from '../src/sim/enemies'
+import { LASTING_CHARGES, PLAN_AHEAD, planIntents, rollDrops, stepToward } from '../src/sim/enemies'
 import { enemyCount, enemyKinds, generateLevel, SAFE_DISTANCE, START } from '../src/sim/levelgen'
 import { newRun, PAR_BONUS } from '../src/sim/state'
 import { EXIT_SPAWN, step } from '../src/sim/turn'
@@ -173,7 +173,7 @@ describe('plans three moves ahead', () => {
       ########
       #P.....#
       ########`)
-    s.enemies.push({ id: 0, kind: 'drifter', pos: v(6, 1), dir: 'left', plan: [v(5, 1), v(4, 1), v(3, 1)], spawned: false })
+    s.enemies.push({ id: 0, kind: 'drifter', pos: v(6, 1), dir: 'left', plan: [v(5, 1), v(4, 1), v(3, 1)], spawned: false, sheltered: false })
     s.cells[1 * s.width + 4] = 'block' // the second move is now blocked
     planIntents(s)
     // Keeps (5,1); the Drifter, blocked going left, bounces back and forth from there.
@@ -185,8 +185,8 @@ describe('plans three moves ahead', () => {
       ######
       #P..B#
       ######`), v(3, 1))
-    s.charges.freeze = 1
-    const { state } = step(s, { id: 'freeze' })
+    s.drops.push({ pos: v(2, 1), kind: 'freeze' })
+    const { state } = step(s, { id: 'move', target: v(2, 1) })
     expect(state.enemies[0]!.plan[0]).toEqual(v(3, 1))
   })
 })
@@ -257,13 +257,13 @@ describe('drops', () => {
     expect(events[1]).toEqual({ type: 'pickedUp', drop: { pos: v(2, 1), kind: 'coins', amount: 1 } })
   })
 
-  it('walking onto a charge adds one to that action', () => {
+  it('walking onto a charge applies it', () => {
     const s = board(`
       #####
       #P..#
       #####`)
     s.drops.push({ pos: v(2, 1), kind: 'shield' })
-    expect(step(s, { id: 'move', target: v(2, 1) }).state.charges.shield).toBe(1)
+    expect(step(s, { id: 'move', target: v(2, 1) }).state.shield).toBe(true)
   })
 
   it('flame burns drops lying in the open, but not the ones its own kills leave', () => {
@@ -376,13 +376,13 @@ describe('the exit', () => {
       ####
       #PE#
       ####`, { fire: 3 }), coins: 4, lives: 2 }
-    s.charges.shield = 2
+    s.boosts.pierce = 2
     const cleared = step(s, { id: 'move', target: v(2, 1) }).state
     const { state, events } = step(cleared, { id: 'continue' })
     expect(events).toEqual([{ type: 'levelStarted', level: 2 }])
     expect(state).toMatchObject({ level: 2, turn: 0, lives: 2, coins: 4 + PAR_BONUS, status: 'playing' })
     expect(state.stats.fire).toBe(3)
-    expect(state.charges.shield).toBe(2)
+    expect(state.boosts.pierce).toBe(2)
     expect(state.enemies.length).toBe(enemyCount(2))
   })
 
@@ -396,21 +396,37 @@ describe('the exit', () => {
     expect(state.enemies).toHaveLength(EXIT_SPAWN)
     expect(state.enemies.every((e) => e.spawned && e.kind === 'blob')).toBe(true)
   })
-})
 
-describe('action charges and abilities', () => {
-  it('a charge is only offered while held, and using one spends it', () => {
+  it('the flame that let them out, still lingering, doesn\'t kill them', () => {
     const s = board(`
-      #####
-      #P..#
-      #####`)
-    expect(isAvailable(s, 'shield')).toBe(false)
-    s.charges.shield = 1
-    expect(isAvailable(s, 'shield')).toBe(true)
-    const { state } = step(s, { id: 'shield' })
-    expect(state.charges.shield).toBe(0)
+      ########
+      #P...1E#
+      ########`)
+    const spawned = step(s, { id: 'wait' }).state
+    expect(spawned.burning.length).toBeGreaterThan(0)
+    const next = step(spawned, { id: 'wait' }).state
+    expect(next.enemies).toHaveLength(EXIT_SPAWN)
+    expect(next.enemies.every((e) => !e.sheltered)).toBe(true)
   })
 
+  it('a new explosion the next turn still kills them', () => {
+    const s = board(`
+      ########
+      #P...1E#
+      ########`)
+    const spawned = step(s, { id: 'wait' }).state
+    expect(spawned.enemies).toHaveLength(EXIT_SPAWN)
+    // A fresh bomb next to the exit, going off this turn: its blast covers the exit and beside it.
+    spawned.bombs.push({ pos: v(5, 1), timer: 1, fire: 1, placedTurn: 0 })
+    const { state: next, events } = step(spawned, { id: 'wait' })
+    // All three die; flaming the exit again lets out three more.
+    const first = spawned.enemies.map((e) => e.id)
+    expect(events.filter((e) => e.type === 'enemyDied').map((e) => 'id' in e && e.id)).toEqual(first)
+    expect(next.enemies.some((e) => first.includes(e.id))).toBe(false)
+  })
+})
+
+describe('action charges and abilities', () => {
   it('kick is an ability: only offered once gained, and never used up', () => {
     const s = board(`
       ######
@@ -470,8 +486,8 @@ describe('action charges and abilities', () => {
       #####
       #P1.#
       #####`)
-    s.charges.shield = 1
-    const r = step(s, { id: 'shield' })
+    s.shield = true
+    const r = step(s, { id: 'wait' })
     expect(r.state.status).toBe('playing')
     expect(r.events).toContainEqual({ type: 'shieldBroke', pos: v(1, 1) })
     expect(r.state.shield).toBe(false)
@@ -491,13 +507,13 @@ describe('action charges and abilities', () => {
     expect(state.enemies).toEqual([]) // the flame got the Blob on the player's tile
   })
 
-  it(`freeze: enemies skip this turn's move and the next ${FREEZE_TURNS - 1}, then move again`, () => {
+  it(`freeze: enemies skip the pickup turn's move and the next ${FREEZE_TURNS - 1}, then move again`, () => {
     let s = intend(board(`
       ########
       #P....B#
       ########`), v(5, 1))
-    s.charges.freeze = 1
-    s = step(s, { id: 'freeze' }).state
+    s.drops.push({ pos: v(2, 1), kind: 'freeze' })
+    s = step(s, { id: 'move', target: v(2, 1) }).state
     expect(s.frozen).toBe(FREEZE_TURNS - 1)
     for (let i = 1; i < FREEZE_TURNS; i++) {
       s = step(s, { id: 'wait' }).state
@@ -508,3 +524,80 @@ describe('action charges and abilities', () => {
     expect(s.enemies[0]!.pos).not.toEqual(v(6, 1))
   })
 })
+
+describe('enemies spread out', () => {
+  /** Three enemies of `kind` on one tile in the middle of an open room, none planned yet. */
+  function stacked(kind: 'blob' | 'seeker' | 'drifter') {
+    const s = board(`
+      #########
+      #P......#
+      #.......#
+      #.......#
+      #.......#
+      #.......#
+      #########`)
+    for (let i = 0; i < 3; i++) s.enemies.push({ id: s.nextEnemyId++, kind, pos: v(5, 3), dir: 'up', plan: [], spawned: true, sheltered: false })
+    planIntents(s)
+    return s
+  }
+
+  for (const kind of ['blob', 'seeker', 'drifter'] as const) {
+    it(`${kind}s on one tile take different first steps, and never share a tile while they can help it`, () => {
+      const s = stacked(kind)
+      for (let t = 0; t < 3; t++) {
+        const tiles = s.enemies.map((e) => `${e.plan[t]!.x},${e.plan[t]!.y}`)
+        expect(new Set(tiles).size).toBe(3)
+      }
+    })
+  }
+
+  it('still share a tile when there is nowhere else to go', () => {
+    const s = board(`
+      #####
+      #P###
+      #.B.#
+      #####`)
+    s.enemies.push({ id: s.nextEnemyId++, kind: 'blob', pos: v(2, 2), dir: 'left', plan: [], spawned: true, sheltered: false })
+    s.cells[2 * s.width + 3] = 'wall'
+    planIntents(s)
+    expect(s.enemies.map((e) => e.plan[0])).toEqual([v(1, 2), v(1, 2)])
+  })
+
+  it('their moves are still exactly the ones shown', () => {
+    let s = stacked('drifter')
+    const shown = s.enemies.map((e) => e.plan[0])
+    s = step(s, { id: 'wait' }).state
+    expect(s.enemies.map((e) => e.pos)).toEqual(shown)
+  })
+})
+
+describe('the last enemy\'s drop', () => {
+  it('is a charge that lasts (Pierce, Power, Nuke or Shield), never Freeze or Decoy', () => {
+    const s = board(`
+      #####
+      #P.D#
+      #####`)
+    const kinds = (last: boolean) => {
+      const seen = new Set<string>()
+      for (let i = 1; i <= 2000; i++) {
+        s.dropRng = i * 7919
+        for (const d of rollDrops(s, s.enemies[0]!, last)) if (d.kind !== 'coins') seen.add(d.kind)
+      }
+      return seen
+    }
+    expect([...kinds(true)].sort()).toEqual([...LASTING_CHARGES].sort())
+    expect(kinds(false)).toContain('freeze')
+    expect(kinds(false)).toContain('decoy')
+  })
+
+  it('is decided by whether any enemy is left once the turn\'s kills are done', () => {
+    const s = board(`
+      #######
+      #P.D1D#
+      #######`)
+    const { state, events } = step(s, { id: 'wait' })
+    expect(state.enemies).toEqual([])
+    const charges = events.flatMap((e) => (e.type === 'dropSpawned' && e.drop.kind !== 'coins' ? [e.drop.kind] : []))
+    for (const c of charges) expect(LASTING_CHARGES).toContain(c)
+  })
+})
diff --git a/test/helpers.ts b/test/helpers.ts
index d8bbb89..b7c2ec9 100644
--- a/test/helpers.ts
+++ b/test/helpers.ts
@@ -1,4 +1,4 @@
-import { noCharges, noPowers, START_LIVES, START_STATS } from '../src/sim/state'
+import { noBoosts, noPowers, START_LIVES, START_STATS } from '../src/sim/state'
 import { DEFAULT_OPTIONS, type Cell, type EnemyKind, type GameState, type Stats, type Vec } from '../src/sim/types'
 
 const ENEMIES: Record<string, EnemyKind> = { B: 'blob', S: 'seeker', D: 'drifter' }
@@ -20,8 +20,8 @@ export function board(picture: string, stats: Partial<Stats> = {}): GameState {
     seed: 1, options: { ...DEFAULT_OPTIONS }, 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: 0, decoy: null, burning: [],
-    powers: noPowers(), star: 0, powerUp: null, runTurns: 0, deaths: 0, crumbling: [], aiRng: 1, dropRng: 1,
+    boosts: noBoosts(), shield: false, frozen: 0, decoy: null, burning: [],
+    powers: noPowers(), star: 0, powerUp: null, offer: null, runTurns: 0, deaths: 0, crumbling: [], aiRng: 1, dropRng: 1,
   }
   rows.forEach((row, y) => {
     if (row.length !== width) throw new Error(`row ${y} is ${row.length} wide, not ${width}`)
@@ -30,7 +30,7 @@ export function board(picture: string, stats: Partial<Stats> = {}): GameState {
       if (ch === 'P') player = { x, y }
       if (/[1-9]/.test(ch)) s.bombs.push({ pos: { x, y }, timer: Number(ch), fire: s.stats.fire, placedTurn: 0 })
       const kind = ENEMIES[ch]
-      if (kind) s.enemies.push({ id: s.nextEnemyId++, kind, pos: { x, y }, dir: 'right', plan: [], spawned: false })
+      if (kind) s.enemies.push({ id: s.nextEnemyId++, kind, pos: { x, y }, dir: 'right', plan: [], spawned: false, sheltered: false })
       if (ch === 'E' || ch === 'e') s.exit = { x, y }
       if (ch === '$') s.drops.push({ pos: { x, y }, kind: 'coins', amount: 1 })
     })
diff --git a/test/options.test.ts b/test/options.test.ts
index 1f942f1..414d656 100644
--- a/test/options.test.ts
+++ b/test/options.test.ts
@@ -1,7 +1,7 @@
 import { describe, expect, it } from 'vitest'
 import { enemyCount, generateLevel, lastLevel, SIZES, START } from '../src/sim/levelgen'
 import { newRecord, replay } from '../src/sim/record'
-import { DEV_STATS, newRun } from '../src/sim/state'
+import { newRun } from '../src/sim/state'
 import { step } from '../src/sim/turn'
 import { DEFAULT_OPTIONS, type Vec } from '../src/sim/types'
 import { board, v } from './helpers'
@@ -64,23 +64,37 @@ describe('small board', () => {
 describe('developer mode', () => {
   const dev = { ...DEFAULT_OPTIONS, dev: true }
 
-  it('has every ability, the developer stats and unlimited charges', () => {
+  it('gives nothing but the gem: the usual stats, abilities and charges', () => {
     const s = newRun(1, 1, 0, dev)
-    expect(Object.values(s.powers).every(Boolean)).toBe(true)
-    expect(s.stats).toEqual(DEV_STATS)
-    const before = s.charges.freeze
-    expect(step(s, { id: 'freeze' }).state.charges.freeze).toBe(before)
+    const plain = newRun(1, 1, 0)
+    expect(s.stats).toEqual(plain.stats)
+    expect(s.powers).toEqual(plain.powers)
+    expect(s.boosts).toEqual(plain.boosts)
+    expect(s.shield).toBe(plain.shield)
   })
 
-  it('can still be killed by an enemy, and gets everything back after dying', () => {
-    const s = { ...board(`
-      #####
-      #PB.#
-      #####`), options: dev }
-    const dead = step(s, { id: 'move', target: v(2, 1) }).state
+  it('the gem is back after a death', () => {
+    const s = newRun(1, 1, 0, dev)
+    s.enemies.push({ id: 99, kind: 'blob', pos: { x: 1, y: 2 }, dir: 'up', plan: [], spawned: false, sheltered: false })
+    const dead = step(s, { id: 'move', target: { x: 1, y: 2 } }).state
     expect(dead.status).toBe('dead')
     const again = step(dead, { id: 'continue' }).state
-    expect(Object.values(again.powers).every(Boolean)).toBe(true)
+    expect(again.powerUp?.pos).toEqual({ x: START.x + 1, y: START.y })
+  })
+
+  it('starts every level with the gem uncovered right of the player', () => {
+    for (const level of [1, 7]) {
+      const s = newRun(5, level, 0, dev)
+      expect(s.powerUp?.pos).toEqual({ x: START.x + 1, y: START.y })
+      expect(s.cells[START.y * s.width + START.x + 1]).toBe('floor')
+      const { state } = step(s, { id: 'move', target: s.powerUp!.pos })
+      expect(state.offer?.length).toBeGreaterThan(0)
+    }
+  })
+
+  it('a normal run has no gem in the open at the start', () => {
+    const s = newRun(5)
+    expect(s.cells[s.powerUp!.pos.y * s.width + s.powerUp!.pos.x]).toBe('block')
   })
 })
 
diff --git a/test/path.test.ts b/test/path.test.ts
index a6a7a01..8a72715 100644
--- a/test/path.test.ts
+++ b/test/path.test.ts
@@ -32,6 +32,35 @@ describe('auto-move path', () => {
     expect(playerPath(s, v(1, 1))).toBeNull()
   })
 
+  it('goes round an open exit unless the exit is the destination', () => {
+    const s = board(`
+      ######
+      #PE..#
+      #....#
+      ######`)
+    const path = playerPath(s, v(4, 1))!
+    expect(path).toHaveLength(5)
+    expect(path).not.toContainEqual(v(2, 1))
+    expect(path.at(-1)).toEqual(v(4, 1))
+    expect(playerPath(s, v(2, 1))).toEqual([v(2, 1)])
+  })
+
+  it('is null when the only way is over the open exit', () => {
+    const s = board(`
+      ######
+      #PE..#
+      ######`)
+    expect(playerPath(s, v(4, 1))).toBeNull()
+  })
+
+  it('crosses a locked exit, which doesn\'t end the level', () => {
+    const s = board(`
+      ######
+      #PE.B#
+      ######`)
+    expect(playerPath(s, v(3, 1))).toEqual([v(2, 1), v(3, 1)])
+  })
+
   it('goes through enemies, which will have moved by then', () => {
     const s = board(`
       ######
diff --git a/test/picks.test.ts b/test/picks.test.ts
index 28ad3e6..18b65b8 100644
--- a/test/picks.test.ts
+++ b/test/picks.test.ts
@@ -58,13 +58,13 @@ describe('Move stands for every way of moving', () => {
     expect(resolvePick(s, 'move', v(5, 1))).toBeNull()
   })
 
-  it('a diagonal step spends a Diagonal charge only when nothing free reaches the tile', () => {
+  it('a diagonal step is taken only when nothing else reaches the tile', () => {
     const s = board(`
       #####
       #P#.#
       ##..#
       #####`)
-    s.charges.diagonal = 1
+    s.powers.diagonal = true
     expect(resolvePick(s, 'move', v(2, 2))).toBe('diagonal')
     s.powers.teleport = true
     expect(resolvePick(s, 'move', v(2, 2))).toBe('teleport')
@@ -89,8 +89,8 @@ describe('Bomb stands for placing and throwing', () => {
       #####
       #P..#
       #####`)
-    s.charges.pierce = 1
-    expect(resolvePick(s, 'pierce', v(2, 1))).toBe('pierce')
-    expect(resolvePick(s, 'pierce', v(3, 1))).toBeNull()
+    s.powers.teleport = true
+    expect(resolvePick(s, 'teleport', v(3, 1))).toBe('teleport')
+    expect(resolvePick(s, 'teleport', v(1, 1))).toBeNull()
   })
 })
diff --git a/test/powerups.test.ts b/test/powerups.test.ts
index 34eb212..22d06aa 100644
--- a/test/powerups.test.ts
+++ b/test/powerups.test.ts
@@ -1,42 +1,140 @@
 import { describe, expect, it } from 'vitest'
 import { ACTIONS, isAvailable } from '../src/sim/actions'
-import { generateLevel, LAST_LEVEL, runLevel } from '../src/sim/levelgen'
+import { generateLevel, LAST_LEVEL } from '../src/sim/levelgen'
 import { newRun } from '../src/sim/state'
-import { COIN_BAG, EXIT_SPAWN, STAR_TURNS, step } from '../src/sim/turn'
+import { COIN_BAG, drawOffer, EXIT_SPAWN, OFFER_SIZE, POWER_UP_WEIGHTS, 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', () => {
+describe('levelgen: the hidden gem', () => {
   for (let seed = 1; seed <= 100; seed++) {
-    it(`seed ${seed}: under a soft block that isn't the exit's`, () => {
+    it(`seed ${seed}: under a soft block that isn't the exit's, in one of the four colours`, () => {
       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)
+      expect(['blue', 'green', 'red', 'yellow']).toContain(g.powerUp.color)
     })
   }
-
-  it('Run comes on level 1 or 2, and only there', () => {
-    for (let seed = 1; seed <= 50; seed++) {
-      const at = runLevel(seed)
-      expect([1, 2]).toContain(at)
-      expect(generateLevel(seed, at).powerUp.kind).toBe('run')
-      for (let level = 1; level <= 12; level++) if (level !== at) expect(generateLevel(seed, level).powerUp.kind).not.toBe('run')
-    }
-  })
 })
 
-/** A board with the power-up `kind` lying uncovered at (2, 1). */
-function withPowerUp(kind: PowerUpKind, picture = `
+/** A board with the gem lying uncovered at (2, 1). */
+function withGem(picture = `
   ######
   #P...#
   ######`): GameState {
   const s = board(picture)
-  s.powerUp = { pos: v(2, 1), kind }
+  s.powerUp = { pos: v(2, 1), color: 'red' }
+  return s
+}
+
+/** A state waiting for a choice from `offer`. */
+function offering(...offer: PowerUpKind[]): GameState {
+  const s = board(`
+    ######
+    #P...#
+    ######`)
+  s.offer = offer
   return s
 }
 
-describe('picking up power-ups', () => {
-  const take = (kind: PowerUpKind) => step(withPowerUp(kind), { id: 'move', target: v(2, 1) })
+const ORDER = POWER_UP_WEIGHTS.map(([kind]) => kind)
+
+describe('the offer', () => {
+  it(`is ${OFFER_SIZE} different power-ups, in the almanac's order, the same for the same seed and level`, () => {
+    for (let seed = 1; seed <= 50; seed++) {
+      const s = newRun(seed, 1 + (seed % 20))
+      const offer = drawOffer(s)
+      expect(offer).toHaveLength(OFFER_SIZE)
+      expect(new Set(offer).size).toBe(OFFER_SIZE)
+      expect([...offer].sort((a, b) => ORDER.indexOf(a) - ORDER.indexOf(b))).toEqual(offer)
+      expect(drawOffer(s)).toEqual(offer)
+    }
+  })
+
+  it('leaves out abilities and passes already held', () => {
+    const s = newRun(3)
+    s.powers = { run: true, kick: true, throw: true, teleport: true, diagonal: true, detonate: true, wallPass: true, bombPass: false, flamePass: true }
+    for (let level = 1; level <= 30; level++) {
+      s.level = level
+      const offer = drawOffer(s)
+      expect(offer).toHaveLength(OFFER_SIZE)
+      for (const k of ['run', 'kick', 'throw', 'teleport', 'diagonal', 'detonator', 'wallPass', 'flamePass'] as PowerUpKind[]) expect(offer).not.toContain(k)
+    }
+  })
+
+  it('is smaller when fewer would do anything', () => {
+    const s = newRun(3)
+    s.powers = { run: true, kick: true, throw: true, teleport: true, diagonal: true, detonate: true, wallPass: true, bombPass: true, flamePass: true }
+    // Left: Bomb+, Fire+, Fuse+, Star, Heart, Coin bag.
+    expect(drawOffer(s)).toHaveLength(OFFER_SIZE)
+  })
+
+  it('offers rare ones less often than common ones', () => {
+    let fire = 0
+    let teleport = 0
+    for (let seed = 1; seed <= 400; seed++) {
+      const offer = drawOffer(newRun(seed))
+      if (offer.includes('firePlus')) fire++
+      if (offer.includes('teleport')) teleport++
+    }
+    expect(fire).toBeGreaterThan(teleport * 2)
+    expect(teleport).toBeGreaterThan(0)
+  })
+})
+
+describe('taking the gem', () => {
+  it('sets up the offer and gives nothing yet; the gem is gone', () => {
+    const { state, events } = step(withGem(), { id: 'move', target: v(2, 1) })
+    expect(state.offer).toEqual(drawOffer(withGem()))
+    expect(state.powerUp).toBeNull()
+    expect(state.stats).toEqual(withGem().stats)
+    expect(events).toContainEqual({ type: 'powerUpTaken', powerUp: { pos: v(2, 1), color: 'red' }, offer: state.offer })
+  })
+
+  it('nothing but choosing is possible until then', () => {
+    const s = offering('heart', 'bombPlus')
+    expect(isAvailable(s, 'move')).toBe(false)
+    expect(isAvailable(s, 'wait')).toBe(false)
+    expect(() => step(s, { id: 'wait' })).toThrow()
+  })
+
+  it('a power-up that is not on offer cannot be chosen', () => {
+    expect(() => step(offering('heart'), { id: 'choose', kind: 'run' })).toThrow()
+  })
+
+  it('choosing takes no turn and nothing else happens', () => {
+    const s = offering('heart', 'bombPlus')
+    s.bombs.push({ pos: v(4, 1), timer: 1, fire: 1, placedTurn: 0 })
+    const { state, events } = step(s, { id: 'choose', kind: 'heart' })
+    expect(events).toEqual([{ type: 'powerUpChosen', kind: 'heart' }])
+    expect(state.offer).toBeNull()
+    expect(state.turn).toBe(s.turn)
+    expect(state.bombs).toEqual(s.bombs)
+  })
+
+  it('a death on the same turn drops the offer; the retry has the gem back', () => {
+    const s = withGem(`
+      ######
+      #P...#
+      ######`)
+    s.bombs.push({ pos: v(3, 1), timer: 1, fire: 1, placedTurn: 0 })
+    const { state } = step(s, { id: 'move', target: v(2, 1) })
+    expect(state.status).toBe('dead')
+    expect(state.offer).toBeNull()
+  })
+
+  it('reaching the exit on the same turn still needs the choice before the next level', () => {
+    const s = offering('heart')
+    s.status = 'cleared'
+    expect(() => step(s, { id: 'continue' })).toThrow()
+    const chosen = step(s, { id: 'choose', kind: 'heart' }).state
+    expect(chosen.lives).toBe(4)
+    expect(step(chosen, { id: 'continue' }).state.level).toBe(2)
+  })
+})
+
+describe('the power-ups', () => {
+  const take = (kind: PowerUpKind) => step(offering(kind), { id: 'choose', kind })
 
   it('stats: Bomb+, Fire+, Fuse+', () => {
     expect(take('bombPlus').state.stats.capacity).toBe(2)
@@ -47,43 +145,44 @@ describe('picking up power-ups', () => {
   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)
+    // Chosen between turns, so all its turns are still to come.
+    expect(take('star').state.star).toBe(STAR_TURNS)
   })
 
-  it('abilities, and the event; the power-up is gone afterwards', () => {
-    const r = take('run')
-    expect(r.state.powers.run).toBe(true)
-    expect(r.state.powerUp).toBeNull()
-    expect(r.events).toContainEqual({ type: 'powerUpTaken', powerUp: { pos: v(2, 1), kind: 'run' } })
+  it('abilities and passes', () => {
+    expect(take('run').state.powers.run).toBe(true)
     expect(take('detonator').state.powers.detonate).toBe(true)
     expect(take('kick').state.powers.kick).toBe(true)
     expect(take('throw').state.powers.throw).toBe(true)
     expect(take('teleport').state.powers.teleport).toBe(true)
+    expect(take('diagonal').state.powers.diagonal).toBe(true)
+    expect(take('wallPass').state.powers.wallPass).toBe(true)
+    expect(take('bombPass').state.powers.bombPass).toBe(true)
+    expect(take('flamePass').state.powers.flamePass).toBe(true)
   })
 
-  it('a power-up still under its block cannot be taken', () => {
-    const s = withPowerUp('heart', `
+  it('a gem still under its block cannot be taken', () => {
+    const s = withGem(`
       ######
       #P+..#
       ######`)
     s.powers.wallPass = true
-    expect(step(s, { id: 'move', target: v(2, 1) }).state.lives).toBe(3)
+    expect(step(s, { id: 'move', target: v(2, 1) }).state.offer).toBeNull()
   })
 
   it('burning its block reveals it', () => {
-    const s = withPowerUp('heart', `
+    const s = withGem(`
       ######
       #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(events).toContainEqual({ type: 'powerUpRevealed', powerUp: { pos: v(4, 1), color: 'red' } })
     expect(state.powerUp).not.toBeNull()
   })
 
   it('flame on it once uncovered destroys it and lets enemies out', () => {
-    const s = withPowerUp('heart', `
+    const s = withGem(`
       ######
       #P..1#
       ######`)
@@ -201,16 +300,16 @@ describe('passes', () => {
     expect(step(s, { id: 'move', target: v(2, 1) }).state.status).toBe('playing')
   })
 
-  it('a death loses the passes, Detonator and Star, but keeps Run, Kick, Throw, Teleport and stats', () => {
+  it('a death keeps every power-up and the stats (since 2026-10-23)', () => {
     const s = { ...board(`
       #####
       #P1.#
       #####`, { capacity: 2 }), star: 0 }
-    s.powers = { run: true, kick: true, throw: true, teleport: true, detonate: true, wallPass: true, bombPass: true, flamePass: false }
+    s.powers = { run: true, kick: true, throw: true, teleport: true, diagonal: 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, kick: true, throw: true, teleport: true, detonate: false, wallPass: false, bombPass: false, flamePass: false })
+    expect(again.powers).toEqual(s.powers)
     expect(again.stats.capacity).toBe(2)
   })
 
diff --git a/test/sprites.test.ts b/test/sprites.test.ts
new file mode 100644
index 0000000..f7e6aee
--- /dev/null
+++ b/test/sprites.test.ts
@@ -0,0 +1,23 @@
+import { readdirSync, readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+/** A PNG's width and height, from its IHDR chunk. */
+function pngSize(file: string): string {
+  const png = readFileSync(file)
+  return `${png.readUInt32BE(16)} × ${png.readUInt32BE(20)}`
+}
+
+describe('board sprites', () => {
+  // LittleJS cuts a 128 × 128 tile out of each board texture (render/sprites.ts). A 100 px file
+  // gave an invalid tile, which crashed the game when a Freeze drop was drawn (2026-10-23).
+  for (const dir of ['assets/kenney', 'assets/composed']) {
+    it(`every PNG in ${dir} is 128 × 128`, () => {
+      const wrong = readdirSync(dir)
+        .filter((f) => f.endsWith('.png'))
+        .map((f) => `${f}: ${pngSize(join(dir, f))}`)
+        .filter((line) => !line.endsWith('128 × 128'))
+      expect(wrong).toEqual([])
+    })
+  }
+})
diff --git a/tools/compose-icons.sh b/tools/compose-icons.sh
index 2ba826b..54fd6d9 100644
--- a/tools/compose-icons.sh
+++ b/tools/compose-icons.sh
@@ -15,8 +15,10 @@ mkdir -p "$OUT"
 
 # ink <white icon> <name>: the same shape in the UI's ink colour, for light backgrounds (CSS
 # turns it white again where the background is dark: class "mono" in controls.ts).
+# Always 128 × 128, like every board sprite: LittleJS cuts tiles of 128 out of each texture, and a
+# 100 px Game Icons file (the padlock) made its tile invalid, which crashed the game (2026-10-23).
 ink() {
-  magick "$1" -fill '#353541' -colorize 100 "$OUT/$2.png"
+  magick "$1" -resize 128x128 -fill '#353541' -colorize 100 "$OUT/$2.png"
   echo "  $OUT/$2.png"
 }
 
@@ -43,7 +45,32 @@ badge "$OUT/icon_move.png" "$I/fastForward.png" powerup_run
 badge "$K/bomb.png" "$I/arrowRight.png" powerup_kick
 badge "$K/bomb.png" "$I/upRight.png" powerup_throw
 badge "$OUT/icon_move.png" "$I/export.png" powerup_teleport
+# Diagonal: the Move figure with four diagonal arrows (a charge until 2026-10-23).
+badge "$OUT/icon_move.png" "$B/arrow_diagonal_cross.png" powerup_diagonal
 badge "$K/bomb.png" "$I/exclamation.png" powerup_detonator
 badge "$K/block_planks.png" "$I/cross.png" powerup_wall_pass
 badge "$K/bomb.png" "$B/shield.png" powerup_bomb_pass
 badge "$K/fireball.png" "$B/shield.png" powerup_flame_pass
+
+# The charges (user request, 2026-10-23: they work on pickup now). Pierce and Power reuse the
+# Wall Pass and Fire+ icons above; the rest:
+badge "$K/bomb.png" "$B/skull.png" charge_nuke
+ink "$I/locked.png" charge_freeze
+ink "$B/pawns.png" charge_decoy
+# Shield: the Move figure inside an empty ring (the board-game timer at 0).
+magick "$B/timer_0.png" -fill '#353541' -colorize 100 \
+  \( "$OUT/icon_move.png" -resize 84x84 \) -gravity center -composite \
+  "$OUT/charge_shield.png"
+echo "  $OUT/charge_shield.png"
+
+# The app's icons for installing it (user request, 2026-10-23): the bomb on the sky blue, in
+# static/icons/. "Maskable" keeps the bomb inside the middle 60%, so any mask shape fits round it.
+mkdir -p static/icons
+appicon() {
+  magick -size "$1x$1" xc:'#c3e3ff' \( "$K/bomb.png" -resize "$2x$2" \) -gravity center -composite "static/icons/$3.png"
+  echo "  static/icons/$3.png"
+}
+appicon 192 150 icon-192
+appicon 512 400 icon-512
+appicon 512 300 icon-maskable-512
+appicon 180 140 apple-touch-icon
diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs
index b056494..e138a8c 100644
--- a/tools/screenshots.mjs
+++ b/tools/screenshots.mjs
@@ -51,7 +51,7 @@ const SHOTS = [
   { name: 'desktop', viewport: DESKTOP, act: press('KeyB', 'KeyD', 'KeyS') },
   // Level 13 has all three enemy types so far, each with its intent arrow.
   { name: 'enemies', query: 'level=13', act: press('KeyD', 'KeyD', 'KeyD', 'KeyD', 'KeyD', 'KeyD') },
-  // Tapped a far tile: the flag, the walk there, and Move turned into Go; then one Go.
+  // Tapped a far tile: the way there in blue, and Move turned into Go; then one Go.
   { name: 'auto-move', act: async (page) => { await tap(3, 3)(page); await page.click('[data-action=move]') } },
   // Held a finger on the tile to the right: a bomb is armed there, with its blast, until release.
   {
@@ -63,21 +63,18 @@ const SHOTS = [
       await page.waitForTimeout(600)
     },
   },
-  // 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 Run under the crate two tiles below the start: bomb it open
+  // With every lasting charge (?charges=1): a bomb placed to the right wears the Pierce, Power and
+  // Nuke badges, and after a step down the danger shows the Nuke's reach; the top bar lists them.
+  { name: 'nuke', query: 'charges=1', act: press('KeyB', 'KeyD', 'KeyS') },
+  // Seed 55 hides the 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'),
-  },
+  { name: 'gem', query: 'seed=55', act: press('KeyB', 'KeyS', 'KeyD', 'KeyD', 'Space', 'KeyA') },
+  // ...then walk back and take it: the choice of power-ups.
+  { name: 'gem-offer', query: 'seed=55', act: press('KeyB', 'KeyS', 'KeyD', 'KeyD', 'Space', 'KeyA', 'KeyA', 'KeyS', 'KeyS') },
   // A small board fits the screen whole: no scrolling.
   { name: 'small', query: 'small', act: async () => {} },
   { name: 'small-desktop', viewport: DESKTOP, query: 'small', act: async () => {} },
-  // Developer mode: every ability and unlimited charges (the ∞ badges); its tag in the top bar.
+  // Developer mode: the level's gem uncovered right of the player, and the mode's tag in the top bar.
   { name: 'dev', query: 'dev', act: async () => {} },
   // "What is that?" (the ? button beside the menu) on, then tapping an enemy explains it; the top bar's items
   // explain themselves with a plain tap.
@@ -104,10 +101,12 @@ const SHOTS = [
       await page.click(`[data-tab=${tab}]`)
     },
   })),
-  // Holding action charges: their buttons appear; Bomb picked offers the charge bombs as choices.
-  { name: 'charges', query: 'charges=2', act: async (page) => { await page.click('[data-action=bomb]') } },
-  // Move picked with every upgrade (Developer mode): steps, runs, teleports, and the explanation.
-  { name: 'move-picked', query: 'dev', act: async (page) => { await page.click('[data-action=move]') } },
+  // What picked-up charges still give, in the top bar's own row (?charges=2: 6 bombs of each, a Shield).
+  { name: 'charges', query: 'charges=2', act: async () => {} },
+  // The demo before the game (?demo): a scene made up on the spot, part-way through, with its caption.
+  { name: 'demo', query: 'demo', act: async (page) => { await page.waitForTimeout(3500) } },
+  // Move picked: the tiles it can step to.
+  { name: 'move-picked', act: async (page) => { await page.click('[data-action=move]') } },
 ]
 
 mkdirSync(OUT, { recursive: true })
diff --git a/vite.config.ts b/vite.config.ts
index 63193ba..a22b448 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -10,9 +10,13 @@ const base = process.env.BASE || './'
 
 export default defineConfig({
   base,
-  // `npm run dev` binds to every interface (--host) so a phone on the same Wi-Fi can open the
-  // game; this prints a scannable QR of that network URL alongside it.
+  // `npm run dev` and `npm run preview` bind to every interface (--host) so a phone on the same
+  // Wi-Fi can open the game; this prints a scannable QR of that network URL alongside it.
   plugins: [qrcode()],
+  // In `npm run dev`, the game's console output and uncaught errors, from any device (a phone on
+  // the Wi-Fi too), are printed in this terminal (user request, 2026-10-23: a crash on the phone
+  // showed nothing anywhere). Caught errors are logged with their stack by src/devlog.ts.
+  server: { forwardConsole: { unhandledErrors: true, logLevels: ['error', 'warn', 'info', 'log'] } },
   // Not `public/`: GitLab Pages requires its artifact to be a directory named `public`, and
   // `mv dist public` moves dist *inside* an existing `public/` instead of renaming it.
   publicDir: 'static',