fuzzyworld

explodorama / commit

Balance lab; one charge at a time, used on purpose; Proximity next-to only

d3e4ed26516d672371ae28d30bf048bb3f131ab0 · Pavel Safronov · 2026-10-25 10:25:22 -0700 · parents b80fa9256a71

Claude Code usage: this commit ~300.0K out / ~141.4M cached · cost unknown (no per-model trailer) · session 4 tally 3.2K in / 1.5M out / 669.7M cached · usage by commit and session

Balance lab; one charge at a time, used on purpose; Proximity next-to only

- The balance lab (tools/balance, npm run balance): bots at three skill
  levels play the real rules on all cores; paired experiments (each
  power-up held from level 1) and structural measures, reported in
  docs/balance. Found: Teleport and Flame Pass dominate, Proximity alert
  cost a strong player 3 levels, and the Nuke trap (Nukes forced on
  every bomb made a level impossible).
- One charge carried at a time, used with a new charge button (V), always
  shown; Bomb always places a plain bomb. Every charge is deliberate:
  Shield raised (up until a hit, across levels), Decoy put up to 3 tiles
  away in a line (until the level ends or it is hit), Freeze, ICBM.
  Another kind picked up, chosen or bought asks: keep yours and sell the
  new one, or take it and sell yours.
- Proximity alert: only an enemy next to the bomb sets it off.
- Fix: a resumed run's open choice or shop came back after the demo.
- SIM_VERSION 31.

Usage: 3.2K in / 1.5M out / 669.7M cached (session tally)
rtk:   1.9M saved, 39.0% over 4559 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 .gitignore                           |   3 +
 CHANGELOG.md                         |   4 +
 PLAN.md                              | 102 ++++++++++++-
 README.md                            |  88 +++++++----
 decisions.md                         |  79 ++++++++++
 docs/balance/powers-average.md       |  55 +++++++
 docs/balance/powers-careful.md       |  55 +++++++
 docs/balance/structure.md            |  32 ++++
 docs/screenshots/almanac-charges.png | Bin 313849 -> 387660 bytes
 docs/screenshots/almanac-enemies.png | Bin 314698 -> 316622 bytes
 docs/screenshots/almanac.png         | Bin 535107 -> 537608 bytes
 docs/screenshots/auto-move.png       | Bin 214599 -> 216615 bytes
 docs/screenshots/bomb-picked.png     | Bin 213863 -> 215795 bytes
 docs/screenshots/charge-offer.png    | Bin 0 -> 170953 bytes
 docs/screenshots/charges.png         | Bin 225516 -> 224664 bytes
 docs/screenshots/countdown.png       | Bin 218619 -> 219725 bytes
 docs/screenshots/death.png           | Bin 232923 -> 233761 bytes
 docs/screenshots/demo.png            | Bin 959912 -> 962995 bytes
 docs/screenshots/desktop.png         | Bin 311441 -> 313060 bytes
 docs/screenshots/dev-offer.png       | Bin 206914 -> 238816 bytes
 docs/screenshots/dev.png             | Bin 217227 -> 219429 bytes
 docs/screenshots/enemies.png         | Bin 213010 -> 215183 bytes
 docs/screenshots/gem-offer.png       | Bin 213284 -> 196435 bytes
 docs/screenshots/gem.png             | Bin 198082 -> 200068 bytes
 docs/screenshots/info-enemy.png      | Bin 142755 -> 144526 bytes
 docs/screenshots/info-hud.png        | Bin 170239 -> 187998 bytes
 docs/screenshots/info-mode.png       | Bin 213090 -> 214124 bytes
 docs/screenshots/lingering.png       | Bin 217061 -> 219234 bytes
 docs/screenshots/long-press.png      | Bin 217236 -> 219409 bytes
 docs/screenshots/menu.png            | Bin 145160 -> 146351 bytes
 docs/screenshots/move-picked.png     | Bin 213922 -> 216061 bytes
 docs/screenshots/new-run.png         | Bin 158203 -> 160024 bytes
 docs/screenshots/nuke.png            | Bin 238012 -> 231997 bytes
 docs/screenshots/shop.png            | Bin 193997 -> 205099 bytes
 docs/screenshots/start.png           | Bin 213224 -> 215409 bytes
 package.json                         |   3 +-
 src/main.ts                          |  49 ++++--
 src/render/board.ts                  |   2 +-
 src/render/effects.ts                |   5 +-
 src/sim/actions.ts                   | 124 +++++++++------
 src/sim/charges.ts                   |  76 +++++++++
 src/sim/demo.ts                      |  31 ++--
 src/sim/enemies.ts                   |  21 +--
 src/sim/explosions.ts                |   8 +-
 src/sim/picks.ts                     |   7 +-
 src/sim/record.ts                    |  14 +-
 src/sim/shop.ts                      |   5 +-
 src/sim/state.ts                     |  35 ++---
 src/sim/turn.ts                      |  39 +++--
 src/sim/types.ts                     |  52 +++++--
 src/style.css                        |  30 +++-
 src/ui/almanac.ts                    |   2 +-
 src/ui/chargeOffer.ts                |  34 +++++
 src/ui/controls.ts                   |  28 +++-
 src/ui/feedback.ts                   |   2 +
 src/ui/info.ts                       |  91 ++++++-----
 test/balance.test.ts                 |  34 +++++
 test/charges.test.ts                 | 231 ++++++++++++++++++++--------
 test/danger.test.ts                  |  14 +-
 test/demo.test.ts                    |   2 +
 test/enemies.test.ts                 |  28 ++--
 test/helpers.ts                      |   4 +-
 test/new-powers.test.ts              |  24 ++-
 test/options.test.ts                 |  38 +++--
 test/shop.test.ts                    |  14 +-
 tools/balance/bot.ts                 | 271 ++++++++++++++++++++++++++++++++
 tools/balance/lab.ts                 | 288 +++++++++++++++++++++++++++++++++++
 tools/balance/play.ts                |  88 +++++++++++
 tools/balance/run.mjs                |  23 +++
 tools/balance/show.ts                |  23 +++
 tools/balance/stats.ts               |  22 +++
 tools/balance/structure.ts           |  85 +++++++++++
 tools/screenshots.mjs                |  10 +-
 tsconfig.json                        |   2 +-
 74 files changed, 1934 insertions(+), 343 deletions(-)

diff --git a/.gitignore b/.gitignore
index 3ad39ec..53e4a57 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,6 @@ dist/
 
 # Left by the test runner.
 .vitest/
+
+# The balance lab's bundle (tools/balance/run.mjs).
+.balance/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bfc5c6f..b8b949a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,8 @@ saved before the change is dropped and the next visit starts a new run.
 
 ### Added
 
+- **One charge at a time, used when you choose:** a charge button beside Bomb (or V) puts down a Pierce, Power or Nuke bomb, fires an ICBM, freezes the enemies or places a Decoy; Bomb always places a plain bomb; a Shield is raised with it too and stays up until something hits you, and a Decoy is put up to 3 tiles away in a straight line and lasts until the level ends or something reaches it. The charge button is always there (an empty slot with none). Picking up, choosing or buying a different charge asks: keep yours and sell the new one, or take it and sell yours. Ends the Nuke trap, where a Nuke forced on every bomb made a level impossible. Version 29: runs saved before don't resume.
+- **The balance lab** (for development): bots play thousands of games on the real rules to measure what each power-up does (`npm run balance`, reports in `docs/balance/`).
 - **Four new enemies:** the **Sprinter** (rests, then dashes 2 tiles), the **Ghost** (every other turn, chasing you through crates), the **Wisp** (wanders through crates) and the **Hunter** (always chases). A new type now comes every 3 levels, so a run meets all seven. Version 28: runs saved before don't resume.
 - **A frame-rate meter** (`?fps`), and `?gl=0`, `?ratio=1` and `?aa` to find what makes a device slow.
 - **Sounds and vibration:** Kenney's sound effects (and a few made with ZzFX) for bombs, blasts, enemies, pickups, the gem, the exit, hits and a cleared level, timed with the animations; buttons click. Phones that can (Android) vibrate for blasts, hits and pickups. Both can be switched off in the menu. The demo and the clips stay silent.
@@ -87,6 +89,7 @@ saved before the change is dropped and the next visit starts a new run.
 
 ### Changed
 
+- **Proximity alert:** a bomb now goes off only when an enemy is right next to it, not anywhere in its reach, so a bomb dropped between you and an enemy no longer blows up next to you.
 - The demo plays its scenes in one order, numbered, with ◀ ▶ (and ← →) to go through them and ↺ back to the first; it carries on where it was, after a reload too.
 - The menu can play the demo again (**Watch the demo**), and names the run's modes in one short line.
 - The game is now called **EXPLODORAMA!**
@@ -132,6 +135,7 @@ saved before the change is dropped and the next visit starts a new run.
 
 ### Fixed
 
+- A run resumed with a gem's choice or the shop open lost its dialog when the opening demo ended; it opens again now.
 - Dialogs never run off the bottom of a phone's screen: only their middle scrolls, and their buttons always show.
 - Long dialogs (the almanac, the rainbow gem's offer) scroll with a finger again.
 - Every dialog opens scrolled to the top: it used to keep where the last one was scrolled to.
diff --git a/PLAN.md b/PLAN.md
index 57a19ce..3a90150 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -164,12 +164,33 @@ they first appear on).
 | **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). |
 | **Slide** | Slide power-up (*2026-10-24, user request*; weight 6) | Slide in a straight line until the next tile is a wall, crate or bomb (user's choice); can't stop early. Picks up what it passes; an enemy on the way is crashed into (death, as Run's); flame on the way burns unless flame-safe; the open exit stops it. Highlight: the end tile, 2+ away. Part of Move, after Teleport (a teleport to the same tile wins). L. A tap on the end tile with nothing picked slides too (`tapMeaning`, before auto-move's walk; user report, same day). |
 | **RPG** | RPG power-up (*2026-10-24, user request*; weight 6) | Fire a bomb in a straight line: it flies until the next tile is a wall, crate, bomb or enemy (or flame) and lands just before it (user's choice), 2+ tiles away (1 is a plain bomb); counts down as usual, within the bomb limit. Part of Bomb. F. |
-| **Proximity alert** | Proximity power-up (*2026-10-24, user request*; weight 5) | Passive: the player's bombs placed while held go off the turn an enemy is in their reach, after the enemies move — one that walked in or one already there (user's choice). The danger overlay predicts it from the enemies' next moves. |
+| **Proximity alert** | Proximity power-up (*2026-10-24, user request*; weight 5) | Passive: the player's bombs placed while held go off the turn an enemy is **next to one** (one of its 4 neighbours; *since 2026-10-25, user request — it was anywhere in its reach, which the balance lab found turned the defensive bomb into a trap*), after the enemies move — one that walked up or one already there (user's choice). The danger overlay predicts it from the enemies' next moves. |
 | **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. |
 
 *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 Charges (dropped by slain enemies; temporary changes since 2026-10-23)
+### 3.3 Charges (dropped by slain enemies; one carried at a time since 2026-10-24)
+
+*Redesigned again 2026-10-24 (user request; §19.7, built 2026-10-25):* the player **carries one
+charge** (`state.charge`: its kind and uses left; `sim/charges.ts`) and **uses it on purpose**
+with the **charge button** beside Bomb (key V): a charged bomb (`charged`) for Pierce, Power and
+Nuke, a launch (`icbm`), a Freeze (`freeze`, at once), a Decoy (`decoy`, on the player's tile or
+one next to them). Bomb always places a plain bomb. A Shield is passive. The same kind picked up
+adds to it; another kind asks (`chargeOffer`, the `sellCharge` / `swapCharge` actions, no turn):
+keep yours and sell the new one, or take it and sell yours. A charge sells for half its shop
+price, rounded up, for what is left (`salePrice`). *Since 2026-10-25 (user report)* one chosen
+from a gem or bought in the shop asks the same (it went straight in, selling the one held), and
+the charge button is always there: an empty dashed slot with none, greyed out while it can't act,
+and explained by "What is that?" (which can now be used on any button, even a greyed one).
+*Also 2026-10-25 (user request):* the **Shield** is raised with the charge button too (`shield`
+action; carried, it does nothing) and then **stays up until a hit lands**, through any number of
+levels (`state.shieldUp`, carried from level to level). The **Decoy** goes up to `DECOY_RANGE` 3
+tiles away in a straight line, every tile on the way free — a wall, crate, bomb, enemy or flame
+stops it, as a slide or a kick is stopped — and **lasts until the level ends**, an enemy reaches
+it, or flame burns it. (For one round both lasted 3 turns; the user found that far too short.)
+`SIM_VERSION` 31. `boosts` and `shield` are gone from the state; `?charges=n` became
+`?charge=<kind>`. This also ends the Nuke trap the balance lab found (§19.1). The rest of this
+section is the earlier design, kept as history.
 
 *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
@@ -1003,6 +1024,12 @@ tools/                 compose-icons.sh (composed sprites and the app icons), sc
 
 ### Backlog
 
+- **UI changes, queued (user, 2026-10-25; to do eventually):**
+  1. **The demo's title on top:** "EXPLODORAMA!" moves to the very top, above the board, so the
+     demo reads title, then the TV, then the caption. Once the game starts, both the title and the
+     caption go.
+  2. **A less packed menu:** Resume and **New** (was "New run") smaller and side by side; the other
+     buttons smaller, as icons and/or single words — **Almanac**, **Scores**, **Demo**, **Bug**.
 - ~~**Bug reports from players**~~ *Built 2026-10-24* (user request): GitLab Service Desk is on
   (the user turned it on; an email to its address becomes a confidential issue, no GitLab account
   needed, replies go back by email), and the menu's **Report a bug** opens an email to it, filled
@@ -1071,6 +1098,41 @@ well *locally*, over the next few turns, and that is how it is used below.
 *Decided (user, 2026-10-24):* **the full version, with bots** (the structural measures come along
 as its first report). Still open: which power-ups to measure first — the passes, given 19.5.
 
+**Built (2026-10-25):** `tools/balance/`, run with `npm run balance -- <command>` (README, "The
+balance lab"). Bots at three skill levels (`careful`: survives what it can see ahead, wins about
+80% of runs; `average` and `sloppy`: look 2 and 1 turns ahead and slip at times, clearing about 5
+and 3 levels). Experiments `powers` (each power-up held from level 1) and `structure` (escapable
+bombs, reach in 3 turns, safe actions, on 1,781 real positions). Reports in `docs/balance/`.
+The bots don't shop, and take the first thing a gem offers.
+
+**First findings (2026-10-25, rules version 28):**
+
+- **Teleport and Flame Pass are in a class of their own.** For the average bot each turns 5 levels
+  into 20 (wins 2% → 99%); for the careful bot both are the top two. Teleport triples the tiles
+  you can reach in 3 turns (10 → 39) and lets you escape 19% more bombs; Flame Pass and a Star make
+  23–25% more bombs escapable. Diagonal comes next (+8 levels for the average bot).
+- **Some power-ups hurt a weaker player:** Fire +1 (−2.6 levels for the average bot: bigger blasts
+  catch you), Fuse +1 (−1.7, and +24 turns a level), Slide (−1.1), Bomb Pass (−0.7; +6% of turns
+  spent in danger).
+- **Proximity alert costs the careful bot 3 levels** (wins 82% → 40%). Its bombs go off the moment an
+  enemy is in reach, so the usual defence — a bomb dropped between you and an enemy coming down a
+  corridor — blows up next to you instead. Enemies then catch the player in dead ends.
+- **The Nuke trap:** 6% of careful runs stall because Nuke charges are forced onto every bomb, and in
+  the closed start corner every Nuke kills the player — the level can't be finished. Fixed by 19.7's
+  deliberate use.
+- **Within par:** the careful bot clears 20–70% of levels within par; level 1 is the hardest (20%).
+
+**After one charge at a time and the Proximity change (2026-10-25, rules version 29),** same seeds:
+
+- **The Nuke trap is gone:** careful runs that stall fell from 6% to 1%; the careful bot clears
+  18.8 levels (was 18.5) and wins 85% (was 82%), in fewer turns a level (53, was 56). The average
+  bot is unchanged (5.4 levels).
+- **Proximity alert** now costs the careful bot 0.7 levels (was 3.2) and wins 11% fewer runs (was
+  42% fewer); for the average bot it still helps a little (+0.7).
+- **Teleport and Flame Pass are still far ahead** (+14.5 and +14.3 levels for the average bot):
+  untouched by these changes, and the next thing to decide (19.5: the passes leave the power-ups
+  or become charges).
+
 ### 19.2 Bigger boards for later levels
 
 **Today:** every level is 9 × 11, the whole board on screen at once (a choice made 2026-10-23). It
@@ -1234,6 +1296,42 @@ or **become temporary charges** (for example Flame Pass for 5 turns). Which one
 lab's first experiment (19.1): the passes as they are, as charges of a few lengths, and gone,
 compared on the same seeds.
 
+### 19.7 One charge at a time (user, 2026-10-24; built 2026-10-25, see §3.3)
+
+**Idea:** the player carries **only one charge**. Picking up another opens a choice: **sell** the
+new one for a few coins and keep what you have, or **swap**: take the new one instead. Every
+pickup becomes a decision, and charges no longer pile up (three Nukes and a Pierce on one bomb).
+
+**Proposal, worked out:**
+
+- **The slot** holds one charge and what is left of it: Pierce, Power or Nuke with its bombs left
+  (3 when picked up), ICBM with its launches, or a Shield. The top bar shows it in one place.
+- **Freeze and Decoy become carried charges** (*decided, user, 2026-10-24*): no longer used the
+  moment they are picked up, but held in the slot like the rest and used with the charge button when
+  the player chooses (a Freeze on the spot; a Decoy on a tile picked next to the player, or on the
+  player's own tile).
+- **Picking one up with the slot empty:** it goes in, as now.
+- **The same kind as the one held:** it adds to it (3 more Pierce bombs), no question asked.
+- **A different kind:** a dialog, like the gem's offer, taking no turn:
+  - **Sell it:** keep what you hold; the new one is sold for coins.
+  - **Swap:** the new one goes in the slot; the old one is **sold** (what is left of it) — *decided,
+    user, 2026-10-24* — since leaving it on the floor would ask again at once.
+  The sale price: half its shop price (§8), rounded up, scaled by what is left of it — 2 to 3 coins.
+- **The shop:** buying a charge while holding another asks the same, or simply swaps (the old one
+  sold).
+- **Gauntlet (19.5):** a loadout starts with one charge.
+- **Deliberate use** (*decided, user, 2026-10-24*): the Bomb button places a **plain** bomb, and a
+  second button (the charge's icon, with its count) uses the charge — a charged bomb for Pierce,
+  Power and Nuke, a launch for ICBM, a Freeze, a Decoy. A Shield stays passive (it works when hit). That would also
+  fix the Nuke trap the balance lab found (a Nuke forced on every bomb in the closed start corner
+  kills the player whatever they do, so the level can't be finished), and give the player the choice
+  of when to spend a charge.
+- `SIM_VERSION` goes up; the balance lab compares the game before and after (survival, coins, how
+  often each choice is taken by a bot that sells or swaps by a simple rule).
+
+*All three questions settled by the user, 2026-10-24.* Built right after the balance lab's first
+report, so the lab can compare the game before and after on the same seeds.
+
 ### 19.6 How these fit together
 
 - **19.1 first** helps the rest: it can tell whether the passes need nerfing, test a gauntlet
diff --git a/README.md b/README.md
index 740ac7e..5675439 100644
--- a/README.md
+++ b/README.md
@@ -7,11 +7,11 @@ 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, seven enemy types, coins, all
-seven charges that work when picked up, a hidden gem on every level that offers a choice of power-ups, the shop every 3rd level, the exit, the 20-level run with its ending, sounds and vibration, and saving
+seven charges (one carried at a time, used when you choose), a hidden gem on every level that offers a choice of power-ups, the shop every 3rd level, the exit, the 20-level run with its ending, sounds and vibration, and saving
 mid-run work. Curses and the Reaper enemy are planned —
 see [PLAN.md](PLAN.md).
 
-Version: **28**, shown at the bottom of the menu with the commit the game was built from (a link
+Version: **31**, shown at the bottom of the menu with the commit the game was built from (a link
 to it on GitLab, plus "plus local changes" in a build with uncommitted changes) and a link to the
 [source code](https://gitlab.com/PavelSafronov/explodorama). The version is the rules version
 (`SIM_VERSION` in [src/sim/record.ts](src/sim/record.ts)): it goes up by one whenever a rule
@@ -83,7 +83,7 @@ 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 | **M**, then a direction — or W A S D / arrow keys straight away (Q E Z C diagonally, with Diagonal) | 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 (4 with Run too), over anything. **With Kick:** walking into a bomb kicks it. **With Diagonal:** or a diagonal step. **With Slide:** or slide in a straight line until a wall, crate or bomb stops you. |
-| **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 a crate, bomb or enemy (not a pillar). **With RPG:** or fired in a straight line, as far as it flies. **With ICBM launches:** a far tile fires an ICBM, which goes off at once. Pierce, Power and Nuke charges change your next bombs by themselves (see Charges). |
+| **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 a crate, bomb or enemy (not a pillar). **With RPG:** or fired in a straight line, as far as it flies. Always a plain bomb: Pierce, Power, Nuke and ICBM are used with the charge button (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
@@ -146,25 +146,39 @@ got back.
 support it) to arm a bomb there: its blast shows, and letting go places it. Sliding off the
 tile first cancels it.
 
-**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.
+**Charges** are things enemies sometimes drop. **You carry one at a time**, and use it **when
+you choose**, with the **charge button** beside Bomb (or **V**). It shows the charge's icon and
+how many uses are left, or an empty dashed slot when you carry none (greyed out then); "What is
+that?" on it explains charges and the choice below. Every charge is used with it, the Shield too
+(since 2026-10-25)
+(since 2026-10-24: until then every charge worked the moment it was picked up, and they
+piled up). The top bar shows it too.
+
+- Picking up **the same kind** adds to it (3 more Pierce bombs).
+- Picking up **another kind** asks: **keep** yours and sell the new one, or **take** the new
+  one and sell yours (for what is left of it). A charge sells for half its shop price, rounded
+  up. The choice takes no turn.
+- The same goes for a charge chosen from a gem or bought in the shop: with another kind held, it
+  asks (since 2026-10-25, user report: it used to replace the one held without asking).
+- What you carry 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.)
 
+<img src="docs/screenshots/charge-offer.png" alt="Picking up a Nuke while carrying a Shield: the dialog shows both, with Keep Shield (+3 coins) and Take Nuke (+3 coins)" width="260">
+
 | 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. |
-| **ICBM** | bomb with a target badge | 3 launches: **Bomb** fires a bomb as an RPG's (or **I**, then a direction) and it goes off the moment it lands, at any distance. Your bomb limit doesn't count. While you have launches, a far tile picked with Bomb is an ICBM. |
+| **Pierce** | the Wall Pass icon (crate with ✕) | **3 bombs** whose flame goes **through** crates, burning every one in reach. The charge button puts one down. |
+| **Power** | the Fire +1 icon | **3 bombs** whose flame reaches all the way to the walls (crates still stop it). |
+| **Nuke** | bomb with a skull badge | **3 bombs** that 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 | Press the charge button to raise it: a ring round you that **stays up, level after level, until something hits you**. It absorbs that hit (flame or enemy), and anything else that hits you that turn. Then it's gone. Until 2026-10-25 it worked by itself while carried. |
+| **Freeze** | a padlock | Press the charge button: every enemy freezes in place for **3 turns**, that one 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 | Press the charge button, then tap where it goes — **up to 3 tiles away in a straight line**, with nothing in the way (a wall, crate, bomb, enemy or flame stops it, as it stops a slide): a pale copy of you. Enemies that chase you (Seekers, Ghosts, Hunters, Sprinters) go after it instead, **until the level ends** — or until an enemy reaches it or flame burns it, which destroys it. It doesn't block anyone. |
+| **ICBM** | bomb with a target badge | 3 launches: the charge button fires a bomb as an RPG's (or **I**, then a direction) and it goes off the moment it lands, at any distance. Your bomb limit doesn't count. |
 
-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.
+A Pierce, Power or Nuke bomb wears its charge's icon as a small badge. **Bomb** always puts down a
+plain bomb.
 
 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.
@@ -189,8 +203,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.
+- Pierce, Power and Nuke bombs are put down with the charge button (see Charges above); such a
+  bomb wears its charge's icon as a badge.
 
 ### Danger
 
@@ -237,11 +251,8 @@ Level 1 has 2 enemies, with one more every 4 levels (at most 5). Enemies never s
 ### Drops and coins
 
 A slain enemy often leaves **coins** (bronze = 1, silver = 2, gold = 5: about 1 coin a kill on average) or sometimes 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. And no enemy drops a Decoy, Freeze
-or Shield while one already lies on the board or is working (a decoy out, enemies frozen, a
-shield up): a second one would be wasted. Pierce, Power and Nuke add up, so they always can. Drops never share a tile, and never land on the uncovered exit: the new one rolls to the
+(see Charges) on its tile. No enemy drops a Decoy, Freeze or Shield while one already lies on
+the board. Pierce, Power, Nuke and ICBM add up, so they always can. Drops never share a tile, and never land on the uncovered exit: 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 are spent in **the shop** (below).
 
@@ -288,7 +299,7 @@ before moving on.
 | **Slide** | the Move figure with a two-way-arrow badge | **Move** can also slide in a straight line until the next tile is a wall, crate or bomb — **tap the tile where it stops** (or **L**, then a direction) — picking up whatever it passes. It can't stop early: an enemy on the way is crashed into (a life lost), and flame on the way burns (unless Flame Pass or a Star). The open exit stops it. |
 | **RPG** | bomb with a forward-arrow badge | **Bomb** can also fire a bomb in a straight line (or **F**, then a direction): it flies until the next tile is a wall, crate, bomb or enemy and lands just before it, then counts down as usual. |
 | **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. |
-| **Proximity alert** | bomb with a warning-sign badge | Your bombs go off the turn an enemy is in their reach — one that walks in, or one already there when you place it — wherever you are standing. The danger overlay shows it ahead, from the enemies' arrows. |
+| **Proximity alert** | bomb with a warning-sign badge | Your bombs go off the turn an enemy is **right next to** one — one that walks up, or one already there when you place it — wherever you are standing. (Until 2026-10-25, anywhere in its reach.) The danger overlay shows it ahead, from the enemies' arrows. |
 | **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. |
@@ -321,7 +332,7 @@ runs: leftover coins only count on the high-score board.
 
 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 everything else**: coins, stats, every
-power-up (abilities and passes included) and what your charges still give. (The original took
+power-up (abilities and passes included) and the charge you carry. (The original took
 the passes and the Detonator away; since 2026-10-23 a death costs only the life.) The banner says how and where you died — "Level 3, turn 12: you walked into a Blob." — and so
 does the game-over banner. Both replay **your own last turns** before the death, over and over,
 on the part of the level where it happened, so you can see what went wrong. With none left, the run is over and the seed is shown, so the same board can be
@@ -364,8 +375,8 @@ 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 — 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
+fuse, then — in their own row — the passes you hold, a Star's countdown, the charge you carry
+(with its uses left), and a Freeze or Decoy at work (with its turns left). 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.
 
@@ -396,11 +407,28 @@ 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)
+npm run balance -- help   # the balance lab: bots play many games to measure the rules (below)
 ```
 
+**The balance lab** (`tools/balance/`, PLAN.md §19.1) measures what a rule or a power-up does to
+the game. Bots play the real rules in Node, on all CPU cores, at three skill levels: **careful**
+(checks every turn that it can survive what the enemies and bombs will do next; wins about 80% of
+runs), **average** and **sloppy** (look fewer turns ahead and slip now and then; they clear about
+5 and 3 levels). An experiment plays the same seeds once per arm, so differences come from the
+change, not the boards, and reports each with a 95% interval in `docs/balance/`:
+
+- `npm run balance -- powers --skill careful` — each power-up held from level 1, against none.
+- `npm run balance -- structure` — what each power-up changes on real positions, no bot judging:
+  how many bomb placements can be survived, tiles reachable in 3 turns, safe actions.
+- `npm run balance -- runs` and `npm run balance -- trace <seed> <level>` — the bot's play, run by
+  run or board by board.
+
+Options: `--seeds N` (200 by default), `--skill careful|average|sloppy`, `--levels N`. The 3,600
+runs of `powers` take about 10 minutes on 10 cores.
+
 URL parameters: `?seed=123` plays a fixed board; `?level=4` starts at a later level (level 19
-has all seven 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; `?dev` and `?immortal` set the modes; `?demo` shows the demo, which test URLs otherwise skip (with `?seed`, the same demo every time). `?fps` shows a frame-rate meter in the bottom left corner: frames a second, the slowest frame, the game's own time per frame, and the canvas size. For tracking down slowness on a device: `?gl=0` draws with Canvas 2D instead of WebGL (no demo TV effect, no clips), `?ratio=1` draws at one pixel per CSS pixel, `?aa` turns WebGL's antialiasing on (it is off: it held Android Chrome to 12 fps). These four keep your saved run. A URL with any of these parameters plays a test board: it doesn't load or overwrite your
+has all seven enemy types); `?charge=nuke` (or any charge) starts carrying that charge, for
+trying it out; `?dev` and `?immortal` set the modes; `?demo` shows the demo, which test URLs otherwise skip (with `?seed`, the same demo every time). `?fps` shows a frame-rate meter in the bottom left corner: frames a second, the slowest frame, the game's own time per frame, and the canvas size. For tracking down slowness on a device: `?gl=0` draws with Canvas 2D instead of WebGL (no demo TV effect, no clips), `?ratio=1` draws at one pixel per CSS pixel, `?aa` turns WebGL's antialiasing on (it is off: it held Android Chrome to 12 fps). These four keep your saved run. A URL with any of these parameters plays a test board: it doesn't load or overwrite your
 saved run.
 
 Deployed to GitLab Pages by `.gitlab-ci.yml` on every push to the default branch.
diff --git a/decisions.md b/decisions.md
index eb9e129..848bdbd 100644
--- a/decisions.md
+++ b/decisions.md
@@ -1848,3 +1848,82 @@ The user's answers, recorded in PLAN.md §19:
 - **Final answers (same day):** bushes B + D + E as recommended; the 9 × 13 view; one acid bomb
   cracks a pillar and a second hit brings the roof down 4 turns later. The plan is agreed; the
   balance lab comes first.
+
+### 2026-10-24 — Proposal: one charge at a time (PLAN.md §19.7)
+
+User idea: carry one charge only; picking up another asks to sell the new one for coins or swap
+it for the one held. Worked out as a proposal, with three questions for the user: whether the
+swapped-out charge is sold or dropped, whether Freeze and Decoy become carried charges, and whether
+a charge should be used deliberately (a second bomb button) — which would also fix the Nuke trap
+the balance lab found. Not built.
+- **Answers (same day):** the swapped-out charge is sold; Freeze and Decoy become carried charges;
+  charges are used deliberately with their own button (Shield stays passive). To build right after
+  the balance lab's first report.
+
+### 2026-10-25 — The balance lab, and its first report
+
+Built as agreed (PLAN.md §19.1): `tools/balance/`, bundled with Vite into `.balance/lab.mjs` (the
+sim is TypeScript; Node can't import it directly) and run on worker threads, one per core but one.
+Choices made while building:
+
+- **Three skills of one bot**, not three bots: they differ in how many turns ahead they check they
+  can survive (careful: as far as the enemies' plans and the bombs' fuses, up to 6; average 2;
+  sloppy 1) and how often they slip (0, 3%, 8% of turns: some action that doesn't kill at once).
+  The survival check plays the real rules (`step()`), moving and waiting only.
+- **What it is after:** the open exit; else an uncovered gem or a drop; else a tile next to where a
+  bomb would catch a crate or an enemy's route, with a walk out of the blast in time. A tile it keeps
+  returning to costs a little more each time, so it moves on rather than waiting for ever.
+- **Only what a player sees:** the hidden exit and gem aren't used.
+- **Paired experiments:** the same seeds per arm; differences with 95% intervals.
+- **Gem and shop:** the bots take the first thing a gem offers, and don't shop (coins aren't used).
+
+The first report's findings are in PLAN.md §19.1. The biggest: Teleport and Flame Pass turn a weak
+player into a winner; Proximity alert costs a strong one 3 levels, by turning the defensive bomb
+into a trap; and Nuke charges can make a level impossible (the Nuke trap).
+
+### 2026-10-25 — One charge at a time, used on purpose (built)
+
+Built as agreed (PLAN.md §19.7, §3.3). Choices made while building:
+
+- **The slot** is `state.charge` (kind and uses left); `boosts` and `shield` are gone. Shield is a
+  charge in the slot that the hurt check spends.
+- **Uses:** the charge button (key V, after B for Bomb, since C is a diagonal step) stands for
+  `charged` (a Pierce, Power or Nuke bomb), `icbm`, `freeze` (acts at once, like Wait) and `decoy`
+  (on the player's tile or a free one next to them). It is hidden with no charge, or a Shield, and
+  greyed while it can't be used (no room for another bomb). ICBM left the Bomb button.
+- **The choice** is `chargeOffer` with `sellCharge` / `swapCharge`, taking no turn, like the gem's.
+  A second different charge picked up in the same turn, while one is waiting, is sold. A death that
+  turn drops the choice (the retry has the charge lying there again). From a gem or the shop the
+  charge goes straight in, the old one sold: those are chosen on purpose.
+- **Price:** half the shop price, rounded up, scaled by the uses left (2–3 coins for a full one).
+- **Drops:** with every charge now carried to the next level, the last enemy may drop any charge
+  (it dropped only the lasting ones); a Decoy, Freeze or Shield isn't dropped while one lies on the
+  board.
+- **The demo** skips any turn that would ask the choice (no gesture plays a dialog), and its
+  captions name the charge button.
+- **A fix on the way:** a run resumed with a choice open (a gem's, a charge's, or the shop) lost
+  its dialog when the opening demo ended; it now opens again.
+- `?charges=n` is now `?charge=<kind>`. `SIM_VERSION` 29.
+
+### 2026-10-25 — Proximity alert: only an enemy next to the bomb
+
+User request, after the balance lab found Proximity alert cost the careful bot 3 levels: a bomb
+goes off only when an enemy is on one of its 4 neighbouring tiles, not anywhere in its reach. A
+bomb dropped between you and an enemy coming down a corridor now waits until the enemy is at it.
+- **Follow-up (user report, 2026-10-25):** in Developer mode a Decoy chosen from the rainbow gem
+  replaced a held Nuke without asking. Now every charge that comes along — picked up, chosen from a
+  gem, or bought (the shop waits for the answer, then comes back) — asks when another kind is held.
+  And the charge button is always shown (user request): the charge and its uses, or an empty dashed
+  slot; greyed out while it can't act. "What is that?" on it explains the button, carrying one at a
+  time and the sell-or-swap choice; to make that possible, "What is that?" now works on greyed-out
+  buttons too.
+- **Shield and Decoy (user request, 2026-10-25):** "why does shield work by itself?" — now it is
+  raised with the charge button like the rest. Raised, it stays up **3 turns** (the turn included;
+  `SHIELD_TURNS`, my choice, to tune) and absorbs the next hit; carried, it does nothing. Without a
+  limit it would be the old passive Shield behind one tap. The **Decoy** goes up to 3 tiles away in
+  a straight line, each tile on the way free (floor, no bomb, enemy or flame), as a slide or kick
+  stops at whatever is in the way; no longer on the player's own tile. `SIM_VERSION` 30.
+- **No time limits (user request, 2026-10-25):** 3 turns was very short for both. A raised **Shield**
+  now stays up until a hit lands, however many levels later (`shieldUp`, carried with the run). A
+  **Decoy** lasts until the level ends, or until it takes a single hit: an enemy reaching its tile,
+  or flame. `SIM_VERSION` 31.
diff --git a/docs/balance/powers-average.md b/docs/balance/powers-average.md
new file mode 100644
index 0000000..b9927be
--- /dev/null
+++ b/docs/balance/powers-average.md
@@ -0,0 +1,55 @@
+# Balance lab: powers (average bot)
+
+2026-10-25 · rules version 29 · 200 seeds per arm · 179 s.
+
+Each arm plays the same seeds as the baseline; its columns are the difference from the baseline,
+per run, with a 95% interval. ✱ marks a difference the interval says is real. Sorted by levels
+gained. The bots answer a gem by taking the first thing offered and never shop.
+
+| Arm | Levels cleared | Won | Lives lost | Turns per level | Turns in danger | Stalled |
+|---|---|---|---|---|---|---|
+| **baseline** | 5.42 | 2% | 2.98 | 60.3 | 3.4% | 0% |
+| teleport | +14.52 ± 0.54 ✱ | +97 ± 2 ✱% | −2.90 ± 0.06 ✱ | −23.8 ± 2.4 ✱ | −1.0 ± 0.2 ✱ | 0% |
+| flamePass | +14.32 ± 0.59 ✱ | +96 ± 3 ✱% | −2.65 ± 0.09 ✱ | −12.0 ± 2.5 ✱ | +7.1 ± 0.4 ✱ | 0% |
+| diagonal | +8.27 ± 0.82 ✱ | +23 ± 6 ✱% | −0.44 ± 0.12 ✱ | −15.1 ± 2.4 ✱ | −0.2 ± 0.2 ✱ | 0% |
+| kick | +3.42 ± 0.77 ✱ | +7 ± 4 ✱% | −0.18 ± 0.10 ✱ | −4.7 ± 2.8 ✱ | +0.3 ± 0.2 ✱ | 0% |
+| run | +2.08 ± 0.83 ✱ | +9 ± 4 ✱% | −0.27 ± 0.12 ✱ | −10.8 ± 2.7 ✱ | −0.3 ± 0.2 ✱ | 0% |
+| detonator | +1.89 ± 0.76 ✱ | +2 ± 3% | −0.07 ± 0.07 ✱ | −7.8 ± 3.1 ✱ | −0.4 ± 0.2 ✱ | 0% |
+| wallPass | +1.08 ± 0.74 ✱ | +5 ± 4 ✱% | −0.12 ± 0.08 ✱ | −2.8 ± 4.3 | +0.4 ± 0.2 ✱ | 1% |
+| proximity | +0.70 ± 0.59 ✱ | +2 ± 3% | −0.07 ± 0.07 ✱ | −13.6 ± 2.5 ✱ | +0.8 ± 0.2 ✱ | 0% |
+| heart | +0.61 ± 0.25 ✱ | +2 ± 2 ✱% | +0.96 ± 0.03 ✱ | +1.6 ± 1.0 ✱ | +0.3 ± 0.1 ✱ | 0% |
+| rpg | +0.49 ± 0.59 | −1 ± 2% | −0.01 ± 0.04 | −15.1 ± 2.6 ✱ | +0.3 ± 0.3 ✱ | 0% |
+| star | +0.38 ± 0.44 | +3 ± 2 ✱% | −0.07 ± 0.05 ✱ | +1.7 ± 3.7 | +0.0 ± 0.1 | 1% |
+| throw | +0.05 ± 0.65 | +4 ± 4% | −0.09 ± 0.07 ✱ | −9.6 ± 2.8 ✱ | +0.5 ± 0.3 ✱ | 0% |
+| bombPlus | −0.34 ± 0.70 | +2 ± 3% | −0.07 ± 0.06 ✱ | −4.0 ± 3.1 ✱ | +1.5 ± 0.3 ✱ | 1% |
+| bombPass | −0.45 ± 0.60 | +2 ± 3% | −0.06 ± 0.06 ✱ | −2.5 ± 2.8 | +5.7 ± 0.3 ✱ | 0% |
+| slide | −1.06 ± 0.60 ✱ | +1 ± 3% | −0.03 ± 0.05 | −3.5 ± 2.8 ✱ | −0.2 ± 0.2 | 0% |
+| fusePlus | −1.64 ± 0.57 ✱ | −1 ± 2% | −0.04 ± 0.05 | +23.2 ± 5.5 ✱ | −0.8 ± 0.2 ✱ | 1% |
+| firePlus | −2.69 ± 0.59 ✱ | −1 ± 1% | +0.01 ± 0.01 | −6.7 ± 3.4 ✱ | +1.2 ± 0.3 ✱ | 0% |
+
+## The baseline, level by level
+
+| Level | Runs reaching it | Lives lost there | Turns there | Within par |
+|---|---|---|---|---|
+| 1 | 100% | 0.20 | 73 | 14% |
+| 2 | 97% | 0.26 | 70 | 25% |
+| 3 | 93% | 0.44 | 58 | 30% |
+| 4 | 81% | 0.66 | 52 | 30% |
+| 5 | 62% | 0.65 | 56 | 36% |
+| 6 | 48% | 0.56 | 57 | 40% |
+| 7 | 39% | 0.56 | 58 | 37% |
+| 8 | 31% | 0.80 | 59 | 36% |
+| 9 | 23% | 0.67 | 62 | 43% |
+| 10 | 18% | 0.69 | 44 | 46% |
+| 11 | 12% | 0.50 | 47 | 46% |
+| 12 | 8% | 0.44 | 48 | 56% |
+| 13 | 7% | 0.54 | 45 | 77% |
+| 14 | 5% | 0.30 | 45 | 80% |
+| 15 | 5% | 0.22 | 46 | 67% |
+| 16 | 4% | 0.00 | 42 | 88% |
+| 17 | 4% | 0.25 | 32 | 63% |
+| 18 | 3% | 0.33 | 27 | 67% |
+| 19 | 2% | 0.50 | 42 | 75% |
+| 20 | 2% | 0.33 | 46 | 100% |
+
+Lives lost in the baseline, by cause: blast 516, caughtByEnemy 17, walkedIntoEnemy 46, chain 14, lingering 4.
diff --git a/docs/balance/powers-careful.md b/docs/balance/powers-careful.md
new file mode 100644
index 0000000..c3ab824
--- /dev/null
+++ b/docs/balance/powers-careful.md
@@ -0,0 +1,55 @@
+# Balance lab: powers (careful bot)
+
+2026-10-25 · rules version 29 · 200 seeds per arm · 622 s.
+
+Each arm plays the same seeds as the baseline; its columns are the difference from the baseline,
+per run, with a 95% interval. ✱ marks a difference the interval says is real. Sorted by levels
+gained. The bots answer a gem by taking the first thing offered and never shop.
+
+| Arm | Levels cleared | Won | Lives lost | Turns per level | Turns in danger | Stalled |
+|---|---|---|---|---|---|---|
+| **baseline** | 18.82 | 85% | 0.59 | 52.6 | 4.3% | 1% |
+| teleport | +1.18 ± 0.50 ✱ | +16 ± 5 ✱% | −0.59 ± 0.15 ✱ | −13.8 ± 1.3 ✱ | −2.3 ± 0.2 ✱ | 0% |
+| flamePass | +1.07 ± 0.46 ✱ | +15 ± 5 ✱% | −0.57 ± 0.15 ✱ | −7.1 ± 1.4 ✱ | +5.7 ± 0.3 ✱ | 0% |
+| diagonal | +1.06 ± 0.51 ✱ | +13 ± 5 ✱% | −0.47 ± 0.16 ✱ | −7.2 ± 1.3 ✱ | −1.9 ± 0.2 ✱ | 0% |
+| run | +0.95 ± 0.53 ✱ | +11 ± 6 ✱% | −0.36 ± 0.18 ✱ | −7.9 ± 1.5 ✱ | −0.5 ± 0.2 ✱ | 0% |
+| wallPass | +0.85 ± 0.56 ✱ | +12 ± 6 ✱% | −0.44 ± 0.16 ✱ | −4.2 ± 1.5 ✱ | +0.5 ± 0.2 ✱ | 1% |
+| bombPass | +0.78 ± 0.52 ✱ | +11 ± 5 ✱% | −0.33 ± 0.15 ✱ | −0.1 ± 1.5 | +5.2 ± 0.3 ✱ | 0% |
+| throw | +0.73 ± 0.53 ✱ | +11 ± 6 ✱% | −0.32 ± 0.16 ✱ | −8.9 ± 1.4 ✱ | +0.6 ± 0.2 ✱ | 0% |
+| rpg | +0.69 ± 0.52 ✱ | +10 ± 5 ✱% | −0.34 ± 0.17 ✱ | −11.4 ± 1.4 ✱ | +0.7 ± 0.2 ✱ | 0% |
+| slide | +0.69 ± 0.54 ✱ | +7 ± 6 ✱% | −0.21 ± 0.18 ✱ | −5.8 ± 1.4 ✱ | −0.6 ± 0.2 ✱ | 1% |
+| detonator | +0.64 ± 0.54 ✱ | +5 ± 6% | −0.10 ± 0.18 | −5.3 ± 1.5 ✱ | −0.7 ± 0.2 ✱ | 0% |
+| firePlus | +0.54 ± 0.51 ✱ | +4 ± 6% | −0.04 ± 0.18 | −6.2 ± 1.3 ✱ | +0.9 ± 0.2 ✱ | 1% |
+| kick | +0.41 ± 0.59 | +4 ± 6% | −0.11 ± 0.19 | −4.0 ± 1.5 ✱ | +0.2 ± 0.2 ✱ | 0% |
+| bombPlus | +0.21 ± 0.47 |  0 ± 6% | +0.03 ± 0.17 | −0.1 ± 1.4 | +0.4 ± 0.2 ✱ | 0% |
+| star | +0.18 ± 0.21 | +2 ± 2% | −0.04 ± 0.08 | −0.2 ± 0.9 | +0.1 ± 0.1 | 0% |
+| heart | +0.18 ± 0.21 | +2 ± 2% | +0.14 ± 0.05 ✱ | +0.3 ± 0.3 | +0.0 ± 0.0 | 1% |
+| fusePlus | +0.06 ± 0.67 | −2 ± 7% | +0.13 ± 0.20 | +11.1 ± 2.1 ✱ | −0.5 ± 0.2 ✱ | 1% |
+| proximity | −0.71 ± 0.65 ✱ | −11 ± 8 ✱% | +0.55 ± 0.22 ✱ | −7.1 ± 1.4 ✱ | +0.5 ± 0.2 ✱ | 0% |
+
+## The baseline, level by level
+
+| Level | Runs reaching it | Lives lost there | Turns there | Within par |
+|---|---|---|---|---|
+| 1 | 100% | 0.02 | 67 | 19% |
+| 2 | 100% | 0.02 | 57 | 33% |
+| 3 | 99% | 0.00 | 56 | 31% |
+| 4 | 99% | 0.02 | 48 | 42% |
+| 5 | 99% | 0.02 | 57 | 46% |
+| 6 | 98% | 0.05 | 54 | 52% |
+| 7 | 97% | 0.00 | 54 | 52% |
+| 8 | 97% | 0.02 | 55 | 49% |
+| 9 | 97% | 0.04 | 58 | 55% |
+| 10 | 96% | 0.00 | 53 | 70% |
+| 11 | 95% | 0.04 | 51 | 65% |
+| 12 | 94% | 0.01 | 52 | 69% |
+| 13 | 94% | 0.03 | 53 | 76% |
+| 14 | 94% | 0.03 | 49 | 82% |
+| 15 | 93% | 0.05 | 50 | 81% |
+| 16 | 92% | 0.04 | 47 | 87% |
+| 17 | 91% | 0.02 | 47 | 85% |
+| 18 | 91% | 0.06 | 45 | 87% |
+| 19 | 89% | 0.10 | 47 | 84% |
+| 20 | 87% | 0.08 | 44 | 84% |
+
+Lives lost in the baseline, by cause: walkedIntoEnemy 67, blast 30, caughtByEnemy 21, chain 1.
diff --git a/docs/balance/structure.md b/docs/balance/structure.md
new file mode 100644
index 0000000..a5d458b
--- /dev/null
+++ b/docs/balance/structure.md
@@ -0,0 +1,32 @@
+# Balance lab: structure
+
+2026-10-25 · rules version 29 · 1781 positions from 60 seeds · 21 s.
+
+Positions from the Careful bot's games (every 10th turn), measured as they are and with each
+power-up added; the columns are the difference from the baseline, with a 95% interval (✱: real).
+
+- **Escapable bombs:** of the tiles a bomb can go on, the share the player can survive after,
+  getting away by any of their moves (positions where no bomb can go down are left out).
+- **Reach in 3 turns:** distinct tiles the player can be on after up to 3 turns, by any actions.
+- **Safe actions:** actions now that the player can survive after.
+
+| Arm | Escapable bombs | Reach in 3 turns | Safe actions |
+|---|---|---|---|
+| **baseline** | 77% | 11.0 | 5.1 |
+| teleport | +16 ± 2 ✱ | +27.9 ± 0.3 ✱ | +11.4 ± 0.2 ✱ |
+| slide | +2 ± 1 ✱ | +10.0 ± 0.3 ✱ | +1.5 ± 0.1 ✱ |
+| diagonal | +12 ± 2 ✱ | +7.6 ± 0.2 ✱ | +1.4 ± 0.1 ✱ |
+| run | +2 ± 1 ✱ | +8.4 ± 0.3 ✱ | +1.4 ± 0.1 ✱ |
+| star | +23 ± 2 ✱ | +1.7 ± 0.1 ✱ | +0.6 ± 0.0 ✱ |
+| flamePass | +21 ± 2 ✱ | +1.2 ± 0.1 ✱ | +0.5 ± 0.0 ✱ |
+| wallPass | +5 ± 1 ✱ | +1.7 ± 0.1 ✱ | +0.2 ± 0.0 ✱ |
+| bombPass | +7 ± 1 ✱ | +0.6 ± 0.1 ✱ | +0.2 ± 0.0 ✱ |
+| kick | +3 ± 1 ✱ | +0.0 ± 0.0 | +0.2 ± 0.0 ✱ |
+| fusePlus | +1 ± 0 ✱ |  0.0 ± 0.0 | +0.0 ± 0.0 ✱ |
+| throw |  0 ± 0 | +0.0 ± 0.0 | +1.3 ± 0.1 ✱ |
+| detonator |  0 ± 0 | +0.0 ± 0.0 | +0.3 ± 0.0 ✱ |
+| bombPlus |  0 ± 0 |  0.0 ± 0.0 | +0.3 ± 0.0 ✱ |
+| rpg |  0 ± 0 |  0.0 ± 0.0 | +1.3 ± 0.1 ✱ |
+| heart |  0 ± 0 |  0.0 ± 0.0 |  0.0 ± 0.0 |
+| proximity | −1 ± 0 ✱ |  0.0 ± 0.0 | −0.0 ± 0.0 ✱ |
+| firePlus | −4 ± 1 ✱ |  0.0 ± 0.0 | −0.1 ± 0.0 ✱ |
diff --git a/docs/screenshots/almanac-charges.png b/docs/screenshots/almanac-charges.png
index 57b96d1..baca1ac 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 b6f6744..b512ed3 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 7f6ceac..78588f9 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 4edddc0..244f173 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 7072e85..806960e 100644
Binary files a/docs/screenshots/bomb-picked.png and b/docs/screenshots/bomb-picked.png differ
diff --git a/docs/screenshots/charge-offer.png b/docs/screenshots/charge-offer.png
new file mode 100644
index 0000000..7200353
Binary files /dev/null and b/docs/screenshots/charge-offer.png differ
diff --git a/docs/screenshots/charges.png b/docs/screenshots/charges.png
index 48832e6..9b0e846 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 c841a7b..bba52cd 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 646bc1b..dc89596 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
index ce59e00..c308df8 100644
Binary files a/docs/screenshots/demo.png and b/docs/screenshots/demo.png differ
diff --git a/docs/screenshots/desktop.png b/docs/screenshots/desktop.png
index 53527a9..5bfc224 100644
Binary files a/docs/screenshots/desktop.png and b/docs/screenshots/desktop.png differ
diff --git a/docs/screenshots/dev-offer.png b/docs/screenshots/dev-offer.png
index 51736fc..ba5889a 100644
Binary files a/docs/screenshots/dev-offer.png and b/docs/screenshots/dev-offer.png differ
diff --git a/docs/screenshots/dev.png b/docs/screenshots/dev.png
index 07535fe..981408f 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 b8e6958..dcbb78f 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
index 15ca817..1fc80ca 100644
Binary files a/docs/screenshots/gem-offer.png and b/docs/screenshots/gem-offer.png differ
diff --git a/docs/screenshots/gem.png b/docs/screenshots/gem.png
index 830f56d..47f01c8 100644
Binary files a/docs/screenshots/gem.png and b/docs/screenshots/gem.png differ
diff --git a/docs/screenshots/info-enemy.png b/docs/screenshots/info-enemy.png
index 25984c1..f14ee55 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 d26da3a..5ac94d9 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 2e5e841..31496e6 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 fb203d1..a333d5e 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 9b344a2..5a4e2d2 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 b7866bd..3c96343 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 1b67e7f..1dc95e3 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 737f559..a6f6331 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
index 56fd028..cedc9ae 100644
Binary files a/docs/screenshots/nuke.png and b/docs/screenshots/nuke.png differ
diff --git a/docs/screenshots/shop.png b/docs/screenshots/shop.png
index 18c41db..02c6d97 100644
Binary files a/docs/screenshots/shop.png and b/docs/screenshots/shop.png differ
diff --git a/docs/screenshots/start.png b/docs/screenshots/start.png
index f8b1a48..e742b63 100644
Binary files a/docs/screenshots/start.png and b/docs/screenshots/start.png differ
diff --git a/package.json b/package.json
index 6832857..a03d5ae 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,8 @@
     "preview": "vite preview --host",
     "test": "vitest run",
     "test:watch": "vitest",
-    "screenshots": "npm run build && node tools/screenshots.mjs"
+    "screenshots": "npm run build && node tools/screenshots.mjs",
+    "balance": "node tools/balance/run.mjs"
   },
   "repository": {
     "type": "git",
diff --git a/src/main.ts b/src/main.ts
index 0f9bb72..7732e89 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -23,7 +23,7 @@ import { animateTurn as animateBoard, clearAnimation, newAnimation, pruneAnimati
 import { updateCamera } from './render/camera'
 import { CHARGE_ICONS, MONO_ICONS, POWER_UP_ICONS, TEXTURES } from './render/sprites'
 import { isAvailable, isValidTarget, ACTIONS } from './sim/actions'
-import { canPick, resolvePick, tapMeaning } from './sim/picks'
+import { canPick, membersOf, resolvePick, tapMeaning } from './sim/picks'
 import { enemyAt } from './sim/enemies'
 import { autoMoveAction, playerPath, safeStep as isSafeStep } from './sim/path'
 import { predictDanger, type Danger } from './sim/danger'
@@ -31,11 +31,12 @@ import { newRecord, replay, SIM_VERSION, type RunRecord } from './sim/record'
 import { cellAt, eq, inBounds, isBurning, newRun, PAR_BONUS } from './sim/state'
 import { LAST_LEVEL } from './sim/levelgen'
 import { step } from './sim/turn'
-import { DEFAULT_OPTIONS, DIRS, type Action, type ActionId, type ChargeId, type Dir, type GameEvent, type GameState, type RunOptions, type Vec } from './sim/types'
+import { CHARGE_IDS, DEFAULT_OPTIONS, DIRS, type Action, type ActionId, type ChargeId, type Dir, type GameEvent, type GameState, type RunOptions, type Vec } from './sim/types'
 import { openAlmanac } from './ui/almanac'
 import { createControls } from './ui/controls'
 import { closeDialog, dialogOpen, openDialog, setDialogInDemo, type DialogButton } from './ui/dialog'
 import { actionInfo, chargeInfo, hudInfo, tileInfo, type Info } from './ui/info'
+import { openChargeOffer } from './ui/chargeOffer'
 import { openOffer } from './ui/offer'
 import { showShop, updateShop } from './ui/shop'
 import { startClip, drawClips } from './ui/clip'
@@ -57,8 +58,8 @@ import questionIcon from '../assets/kenney-icons/question.png'
 // seed — chosen here, never inside the sim.
 const params = new URLSearchParams(location.search)
 const startLevel = Number(params.get('level')) || 1
-// `?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
+// `?charge=nuke` starts carrying that charge (state.ts `newRun`), to try it.
+const startCharge = (CHARGE_IDS as readonly string[]).includes(params.get('charge') ?? '') ? (params.get('charge') as ChargeId) : null
 
 let record: RunRecord
 let state: GameState
@@ -107,8 +108,8 @@ function startRun(seed: number, options: RunOptions): void {
   chargesSeen.clear()
   lastTurns = []
   resetView()
-  record = newRecord(seed, startLevel, startCharges, options)
-  state = newRun(seed, startLevel, startCharges, options)
+  record = newRecord(seed, startLevel, startCharge, options)
+  state = newRun(seed, startLevel, startCharge, options)
   save()
   afterChange()
 }
@@ -136,7 +137,7 @@ function rememberOptions(options: RunOptions): void {
 
 /**
  * Mid-run save (PLAN.md §11): the run record goes to localStorage after every action, and is
- * replayed on the next visit. A URL that sets up a test board (`?seed`, `?level`, `?charges`)
+ * replayed on the next visit. A URL that sets up a test board (`?seed`, `?level`, `?charge`)
  * neither loads nor saves, so trying something never replaces the real run. Storage can be
  * missing or full; then the game simply doesn't save.
  */
@@ -146,7 +147,7 @@ const SAVE_KEY = 'explodorama.run'
 function urlOptions(): RunOptions {
   return { dev: params.has('dev'), immortal: params.has('immortal') }
 }
-const testing = ['seed', 'level', 'charges', 'dev', 'immortal'].some((p) => params.has(p))
+const testing = ['seed', 'level', 'charge', 'dev', 'immortal'].some((p) => params.has(p))
 
 function save(): void {
   if (testing) return
@@ -169,8 +170,9 @@ function resumeSaved(): boolean {
     record = saved
     state = s
     afterChange()
-    // Closed with a gem's choice still open, or in the shop: it opens again.
+    // Closed with a gem's choice (or a charge's) still open, or in the shop: it opens again.
     showOffer()
+    showChargeOffer()
     if (state.shop) openShopDialog()
     return true
   } catch {
@@ -189,6 +191,7 @@ const MOVES: ReadonlySet<Action['id']> = new Set(['move', 'run', 'diagonal', 'te
 function act(action: Action, auto = false): 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
+  if (state.chargeOffer && action.id !== 'sellCharge' && action.id !== 'swapCharge') return
   lastActionAt = time
   if (!auto && MOVES.has(action.id) && 'target' in action && !path?.some((t) => eq(t, action.target!))) destination = null
   // step() throws on an action the rules don't allow; then nothing happens, and it is reported.
@@ -214,18 +217,24 @@ function act(action: Action, auto = false): void {
   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)
+    // A charge picked up while carrying another: sell it, or swap.
+    if (e.type === 'chargeOffered') window.setTimeout(showChargeOffer, 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)
+    if (charge && !result.events.some((x) => x.type === 'chargeOffered')) window.setTimeout(() => state.offer || state.chargeOffer || showInfo(chargeInfo(charge)), PICKUP_DIALOG_MS)
   }
   selected = null
   inspecting = false
   save()
   afterChange()
   // The shop, after every few levels: opened by going on from the level, then kept up to date.
+  // A gem's choice made while a charge's was waiting too: that one next.
+  if (action.id === 'choose') window.setTimeout(showChargeOffer, PICKUP_DIALOG_MS)
   if (action.id === 'continue' && state.shop) openShopDialog()
   else if (action.id === 'buy' || action.id === 'reroll') updateShop(state)
+  // A charge bought while carrying another asked first; the shop comes back once it is answered.
+  else if ((action.id === 'sellCharge' || action.id === 'swapCharge') && state.shop) openShopDialog()
 }
 
 /** The shop's dialog (ui/shop.ts): buying and restocking are turns of the run record, taking no time. */
@@ -273,6 +282,11 @@ function showOffer(): void {
   if (state.offer) openOffer(state.offer, (kind) => act({ id: 'choose', kind }))
 }
 
+/** The choice a charge picked up while carrying another asks for (one charge at a time). */
+function showChargeOffer(): void {
+  if (state.chargeOffer && !state.offer) openChargeOffer(state, (swap) => act({ id: swap ? 'swapCharge' : 'sellCharge' }))
+}
+
 function afterChange(): void {
   danger = predictDanger(state)
   // The walk is worked out again every turn, since bombs and crates change; reaching the
@@ -553,8 +567,10 @@ 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, then aimed at a tile.
-  if (id === 'wait') act({ id })
+  // Wait, and a Freeze from the charge button, happen at once. Everything else is picked first,
+  // then aimed at a tile.
+  const now = membersOf(id).find((a) => isAvailable(state, a) && !ACTIONS[a].needsTarget)
+  if (now) act({ id: now })
   else {
     selected = pressed ? null : id
     afterChange()
@@ -605,6 +621,8 @@ const ACTION_KEYS: Record<string, ActionId> = {
   KeyB: 'bomb', KeyR: 'run', KeyX: 'detonate', KeyK: 'kick', KeyT: 'throw', KeyG: 'diagonal', KeyP: 'teleport',
   // Added 2026-10-24: F fires an RPG bomb, I an ICBM, L slides.
   KeyF: 'rpg', KeyI: 'icbm', KeyL: 'slide',
+  // V uses the charge held (2026-10-24, one charge at a time): next to B for Bomb.
+  KeyV: 'charged',
 }
 
 /** Q / E / Z / C: a diagonal step up-left, up-right, down-left, down-right (with Diagonal). */
@@ -878,7 +896,7 @@ function nextScene(): void {
   if (item === 'topBar' || item === 'whatIsThat') {
     demo.scene = { ...scene, actions: [], gestures: [] }
     // The tour's room holds a power-up and a charge, so the top bar's last row has something to show.
-    if (item === 'topBar') state = { ...state, powers: { ...state.powers, kick: true }, boosts: { ...state.boosts, pierce: 3 } }
+    if (item === 'topBar') state = { ...state, powers: { ...state.powers, kick: true }, charge: { kind: 'pierce', left: 3 } }
     demo.beats = item === 'topBar' ? topBarTour() : whatIsThat()
   } else {
     const icon = SCENE_ICONS[item]
@@ -1142,6 +1160,11 @@ function endDemo(): void {
   armed = null
   resetView()
   afterChange()
+  // A run resumed with a choice still open (a gem's, a charge's, the shop): the demo closed its
+  // dialog, so it opens again.
+  showOffer()
+  showChargeOffer()
+  if (state.shop) openShopDialog()
 }
 
 // The tap or key that ends the demo goes no further: not to the board, nor to a button that
diff --git a/src/render/board.ts b/src/render/board.ts
index 9002b59..ca8ba12 100644
--- a/src/render/board.ts
+++ b/src/render/board.ts
@@ -257,7 +257,7 @@ export function drawBoard(view: View): void {
   // 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.playerMove)
-  if (s.shield) drawCircle(at, 0.88 * appearScale(fx, key.shield, time), C.clear, 0.06, C.shield)
+  if (s.shieldUp) 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).add(PLAYER_LIFT), tileSize, sprite('character_green_idle'), C.ghost, 0, view.facingLeft)
diff --git a/src/render/effects.ts b/src/render/effects.ts
index f61f985..2bf426e 100644
--- a/src/render/effects.ts
+++ b/src/render/effects.ts
@@ -169,8 +169,6 @@ 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: boom })
@@ -196,6 +194,9 @@ export function startEffects(fx: Effects, prev: GameState, next: GameState, even
       case 'decoyGone':
         fx.gone.push({ kind: 'burned', sprite: 'character_green_front', pos: e.pos, size: 0.9, at: boom })
         break
+      case 'shieldRaised':
+        fx.appeared.set(key.shield, now)
+        break
       case 'shieldBroke':
         fx.gone.push({ kind: 'shield', pos: e.pos, at: boom })
         break
diff --git a/src/sim/actions.ts b/src/sim/actions.ts
index ea83783..49e6ebd 100644
--- a/src/sim/actions.ts
+++ b/src/sim/actions.ts
@@ -1,6 +1,7 @@
+import { BOMB_CHARGES, holds, spendCharge } from './charges'
 import { enemyAt } from './enemies'
 import { add, bombAt, cellAt, eq, exitOpen, isBurning, isFree, playerCanEnter } from './state'
-import { DIRS, DIR_LIST, type ActionId, type Bomb, type ChargeId, type GameEvent, type GameState, type Powers, type Vec } from './types'
+import { DIRS, DIR_LIST, type ActionId, type Bomb, type GameEvent, type GameState, type Powers, type Vec } from './types'
 
 /**
  * The board actions (PLAN.md §3). Adding one = an entry here, its tests, and a button.
@@ -28,14 +29,13 @@ function canHoldBomb(s: GameState, p: Vec): boolean {
   return isFree(s, p) && !isBurning(s, p) && !enemyAt(s, p)
 }
 
-/** 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[]): Bomb {
+/**
+ * A bomb from the player: a plain one, or with `boost` a Pierce, Power or Nuke bomb (the charge
+ * button; since 2026-10-24 a charge is used on purpose, no longer on every bomb).
+ */
+function placeBomb(s: GameState, pos: Vec, events: GameEvent[], boost?: 'pierce' | 'power' | 'nuke'): Bomb {
   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
-  }
+  if (boost) bomb[boost] = true
   // A Power bomb's flame reaches as far as the level is wide: in practice, to the walls.
   if (bomb.power) bomb.fire = Math.max(s.width, s.height)
   if (s.powers.proximity) bomb.proximity = true
@@ -120,45 +120,15 @@ 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, 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).
+ * Freeze: the enemy moves skipped, the turn it is used included (user request, 2026-10-23: "frozen
+ * for 3 turns").
  */
 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 Decoy can be put, in a straight line (user request, 2026-10-25). */
+export const DECOY_RANGE = 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':
-    case 'icbm':
-      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 {
   const d = { x: from.x - s.player.x, y: from.y - s.player.y }
@@ -304,15 +274,77 @@ export const ACTIONS: Record<ActionId, ActionDef> = {
     id: 'icbm',
     label: 'ICBM',
     needsTarget: true,
-    available: (s) => s.boosts.icbm > 0,
+    available: (s) => holds(s, 'icbm'),
     // Fired as an RPG's, at any distance; it goes off this turn, so the bomb limit doesn't apply.
     validTargets: (s) => flightTargets(s, 1),
     apply(s, target, events) {
-      s.boosts.icbm--
+      spendCharge(s)
       placeBomb(s, target!, events).timer = 0
       events.push({ type: 'bombFired', from: { ...s.player }, to: { ...target! } })
     },
   },
+  // The carried charge, used on purpose (PLAN.md §19.7; user request, 2026-10-24).
+  charged: {
+    id: 'charged',
+    label: 'Charged bomb',
+    needsTarget: true,
+    available: (s) => !!s.charge && BOMB_CHARGES.includes(s.charge.kind),
+    validTargets: bombTargets,
+    apply(s, target, events) {
+      const kind = s.charge!.kind as 'pierce' | 'power' | 'nuke'
+      spendCharge(s)
+      placeBomb(s, target!, events, kind)
+    },
+  },
+  freeze: {
+    id: 'freeze',
+    label: 'Freeze',
+    needsTarget: false,
+    available: (s) => holds(s, 'freeze'),
+    validTargets: () => [],
+    apply(s, _target, events) {
+      spendCharge(s)
+      s.frozen = Math.max(s.frozen, FREEZE_TURNS)
+      events.push({ type: 'frozen', turns: FREEZE_TURNS })
+    },
+  },
+  decoy: {
+    id: 'decoy',
+    label: 'Decoy',
+    needsTarget: true,
+    available: (s) => holds(s, 'decoy'),
+    // Up to DECOY_RANGE tiles away in a straight line, each tile on the way free: like a slide or a
+    // kick, nothing gets past a wall, crate, bomb, enemy or flame (user request, 2026-10-25; it went
+    // on the player's tile or next to it). Chasing enemies go there instead.
+    validTargets: (s) =>
+      DIR_LIST.flatMap((d) => {
+        const tiles: Vec[] = []
+        let p = s.player
+        for (let i = 0; i < DECOY_RANGE; i++) {
+          p = add(p, DIRS[d])
+          if (cellAt(s, p) !== 'floor' || bombAt(s, p) || enemyAt(s, p) || isBurning(s, p)) break
+          tiles.push(p)
+        }
+        return tiles
+      }),
+    apply(s, target, events) {
+      spendCharge(s)
+      s.decoy = { pos: { ...target! } }
+      events.push({ type: 'decoyPlaced', pos: s.decoy.pos })
+    },
+  },
+  shield: {
+    id: 'shield',
+    label: 'Shield',
+    needsTarget: false,
+    available: (s) => holds(s, 'shield') && !s.shieldUp,
+    validTargets: () => [],
+    apply(s, _target, events) {
+      spendCharge(s)
+      s.shieldUp = true
+      events.push({ type: 'shieldRaised' })
+    },
+  },
   slide: {
     id: 'slide',
     label: 'Slide',
@@ -334,7 +366,7 @@ export function slidePassed(s: GameState, target: Vec): Vec[] {
 }
 
 export const ACTION_ORDER: readonly ActionId[] = [
-  'move', 'bomb', 'wait', 'run', 'detonate', 'kick', 'throw', 'diagonal', 'teleport', 'rpg', 'icbm', 'slide',
+  'move', 'bomb', 'wait', 'run', 'detonate', 'kick', 'throw', 'diagonal', 'teleport', 'rpg', 'icbm', 'slide', 'charged', 'freeze', 'decoy', 'shield',
 ]
 
 export function isValidTarget(s: GameState, id: ActionId, target: Vec): boolean {
@@ -343,7 +375,7 @@ 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' || s.offer) return false
+  if (s.status !== 'playing' || s.offer || s.chargeOffer) return false
   const ability = ACTIONS[id].ability
   if (ability && !s.powers[ability]) return false
   const def = ACTIONS[id]
diff --git a/src/sim/charges.ts b/src/sim/charges.ts
new file mode 100644
index 0000000..f028c5f
--- /dev/null
+++ b/src/sim/charges.ts
@@ -0,0 +1,76 @@
+import { PRICES } from './shop'
+import type { ChargeId, GameEvent, GameState } from './types'
+
+/**
+ * One charge at a time (PLAN.md §19.7; user request, 2026-10-24). The player carries a single
+ * charge and uses it when they choose, with the charge button; a Shield works by itself. Picking
+ * up a different one asks: sell the new one for coins, or swap it in (the old one is sold). The
+ * same kind adds to what is held.
+ */
+
+/** How many bombs one Pierce, Power or Nuke charge changes, and an ICBM's launches (user's choice, 2026-10-23). */
+export const BOMB_BOOST = 3
+
+/** What one charge gives when picked up: bombs or launches, or a single use. */
+export const CHARGE_AMOUNT: Record<ChargeId, number> = {
+  pierce: BOMB_BOOST, power: BOMB_BOOST, nuke: BOMB_BOOST, icbm: BOMB_BOOST, shield: 1, freeze: 1, decoy: 1,
+}
+
+/** The charges used as a bomb from the charge button. */
+export const BOMB_CHARGES: readonly ChargeId[] = ['pierce', 'power', 'nuke']
+
+/** What a charge sells for: half its shop price, rounded up, for as much of it as is left. */
+export function salePrice(kind: ChargeId, left = CHARGE_AMOUNT[kind]): number {
+  return Math.ceil(((PRICES[kind] / 2) * left) / CHARGE_AMOUNT[kind])
+}
+
+function sell(s: GameState, kind: ChargeId, left: number, events: GameEvent[]): void {
+  const coins = salePrice(kind, left)
+  s.coins += coins
+  events.push({ type: 'chargeSold', kind, coins })
+}
+
+/**
+ * A charge comes to the player — picked up, chosen from a gem or bought: into the empty slot, onto
+ * the same kind, or else as a choice to make (`chargeOffer`; user request, 2026-10-25: a gem's
+ * charge replaced the one held without asking). `swap`: the choice made, take it and sell the one
+ * held. A second one coming in the same turn, while a choice is already waiting, is sold.
+ */
+export function takeCharge(s: GameState, kind: ChargeId, events: GameEvent[], swap = false): void {
+  const held = s.charge
+  if (!held) s.charge = { kind, left: CHARGE_AMOUNT[kind] }
+  else if (held.kind === kind) held.left += CHARGE_AMOUNT[kind]
+  else if (swap) {
+    sell(s, held.kind, held.left, events)
+    s.charge = { kind, left: CHARGE_AMOUNT[kind] }
+  } else if (s.chargeOffer) sell(s, kind, CHARGE_AMOUNT[kind], events)
+  else {
+    s.chargeOffer = kind
+    events.push({ type: 'chargeOffered', kind, held: held.kind })
+  }
+}
+
+/** The choice: keep the charge held and sell the new one. */
+export function sellOffered(s: GameState, events: GameEvent[]): void {
+  const kind = s.chargeOffer!
+  s.chargeOffer = null
+  sell(s, kind, CHARGE_AMOUNT[kind], events)
+}
+
+/** The choice: take the new charge; the one held is sold, for what is left of it. */
+export function swapOffered(s: GameState, events: GameEvent[]): void {
+  const kind = s.chargeOffer!
+  s.chargeOffer = null
+  takeCharge(s, kind, events, true)
+}
+
+/** One use of the charge held: the slot empties when none is left. */
+export function spendCharge(s: GameState): void {
+  if (!s.charge) return
+  if (--s.charge.left <= 0) s.charge = null
+}
+
+/** Whether the player holds this charge now. */
+export function holds(s: GameState, kind: ChargeId): boolean {
+  return s.charge?.kind === kind
+}
diff --git a/src/sim/demo.ts b/src/sim/demo.ts
index 151dc54..b5349f8 100644
--- a/src/sim/demo.ts
+++ b/src/sim/demo.ts
@@ -5,7 +5,7 @@ import { planIntents } from './enemies'
 import { Rng } from './rng'
 import { autoMoveAction, playerPath, safeStep } from './path'
 import { membersOf, resolvePick, tapMeaning } from './picks'
-import { exitOpen, idx, inBounds, noBoosts, noPowers, START_LIVES, START_STATS } from './state'
+import { exitOpen, idx, inBounds, noPowers, START_LIVES, START_STATS } from './state'
 import { step } from './turn'
 import {
   DEFAULT_OPTIONS,
@@ -61,7 +61,7 @@ export type Gesture =
   | { kind: 'go' }
 
 /** The action bar's buttons (ui/controls.ts). */
-export type ButtonId = 'move' | 'bomb' | 'wait'
+export type ButtonId = 'move' | 'bomb' | 'charged' | 'wait'
 
 export interface Scene {
   kind: SceneKind
@@ -304,7 +304,7 @@ export const SCENES: Record<SceneKind, SceneDef> = {
     shows: (t, seen) => seen.used.has('rpg') && kills(t).length > 0,
   },
   proximity: {
-    caption: 'Proximity alert: your bombs go off as soon as an enemy is in their reach.',
+    caption: 'Proximity alert: your bombs go off as soon as an enemy is right next to one.',
     enemies: ['blob', 'seeker'],
     prepare: (s) => (s.powers.proximity = true),
     shows: (t) => t.events.some((e) => e.type === 'proximityTriggered') && kills(t).length > 0,
@@ -316,7 +316,7 @@ export const SCENES: Record<SceneKind, SceneDef> = {
     shows: (t) => t.action.id === 'slide' && steps(t.prev.player, t.next.player) >= 3,
   },
   icbm: {
-    caption: 'ICBM: press Bomb, then tap where it lands — it goes off at once.',
+    caption: 'ICBM: press the charge button, then tap where it lands — it goes off at once.',
     enemies: ['blob', 'drifter'],
     prepare: dropping('icbm', 1),
     shows: (t) => t.action.id === 'icbm' && kills(t).length > 0,
@@ -346,7 +346,7 @@ export const SCENES: Record<SceneKind, SceneDef> = {
     shows: (t) => explosions(t).some((e) => e.tiles.some((p) => at(p, t.next.player))) || t.next.enemies.some((e) => at(e.pos, t.next.player)),
   },
   pierce: {
-    caption: 'Pierce: your next 3 bombs burn through crates.',
+    caption: 'Pierce: the charge button puts down bombs that burn through crates.',
     enemies: ['blob'],
     // Fire 2, and a row of two crates to burn through, with the charge on the way there.
     prepare: (s, rng) => ((s.stats.fire = 2), crateRow(s, rng)),
@@ -358,32 +358,32 @@ export const SCENES: Record<SceneKind, SceneDef> = {
     },
   },
   power: {
-    caption: 'Power: your next 3 bombs reach all the way to the walls.',
+    caption: 'Power: the charge button puts down bombs whose flame reaches the walls.',
     enemies: ['blob', 'drifter'],
     prepare: dropping('power', 1),
     shows: (t, seen) => seen.picked.has('power') && kills(t).some((k) => explosions(t).some((e) => steps(e.pos, k.pos) >= 3)),
   },
   nuke: {
-    caption: 'Nuke: your next 3 bombs burn every tile 2 steps around them, round corners too.',
+    caption: 'Nuke: the charge button puts down bombs that burn every tile 2 steps around, round corners too.',
     enemies: ['blob', 'seeker'],
     prepare: dropping('nuke', 1),
     // Killed off the bomb's row and column: only a Nuke reaches there.
     shows: (t, seen) => seen.picked.has('nuke') && kills(t).some((k) => explosions(t).some((e) => e.pos.x !== k.pos.x && e.pos.y !== k.pos.y)),
   },
   shield: {
-    caption: 'Shield: the next hit is absorbed, then the shield is gone.',
+    caption: 'Shield: press the charge button to raise it, and it absorbs the next hit.',
     enemies: ['seeker', 'seeker'],
     prepare: dropping('shield'),
     shows: (t) => t.events.some((e) => e.type === 'shieldBroke'),
   },
   freeze: {
-    caption: 'Freeze stops every enemy in its tracks. Then bomb them.',
+    caption: 'Freeze: press the charge button and every enemy stops in its tracks. Then bomb them.',
     enemies: ['seeker', 'blob', 'blob'],
     prepare: dropping('freeze'),
     shows: (t, seen) => seen.picked.has('freeze') && kills(t).length > 0 && t.prev.frozen > 0,
   },
   decoy: {
-    caption: 'Decoy: a copy of you that Seekers chase instead.',
+    caption: 'Decoy: press the charge button and put down a copy of you that Seekers chase instead.',
     enemies: ['seeker', 'seeker'],
     prepare: dropping('decoy'),
     shows: (t, seen) => seen.picked.has('decoy') && kills(t).length > 0 && !!t.prev.decoy,
@@ -592,7 +592,7 @@ function makeRoom(kind: SceneKind, rng: Rng): GameState {
     seed: rng.int(2 ** 31), options: { ...DEFAULT_OPTIONS }, 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: DEMO_PAR, coins: 0,
-    boosts: noBoosts(), shield: false, frozen: 0, decoy: null, burning: [],
+    charge: null, chargeOffer: null, shieldUp: false, frozen: 0, decoy: null, burning: [],
     powers: noPowers(), star: 0, gems: [], offer: null, shop: null, death: null, runTurns: 0, runKills: 0, deaths: 0, crumbling: [],
     aiRng: rng.int(2 ** 31), dropRng: rng.int(2 ** 31),
   }
@@ -708,6 +708,8 @@ function rollout(kind: SceneKind, start: GameState, rng: Rng): GameTurn[] | null
       const play = def.steer?.(s, rng, walking) ?? playOf(def, s, randomAction(s, rng))
       if (!play) continue
       const r = step(s, play.action)
+      // A second charge picked up would ask to sell or swap, which no gesture plays: not in a scene.
+      if (r.state.chargeOffer) continue
       const t = { prev: s, ...play, events: r.events, next: r.state, walking: was !== null }
       const alive = r.state.status === 'playing' || r.state.status === 'cleared' || r.state.status === 'won'
       if ((alive && (def.dies || reasonable(kind, t))) || (def.dies && def.shows(t, seen))) turn = t
@@ -732,7 +734,7 @@ function rollout(kind: SceneKind, start: GameState, rng: Rng): GameTurn[] | null
 function reasonable(kind: SceneKind, t: Turn): boolean {
   const punishing = kind !== 'doorBlast'
   if (punishing && t.events.some((e) => e.type === 'enemiesSpawned')) return false
-  const placing = t.action.id === 'bomb' || t.action.id === 'throw' || t.action.id === 'rpg'
+  const placing = t.action.id === 'bomb' || t.action.id === 'throw' || t.action.id === 'rpg' || t.action.id === 'charged'
   if (!placing || !('target' in t.action) || !t.action.target) return true
   const target = t.action.target
   const bomb = t.next.bombs.find((b) => at(b.pos, target))
@@ -754,11 +756,13 @@ function reasonable(kind: SceneKind, t: Turn): boolean {
  */
 function gestureFor(def: SceneDef, s: GameState, action: Action): Gesture | null {
   if (action.id === 'wait') return { kind: 'button', button: 'wait' }
+  // A Freeze or a Shield: the charge button alone (it acts at once).
+  if (action.id === 'freeze' || action.id === 'shield') return { kind: 'button', button: 'charged' }
   if (!('target' in action) || !action.target) return null
   const tile = action.target
   if (action.id === 'bomb' && !def.bombButton) return { kind: 'hold', tile }
   if (TAPPED.has(action.id) && tapMeaning(s, tile) === action.id) return { kind: 'tap', tile }
-  const button: ButtonId = membersOf('move').includes(action.id) ? 'move' : 'bomb'
+  const button: ButtonId = membersOf('move').includes(action.id) ? 'move' : membersOf('charged').includes(action.id) ? 'charged' : 'bomb'
   return resolvePick(s, button, tile) === action.id ? { kind: 'button', button, tile } : null
 }
 
@@ -773,6 +777,7 @@ function playOf(def: SceneDef, s: GameState, action: Action | null): Play | null
 /** Weights for a random action: mostly moving, bombing now and then, sometimes waiting; abilities when held. */
 const WEIGHTS: Partial<Record<ActionId, number>> = {
   move: 5, bomb: 2, wait: 1, detonate: 3, run: 2, kick: 3, throw: 2, teleport: 2, diagonal: 2, rpg: 2, icbm: 3, slide: 3,
+  charged: 3, freeze: 2, decoy: 2, shield: 2,
 }
 
 function randomAction(s: GameState, rng: Rng): Action {
diff --git a/src/sim/enemies.ts b/src/sim/enemies.ts
index 8cc2e86..2eb6e85 100644
--- a/src/sim/enemies.ts
+++ b/src/sim/enemies.ts
@@ -251,24 +251,19 @@ const DROP_CHANCE: Record<EnemyKind, { coins: number; charge: number }> = {
 }
 
 /**
- * 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).
+ * The charges that still help once every enemy is gone: all of them, since one charge at a time
+ * (2026-10-24) every charge is carried to the next level. (Until then Freeze and Decoy worked on
+ * pickup, so the last enemy dropped only Pierce, Power, Nuke, Shield or ICBM.)
  */
-export const LASTING_CHARGES: readonly ChargeId[] = ['pierce', 'power', 'nuke', 'shield', 'icbm']
+export const LASTING_CHARGES: readonly ChargeId[] = CHARGE_IDS
 
 /**
- * A Decoy, Freeze or Shield already lying on the board, or already working, makes another one
- * useless: there is one decoy at a time, a second Freeze doesn't add turns, a second Shield
- * nothing (user report, 2026-10-23: a second Decoy dropped beside the first). Pierce, Power and
- * Nuke add up, so they are never wasted.
+ * A Decoy, Freeze or Shield already lying on the board makes another one useless (user report,
+ * 2026-10-23: a second Decoy dropped beside the first). Pierce, Power, Nuke and ICBM add up, so
+ * they are never wasted.
  */
 function wasted(s: GameState, id: ChargeId): boolean {
-  const lying = s.drops.some((d) => d.kind === id)
-  if (id === 'decoy') return lying || !!s.decoy
-  if (id === 'freeze') return lying || s.frozen > 0
-  if (id === 'shield') return lying || s.shield
-  return false
+  return (id === 'decoy' || id === 'freeze' || id === 'shield') && s.drops.some((d) => d.kind === id)
 }
 
 /** Every charge that wouldn't be wasted is equally likely; with `last`, every lasting one. */
diff --git a/src/sim/explosions.ts b/src/sim/explosions.ts
index f3601ef..59e1cdb 100644
--- a/src/sim/explosions.ts
+++ b/src/sim/explosions.ts
@@ -62,13 +62,15 @@ export function detonate(s: GameState, events: GameEvent[]): Set<number> {
 
 /**
  * Proximity alert (user request, 2026-10-24): each of the player's proximity bombs with an enemy
- * in its reach goes off this turn. `enemyTiles`: where the enemies are (the danger overlay passes
- * where they are going next).
+ * **next to it** (one of its 4 neighbouring tiles) goes off this turn. `enemyTiles`: where the
+ * enemies are (the danger overlay passes where they are going next). *Since 2026-10-25 (user
+ * request):* it went off for an enemy anywhere in its reach, which the balance lab found turned the
+ * defensive bomb (dropped between you and an enemy in a corridor) into a trap for its owner.
  */
 export function proximityAlert(s: GameState, events: GameEvent[], enemyTiles: Vec[] = s.enemies.map((e) => e.pos)): void {
   for (const b of s.bombs) {
     if (!b.proximity || b.timer <= 0) continue
-    if (!blastTiles(s, b).some((t) => enemyTiles.some((e) => e.x === t.x && e.y === t.y))) continue
+    if (!enemyTiles.some((e) => Math.abs(e.x - b.pos.x) + Math.abs(e.y - b.pos.y) === 1)) continue
     b.timer = 0
     events.push({ type: 'proximityTriggered', pos: { ...b.pos } })
   }
diff --git a/src/sim/picks.ts b/src/sim/picks.ts
index 833c682..ba4ccd3 100644
--- a/src/sim/picks.ts
+++ b/src/sim/picks.ts
@@ -14,12 +14,13 @@ import type { ActionId, GameState, Vec } from './types'
  * a kick beats walking onto the bomb with Bomb Pass (walking into a bomb kicks it, if it can
  * slide), a plain step beats a run, and a diagonal step beats a teleport to the same tile (a
  * tile next to you is a step; user report, 2026-10-23). A teleport beats a slide to the same
- * tile (it can't crash or burn). While ICBM launches are left, a far tile is an ICBM rather than
- * an RPG bomb (2026-10-24): the commit preview shows the blast going off at once.
+ * tile (it can't crash or burn). The charge button (2026-10-24, one charge at a time) stands for
+ * whichever use the charge held has: a charged bomb, an ICBM launch, a Freeze or a Decoy.
  */
 const GROUPS: Partial<Record<ActionId, readonly ActionId[]>> = {
   move: ['kick', 'move', 'run', 'diagonal', 'teleport', 'slide'],
-  bomb: ['bomb', 'throw', 'icbm', 'rpg', 'detonate'],
+  bomb: ['bomb', 'throw', 'rpg', 'detonate'],
+  charged: ['charged', 'icbm', 'freeze', 'decoy', 'shield'],
 }
 
 /** The actions a picked one stands for: its group, or just itself. */
diff --git a/src/sim/record.ts b/src/sim/record.ts
index 12b6210..86c6a1c 100644
--- a/src/sim/record.ts
+++ b/src/sim/record.ts
@@ -1,32 +1,32 @@
 import { newRun } from './state'
 import { step } from './turn'
-import { DEFAULT_OPTIONS, type Action, type GameEvent, type GameState, type RunOptions } from './types'
+import { DEFAULT_OPTIONS, type Action, type ChargeId, type GameEvent, type GameState, type RunOptions } from './types'
 
 /**
  * Bumped whenever a rule change would make an old record replay differently. Records from
  * another version can't be trusted to replay (PLAN.md §11).
  */
-export const SIM_VERSION = 28
+export const SIM_VERSION = 31
 
 /** Everything needed to reproduce a run exactly: mid-run saves, replays, certificates. */
 export interface RunRecord {
   simVersion: number
   seed: number
   startLevel: number
-  /** Action charges of each kind given at the start (testing aid; 0 in a normal run). */
-  startCharges: number
+  /** The charge carried from the start (testing aid, `?charge=`; null in a normal run). */
+  startCharge: ChargeId | null
   /** Board size and modes, chosen when the run started. */
   options: RunOptions
   actions: Action[]
 }
 
-export function newRecord(seed: number, startLevel = 1, startCharges = 0, options: RunOptions = DEFAULT_OPTIONS): RunRecord {
-  return { simVersion: SIM_VERSION, seed, startLevel, startCharges, options: { ...options }, actions: [] }
+export function newRecord(seed: number, startLevel = 1, startCharge: ChargeId | null = null, options: RunOptions = DEFAULT_OPTIONS): RunRecord {
+  return { simVersion: SIM_VERSION, seed, startLevel, startCharge, options: { ...options }, actions: [] }
 }
 
 /** `onEvent` sees every event on the way, for the UI to rebuild what it remembers about the run. */
 export function replay(record: RunRecord, onEvent?: (e: GameEvent) => void): GameState {
-  let s = newRun(record.seed, record.startLevel, record.startCharges, record.options)
+  let s = newRun(record.seed, record.startLevel, record.startCharge, record.options)
   for (const a of record.actions) {
     const result = step(s, a)
     if (onEvent) result.events.forEach(onEvent)
diff --git a/src/sim/shop.ts b/src/sim/shop.ts
index 8d9491c..cf6ce72 100644
--- a/src/sim/shop.ts
+++ b/src/sim/shop.ts
@@ -1,4 +1,4 @@
-import { applyCharge } from './actions'
+import { takeCharge } from './charges'
 import { LAST_LEVEL } from './levelgen'
 import { stream } from './rng'
 import { applyPowerUp, heldAlready, POWER_UP_WEIGHTS } from './turn'
@@ -67,7 +67,8 @@ export function buy(s: GameState, kind: OfferKind, events: GameEvent[]): void {
   if (s.coins < PRICES[kind]) throw new Error(`${kind} costs ${PRICES[kind]}, ${s.coins} coins`)
   s.coins -= PRICES[kind]
   shop.sold.push(kind)
-  if (isCharge(kind)) applyCharge(s, kind, events)
+  // A charge bought goes in the slot; with another kind held, the player chooses (PLAN.md §19.7).
+  if (isCharge(kind)) takeCharge(s, kind, events)
   else applyPowerUp(s, kind)
   events.push({ type: 'bought', kind, price: PRICES[kind] })
 }
diff --git a/src/sim/state.ts b/src/sim/state.ts
index 20bc73b..f0ef6f7 100644
--- a/src/sim/state.ts
+++ b/src/sim/state.ts
@@ -1,8 +1,8 @@
 import { planIntents } from './enemies'
 import { generateLevel } from './levelgen'
 import { hashSeed } from './rng'
-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'
+import { CHARGE_AMOUNT } from './charges'
+import { DEFAULT_OPTIONS, type Bomb, type Cell, type ChargeId, type GameState, type HeldCharge, 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 }
@@ -17,9 +17,12 @@ export interface Carry {
   lives: number
   stats: Stats
   coins: number
-  boosts: Boosts
-  /** A shield picked up and not yet broken goes on to the next level. */
-  shield: boolean
+  /**
+   * The charge carried goes on to the next level, and through a death. A raised Shield goes on to the
+   * next level (no death can happen while it is up: it absorbs every hit of the turn it breaks in).
+   */
+  charge: HeldCharge | null
+  shieldUp: boolean
   powers: Powers
   star: number
   runTurns: number
@@ -34,20 +37,14 @@ export function noPowers(): Powers {
   }
 }
 
-export function noBoosts(): Boosts {
-  return { pierce: 0, power: 0, nuke: 0, icbm: 0 }
-}
-
 /**
- * `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).
+ * `charge`: start carrying that charge, as if just picked up (a testing aid, `?charge=` in the
+ * URL; it was `?charges=n`, n of each, until one charge at a time, 2026-10-24).
  */
-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, icbm: n * BOMB_BOOST } : noBoosts()
+export function newRun(seed: number, level = 1, charge: ChargeId | null = null, options: RunOptions = DEFAULT_OPTIONS): GameState {
   return createLevel(seed, level, options, {
-    lives: START_LIVES, stats: { ...START_STATS }, coins: 0, boosts, shield: n > 0, powers: noPowers(), star: 0,
-    runTurns: 0, runKills: 0, deaths: 0,
+    lives: START_LIVES, stats: { ...START_STATS }, coins: 0, charge: charge && { kind: charge, left: CHARGE_AMOUNT[charge] }, shieldUp: false,
+    powers: noPowers(), star: 0, runTurns: 0, runKills: 0, deaths: 0,
   })
 }
 
@@ -59,11 +56,11 @@ export function createLevel(seed: number, level: number, options: RunOptions, ca
   const { width, height, cells, start, exit, powerUp, enemies, par } = generateLevel(seed, level)
   const s: GameState = {
     seed, options: { ...options }, level, turn: 0, width, height, cells, player: start,
-    lives: carry.lives, stats: { ...carry.stats }, coins: carry.coins, boosts: { ...carry.boosts },
+    lives: carry.lives, stats: { ...carry.stats }, coins: carry.coins, charge: carry.charge && { ...carry.charge }, chargeOffer: null,
     bombs: [], status: 'playing',
     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: carry.shield, frozen: 0, decoy: null, burning: [],
+    drops: [], exit, par, shieldUp: carry.shieldUp, frozen: 0, decoy: null, burning: [],
     powers: { ...carry.powers }, star: carry.star, gems: [powerUp], offer: null, shop: null, death: null, runTurns: carry.runTurns, runKills: carry.runKills, deaths: carry.deaths, crumbling: [],
     aiRng: hashSeed(seed, 'ai', level), dropRng: hashSeed(seed, 'drops', level),
   }
@@ -85,7 +82,7 @@ export function createLevel(seed: number, level: number, options: RunOptions, ca
  */
 export function carryOf(s: GameState): Carry {
   return {
-    lives: s.lives, stats: s.stats, coins: s.coins, boosts: s.boosts, shield: s.shield, powers: s.powers, star: s.star,
+    lives: s.lives, stats: s.stats, coins: s.coins, charge: s.charge, shieldUp: s.shieldUp, powers: s.powers, star: s.star,
     runTurns: s.runTurns, runKills: s.runKills, deaths: s.deaths,
   }
 }
diff --git a/src/sim/turn.ts b/src/sim/turn.ts
index 9b35197..8d7297f 100644
--- a/src/sim/turn.ts
+++ b/src/sim/turn.ts
@@ -1,4 +1,5 @@
-import { ACTIONS, applyCharge, isAvailable, isValidTarget, slidePassed } from './actions'
+import { ACTIONS, isAvailable, isValidTarget, slidePassed } from './actions'
+import { sellOffered, swapOffered, takeCharge } from './charges'
 import { enemyAt, moveEnemies, planIntents, rollDrops } from './enemies'
 import { enemyKinds, LAST_LEVEL } from './levelgen'
 import { detonate, proximityAlert, tickBombs } from './explosions'
@@ -32,12 +33,21 @@ export function step(prev: GameState, action: Action): StepResult {
     if (!s.offer?.includes(action.kind)) throw new Error(`${action.kind} is not on offer`)
     s.offer = null
     events.push({ type: 'powerUpChosen', kind: action.kind })
-    if (isCharge(action.kind)) applyCharge(s, action.kind, events)
+    // A charge chosen while carrying another kind asks, as a pickup does.
+    if (isCharge(action.kind)) takeCharge(s, action.kind, events)
     else applyPowerUp(s, action.kind)
     return { state: s, events }
   }
+  // A charge picked up while carrying another: sell it, or swap it in. No turn passes.
+  if (action.id === 'sellCharge' || action.id === 'swapCharge') {
+    if (!s.chargeOffer) throw new Error(`${action.id} with no charge on offer`)
+    if (action.id === 'sellCharge') sellOffered(s, events)
+    else swapOffered(s, events)
+    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 (s.chargeOffer && s.status !== 'dead') throw new Error(`${action.id} while a charge is on offer`)
 
   // The shop: buying and restocking take no turn; going on leaves it for the next level.
   if (action.id === 'buy' || action.id === 'reroll') {
@@ -98,8 +108,8 @@ export function step(prev: GameState, action: Action): StepResult {
   const hurt = (how: Death): void => {
     const cause = how.cause === 'walkedIntoEnemy' || how.cause === 'caughtByEnemy' ? 'enemy' : 'flame'
     if (s.options.immortal || shielded || s.star > 0 || (cause === 'flame' && s.powers.flamePass)) return
-    if (s.shield) {
-      s.shield = false
+    if (s.shieldUp) {
+      s.shieldUp = false
       shielded = true
       events.push({ type: 'shieldBroke', pos: s.player })
       return
@@ -122,6 +132,11 @@ export function step(prev: GameState, action: Action): StepResult {
     moveEnemies(s, events)
     const caughtBy = enemyAt(s, s.player)
     if (caughtBy) hurt({ cause: 'caughtByEnemy', enemy: caughtBy.kind, turn: s.turn + 1 })
+    // An enemy that reaches the Decoy takes it: one hit and it is gone.
+    if (s.decoy && enemyAt(s, s.decoy.pos)) {
+      events.push({ type: 'decoyGone', pos: s.decoy.pos, burned: false })
+      s.decoy = null
+    }
   }
   // Proximity alert: a bomb with an enemy in its reach goes off this turn (user's choice,
   // 2026-10-24: wherever the enemy is now, one that just walked in or one already there).
@@ -192,16 +207,16 @@ export function step(prev: GameState, action: Action): StepResult {
   // This turn's flame lingers through the next turn; last turn's goes out.
   s.burning = [...burned]
 
-  // Phase 5: enemies announce their next intents. A decoy lasts a fixed number of these; so does a Star.
+  // Phase 5: enemies announce their next intents. A Star lasts a fixed number of these.
   planIntents(s)
   if (s.star > 0) s.star--
-  if (s.decoy && --s.decoy.turns <= 0) {
-    events.push({ type: 'decoyGone', pos: s.decoy.pos, burned: false })
-    s.decoy = null
-  }
 
-  // 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
+  // Died the turn a gem was taken: no choice. The retry has the gem back. A charge on offer the
+  // same turn is gone too: the retry has it lying where it was.
+  if (s.status === 'dead' || s.status === 'gameOver') {
+    s.offer = null
+    s.chargeOffer = null
+  }
 
   s.turn++
   s.runTurns++
@@ -283,8 +298,8 @@ 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 applyCharge(s, d.kind, events)
     events.push({ type: 'pickedUp', drop: d })
+    if (d.kind !== 'coins') takeCharge(s, d.kind, events)
   }
   const gem = s.gems.find((g) => eq(g.pos, p) && cellAt(s, p) === 'floor')
   if (gem) {
diff --git a/src/sim/types.ts b/src/sim/types.ts
index 8b228ae..2747ee5 100644
--- a/src/sim/types.ts
+++ b/src/sim/types.ts
@@ -182,20 +182,24 @@ export function isCharge(kind: OfferKind): kind is 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
-  /** ICBM launches left: a bomb fired as far as it flies, going off that same turn (user request, 2026-10-24). */
-  icbm: number
+/**
+ * The one charge the player carries (PLAN.md §19.7; user request, 2026-10-24) and what is left
+ * of it: bombs for Pierce, Power and Nuke, launches for ICBM, 1 for the rest. Used deliberately,
+ * with the charge button (a Shield works by itself, when the player is hit).
+ */
+export interface HeldCharge {
+  kind: ChargeId
+  left: number
 }
 
 /** A stand-in for the player that chasing enemies go after instead, for a few turns. */
+/**
+ * A Decoy put down with the charge button: chasers head for it instead of the player. It lasts
+ * until the level ends, an enemy reaches it, or flame burns it (user request, 2026-10-25; it lasted
+ * 3 turns).
+ */
 export interface Decoy {
   pos: Vec
-  /** Enemy plannings left before it disappears. */
-  turns: number
 }
 
 /** Something lying on a floor tile to walk over and collect: coins, or one action charge. */
@@ -224,9 +228,19 @@ export interface GameState {
   /** Finishing the level in at most this many turns pays PAR_BONUS coins. */
   par: number
   coins: number
-  boosts: Boosts
-  /** Shield armed (picked up): the next hit (flame or enemy) is absorbed. Kept to the next level. */
-  shield: boolean
+  /** The one charge carried, if any; kept to the next level and through a death. */
+  charge: HeldCharge | null
+  /**
+   * A charge just picked up while another is carried: the player sells it, or swaps it for the one
+   * held (which is sold). Nothing else happens until they choose; it takes no turn.
+   */
+  chargeOffer: ChargeId | null
+  /**
+   * A Shield raised with the charge button (2026-10-25: it worked by itself while carried). It stays
+   * up, from level to level, until a hit lands: it absorbs that one (and anything else that hits
+   * that turn), then it is down (user request, 2026-10-25: no time limit).
+   */
+  shieldUp: boolean
   /** Freeze: how many more enemy moves are skipped (FREEZE_TURNS when used; the turn it is used counts). */
   frozen: number
   powers: Powers
@@ -269,12 +283,17 @@ export interface GameState {
 /** The actions the player can pick on the board. */
 export type ActionId =
   | 'move' | 'bomb' | 'wait' | 'run' | 'detonate' | 'kick' | 'throw' | 'teleport' | 'diagonal' | 'rpg' | 'icbm' | 'slide'
+  /** The carried charge, used (2026-10-24): a Pierce, Power or Nuke bomb; a Freeze; a Decoy; a Shield raised. */
+  | 'charged' | 'freeze' | 'decoy' | 'shield'
 
 /** `choose`: one of the gem's offer. It takes no turn. */
 export type Action =
   | { id: ActionId; target?: Vec }
   | { id: 'continue' }
   | { id: 'choose'; kind: OfferKind }
+  /** A charge picked up while carrying another: sell the new one, or swap it in (the old one sold). No turn. */
+  | { id: 'sellCharge' }
+  | { id: 'swapCharge' }
   /** In the shop (sim/shop.ts): buy one of its items, or restock it. */
   | { id: 'buy'; kind: OfferKind }
   | { id: 'reroll' }
@@ -325,7 +344,16 @@ export type GameEvent =
   /** Proximity alert: an enemy in a bomb's reach set it off. */
   | { type: 'proximityTriggered'; pos: Vec }
   | { type: 'shieldBroke'; pos: Vec }
+  /** A charge picked up while another was carried: the choice to make. */
+  | { type: 'chargeOffered'; kind: ChargeId; held: ChargeId }
+  /** A charge sold for coins: the new one, or the old one swapped out. */
+  | { type: 'chargeSold'; kind: ChargeId; coins: number }
+  /** Shield raised with the charge button: up until the next hit. */
+  | { type: 'shieldRaised' }
+  /** Freeze used: every enemy stops for FREEZE_TURNS turns. */
+  | { type: 'frozen'; turns: number }
   | { type: 'decoyPlaced'; pos: Vec }
+  /** A Decoy gone: burned by flame, or reached by an enemy. */
   | { type: 'decoyGone'; pos: Vec; burned: boolean }
   | { type: 'powerUpRevealed'; powerUp: PowerUp }
   | { type: 'powerUpBurned'; powerUp: PowerUp }
diff --git a/src/style.css b/src/style.css
index b632190..e4e73c9 100644
--- a/src/style.css
+++ b/src/style.css
@@ -202,9 +202,10 @@ img {
 }
 
 #actions > button {
-  /* Icons only (names are in the explanation strip and the tooltip). Three to a row, so Move /
-     Bomb / Wait fill the first and the rest wrap below. */
-  flex: 1 0 calc(33.333% - 6px);
+  /* Icons only (names are in the explanation strip and the tooltip). Up to four to a row: Move,
+     Bomb, the charge (when one is held, 2026-10-24) and Wait, sharing the row's width. */
+  position: relative;
+  flex: 1 0 calc(25% - 6px);
   display: grid;
   grid-template-columns: auto;
   align-items: center;
@@ -232,6 +233,29 @@ img {
   display: none;
 }
 
+/* The charge button with no charge: an empty slot, a dashed square where a charge's icon goes. */
+#actions .slot {
+  width: 30px;
+  height: 30px;
+  border: 3px dashed currentColor;
+  border-radius: 8px;
+  opacity: 0.6;
+}
+
+/* The charge button's uses left, in a small badge at its bottom right (the icon's own badge is
+   at its top right). */
+#actions .count {
+  position: absolute;
+  bottom: 4px;
+  right: 6px;
+  min-width: 18px;
+  padding: 0 4px;
+  border-radius: 9px;
+  background: var(--ink);
+  color: #fff;
+  font: 800 12px/18px ui-rounded, system-ui, sans-serif;
+}
+
 /* On a phone there is no keyboard to hint at. */
 @media (max-width: 600px) {
   #actions kbd {
diff --git a/src/ui/almanac.ts b/src/ui/almanac.ts
index d97333c..b89ae40 100644
--- a/src/ui/almanac.ts
+++ b/src/ui/almanac.ts
@@ -54,7 +54,7 @@ function body(tab: AlmanacTab): string {
       POWER_UPS.map((k) => entry(POWER_UP_ICONS[k], POWER_UP_TEXT[k].name, [POWER_UP_TEXT[k].text], POWER_UP_CLIP[k])).join('')
   } else if (tab === 'charges') {
     list =
-      '<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>' +
+      '<p class="almanac-intro">Enemies sometimes drop these. You carry one at a time and use it with the charge button, beside Bomb; a Shield works by itself.</p>' +
       CHARGE_IDS.map((id) => entry(CHARGE_ICONS[id], CHARGE_TEXT[id].name, [CHARGE_TEXT[id].text], id)).join('')
   } else {
     list =
diff --git a/src/ui/chargeOffer.ts b/src/ui/chargeOffer.ts
new file mode 100644
index 0000000..7662b2c
--- /dev/null
+++ b/src/ui/chargeOffer.ts
@@ -0,0 +1,34 @@
+import { CHARGE_AMOUNT, salePrice } from '../sim/charges'
+import type { GameState } from '../sim/types'
+import { CHARGE_ICONS, spriteUrl } from '../render/sprites'
+import { openDialog } from './dialog'
+import { CHARGE_TEXT } from './info'
+
+/**
+ * A charge picked up while carrying another (PLAN.md §19.7; user request, 2026-10-24): the player
+ * carries one at a time. Keep the one held and sell the new one, or swap: take the new one, and
+ * the one held is sold for what is left of it. It must be answered; it takes no turn.
+ */
+export function openChargeOffer(s: GameState, answer: (swap: boolean) => void): void {
+  const offered = s.chargeOffer
+  const held = s.charge
+  if (!offered || !held) return
+  const card = (kind: typeof offered, note: string) =>
+    `<div class="almanac-entry"><img src="${spriteUrl(CHARGE_ICONS[kind])}" alt="" draggable="false">` +
+    `<div><strong>${CHARGE_TEXT[kind].name}</strong> <small>${note}</small><p>${CHARGE_TEXT[kind].text}</p></div></div>`
+  const newPrice = salePrice(offered)
+  const oldPrice = salePrice(held.kind, held.left)
+  openDialog({
+    icon: spriteUrl(CHARGE_ICONS[offered]),
+    title: `${CHARGE_TEXT[offered].name}!`,
+    body:
+      `<p>You can carry one charge at a time. The one you don’t keep is sold for coins.</p>` +
+      card(offered, `new · ${CHARGE_AMOUNT[offered] > 1 ? `${CHARGE_AMOUNT[offered]} uses` : '1 use'}`) +
+      card(held.kind, `yours · ${held.left} ${held.left === 1 ? 'use' : 'uses'} left`),
+    buttons: [
+      { label: `Keep ${CHARGE_TEXT[held.kind].name} · +${newPrice}`, kind: 'plain', onClick: () => answer(false) },
+      { label: `Take ${CHARGE_TEXT[offered].name} · +${oldPrice}`, kind: 'primary', onClick: () => answer(true) },
+    ],
+    required: true,
+  })
+}
diff --git a/src/ui/controls.ts b/src/ui/controls.ts
index 5b74fd8..c197a8d 100644
--- a/src/ui/controls.ts
+++ b/src/ui/controls.ts
@@ -28,8 +28,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). Charges have no buttons: they work when picked up, and
- * what they still give shows in the top bar (user request, 2026-10-23).
+ * throws and detonates (sim/picks.ts). The charge button (2026-10-24: one charge at a time, used
+ * on purpose) shows the charge held, with what is left of it, or an empty slot.
  */
 interface ButtonDef {
   id: ActionId
@@ -42,7 +42,8 @@ interface ButtonDef {
 
 const BUTTONS: ButtonDef[] = [
   { id: 'move', label: 'Move', icon: 'icon_move', key: 'M', group: ['move', 'run', 'teleport', 'kick', 'diagonal', 'slide'] },
-  { id: 'bomb', label: 'Bomb', icon: 'bomb', key: 'B', group: ['bomb', 'throw', 'icbm', 'rpg', 'detonate'] },
+  { id: 'bomb', label: 'Bomb', icon: 'bomb', key: 'B', group: ['bomb', 'throw', 'rpg', 'detonate'] },
+  { id: 'charged', label: 'Charge', icon: 'bomb', key: 'V', group: ['charged', 'icbm', 'freeze', 'decoy', 'shield'] },
   { id: 'wait', label: 'Wait', icon: 'icon_wait', key: 'Space', group: ['wait'] },
 ]
 
@@ -169,8 +170,25 @@ export function createControls(handlers: Handlers) {
       current = s
       currentSelected = selected
       for (const [def, b] of buttons) {
-        b.disabled = !def.group.some((id) => canPick(s, id))
+        // With "What is that?" on, every button can be tapped, to be explained (even one that can't act now).
+        b.disabled = !inspecting && !def.group.some((id) => canPick(s, id))
         b.setAttribute('aria-pressed', String(!!selected && def.group.includes(selected)))
+        if (def.id === 'charged') {
+          // Always there (user request, 2026-10-25): the charge held, with its icon and what is left;
+          // with none, an empty slot. Greyed out while it can't be used (none held, a Shield, which
+          // works by itself, or no room for another bomb). "What is that?" on it explains it all.
+          const kind = s.charge?.kind
+          const shown = kind ? `${kind}:${s.charge!.left}` : 'empty'
+          if (b.dataset.kind !== shown) {
+            b.dataset.kind = shown
+            const name = kind ? CHARGE_TEXT[kind].name : 'Charge'
+            b.innerHTML = kind
+              ? `${icon(CHARGE_ICONS[kind])}<span class="count">${s.charge!.left}</span><kbd>${def.key}</kbd>`
+              : `<span class="slot" aria-hidden="true"></span><kbd>${def.key}</kbd>`
+            b.title = kind ? name : 'Charge: none carried'
+            b.setAttribute('aria-label', kind ? `${name} charge, ${s.charge!.left} left` : 'Charge: none carried')
+          }
+        }
         if (def.id === 'move') {
           // 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
@@ -201,7 +219,7 @@ 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).
+      // The charge held, with what is left, and a Freeze or Decoy at work, with its turns.
       const charges = CHARGE_IDS.map((id) => {
         const left = chargeLeft(s, id)
         if (!left) return ''
diff --git a/src/ui/feedback.ts b/src/ui/feedback.ts
index 847769b..a0588a9 100644
--- a/src/ui/feedback.ts
+++ b/src/ui/feedback.ts
@@ -145,6 +145,8 @@ export function turnFeedback(events: readonly GameEvent[], action: Action, anim:
       case 'rerolled': play('switch'); break
       case 'exitOpened': play('magic', 0.8, 0.8, boom); break
       case 'shieldBroke': play('bump', 1, 0.7, boom); vibrate(40, boom); break
+      case 'shieldRaised': play('magic', 0.8, 1.1); break
+      case 'frozen': play('magic', 0.8, 0.7); break
       case 'enemiesSpawned': play('hurt', 0.8, 0.5, boom); break
       case 'decoyPlaced': play('charge'); break
       case 'playerDied': play('hurt', 1, 1, boom); vibrate([60, 40, 120], boom); break
diff --git a/src/ui/info.ts b/src/ui/info.ts
index 3aaf144..32802dc 100644
--- a/src/ui/info.ts
+++ b/src/ui/info.ts
@@ -1,4 +1,5 @@
-import { BOMB_BOOST, DECOY_TURNS, FREEZE_TURNS, NUKE_RANGE, TELEPORT_RANGE, teleportRange } from '../sim/actions'
+import { DECOY_RANGE, FREEZE_TURNS, NUKE_RANGE, TELEPORT_RANGE, teleportRange } from '../sim/actions'
+import { BOMB_BOOST, salePrice } from '../sim/charges'
 import { LAST_LEVEL } from '../sim/levelgen'
 import { bombAt, cellAt, eq, exitOpen, isBurning, CLEAR_BONUS, PAR_BONUS } from '../sim/state'
 import { COIN_BAG, EXIT_SPAWN, OFFER_SIZE, STAR_TURNS } from '../sim/turn'
@@ -49,7 +50,7 @@ export const POWER_UP_TEXT: Record<PowerUpKind, { name: string; text: string }>
   teleport: { name: 'Teleport', text: `Move can also take you to any empty tile up to ${TELEPORT_RANGE} steps away, over anything (${TELEPORT_RANGE + 1} with Run; with Diagonal, a diagonal step counts as one).` },
   diagonal: { name: 'Diagonal', text: 'Move can also step diagonally, even squeezing between two blocked tiles. With Teleport, it reaches a square around you: a diagonal step counts as one.' },
   rpg: { name: 'RPG', text: 'Bomb can also fire a bomb in a straight line: it flies until a wall, crate, bomb or enemy is next, and lands just before it.' },
-  proximity: { name: 'Proximity alert', text: 'Your bombs go off as soon as an enemy is in their reach — wherever you are standing.' },
+  proximity: { name: 'Proximity alert', text: 'Your bombs go off as soon as an enemy is right next to one — wherever you are standing.' },
   slide: { name: 'Slide', text: 'Move can also slide in a straight line until a wall, crate or bomb stops you, picking up what you pass. An enemy on the way stops you the hard way; flame on the way burns.' },
 }
 
@@ -60,35 +61,39 @@ export const POWER_UP_CLIP: Partial<Record<PowerUpKind, SceneKind>> = {
   rpg: 'rpg', proximity: 'proximity', slide: 'slide',
 }
 
-/** What each charge does once picked up (user request, 2026-10-23: they work at once, as a temporary change). */
+/**
+ * What each charge does (PLAN.md §19.7; since 2026-10-24 one is carried at a time and used with the
+ * charge button, beside Bomb).
+ */
 export const CHARGE_TEXT: Record<ChargeId, { name: string; text: string }> = {
-  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.` },
-  icbm: { name: 'ICBM', text: `${BOMB_BOOST} launches: Bomb fires a bomb as far as it flies, as an RPG's, and it goes off the moment it lands. Your bomb limit doesn't count.` },
+  pierce: { name: 'Pierce', text: `${BOMB_BOOST} bombs whose flame goes through crates, burning every one in reach. Put one down with the charge button.` },
+  power: { name: 'Power', text: `${BOMB_BOOST} bombs whose flame reaches all the way to the walls (crates still stop it). Put one down with the charge button.` },
+  nuke: { name: 'Nuke', text: `${BOMB_BOOST} bombs that also burn every tile within ${NUKE_RANGE} steps, through crates and round corners. Get well away! Put one down with the charge button.` },
+  shield: { name: 'Shield', text: 'Press the charge button to raise it. It stays up, level after level, until something hits you: it absorbs that hit (flame or enemy), and anything else that hits you that turn. Then it is gone.' },
+  freeze: { name: 'Freeze', text: `Press the charge button: every enemy freezes in place for ${FREEZE_TURNS} turns, that one included, thawing as it goes.` },
+  decoy: { name: 'Decoy', text: `Press the charge button, then tap where it goes: up to ${DECOY_RANGE} tiles away in a straight line, with nothing in the way. A copy of you that enemies chasing you go after instead, until the level ends, an enemy reaches it, or flame burns it.` },
+  icbm: { name: 'ICBM', text: `${BOMB_BOOST} launches: the charge button fires a bomb as far as it flies, as an RPG's, and it goes off the moment it lands. Your bomb limit doesn't count.` },
+}
+
+/** The choice when a second charge comes along (PLAN.md §19.7), for the charge button's explanation. */
+const CHOICE_TEXT =
+  'Picking up a charge of another kind (or choosing or buying one) while you carry one asks: keep yours and sell the new one, or take the new one and sell yours. Either way you get coins for the one you let go: half its shop price, for what is left of it. Picking up the same kind adds its uses to yours.'
+
+/** The rule shared by every charge, for their dialogs (PLAN.md §19.7). */
+export function chargeRule(kind: ChargeId): string {
+  return `You carry one charge at a time. Picking up another asks: sell it (${kind === 'shield' || kind === 'freeze' || kind === 'decoy' ? '' : 'a full one '}for ${salePrice(kind)} coins) or swap it for the one you hold, which is sold.`
 }
 
 /**
- * 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.
+ * How much the charge held still has to give, for the top bar: bombs or launches left, true for
+ * a single use, 0 when it isn't the one held. A Freeze or Decoy at work shows its turns instead.
  */
 export function chargeLeft(s: GameState, id: ChargeId): number | true | 0 {
-  switch (id) {
-    case 'pierce':
-    case 'power':
-    case 'nuke':
-    case 'icbm':
-      return s.boosts[id]
-    case 'shield':
-      return s.shield || 0
-    case 'freeze':
-      return s.frozen
-    case 'decoy':
-      return s.decoy?.turns ?? 0
-  }
+  if (s.charge?.kind === id) return s.charge.left > 1 || ['pierce', 'power', 'nuke', 'icbm'].includes(id) ? s.charge.left : true
+  if (id === 'shield') return s.shieldUp ? true : 0
+  if (id === 'freeze') return s.frozen
+  if (id === 'decoy') return s.decoy ? true : 0
+  return 0
 }
 
 const ARROWS: Record<Dir, string> = { up: '↑', down: '↓', left: '←', right: '→' }
@@ -255,7 +260,7 @@ export function tileInfo(s: GameState, p: Vec): Info | null {
     return { icon: 'bomb', title: `${kinds.length ? `${kinds.join(' + ')} bomb` : 'Bomb'}: ${bomb.timer}`, body, clip }
   }
   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.`], clip: 'decoy' }
+    return { icon: 'character_green_front', title: 'Decoy', body: ['Enemies chasing you go after it instead, until the level ends. An enemy reaching it, or flame, destroys it.'], clip: 'decoy' }
   }
   if (isBurning(s, p)) {
     return {
@@ -312,7 +317,7 @@ 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.'], clip: 'coins' }
     const t = CHARGE_TEXT[drop.kind]
-    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.'], clip: drop.kind }
+    return { icon: CHARGE_ICONS[drop.kind], title: `${t.name} charge`, body: [t.text, 'Walk onto it to take it (you carry one charge at a time). A blast destroys it.'], clip: drop.kind }
   }
   return null
 }
@@ -320,7 +325,7 @@ export function tileInfo(s: GameState, p: Vec): Info | null {
 /** 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! It works right away.', t.text, 'The top bar shows what it still has to give.'], clip: kind }
+  return { icon: CHARGE_ICONS[kind], title: `${t.name} charge`, body: ['You picked it up!', t.text, chargeRule(kind)], clip: kind }
 }
 
 /** A charge in the top bar, with what it has left. */
@@ -364,20 +369,38 @@ export function actionInfo(s: GameState, picked: ActionId): Info {
     case 'bomb':
     case 'throw':
     case 'detonate':
-    case 'rpg':
-    case 'icbm': {
+    case 'rpg': {
       const body = [
         `Tap a highlighted tile next to you. The bomb goes off after ${turns(s.stats.fuse)} of yours; its blast reaches ${s.stats.fire} ${s.stats.fire === 1 ? 'tile' : 'tiles'} each way. Up to ${s.stats.capacity} on the board at once.`,
       ]
       if (s.powers.throw) body.push('Throw: or 2 tiles away in a straight line, over a crate, bomb or enemy (not a pillar).')
       if (s.powers.rpg) body.push('RPG: or as far as it flies, landing before the first wall, crate, bomb or enemy.')
-      if (s.boosts.icbm > 0) body.push(`ICBM: ${s.boosts.icbm} ${s.boosts.icbm === 1 ? 'launch' : 'launches'} left — a bomb fired as far as it flies that goes off at once. While any are left, a far tile is an ICBM.`)
       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'}.`)
-      }
+      if (s.charge) body.push(`A plain bomb: your ${CHARGE_TEXT[s.charge.kind].name} is used with the charge button.`)
       return { icon: 'bomb', title: 'Bomb', body, clip: picked === 'bomb' ? 'holdBomb' : picked }
     }
+    case 'charged':
+    case 'icbm':
+    case 'freeze':
+    case 'decoy':
+    case 'shield': {
+      const intro = 'The charge button uses the charge you carry. Enemies sometimes drop charges, and the shop sells them; you carry one at a time.'
+      if (!s.charge) {
+        return {
+          icon: 'bomb',
+          title: 'Charge',
+          body: [
+            intro,
+            'You carry none right now, so it does nothing. Once you pick one up, its picture and how many uses it has left show here.',
+            CHOICE_TEXT,
+          ],
+        }
+      }
+      const kind = s.charge.kind
+      const left = s.charge.left
+      const body = [intro, `${CHARGE_TEXT[kind].name}: ${CHARGE_TEXT[kind].text}`, `${left} ${left === 1 ? 'use' : 'uses'} left.`, CHOICE_TEXT]
+      return { icon: CHARGE_ICONS[kind], title: `Charge: ${CHARGE_TEXT[kind].name}`, body, clip: kind }
+    }
     case 'wait':
       return { icon: 'icon_wait', title: 'Wait', body: ['Do nothing; the world takes its turn. Tapping yourself does the same.'], clip: 'wait' }
   }
diff --git a/test/balance.test.ts b/test/balance.test.ts
new file mode 100644
index 0000000..77c8fc4
--- /dev/null
+++ b/test/balance.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest'
+import { canSurvive, makeBot, SKILLS } from '../tools/balance/bot'
+import { playRun } from '../tools/balance/play'
+import { board, v } from './helpers'
+
+describe('the balance lab', () => {
+  it('the Careful bot clears the first levels of a seed without stalling', () => {
+    const r = playRun(1, makeBot(1), () => {}, 3)
+    expect(r.levelsCleared).toBe(3)
+    expect(r.stalled).toBe(false)
+  })
+
+  it('the same seed and bot play the same run', () => {
+    const a = playRun(2, makeBot(2, SKILLS.average), () => {}, 2)
+    const b = playRun(2, makeBot(2, SKILLS.average), () => {}, 2)
+    expect(a).toEqual(b)
+  })
+
+  it('canSurvive sees a dead end in a bomb’s blast', () => {
+    const s = board(`
+      #####
+      #P1.#
+      #####`)
+    s.bombs[0]!.timer = 2
+    expect(canSurvive(s, 3)).toBe(false)
+    const open = board(`
+      ######
+      #P..1#
+      ######`)
+    open.player = v(2, 1)
+    open.bombs[0]!.timer = 2
+    expect(canSurvive(open, 3)).toBe(true)
+  })
+})
diff --git a/test/charges.test.ts b/test/charges.test.ts
index 38945df..59ae0ae 100644
--- a/test/charges.test.ts
+++ b/test/charges.test.ts
@@ -1,5 +1,7 @@
 import { describe, expect, it } from 'vitest'
-import { ACTIONS, BOMB_BOOST, DECOY_TURNS, FREEZE_TURNS, isAvailable, NUKE_RANGE, TELEPORT_RANGE } from '../src/sim/actions'
+import { ACTIONS, DECOY_RANGE, FREEZE_TURNS, isAvailable, NUKE_RANGE, TELEPORT_RANGE } from '../src/sim/actions'
+import { BOMB_BOOST, salePrice } from '../src/sim/charges'
+import { PRICES } from '../src/sim/shop'
 import { stepToward } from '../src/sim/enemies'
 import { blastTiles } from '../src/sim/explosions'
 import { carryOf, createLevel } from '../src/sim/state'
@@ -14,59 +16,190 @@ function pickUp(s: GameState, kind: ChargeId): GameState {
   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)
-    }
+describe('one charge at a time (PLAN.md §19.7)', () => {
+  const corridor = () => board(`
+    ########
+    #P.....#
+    ########`)
+
+  it('a charge picked up with none held goes into the slot, with its uses', () => {
+    for (const kind of ['pierce', 'power', 'nuke', 'icbm'] as const) expect(pickUp(corridor(), kind).charge).toEqual({ kind, left: BOMB_BOOST })
+    for (const kind of ['shield', 'freeze', 'decoy'] as const) expect(pickUp(corridor(), kind).charge).toEqual({ kind, left: 1 })
+  })
+
+  it('the same kind adds to what is held, no question asked', () => {
+    const s = pickUp(pickUp(corridor(), 'pierce'), 'pierce')
+    expect(s.charge).toEqual({ kind: 'pierce', left: 2 * BOMB_BOOST })
+    expect(s.chargeOffer).toBeNull()
+  })
+
+  it('another kind asks: nothing else happens until the player sells it or swaps', () => {
+    const s = pickUp(pickUp(corridor(), 'pierce'), 'nuke')
+    expect(s.chargeOffer).toBe('nuke')
+    expect(s.charge).toEqual({ kind: 'pierce', left: BOMB_BOOST })
+    expect(() => step(s, { id: 'wait' })).toThrow()
+  })
+
+  it('selling keeps the charge held and pays for the new one', () => {
+    const s = pickUp(pickUp(corridor(), 'pierce'), 'nuke')
+    const { state, events } = step(s, { id: 'sellCharge' })
+    expect(state.charge).toEqual({ kind: 'pierce', left: BOMB_BOOST })
+    expect(state.coins).toBe(s.coins + salePrice('nuke'))
+    expect(events).toContainEqual({ type: 'chargeSold', kind: 'nuke', coins: salePrice('nuke') })
+    expect(state.turn).toBe(s.turn)
+  })
+
+  it('swapping takes the new one and sells what is left of the old one', () => {
+    let s = pickUp(corridor(), 'pierce')
+    s = step(s, { id: 'charged', target: v(1, 1) }).state
+    s = pickUp(step(s, { id: 'wait' }).state, 'nuke')
+    expect(s.charge).toEqual({ kind: 'pierce', left: BOMB_BOOST - 1 })
+    const { state } = step(s, { id: 'swapCharge' })
+    expect(state.charge).toEqual({ kind: 'nuke', left: BOMB_BOOST })
+    expect(state.coins).toBe(s.coins + salePrice('pierce', BOMB_BOOST - 1))
+  })
+
+  it('the sale price is half the shop price, rounded up, for what is left', () => {
+    expect(salePrice('decoy')).toBe(Math.ceil(PRICES.decoy / 2))
+    expect(salePrice('nuke', 1)).toBe(Math.ceil(PRICES.nuke / 2 / BOMB_BOOST))
+  })
+
+  it('what is held goes on to the next level, and through a death', () => {
+    const s = pickUp(corridor(), 'pierce')
+    expect(createLevel(s.seed, 2, s.options, carryOf(s)).charge).toEqual({ kind: 'pierce', left: BOMB_BOOST })
+    expect(createLevel(s.seed, 1, s.options, carryOf(s)).charge).toEqual({ kind: 'pierce', left: BOMB_BOOST })
+  })
+})
+
+describe('charges are used on purpose', () => {
+  it('Bomb places a plain bomb; the charge button a charged one, using it up', () => {
+    let s = board(`
+      #########
+      #P......#
+      #########`, { capacity: 5 })
+    s.charge = { kind: 'nuke', left: 1 }
+    s = step(s, { id: 'bomb', target: v(2, 1) }).state
+    expect(s.bombs[0]!.nuke).toBeUndefined()
+    expect(s.charge).toEqual({ kind: 'nuke', left: 1 })
+    s.player = v(4, 1)
+    s = step(s, { id: 'charged', target: v(5, 1) }).state
+    expect(s.bombs[1]!.nuke).toBe(true)
+    expect(s.charge).toBeNull()
   })
 
-  it('Shield is armed at once', () => {
-    expect(pickUp(board(`
+  it('Shield, raised with the charge button, absorbs the next hit (2026-10-25)', () => {
+    const s = board(`
+      #####
+      #P1.#
+      #####`)
+    s.charge = { kind: 'shield', left: 1 }
+    s.bombs[0]!.timer = 1
+    // Carried, it does nothing: the blast kills.
+    expect(step(s, { id: 'wait' }).state.status).toBe('dead')
+    const { state, events } = step(s, { id: 'shield' })
+    expect(state.status).toBe('playing')
+    expect(state.charge).toBeNull()
+    expect(state.shieldUp).toBe(false)
+    expect(events.some((e) => e.type === 'shieldRaised')).toBe(true)
+    expect(events.some((e) => e.type === 'shieldBroke')).toBe(true)
+  })
+
+  it('a raised Shield stays up until a hit, level after level (user request, 2026-10-25)', () => {
+    let s = board(`
       #####
       #P..#
-      #####`), 'shield').shield).toBe(true)
+      #####`)
+    s.charge = { kind: 'shield', left: 1 }
+    s = step(s, { id: 'shield' }).state
+    for (let t = 0; t < 20; t++) s = step(s, { id: 'wait' }).state
+    expect(s.shieldUp).toBe(true)
+    expect(createLevel(s.seed, 2, s.options, carryOf(s)).shieldUp).toBe(true)
   })
 
-  it(`Freeze freezes every enemy for ${FREEZE_TURNS} turns, the pickup turn included`, () => {
+  it(`Freeze, used, freezes every enemy for ${FREEZE_TURNS} turns, that one included`, () => {
     const s = board(`
       #######
       #P...B#
       #######`)
+    s.charge = { kind: 'freeze', left: 1 }
     s.enemies[0]!.plan = [v(4, 1), v(3, 1), v(2, 1)]
-    let after = pickUp(s, 'freeze')
+    let after = step(s, { id: 'freeze' }).state
     expect(after.enemies[0]!.pos).toEqual(v(5, 1))
+    expect(after.charge).toBeNull()
     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', () => {
+  it('a Freeze picked up waits in the slot: enemies keep moving', () => {
     const s = board(`
+      #######
+      #P...B#
+      #######`)
+    s.enemies[0]!.plan = [v(4, 1), v(3, 1), v(2, 1)]
+    s.drops.push({ pos: v(2, 1), kind: 'freeze' })
+    const after = step(s, { id: 'move', target: v(2, 1) }).state
+    expect(after.enemies[0]!.pos).toEqual(v(4, 1))
+    expect(after.charge).toEqual({ kind: 'freeze', left: 1 })
+  })
+
+  it(`Decoy goes up to ${DECOY_RANGE} tiles away in a straight line, nothing in the way, and chasers head there`, () => {
+    const s = board(`
+      #########
+      #P....S.#
+      #.#######
+      #.#######
+      #+#######
+      #########`)
+    s.charge = { kind: 'decoy', left: 1 }
+    // Right: 3 free tiles. Down: 2, then a crate stops it.
+    expect(ACTIONS.decoy.validTargets(s)).toEqual([v(1, 2), v(1, 3), v(2, 1), v(3, 1), v(4, 1)])
+    const { state, events } = step(s, { id: 'decoy', target: v(4, 1) })
+    expect(events).toContainEqual({ type: 'decoyPlaced', pos: v(4, 1) })
+    expect(state.decoy).toEqual({ pos: v(4, 1) })
+    expect(stepToward(state, state.enemies[0]!)?.to.x).toBeLessThan(state.enemies[0]!.pos.x)
+  })
+
+  it('a Decoy lasts until an enemy reaches it (user request, 2026-10-25: it lasted 3 turns)', () => {
+    let s = board(`
       ########
-      #P....S#
+      #P.....#
+      #.######
       ########`)
-    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)
+    s.decoy = { pos: v(6, 1) }
+    for (let t = 0; t < 10; t++) s = step(s, { id: 'move', target: t % 2 ? v(1, 1) : v(1, 2) }).state
+    expect(s.decoy).toEqual({ pos: v(6, 1) })
+    s.enemies.push({ id: 0, kind: 'blob', pos: v(5, 1), dir: 'right', plan: [v(6, 1)], spawned: false, sheltered: false })
+    const { state, events } = step(s, { id: 'wait' })
+    expect(state.decoy).toBeNull()
+    expect(events).toContainEqual({ type: 'decoyGone', pos: v(6, 1), burned: false })
   })
 
-  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)
+  it('ICBM: the charge button fires it, using a launch', () => {
+    const s = board(`
+      ########
+      #P....B#
+      ########`)
+    s.charge = { kind: 'icbm', left: 2 }
+    expect(isAvailable(s, 'icbm')).toBe(true)
+    const { state } = step(s, { id: 'icbm', target: v(5, 1) })
+    expect(state.charge).toEqual({ kind: 'icbm', left: 1 })
+    expect(state.enemies).toEqual([])
+  })
+
+  it('a charge chosen from a gem while carrying another kind asks too (user report, 2026-10-25)', () => {
+    const s = board(`
+      #####
+      #P..#
+      #####`)
+    s.charge = { kind: 'pierce', left: 2 }
+    s.offer = ['nuke']
+    const { state } = step(s, { id: 'choose', kind: 'nuke' })
+    expect(state.chargeOffer).toBe('nuke')
+    expect(state.charge).toEqual({ kind: 'pierce', left: 2 })
+    const swapped = step(state, { id: 'swapCharge' }).state
+    expect(swapped.charge).toEqual({ kind: 'nuke', left: BOMB_BOOST })
+    expect(swapped.coins).toBe(salePrice('pierce', 2))
   })
 })
 
@@ -107,35 +240,7 @@ describe('Teleport', () => {
   })
 })
 
-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...#
-      ######`)
-    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)
-  })
-
+describe('charged bombs', () => {
   it('Pierce: the flame goes through soft blocks and burns them all', () => {
     const s = board(`
       ########
@@ -150,8 +255,8 @@ describe('boosted bombs', () => {
       ##########
       #P.......#
       ##########`)
-    s.boosts.power = 1
-    const { state } = step(s, { id: 'bomb', target: v(2, 1) })
+    s.charge = { kind: 'power', left: 1 }
+    const { state } = step(s, { id: 'charged', target: v(2, 1) })
     expect(state.bombs[0]!.power).toBe(true)
     expect(blastTiles(state, state.bombs[0]!)).toHaveLength(8)
   })
diff --git a/test/danger.test.ts b/test/danger.test.ts
index 044a6d5..aafcfae 100644
--- a/test/danger.test.ts
+++ b/test/danger.test.ts
@@ -12,6 +12,8 @@ function randomAction(s: GameState, rng: Rng): Action {
   if (s.status === 'dead' || s.status === 'cleared') return { id: 'continue' }
   // A gem taken: its choice comes first.
   if (s.offer) return { id: 'choose', kind: s.offer[0]! }
+  // A second charge picked up: sell it or swap, at random.
+  if (s.chargeOffer) return { id: rng.next() < 0.5 ? 'sellCharge' : 'swapCharge' }
   const ids = ACTION_ORDER.filter((id) => isAvailable(s, id))
   const id = ids[rng.int(ids.length)]!
   const targets = ACTIONS[id].validTargets(s)
@@ -29,7 +31,7 @@ describe('danger overlay', () => {
   it('"now" is exactly what burns if the player waits, and "next" what burns the turn after', () => {
     const rng = new Rng(7)
     for (let seed = 1; seed <= 20; seed++) {
-      let s = newRun(seed, 1 + (seed % 20), 1)
+      let s = newRun(seed, 1 + (seed % 20), 'nuke')
       for (let t = 0; t < 150 && s.status !== 'gameOver'; t++) {
         if (s.status === 'playing' && !s.offer) {
           const danger = predictDanger(s)
@@ -46,9 +48,9 @@ describe('danger overlay', () => {
 describe('run record', () => {
   it('replays to the identical state', () => {
     const rng = new Rng(99)
-    // With charges, so kicks, throws, shields and freezes are in the mix too.
-    const record = newRecord(1234, 1, 2)
-    let s = newRun(record.seed, 1, 2)
+    // With a charge, so charged bombs and the sell-or-swap choice are in the mix too.
+    const record = newRecord(1234, 1, 'pierce')
+    let s = newRun(record.seed, 1, 'pierce')
     for (let t = 0; t < 300 && s.status !== 'gameOver'; t++) {
       const a = randomAction(s, rng)
       record.actions.push(a)
@@ -59,8 +61,8 @@ describe('run record', () => {
 
   it('shows every event on the way, the same as playing it', () => {
     const rng = new Rng(5)
-    const record = newRecord(77, 1, 2)
-    let s = newRun(record.seed, 1, 2)
+    const record = newRecord(77, 1, 'freeze')
+    let s = newRun(record.seed, 1, 'freeze')
     const played: string[] = []
     for (let t = 0; t < 200 && s.status !== 'gameOver'; t++) {
       const a = randomAction(s, rng)
diff --git a/test/demo.test.ts b/test/demo.test.ts
index 1ba5a28..90cad64 100644
--- a/test/demo.test.ts
+++ b/test/demo.test.ts
@@ -23,6 +23,8 @@ function controlsTake(s: GameState, g: Gesture, destination: Vec | null): Action
   if (g.kind === 'walk') return tapMeaning(s, g.tile) === 'walk' ? autoMoveAction(s, g.tile, safe) : null
   if (g.kind === 'go') return destination && autoMoveAction(s, destination, safe)
   if (g.button === 'wait') return { id: 'wait' }
+  // The charge button alone: a Freeze or a Shield, used at once (main.ts `pick`).
+  if (g.button === 'charged' && !g.tile) return { id: s.charge?.kind === 'shield' ? 'shield' : 'freeze' }
   const id = g.tile && resolvePick(s, g.button, g.tile)
   return id ? { id, target: g.tile } : null
 }
diff --git a/test/enemies.test.ts b/test/enemies.test.ts
index d7d6b3e..b66c0e8 100644
--- a/test/enemies.test.ts
+++ b/test/enemies.test.ts
@@ -189,8 +189,8 @@ describe('plans three moves ahead', () => {
       ######
       #P..B#
       ######`), v(3, 1))
-    s.drops.push({ pos: v(2, 1), kind: 'freeze' })
-    const { state } = step(s, { id: 'move', target: v(2, 1) })
+    s.charge = { kind: 'freeze', left: 1 }
+    const { state } = step(s, { id: 'freeze' })
     expect(state.enemies[0]!.plan[0]).toEqual(v(3, 1))
   })
 })
@@ -267,7 +267,7 @@ describe('drops', () => {
       #P..#
       #####`)
     s.drops.push({ pos: v(2, 1), kind: 'shield' })
-    expect(step(s, { id: 'move', target: v(2, 1) }).state.shield).toBe(true)
+    expect(step(s, { id: 'move', target: v(2, 1) }).state.charge).toEqual({ kind: 'shield', left: 1 })
   })
 
   it('flame burns drops lying in the open, but not the ones its own kills leave', () => {
@@ -398,13 +398,13 @@ describe('the exit', () => {
       ####
       #PE#
       ####`, { fire: 3 }), coins: 4, lives: 2 }
-    s.boosts.pierce = 2
+    s.charge = { kind: 'pierce', left: 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 + CLEAR_BONUS + PAR_BONUS, status: 'playing' })
     expect(state.stats.fire).toBe(3)
-    expect(state.boosts.pierce).toBe(2)
+    expect(state.charge).toEqual({ kind: 'pierce', left: 2 })
     expect(state.enemies.length).toBe(enemyCount(2))
   })
 
@@ -532,11 +532,11 @@ describe('action charges and abilities', () => {
       #####
       #P1.#
       #####`)
-    s.shield = true
+    s.shieldUp = 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)
+    expect(r.state.shieldUp).toBe(false)
     expect(r.state.lives).toBe(3)
   })
 
@@ -546,20 +546,20 @@ describe('action charges and abilities', () => {
       #PB.#
       #1###
       #####`), v(1, 1))
-    s.shield = true
+    s.shieldUp = true
     const { state } = step(s, { id: 'wait' })
     expect(state.status).toBe('playing')
     expect(state.lives).toBe(3)
     expect(state.enemies).toEqual([]) // the flame got the Blob on the player's tile
   })
 
-  it(`freeze: enemies skip the pickup turn's move and the next ${FREEZE_TURNS - 1}, then move again`, () => {
+  it(`freeze: enemies skip the move of the turn it is used and the next ${FREEZE_TURNS - 1}, then move again`, () => {
     let s = intend(board(`
       ########
       #P....B#
       ########`), v(5, 1))
-    s.drops.push({ pos: v(2, 1), kind: 'freeze' })
-    s = step(s, { id: 'move', target: v(2, 1) }).state
+    s.charge = { kind: 'freeze', left: 1 }
+    s = step(s, { id: 'freeze' }).state
     expect(s.frozen).toBe(FREEZE_TURNS - 1)
     for (let i = 1; i < FREEZE_TURNS; i++) {
       s = step(s, { id: 'wait' }).state
@@ -670,12 +670,6 @@ describe('charge drops are never wasted', () => {
     }
   })
 
-  it('none while one is working', () => {
-    expect(dropped((s) => (s.decoy = { pos: v(2, 1), turns: 2 }))).not.toContain('decoy')
-    expect(dropped((s) => (s.frozen = 2))).not.toContain('freeze')
-    expect(dropped((s) => (s.shield = true))).not.toContain('shield')
-  })
-
   it('Pierce, Power, Nuke and ICBM still drop when one lies there: they add up', () => {
     expect(dropped((s) => s.drops.push({ pos: v(2, 1), kind: 'pierce' }))).toContain('pierce')
     expect(dropped((s) => s.drops.push({ pos: v(2, 1), kind: 'icbm' }))).toContain('icbm')
diff --git a/test/helpers.ts b/test/helpers.ts
index 3147eb0..bddf65f 100644
--- a/test/helpers.ts
+++ b/test/helpers.ts
@@ -1,4 +1,4 @@
-import { noBoosts, noPowers, START_LIVES, START_STATS } from '../src/sim/state'
+import { 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', R: 'sprinter', G: 'ghost', W: 'wisp', H: 'hunter' }
@@ -21,7 +21,7 @@ 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,
-    boosts: noBoosts(), shield: false, frozen: 0, decoy: null, burning: [],
+    charge: null, chargeOffer: null, shieldUp: false, frozen: 0, decoy: null, burning: [],
     powers: noPowers(), star: 0, gems: [], offer: null, shop: null, death: null, runTurns: 0, runKills: 0, deaths: 0, crumbling: [], aiRng: 1, dropRng: 1,
   }
   rows.forEach((row, y) => {
diff --git a/test/new-powers.test.ts b/test/new-powers.test.ts
index f176271..6ae5024 100644
--- a/test/new-powers.test.ts
+++ b/test/new-powers.test.ts
@@ -1,5 +1,6 @@
 import { describe, expect, it } from 'vitest'
-import { ACTIONS, BOMB_BOOST, isAvailable, TELEPORT_RANGE } from '../src/sim/actions'
+import { BOMB_BOOST } from '../src/sim/charges'
+import { ACTIONS, isAvailable, TELEPORT_RANGE } from '../src/sim/actions'
 import { predictDanger } from '../src/sim/danger'
 import { idx } from '../src/sim/state'
 import { step } from '../src/sim/turn'
@@ -56,7 +57,7 @@ describe('ICBM', () => {
       #########`)
     s.drops.push({ pos: v(2, 1), kind: 'icbm' })
     let state = step(s, { id: 'move', target: v(2, 1) }).state
-    expect(state.boosts.icbm).toBe(BOMB_BOOST)
+    expect(state.charge).toEqual({ kind: 'icbm', left: BOMB_BOOST })
     expect(isAvailable(state, 'icbm')).toBe(true)
     state.stats.capacity = 0
     // The Blob is at x = 6 (it stays put): the bomb lands before it, at x = 5, and its blast gets it.
@@ -65,7 +66,7 @@ describe('ICBM', () => {
     expect(r.events.some((e) => e.type === 'exploded')).toBe(true)
     expect(r.state.enemies).toEqual([])
     expect(r.state.bombs).toEqual([])
-    expect(r.state.boosts.icbm).toBe(BOMB_BOOST - 1)
+    expect(r.state.charge).toEqual({ kind: 'icbm', left: BOMB_BOOST - 1 })
   })
 
   it('is not there once its launches are spent', () => {
@@ -78,7 +79,7 @@ describe('ICBM', () => {
 })
 
 describe('Proximity alert', () => {
-  it('sets off a bomb the turn an enemy walks into its reach', () => {
+  it('sets off a bomb the turn an enemy walks up next to it', () => {
     const s = board(`
       ########
       #P.5..B#
@@ -86,7 +87,7 @@ describe('Proximity alert', () => {
     s.powers.proximity = true
     // The player's bomb (timer 5) gets the flag when placed; mark this one as theirs.
     s.bombs[0]!.proximity = true
-    // Blast 1: the bomb at x = 3 reaches x = 4, where the Blob steps.
+    // The Blob steps to x = 4, next to the bomb at x = 3.
     s.enemies[0]!.plan = [v(5, 1), v(4, 1)]
     let state = step(s, { id: 'wait' }).state
     expect(state.bombs).toHaveLength(1)
@@ -97,7 +98,18 @@ describe('Proximity alert', () => {
     expect(state.enemies).toEqual([])
   })
 
-  it('also when the enemy is already in reach as the bomb is placed', () => {
+  it('not for an enemy in its reach but further than next to it (since 2026-10-25)', () => {
+    const s = board(`
+      ########
+      #P.5.B.#
+      ########`, { fire: 3 })
+    s.bombs[0]!.proximity = true
+    s.bombs[0]!.fire = 3
+    const r = step(s, { id: 'wait' })
+    expect(r.events.some((e) => e.type === 'proximityTriggered')).toBe(false)
+  })
+
+  it('also when the enemy is already next to it as the bomb is placed', () => {
     const s = board(`
       #######
       #P.B..#
diff --git a/test/options.test.ts b/test/options.test.ts
index cdc1226..7182a6f 100644
--- a/test/options.test.ts
+++ b/test/options.test.ts
@@ -4,7 +4,7 @@ import { newRecord, replay } from '../src/sim/record'
 import { newRun } from '../src/sim/state'
 import { drawOffer, POWER_UP_WEIGHTS, step } from '../src/sim/turn'
 import { CHARGE_IDS, DEFAULT_OPTIONS, isCharge, type Vec } from '../src/sim/types'
-import { BOMB_BOOST, FREEZE_TURNS } from '../src/sim/actions'
+import { BOMB_BOOST } from '../src/sim/charges'
 import { board, v } from './helpers'
 
 describe('the board (one size since 2026-10-23)', () => {
@@ -62,16 +62,15 @@ describe('developer mode', () => {
   const dev = { ...DEFAULT_OPTIONS, dev: true }
 
   it('gives nothing but the gem: the usual stats, abilities and charges', () => {
-    const s = newRun(1, 1, 0, dev)
-    const plain = newRun(1, 1, 0)
+    const s = newRun(1, 1, null, dev)
+    const plain = newRun(1, 1)
     expect(s.stats).toEqual(plain.stats)
     expect(s.powers).toEqual(plain.powers)
-    expect(s.boosts).toEqual(plain.boosts)
-    expect(s.shield).toBe(plain.shield)
+    expect(s.charge).toEqual(plain.charge)
   })
 
   it('the gem is back after a death', () => {
-    const s = newRun(1, 1, 0, dev)
+    const s = newRun(1, 1, null, 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')
@@ -81,7 +80,7 @@ describe('developer mode', () => {
 
   it('starts every level with an extra gem uncovered right of the player', () => {
     for (const level of [1, 7]) {
-      const s = newRun(5, level, 0, dev)
+      const s = newRun(5, level, null, dev)
       const extra = s.gems.find((g) => g.extra)!
       expect(extra.pos).toEqual({ x: START.x + 1, y: START.y })
       expect(s.cells[START.y * s.width + START.x + 1]).toBe('floor')
@@ -92,7 +91,7 @@ describe('developer mode', () => {
 
   it('keeps the level\'s own gem, hidden under its crate as usual (user request, 2026-10-23)', () => {
     for (let seed = 1; seed <= 20; seed++) {
-      const s = newRun(seed, 1, 0, dev)
+      const s = newRun(seed, 1, null, dev)
       const own = s.gems.filter((g) => !g.extra)
       expect(own).toHaveLength(1)
       expect(own[0]).toEqual(newRun(seed).gems[0])
@@ -101,7 +100,7 @@ describe('developer mode', () => {
   })
 
   it('the extra gem is the rainbow one, and offers every power-up and charge (user request, 2026-10-24)', () => {
-    const s = newRun(1, 1, 0, dev)
+    const s = newRun(1, 1, null, dev)
     expect(s.gems.find((g) => g.extra)!.color).toBe('rainbow')
     expect(s.gems.find((g) => !g.extra)!.color).not.toBe('rainbow')
     const everything = [...POWER_UP_WEIGHTS.map(([kind]) => kind), ...CHARGE_IDS]
@@ -110,26 +109,25 @@ describe('developer mode', () => {
     expect(state.offer).toEqual(everything)
   })
 
-  it('a charge chosen from the extra gem works as if picked up', () => {
-    const s = newRun(1, 1, 0, dev)
+  it('a charge chosen from the extra gem goes into the slot', () => {
+    const s = newRun(1, 1, null, dev)
     const taken = step(s, { id: 'move', target: { x: START.x + 1, y: START.y } }).state
-    expect(step(taken, { id: 'choose', kind: 'freeze' }).state.frozen).toBe(FREEZE_TURNS)
-    expect(step(taken, { id: 'choose', kind: 'shield' }).state.shield).toBe(true)
-    expect(step(taken, { id: 'choose', kind: 'nuke' }).state.boosts.nuke).toBe(BOMB_BOOST)
+    expect(step(taken, { id: 'choose', kind: 'freeze' }).state.charge).toEqual({ kind: 'freeze', left: 1 })
+    expect(step(taken, { id: 'choose', kind: 'shield' }).state.charge).toEqual({ kind: 'shield', left: 1 })
+    expect(step(taken, { id: 'choose', kind: 'nuke' }).state.charge).toEqual({ kind: 'nuke', left: BOMB_BOOST })
     const decoy = step(taken, { id: 'choose', kind: 'decoy' })
-    expect(decoy.state.decoy?.pos).toEqual(taken.player)
-    expect(decoy.events).toContainEqual({ type: 'decoyPlaced', pos: taken.player })
+    expect(decoy.state.charge).toEqual({ kind: 'decoy', left: 1 })
     expect(decoy.state.offer).toBeNull()
   })
 
   it('a level\'s own gem never offers a charge', () => {
     for (let seed = 1; seed <= 30; seed++) {
-      expect(drawOffer(newRun(seed, 1, 0, dev)).some(isCharge)).toBe(false)
+      expect(drawOffer(newRun(seed, 1, null, dev)).some(isCharge)).toBe(false)
     }
   })
 
   it('the extra gem leaves out what the player already has', () => {
-    const s = newRun(1, 1, 0, dev)
+    const s = newRun(1, 1, null, dev)
     s.powers.run = true
     s.powers.flamePass = true
     const offer = drawOffer(s, true)
@@ -201,8 +199,8 @@ describe('crumbling crates (always, since 2026-10-23)', () => {
 describe('run record', () => {
   it('keeps the options, so a replay is the same run', () => {
     const options = { dev: true, immortal: true }
-    const record = newRecord(77, 1, 0, options)
-    let s = newRun(77, 1, 0, options)
+    const record = newRecord(77, 1, null, options)
+    let s = newRun(77, 1, null, options)
     for (const a of [{ id: 'wait' as const }, { id: 'wait' as const }]) {
       record.actions.push(a)
       s = step(s, a).state
diff --git a/test/shop.test.ts b/test/shop.test.ts
index 26574a9..a637a0f 100644
--- a/test/shop.test.ts
+++ b/test/shop.test.ts
@@ -52,13 +52,25 @@ describe('the shop', () => {
     expect(s.coins).toBe(40 - PRICES.run)
     expect(s.powers.run).toBe(true)
     s = step(s, { id: 'buy', kind: 'shield' }).state
-    expect(s.shield).toBe(true)
+    expect(s.charge).toEqual({ kind: 'shield', left: 1 })
     expect(() => step(s, { id: 'buy', kind: 'run' })).toThrow()
     const next = step(s, { id: 'continue' }).state
     expect(next.powers.run).toBe(true)
     expect(next.coins).toBe(40 - PRICES.run - PRICES.shield)
   })
 
+  it('a charge bought while carrying another kind asks; the shop waits for the answer', () => {
+    let s = step(cleared(3, 40), { id: 'continue' }).state
+    s.charge = { kind: 'pierce', left: 3 }
+    s.shop!.items = ['nuke', 'run', 'heart', 'bombPlus']
+    s = step(s, { id: 'buy', kind: 'nuke' }).state
+    expect(s.chargeOffer).toBe('nuke')
+    expect(() => step(s, { id: 'continue' })).toThrow()
+    s = step(s, { id: 'swapCharge' }).state
+    expect(s.charge).toEqual({ kind: 'nuke', left: 3 })
+    expect(step(s, { id: 'continue' }).state.level).toBe(4)
+  })
+
   it('refuses what the player can’t afford', () => {
     const s = step(cleared(3, 3), { id: 'continue' }).state
     expect(() => step(s, { id: 'buy', kind: s.shop!.items[0]! })).toThrow()
diff --git a/tools/balance/bot.ts b/tools/balance/bot.ts
new file mode 100644
index 0000000..9fb49f7
--- /dev/null
+++ b/tools/balance/bot.ts
@@ -0,0 +1,271 @@
+import { ACTIONS, isAvailable } from '../../src/sim/actions'
+import { predictDanger } from '../../src/sim/danger'
+import { PLAN_AHEAD } from '../../src/sim/enemies'
+import { blastTiles } from '../../src/sim/explosions'
+import { Rng } from '../../src/sim/rng'
+import { add, cellAt, exitOpen, idx, inBounds, playerCanPass } from '../../src/sim/state'
+import { step } from '../../src/sim/turn'
+import { DIRS, DIR_LIST, type Action, type ActionId, type GameEvent, type GameState, type Vec } from '../../src/sim/types'
+
+/**
+ * The Careful bot (PLAN.md §19.1): a cautious player that never takes a turn it can't survive.
+ * Every action it could take is tried on the real rules (`step()`); one that leaves no way to
+ * stay alive for the next few turns (by moving and waiting) is out. Of the rest it takes the one
+ * that scores best: kills, crates burned and pickups now, a bomb that will catch an enemy or
+ * crates, and getting closer to what it is after (the open exit, an uncovered gem, a drop, then
+ * enemies and crates). It only uses what a player can see: not where the exit or the gem hide.
+ */
+export interface Bot {
+  name: string
+  act(s: GameState): Action
+}
+
+export const DEBUG = { on: false }
+
+/** Turns the survival check looks ahead, at most (a bomb's fuse plus its lingering flame). */
+const MAX_LOOKAHEAD = 6
+
+/**
+ * How good a player the bot is. `lookahead`: the most turns it checks it can survive (the
+ * Careful bot looks as far as the enemies' plans and the bombs' fuses; fewer turns make it walk
+ * into traps it didn't see). `slip`: the share of turns it takes some other action that doesn't
+ * kill it at once, as a person might misjudge.
+ */
+export interface Skill {
+  name: string
+  lookahead: number
+  slip: number
+}
+
+export const SKILLS: Record<string, Skill> = {
+  careful: { name: 'careful', lookahead: MAX_LOOKAHEAD, slip: 0 },
+  average: { name: 'average', lookahead: 2, slip: 0.03 },
+  sloppy: { name: 'sloppy', lookahead: 1, slip: 0.08 },
+}
+
+export function makeBot(seed: number, skill: Skill = SKILLS.careful!): Bot {
+  const rng = new Rng(seed)
+  const memory: Memory = { attempt: '', visits: new Map() }
+  return { name: skill.name, act: (s) => decide(s, rng, memory, skill) }
+}
+
+export function carefulBot(seed: number): Bot {
+  return makeBot(seed)
+}
+
+/**
+ * How often it has stood on each tile in this attempt at the level. Standing somewhere again
+ * costs a little, so a bot with nothing useful to do where it is moves on instead of waiting
+ * for ever.
+ */
+interface Memory {
+  attempt: string
+  visits: Map<number, number>
+}
+
+function decide(s: GameState, rng: Rng, memory: Memory, skill: Skill): Action {
+  // A gem's offer: the first thing on it (the offer's order comes from the seed).
+  if (s.offer) return { id: 'choose', kind: s.offer[0]! }
+  // A second charge: keep the one held (sell the new one) — the simplest rule.
+  if (s.chargeOffer) return { id: 'sellCharge' }
+  if (s.status !== 'playing') return { id: 'continue' }
+
+  const attempt = `${s.level}:${s.deaths}`
+  if (memory.attempt !== attempt) {
+    memory.attempt = attempt
+    memory.visits.clear()
+  }
+  const here = idx(s, s.player)
+  memory.visits.set(here, (memory.visits.get(here) ?? 0) + 1)
+  const goals = goalDistances(s)
+  const all = candidates(s)
+  // A slip: any action that doesn't end the turn dead.
+  if (skill.slip > 0 && rng.next() < skill.slip) {
+    const alive = all.filter((a) => {
+      try {
+        return step(s, a).state.status === 'playing'
+      } catch {
+        return false
+      }
+    })
+    if (alive.length) return alive[rng.int(alive.length)]!
+  }
+  let best: { action: Action; score: number } | null = null
+  for (const action of all) {
+    let result: ReturnType<typeof step>
+    try {
+      result = step(s, action)
+    } catch {
+      continue
+    }
+    const score = scoreOf(s, result.state, result.events, goals, skill) - (memory.visits.get(idx(s, result.state.player)) ?? 0) + rng.next() * 0.5
+    if (DEBUG.on) console.log(JSON.stringify(action), score.toFixed(1))
+    if (!best || score > best.score) best = { action, score }
+  }
+  return best?.action ?? { id: 'wait' }
+}
+
+/** Every action the player could take now, with every target it allows. */
+export function candidates(s: GameState): Action[] {
+  const out: Action[] = []
+  for (const id of Object.keys(ACTIONS) as ActionId[]) {
+    if (!isAvailable(s, id)) continue
+    const def = ACTIONS[id]
+    if (!def.needsTarget) out.push({ id })
+    else for (const target of def.validTargets(s)) out.push({ id, target })
+  }
+  return out
+}
+
+/** The plain moves (and Wait) that the survival check tries: what any player has. */
+export function escapes(s: GameState): Action[] {
+  return [{ id: 'wait' }, ...ACTIONS.move.validTargets(s).map((target): Action => ({ id: 'move', target }))]
+}
+
+/**
+ * Whether some run of moves and waits (or, with `ways`, other actions) keeps the player alive for
+ * `turns` more turns (or to the end of the level). Positions already tried at the same depth
+ * aren't tried again.
+ */
+export function canSurvive(s: GameState, turns: number, seen = new Set<string>(), ways = escapes): boolean {
+  if (s.status === 'cleared' || s.status === 'won') return true
+  if (s.status !== 'playing') return false
+  if (turns <= 0) return true
+  // A gem or a second charge just taken: its choice first (it takes no turn).
+  if (s.offer) s = step(s, { id: 'choose', kind: s.offer[0]! }).state
+  if (s.chargeOffer) s = step(s, { id: 'sellCharge' }).state
+  const key = `${turns}:${s.player.x},${s.player.y}`
+  if (seen.has(key)) return false
+  seen.add(key)
+  for (const a of ways(s)) {
+    let next: GameState
+    try {
+      next = step(s, a).state
+    } catch {
+      continue
+    }
+    if (canSurvive(next, turns - 1, seen, ways)) return true
+  }
+  return false
+}
+
+/** How far ahead to look: as far as the enemies have announced, and until every bomb has gone off and its flame died. */
+function lookahead(s: GameState): number {
+  const fuse = Math.max(0, ...s.bombs.map((b) => b.timer))
+  return Math.min(MAX_LOOKAHEAD, Math.max(PLAN_AHEAD, fuse + 2))
+}
+
+function scoreOf(s: GameState, next: GameState, events: GameEvent[], goals: Int32Array, skill: Skill): number {
+  if (next.status === 'cleared' || next.status === 'won') return 1e6
+  if (next.status !== 'playing') return -1e9
+  if (!canSurvive(next, Math.min(skill.lookahead, lookahead(next)))) return -1e8
+  let score = 0
+  for (const e of events) {
+    if (e.type === 'enemyDied') score += 200
+    else if (e.type === 'blockDestroyed') score += 15
+    else if (e.type === 'pickedUp') score += e.drop.kind === 'coins' ? 25 : 60
+    else if (e.type === 'powerUpTaken') score += 300
+    else if (e.type === 'enemiesSpawned') score -= 400
+  }
+  // A new bomb: what its blast will catch.
+  const placed = next.bombs.filter((b) => !s.bombs.some((o) => o.pos.x === b.pos.x && o.pos.y === b.pos.y))
+  for (const b of placed) score += bombValue(next, b)
+  // Closer to what it is after; standing still is fine when a bomb is ticking nearby.
+  const d = goals[idx(s, next.player)] ?? -1
+  if (d >= 0) score -= 4 * d
+  // Out of the flame's way where it can be.
+  if (predictDanger(next).now.has(idx(next, next.player))) score -= 3
+  return score
+}
+
+/** A bomb's worth: enemies whose plan passes through its blast before it goes off, crates in reach. */
+function bombValue(s: GameState, bomb: GameState['bombs'][number]): number {
+  const tiles = blastTiles(s, bomb)
+  const has = (p: Vec) => tiles.some((t) => t.x === p.x && t.y === p.y)
+  let value = -10
+  for (const e of s.enemies) {
+    const route = [e.pos, ...e.plan.slice(0, bomb.timer)]
+    if (route.some(has)) value += 60
+  }
+  for (const t of tiles) if (cellAt(s, t) === 'block') value += 12
+  // Never blast the uncovered exit or gem: that lets enemies out.
+  if (cellAt(s, s.exit) === 'floor' && has(s.exit)) value -= 500
+  for (const g of s.gems) if (cellAt(s, g.pos) === 'floor' && has(g.pos)) value -= 500
+  return value
+}
+
+/**
+ * Steps from every tile to the nearest thing worth going to, over tiles the player can walk:
+ * the open exit; else an uncovered gem or a drop; else a place to bomb from — a tile next to
+ * one where a bomb would reach a crate or an enemy's route, with another way out.
+ */
+function goalDistances(s: GameState): Int32Array {
+  const dist = new Int32Array(s.width * s.height).fill(-1)
+  const goals: Vec[] = []
+  if (exitOpen(s)) goals.push(s.exit)
+  else {
+    for (const g of s.gems) if (cellAt(s, g.pos) === 'floor') goals.push(g.pos)
+    for (const d of s.drops) goals.push(d.pos)
+    if (!goals.length) {
+      const near = (p: Vec) => DIR_LIST.map((d) => add(p, DIRS[d])).filter((q) => inBounds(s, q) && playerCanPass(s, q))
+      const routes = s.enemies.flatMap((e) => [e.pos, ...e.plan])
+      // The next bomb as it would be: a Pierce, Power or Nuke one while those last.
+      // A plain bomb: since one charge at a time (2026-10-24) a charge is only used on purpose.
+      const kind = { fire: s.stats.fire }
+      for (let i = 0; i < s.cells.length; i++) {
+        const spot = { x: i % s.width, y: Math.floor(i / s.width) }
+        if (s.cells[i] !== 'floor' || s.bombs.some((b) => b.pos.x === spot.x && b.pos.y === spot.y)) continue
+        const reach = blastTiles(s, { pos: spot, ...kind })
+        const worth = reach.some((t) => cellAt(s, t) === 'block' || routes.some((r) => r.x === t.x && r.y === t.y))
+        if (!worth) continue
+        // Somewhere to stand next to the bomb with a walk out of the blast before it goes off.
+        for (const from of near(spot)) if (canGetClear(s, from, spot, reach)) goals.push(from)
+      }
+    }
+  }
+  const queue: Vec[] = []
+  for (const g of goals) {
+    const i = idx(s, g)
+    if (dist[i] === -1) {
+      dist[i] = 0
+      queue.push(g)
+    }
+  }
+  for (let k = 0; k < queue.length; k++) {
+    const p = queue[k]!
+    for (const d of DIR_LIST) {
+      const q = add(p, DIRS[d])
+      if (!inBounds(s, q) || !playerCanPass(s, q)) continue
+      const i = idx(s, q)
+      if (dist[i] !== -1) continue
+      dist[i] = dist[idx(s, p)]! + 1
+      queue.push(q)
+    }
+  }
+  return dist
+}
+
+/**
+ * Whether, with a bomb on `bomb`, there is a walk from `from` (not through the bomb) to a tile out
+ * of `reach` in fewer steps than the fuse. Enemies are left out: a quick guess for choosing where to
+ * go; the real check is `canSurvive`.
+ */
+function canGetClear(s: GameState, from: Vec, bomb: Vec, reach: Vec[]): boolean {
+  const inReach = (p: Vec) => reach.some((t) => t.x === p.x && t.y === p.y)
+  const seen = new Set([idx(s, from), idx(s, bomb)])
+  let frontier = [from]
+  for (let steps = 0; steps < s.stats.fuse; steps++) {
+    const next: Vec[] = []
+    for (const p of frontier) {
+      if (!inReach(p)) return true
+      for (const d of DIR_LIST) {
+        const q = add(p, DIRS[d])
+        if (!inBounds(s, q) || seen.has(idx(s, q)) || !playerCanPass(s, q)) continue
+        seen.add(idx(s, q))
+        next.push(q)
+      }
+    }
+    frontier = next
+  }
+  return frontier.some((p) => !inReach(p))
+}
diff --git a/tools/balance/lab.ts b/tools/balance/lab.ts
new file mode 100644
index 0000000..2eeafa3
--- /dev/null
+++ b/tools/balance/lab.ts
@@ -0,0 +1,288 @@
+import { mkdirSync, writeFileSync } from 'node:fs'
+import { availableParallelism } from 'node:os'
+import { isMainThread, parentPort, Worker } from 'node:worker_threads'
+import { LAST_LEVEL } from '../../src/sim/levelgen'
+import { SIM_VERSION } from '../../src/sim/record'
+import { newRun } from '../../src/sim/state'
+import { applyPowerUp, POWER_UP_WEIGHTS, step } from '../../src/sim/turn'
+import type { Action, PowerUpKind } from '../../src/sim/types'
+import { DEBUG, makeBot, SKILLS } from './bot'
+import { playRun, type RunStats, type Setup } from './play'
+import { show } from './show'
+import { estimate, signed } from './stats'
+import { measureShape, samplePositions, type Shape } from './structure'
+
+/**
+ * The balance lab (PLAN.md §19.1). Bots play the game on many seeds; an experiment plays the same
+ * seeds once per arm (the baseline, then each change), so every difference is measured on the
+ * same boards. The runs are spread over the CPU's cores.
+ *
+ *   npm run balance -- powers [--seeds 200] [--skill careful|sloppy]   each power-up held from level 1
+ *   npm run balance -- structure [--seeds 60]                          what each power-up changes on real positions
+ *   npm run balance -- runs [--seeds 20] [--skill …]                   the baseline, run by run
+ *   npm run balance -- trace <seed> <level> [turns]                    one level, board by board
+ */
+
+/** An experiment's arms: a name and what it changes at the start of a run. */
+type Arms = Record<string, Setup>
+
+/** Every power-up, held from level 1 (Coin bag left out: the bots don't shop). */
+function powerArms(): Arms {
+  const arms: Arms = { baseline: () => {} }
+  for (const [kind] of POWER_UP_WEIGHTS) {
+    if (kind === 'coinBag') continue
+    arms[kind] = (s) => applyPowerUp(s, kind as PowerUpKind)
+  }
+  return arms
+}
+
+const EXPERIMENTS: Record<string, () => Arms> = {
+  powers: powerArms,
+  // The baseline alone, or with one power-up (`--arm <power-up>`), run by run.
+  runs: () => ({ baseline: powerArms()[process.env.BALANCE_ARM ?? 'baseline'] ?? (() => {}) }),
+}
+
+interface Task {
+  experiment: string
+  arm: string
+  seed: number
+  skill: string
+  maxLevels: number
+}
+
+function runTask(t: Task): RunStats {
+  const arms = EXPERIMENTS[t.experiment]!()
+  return playRun(t.seed, makeBot(t.seed, SKILLS[t.skill]), arms[t.arm]!, t.maxLevels)
+}
+
+/** The structural measures of one seed's positions, for every arm: arm → one Shape per position. */
+function shapeTask(seed: number): Record<string, Shape[]> {
+  const positions = samplePositions(seed)
+  const out: Record<string, Shape[]> = {}
+  for (const [arm, setup] of Object.entries(powerArms())) out[arm] = positions.map((p) => measureShape(p, setup))
+  return out
+}
+
+if (!isMainThread) {
+  parentPort!.on('message', (m: { task?: Task; shapes?: number }) => parentPort!.postMessage(m.task ? runTask(m.task) : shapeTask(m.shapes!)))
+} else {
+  await main()
+}
+
+function option(args: string[], name: string, fallback: string): string {
+  const i = args.indexOf(`--${name}`)
+  return i >= 0 && args[i + 1] ? args[i + 1]! : fallback
+}
+
+async function main(): Promise<void> {
+  const [command = 'help', ...args] = process.argv.slice(2)
+  if (command === 'trace') return trace(args.map(Number))
+  if (command === 'structure') return structure(Number(option(args, 'seeds', '60')))
+  if (command === 'record-until') return recordUntil(args[0] ?? 'chargeOffered')
+  if (!EXPERIMENTS[command]) {
+    console.log('Commands: powers, structure, runs, trace <seed> <level> [turns]. Options: --seeds N, --skill careful|average|sloppy, --levels N.')
+    return
+  }
+  const seeds = Number(option(args, 'seeds', command === 'runs' ? '20' : '200'))
+  const skill = option(args, 'skill', 'careful')
+  process.env.BALANCE_ARM = option(args, 'arm', 'baseline')
+  const maxLevels = Number(option(args, 'levels', String(LAST_LEVEL)))
+  if (!SKILLS[skill]) throw new Error(`no skill ${skill}`)
+  const arms = Object.keys(EXPERIMENTS[command]!())
+  const tasks: Task[] = arms.flatMap((arm) => Array.from({ length: seeds }, (_, i) => ({ experiment: command, arm, seed: i + 1, skill, maxLevels })))
+  const t0 = performance.now()
+  const results = await runAll<RunStats>(tasks.map((task) => ({ task })))
+  const seconds = (performance.now() - t0) / 1000
+  const byArm = new Map<string, RunStats[]>()
+  tasks.forEach((t, i) => byArm.set(t.arm, [...(byArm.get(t.arm) ?? []), results[i]!]))
+  if (command === 'runs') {
+    for (const r of byArm.get('baseline')!) {
+      console.log(r.seed, r.levelsCleared, r.won ? 'won' : r.stalled ? 'stalled' : 'over', JSON.stringify(r.deaths), r.levels.map((l) => `${l.turns}${l.deaths ? '/' + l.deaths + 'd' : ''}`).join(' '))
+    }
+  }
+  const report = writeReport(command, skill, seeds, byArm, seconds)
+  console.log(report)
+}
+
+/** Every message on a pool of worker threads, one per core but one; results in the messages' order. */
+async function runAll<R>(tasks: unknown[]): Promise<R[]> {
+  const results: R[] = new Array(tasks.length)
+  const workers = Math.max(1, Math.min(tasks.length, availableParallelism() - 1))
+  let next = 0
+  let done = 0
+  await Promise.all(
+    Array.from({ length: workers }, () => new Promise<void>((resolve, reject) => {
+      const worker = new Worker(new URL(import.meta.url))
+      const give = () => {
+        if (next >= tasks.length) {
+          void worker.terminate()
+          resolve()
+          return
+        }
+        const i = next++
+        worker.once('message', (r: R) => {
+          results[i] = r
+          if (++done % 10 === 0 || done === tasks.length) process.stderr.write(`\r${done} / ${tasks.length}`)
+          give()
+        })
+        worker.postMessage(tasks[i])
+      }
+      worker.on('error', reject)
+      give()
+    })),
+  )
+  process.stderr.write('\n')
+  return results
+}
+
+/** A run's numbers that the report compares. */
+function measures(r: RunStats) {
+  const attempted = Math.max(1, r.levels.length)
+  const deaths = Object.values(r.deaths).reduce((a, b) => a + (b ?? 0), 0)
+  return {
+    levels: r.levelsCleared,
+    won: r.won ? 1 : 0,
+    deaths,
+    turnsPerLevel: r.turns / attempted,
+    danger: r.turns ? (100 * r.dangerTurns) / r.turns : 0,
+    stalled: r.stalled ? 1 : 0,
+  }
+}
+
+function writeReport(experiment: string, skill: string, seeds: number, byArm: Map<string, RunStats[]>, seconds: number): string {
+  const base = byArm.get('baseline')!.map(measures)
+  const rows: { arm: string; line: string; delta: number }[] = []
+  for (const [arm, runs] of byArm) {
+    const m = runs.map(measures)
+    const diff = (k: keyof ReturnType<typeof measures>) => estimate(m.map((x, i) => x[k] - base[i]![k]))
+    const plain = (k: keyof ReturnType<typeof measures>) => estimate(m.map((x) => x[k]))
+    const levels = plain('levels')
+    const line = arm === 'baseline'
+      ? `| **baseline** | ${levels.mean.toFixed(2)} | ${(100 * plain('won').mean).toFixed(0)}% | ${plain('deaths').mean.toFixed(2)} | ${plain('turnsPerLevel').mean.toFixed(1)} | ${plain('danger').mean.toFixed(1)}% | ${(100 * plain('stalled').mean).toFixed(0)}% |`
+      : `| ${arm} | ${signed(diff('levels'))} | ${signed({ ...diff('won'), mean: 100 * diff('won').mean, ci: 100 * diff('won').ci }, 0)}% | ${signed(diff('deaths'))} | ${signed(diff('turnsPerLevel'), 1)} | ${signed(diff('danger'), 1)} | ${(100 * plain('stalled').mean).toFixed(0)}% |`
+    rows.push({ arm, line, delta: arm === 'baseline' ? Infinity : diff('levels').mean - diff('deaths').mean / 10 })
+  }
+  rows.sort((a, b) => b.delta - a.delta)
+  // The baseline's difficulty curve: how many runs reach each level, and deaths there.
+  const baseRuns = byArm.get('baseline')!
+  const curve: string[] = []
+  for (let level = 1; level <= LAST_LEVEL; level++) {
+    const at = baseRuns.flatMap((r) => r.levels.filter((l) => l.level === level))
+    if (!at.length) break
+    const deaths = at.reduce((a, l) => a + l.deaths, 0)
+    const turns = at.reduce((a, l) => a + l.turns, 0) / at.length
+    curve.push(`| ${level} | ${((100 * at.length) / baseRuns.length).toFixed(0)}% | ${(deaths / at.length).toFixed(2)} | ${turns.toFixed(0)} | ${((100 * at.filter((l) => l.underPar).length) / at.length).toFixed(0)}% |`)
+  }
+  const causes = new Map<string, number>()
+  for (const r of baseRuns) for (const [c, n] of Object.entries(r.deaths)) causes.set(c, (causes.get(c) ?? 0) + (n ?? 0))
+  const text = [
+    `# Balance lab: ${experiment} (${skill} bot)`,
+    '',
+    `${new Date().toISOString().slice(0, 10)} · rules version ${SIM_VERSION} · ${seeds} seeds per arm · ${seconds.toFixed(0)} s.`,
+    '',
+    'Each arm plays the same seeds as the baseline; its columns are the difference from the baseline,',
+    'per run, with a 95% interval. ✱ marks a difference the interval says is real. Sorted by levels',
+    'gained. The bots answer a gem by taking the first thing offered and never shop.',
+    '',
+    '| Arm | Levels cleared | Won | Lives lost | Turns per level | Turns in danger | Stalled |',
+    '|---|---|---|---|---|---|---|',
+    ...rows.map((r) => r.line),
+    '',
+    '## The baseline, level by level',
+    '',
+    '| Level | Runs reaching it | Lives lost there | Turns there | Within par |',
+    '|---|---|---|---|---|',
+    ...curve,
+    '',
+    `Lives lost in the baseline, by cause: ${[...causes].map(([c, n]) => `${c} ${n}`).join(', ') || 'none'}.`,
+    '',
+  ].join('\n')
+  mkdirSync('docs/balance', { recursive: true })
+  writeFileSync(`docs/balance/${experiment}-${skill}.md`, text)
+  return text
+}
+
+/** One level of one seed, as the Careful bot plays it, board by board. */
+function trace([seed = 1, level = 1, turns = 60]: number[]): void {
+  let s = newRun(seed)
+  powerArms()[process.env.BALANCE_ARM ?? 'baseline']?.(s)
+  const bot = makeBot(seed)
+  let shown = 0
+  while (s.status !== 'gameOver' && s.status !== 'won' && shown < turns) {
+    DEBUG.on = process.env.DEBUG_TURN === String(s.turn) && s.level === level
+    const a = bot.act(s)
+    const r = step(s, a)
+    s = r.state
+    if (s.level !== level) continue
+    shown++
+    const notes = r.events.filter((e) => ['playerDied', 'enemyDied', 'exploded', 'levelRestarted'].includes(e.type)).map((e) => e.type)
+    console.log(`turn ${s.turn} ${JSON.stringify(a)} ${notes.join(' ')} ${s.status} fire ${s.stats.fire} fuse ${s.stats.fuse} charge ${JSON.stringify(s.charge)}\n${show(s)}`)
+  }
+}
+
+/** The structural report: each power-up's effect on escapes, reach and safe actions, on the same positions. */
+async function structure(seeds: number): Promise<void> {
+  const t0 = performance.now()
+  const results = await runAll<Record<string, Shape[]>>(Array.from({ length: seeds }, (_, i) => ({ shapes: i + 1 })))
+  const arms = Object.keys(results[0] ?? {})
+  const pooled = (arm: string) => results.flatMap((r) => r[arm] ?? [])
+  const base = pooled('baseline')
+  const rows = arms.map((arm) => {
+    const m = pooled(arm)
+    // Pairs where either side has no value (no bomb could go down) are left out.
+    const diff = (k: keyof Shape, scale = 1) =>
+      estimate(m.flatMap((x, i) => (x[k] === null || base[i]![k] === null ? [] : [scale * (x[k] - base[i]![k])])))
+    const mean = (k: keyof Shape, scale = 1) => estimate(m.flatMap((x) => (x[k] === null ? [] : [scale * x[k]]))).mean
+    return {
+      arm,
+      key: arm === 'baseline' ? Infinity : diff('escape').mean + diff('reach').mean / 10,
+      line: arm === 'baseline'
+        ? `| **baseline** | ${mean('escape', 100).toFixed(0)}% | ${mean('reach').toFixed(1)} | ${mean('safe').toFixed(1)} |`
+        : `| ${arm} | ${signed(diff('escape', 100), 0)} | ${signed(diff('reach'), 1)} | ${signed(diff('safe'), 1)} |`,
+    }
+  })
+  rows.sort((a, b) => b.key - a.key)
+  const text = [
+    '# Balance lab: structure',
+    '',
+    `${new Date().toISOString().slice(0, 10)} · rules version ${SIM_VERSION} · ${base.length} positions from ${seeds} seeds · ${((performance.now() - t0) / 1000).toFixed(0)} s.`,
+    '',
+    'Positions from the Careful bot\'s games (every 10th turn), measured as they are and with each',
+    'power-up added; the columns are the difference from the baseline, with a 95% interval (✱: real).',
+    '',
+    '- **Escapable bombs:** of the tiles a bomb can go on, the share the player can survive after,',
+    '  getting away by any of their moves (positions where no bomb can go down are left out).',
+    '- **Reach in 3 turns:** distinct tiles the player can be on after up to 3 turns, by any actions.',
+    '- **Safe actions:** actions now that the player can survive after.',
+    '',
+    '| Arm | Escapable bombs | Reach in 3 turns | Safe actions |',
+    '|---|---|---|---|',
+    ...rows.map((r) => r.line),
+    '',
+  ].join('\n')
+  mkdirSync('docs/balance', { recursive: true })
+  writeFileSync('docs/balance/structure.md', text)
+  console.log(text)
+}
+
+/**
+ * The first run record (Careful bot, seeds 1 on) that reaches an event of `type`, printed as JSON:
+ * put in the game's save, it reopens the game at that moment (for screenshots of rare dialogs).
+ */
+function recordUntil(type: string): void {
+  for (let seed = 1; seed <= 200; seed++) {
+    let s = newRun(seed)
+    const bot = makeBot(seed)
+    const actions: Action[] = []
+    for (let n = 0; n < 3000 && s.status !== 'gameOver' && s.status !== 'won'; n++) {
+      const a = bot.act(s)
+      actions.push(a)
+      const r = step(s, a)
+      s = r.state
+      if (r.events.some((e) => e.type === type)) {
+        console.log(JSON.stringify({ simVersion: SIM_VERSION, seed, startLevel: 1, startCharge: null, options: s.options, actions }))
+        return
+      }
+    }
+  }
+}
diff --git a/tools/balance/play.ts b/tools/balance/play.ts
new file mode 100644
index 0000000..62eb130
--- /dev/null
+++ b/tools/balance/play.ts
@@ -0,0 +1,88 @@
+import { predictDanger } from '../../src/sim/danger'
+import { LAST_LEVEL } from '../../src/sim/levelgen'
+import { idx, newRun } from '../../src/sim/state'
+import { step } from '../../src/sim/turn'
+import type { Death, GameState } from '../../src/sim/types'
+import type { Bot } from './bot'
+
+/** A level taking longer than this is given up: the bot is stuck, and the run counts as ended there. */
+const STALL_TURNS = 400
+
+export interface LevelStats {
+  level: number
+  /** Turns over every attempt at it. */
+  turns: number
+  deaths: number
+  cleared: boolean
+  underPar: boolean
+}
+
+export interface RunStats {
+  seed: number
+  levelsCleared: number
+  won: boolean
+  /** Ended because a level took over STALL_TURNS turns. */
+  stalled: boolean
+  turns: number
+  kills: number
+  coins: number
+  deaths: Partial<Record<Death['cause'], number>>
+  /** Turns ended standing on a tile that burns this turn or the next (the danger overlay). */
+  dangerTurns: number
+  /** Out of a blast's reach on the turn it went off. */
+  closeCalls: number
+  levels: LevelStats[]
+}
+
+/** What an experiment changes at the start of a run (a power-up held, a stat raised…). */
+export type Setup = (s: GameState) => void
+
+/** One run with `bot`, from level 1 until game over, the win, a stall, or `maxLevels` cleared. */
+export function playRun(seed: number, bot: Bot, setup: Setup, maxLevels = LAST_LEVEL): RunStats {
+  let s = newRun(seed)
+  setup(s)
+  const stats: RunStats = {
+    seed, levelsCleared: 0, won: false, stalled: false, turns: 0, kills: 0, coins: 0, deaths: {},
+    dangerTurns: 0, closeCalls: 0, levels: [],
+  }
+  let level: LevelStats = { level: s.level, turns: 0, deaths: 0, cleared: false, underPar: false }
+  stats.levels.push(level)
+  for (;;) {
+    if (s.status === 'gameOver' || s.status === 'won') break
+    if (s.status === 'cleared' && stats.levelsCleared >= maxLevels) break
+    if (level.turns > STALL_TURNS) {
+      stats.stalled = true
+      break
+    }
+    const prev = s
+    const action = bot.act(s)
+    const { state, events } = step(s, action)
+    s = state
+    // Whatever the bot did with the turn: what it was worth.
+    if (prev.status === 'playing' && !prev.offer && !prev.chargeOffer && action.id !== 'choose') {
+      level.turns++
+      stats.turns++
+      if (s.status === 'playing' && predictDanger(s).now.has(idx(s, s.player))) stats.dangerTurns++
+      const wasAt = prev.player
+      if (s.status === 'playing' && events.some((e) => e.type === 'exploded' && e.tiles.some((t) => t.x === wasAt.x && t.y === wasAt.y))) stats.closeCalls++
+    }
+    for (const e of events) {
+      if (e.type === 'enemyDied') stats.kills++
+      else if (e.type === 'playerDied') {
+        level.deaths++
+        const cause = s.death?.cause
+        if (cause) stats.deaths[cause] = (stats.deaths[cause] ?? 0) + 1
+      } else if (e.type === 'levelCleared') {
+        level.cleared = true
+        level.underPar = e.parBonus > 0
+        stats.levelsCleared++
+      } else if (e.type === 'levelStarted') {
+        level = { level: e.level, turns: 0, deaths: 0, cleared: false, underPar: false }
+        stats.levels.push(level)
+      }
+    }
+    if (s.status === 'won') stats.won = true
+  }
+  stats.coins = s.coins
+  return stats
+}
diff --git a/tools/balance/run.mjs b/tools/balance/run.mjs
new file mode 100644
index 0000000..6eddb9e
--- /dev/null
+++ b/tools/balance/run.mjs
@@ -0,0 +1,23 @@
+/**
+ * The balance lab's launcher (PLAN.md §19.1): bundles tools/balance/lab.ts with the game's sim
+ * (TypeScript) into .balance/lab.mjs with Vite, then runs it with the same arguments.
+ *
+ *   npm run balance -- <command> [options]     (npm run balance -- help)
+ */
+import { build } from 'vite'
+import { pathToFileURL } from 'node:url'
+import { resolve } from 'node:path'
+
+await build({
+  configFile: false,
+  logLevel: 'warn',
+  build: {
+    ssr: 'tools/balance/lab.ts',
+    outDir: '.balance',
+    emptyOutDir: true,
+    target: 'node22',
+    minify: false,
+    rollupOptions: { output: { entryFileNames: 'lab.mjs' } },
+  },
+})
+await import(pathToFileURL(resolve('.balance/lab.mjs')).href)
diff --git a/tools/balance/show.ts b/tools/balance/show.ts
new file mode 100644
index 0000000..508cd22
--- /dev/null
+++ b/tools/balance/show.ts
@@ -0,0 +1,23 @@
+import type { GameState } from '../../src/sim/types'
+
+/** The board as text, for following a bot: P you, E enemy, 1–9 a bomb, + crate, x/X exit (hidden/open), g gem, $ drop. */
+export function show(s: GameState): string {
+  const rows: string[] = []
+  for (let y = 0; y < s.height; y++) {
+    let r = ''
+    for (let x = 0; x < s.width; x++) {
+      const at = (p: { x: number; y: number }) => p.x === x && p.y === y
+      const c = s.cells[y * s.width + x]
+      const bomb = s.bombs.find((b) => at(b.pos))
+      r += at(s.player) ? 'P'
+        : s.enemies.some((e) => at(e.pos)) ? 'E'
+        : bomb ? String(Math.max(0, bomb.timer))
+        : at(s.exit) ? (c === 'floor' ? 'X' : 'x')
+        : s.gems.some((g) => at(g.pos)) ? (c === 'floor' ? 'g' : 'G')
+        : s.drops.some((d) => at(d.pos)) ? '$'
+        : c === 'wall' ? '#' : c === 'block' ? '+' : '.'
+    }
+    rows.push(r)
+  }
+  return rows.join('\n')
+}
diff --git a/tools/balance/stats.ts b/tools/balance/stats.ts
new file mode 100644
index 0000000..f3ba06c
--- /dev/null
+++ b/tools/balance/stats.ts
@@ -0,0 +1,22 @@
+/** Mean and a 95% confidence interval's half-width, for paired differences and plain samples. */
+export interface Estimate {
+  mean: number
+  /** Half the 95% interval: the true mean is within mean ± ci, 19 times in 20. */
+  ci: number
+  n: number
+}
+
+export function estimate(xs: number[]): Estimate {
+  const n = xs.length
+  if (!n) return { mean: 0, ci: 0, n }
+  const mean = xs.reduce((a, b) => a + b, 0) / n
+  const variance = n > 1 ? xs.reduce((a, x) => a + (x - mean) ** 2, 0) / (n - 1) : 0
+  return { mean, ci: 1.96 * Math.sqrt(variance / n), n }
+}
+
+/** "+1.2 ± 0.3", or "+1.2 ± 0.3 ✱" when the interval leaves out 0 (a real difference). */
+export function signed(e: Estimate, digits = 2): string {
+  const sign = e.mean > 0 ? '+' : e.mean < 0 ? '−' : ' '
+  const real = e.n > 1 && Math.abs(e.mean) > e.ci ? ' ✱' : ''
+  return `${sign}${Math.abs(e.mean).toFixed(digits)} ± ${e.ci.toFixed(digits)}${real}`
+}
diff --git a/tools/balance/structure.ts b/tools/balance/structure.ts
new file mode 100644
index 0000000..2ace4ad
--- /dev/null
+++ b/tools/balance/structure.ts
@@ -0,0 +1,85 @@
+import { ACTIONS } from '../../src/sim/actions'
+import { step } from '../../src/sim/turn'
+import { cloneState } from '../../src/sim/state'
+import type { Action, GameState } from '../../src/sim/types'
+import { canSurvive, candidates, makeBot } from './bot'
+import type { Setup } from './play'
+import { newRun } from '../../src/sim/state'
+
+/**
+ * Structural measures (PLAN.md §19.1): what a power-up changes outright on real positions, no
+ * judgement by a bot needed. The positions come from the Careful bot's own games (every 10th
+ * turn); each is measured as it is and with the power-up added.
+ */
+export interface Shape {
+  /**
+   * Of the tiles a bomb can go on, the share after which the player can still survive, getting away
+   * by any of their moves (a Run, a Teleport…); null where no bomb can go down.
+   */
+  escape: number | null
+  /** Distinct tiles the player can be on after up to 3 turns, by any actions. */
+  reach: number
+  /** Actions now that the player can survive after. */
+  safe: number
+}
+
+/** Positions to measure: every 10th turn of the Careful bot's play of `seed`, up to `max`. */
+export function samplePositions(seed: number, max = 30): GameState[] {
+  const out: GameState[] = []
+  let s = newRun(seed)
+  const bot = makeBot(seed)
+  for (let turn = 0; turn < 3000 && out.length < max; turn++) {
+    if (s.status === 'gameOver' || s.status === 'won') break
+    if (s.status === 'playing' && !s.offer && !s.chargeOffer && s.turn % 10 === 5) out.push(cloneState(s))
+    s = step(s, bot.act(s)).state
+  }
+  return out
+}
+
+const LOOK = 5
+
+/** The ways of moving the player has: every action but those that put down or fire a bomb. */
+const MOVES = new Set(['move', 'run', 'teleport', 'diagonal', 'slide', 'kick', 'wait'])
+const ways = (s: GameState): Action[] => candidates(s).filter((a) => MOVES.has(a.id))
+
+function survivable(s: GameState, a: Action): boolean {
+  try {
+    return canSurvive(step(s, a).state, LOOK, new Set(), ways)
+  } catch {
+    return false
+  }
+}
+
+export function measureShape(position: GameState, setup: Setup): Shape {
+  const s = cloneState(position)
+  setup(s)
+  const bombs = ACTIONS.bomb.validTargets(s).map((target): Action => ({ id: 'bomb', target }))
+  const escape = bombs.length ? bombs.filter((a) => survivable(s, a)).length / bombs.length : null
+  const all = candidates(s)
+  const safe = all.filter((a) => survivable(s, a)).length
+  // Breadth-first over whole turns, one state kept per tile reached.
+  const seen = new Set<string>([`${s.player.x},${s.player.y}`])
+  let frontier = [s]
+  for (let depth = 0; depth < 3; depth++) {
+    const next: GameState[] = []
+    for (const f of frontier) {
+      for (const a of candidates(f)) {
+        let r: GameState
+        try {
+          r = step(f, a).state
+        } catch {
+          continue
+        }
+        if (r.status !== 'playing') continue
+        const key = `${r.player.x},${r.player.y}`
+        if (seen.has(key)) continue
+        seen.add(key)
+        let settled = r.offer ? step(r, { id: 'choose', kind: r.offer[0]! }).state : r
+        if (settled.chargeOffer) settled = step(settled, { id: 'sellCharge' }).state
+        next.push(settled)
+      }
+    }
+    frontier = next
+  }
+  return { escape, reach: seen.size, safe }
+}
diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs
index 917eb60..1613885 100644
--- a/tools/screenshots.mjs
+++ b/tools/screenshots.mjs
@@ -66,9 +66,9 @@ const SHOTS = [
       await page.waitForTimeout(600)
     },
   },
-  // 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') },
+  // Carrying a Nuke (?charge=nuke): the charge button (V) puts a Nuke bomb to the right, and after
+  // a step down the danger shows its reach; the top bar and the button show what is left.
+  { name: 'nuke', query: 'charge=nuke', act: press('KeyV', 'KeyD', 'KeyS') },
   // Seed 107: a bomb by the gem's crate, out of the way while it goes off, then back: the gem
   // uncovered beside the player... (found by searching random play with the sim, 2026-10-24; the
   // old seed-55 moves no longer reached a gem)
@@ -107,8 +107,8 @@ const SHOTS = [
       await page.click(`[data-tab=${tab}]`)
     },
   })),
-  // 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 one charge carried (?charge=pierce), in the top bar's own row and on the charge button.
+  { name: 'charges', query: 'charge=pierce', act: async () => {} },
   // The demo before the game (?demo): its finger holding on a tile for a bomb (the ring closed in,
   // the bomb's blast previewed), over the action bar and the caption. Taken mid-press, so no settling.
   {
diff --git a/tsconfig.json b/tsconfig.json
index d49d58e..0ed6fc9 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -13,5 +13,5 @@
     "skipLibCheck": true,
     "types": ["vite/client", "node"]
   },
-  "include": ["src", "test", "vite.config.ts"]
+  "include": ["src", "test", "tools/balance", "vite.config.ts"]
 }