fuzzyworld

explodorama / commit

Board scenes and almanac tab, boxed in costs a life, NES placement measured

6c3fcc3172c6fd6da6ffd4e20ed388a98716b953 · Pavel Safronov · 2026-10-25 23:14:16 -0700 · parents 5f06696031f7

Claude Code usage: this commit ≈0 out / ≈0 cached · cost unknown (no per-model trailer) · session 4 tally 6.8K in / 3.1M out / 1.5G cached · usage by commit and session

Board scenes and almanac tab, boxed in costs a life, NES placement measured

- Demo scenes for the board objects: hiding in a bush, a bush fire, metal
  stopping flame, an oil spill, crossing spikes, a barrel going off.
- The almanac's Board tab (crates, pillars, objects, rubble), sharing its
  text with "What is that?"; four tabs sit two by two on a phone.
- Boxed in (nowhere to step or put a bomb, nothing left to change it)
  costs a life, instead of a soft-lock after a Teleport or falling rubble.
- Lab: the bot bursts oil barrels; traces show the objects; a lab-only
  switch for the NES rule (bomb under the player), measured and summarised
  in the README. Bombs stay next to the player.
- SIM_VERSION 39.

Usage: 6.8K in / 3.1M out / 1.5G cached (session tally)
rtk:   2.3M saved, 55.6% over 4881 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 CHANGELOG.md                      |   2 +
 PLAN.md                           |   6 +-
 README.md                         |  59 +++++++++++++++++---
 decisions.md                      |  52 ++++++++++++++++++
 docs/balance/runs-careful.md      |  42 +++++++-------
 docs/balance/underfoot-average.md |  36 ++++++++++++
 docs/balance/underfoot-careful.md |  39 +++++++++++++
 src/main.ts                       |   1 +
 src/sim/actions.ts                |  17 +++++-
 src/sim/demo.ts                   |  81 ++++++++++++++++++++++++++-
 src/sim/record.ts                 |   2 +-
 src/sim/turn.ts                   |   7 ++-
 src/sim/types.ts                  |   7 ++-
 src/style.css                     |  11 +++-
 src/ui/almanac.ts                 |  14 ++++-
 src/ui/info.ts                    | 112 +++++++++++++++++++++++++-------------
 test/charges.test.ts              |  30 ++++++++++
 test/new-enemies.test.ts          |   2 +
 tools/balance/bot.ts              |  15 +++--
 tools/balance/lab.ts              |  22 ++++++--
 tools/balance/show.ts             |  11 +++-
 21 files changed, 476 insertions(+), 92 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4192c26..a1f47d2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ saved before the change is dropped and the next visit starts a new run.
 
 ### Added
 
+- **Demo scenes for the board objects** (hiding in a bush, a bush fire, metal stopping flame, an oil spill, crossing spikes, a barrel going off), and a **Board** tab in the almanac listing crates, pillars, every object and rubble, with clips.
 - **Board objects:** bushes (they hide you from chasers, and burn a bush a turn), metal crates (nothing breaks them), oil spills (flame burns a whole spill at once), spikes (up every other turn) and oil barrels (flame bursts one: oil round it and a blast the next turn), each from its own level on. Version 37: runs saved before don't resume.
 - **The Acid charge:** a bomb that cracks pillars; a later blast breaks a cracked one, and 4 turns on the ceiling round it falls as rubble for good, crushing what is under it. Two new ways to lose a life: crushed, and walled in (trapped).
 - **TNT**, a power-up worth two Fire +1s, and **a limit of 5 on bombs, blast and fuse**; a power-up with no room left isn't offered, and cards show the limit. Version 34.
@@ -95,6 +96,7 @@ saved before the change is dropped and the next visit starts a new run.
 
 ### Changed
 
+- **Boxed in costs a life:** with nowhere to step and nowhere to put a bomb, and nothing left on the board that could change it (a Teleport into a gap between crates, rubble falling round you), you lose a life instead of being stuck for good. Version 39: runs saved before don't resume.
 - **The danger overlay marks a bomb's tiles from the moment it is placed**, as a red outline until its last two turns, however long the fuse.
 - **Shield and Flame Pass work by themselves while carried:** no button to press. A hit of their kind uses them up (Flame Pass after 3 fire hits) and the charge slot empties. Version 38: runs saved before don't resume.
 - **Diagonal no longer squeezes** between two blocked tiles: one of the two tiles beside a diagonal step must be open. The squeeze was most of what made Diagonal so strong. Version 36.
diff --git a/PLAN.md b/PLAN.md
index ecf51e7..748005e 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -1344,7 +1344,11 @@ spikes**, up or down (simpler to read than planning round the raised ones, and s
 hurt them); a metal crate is placed only where every open tile stays reachable, and Kick doesn't
 move it. Sprites: `bush`, `spikes`, the strong crate in steel grey (`block_metal`, composed),
 `block_strong_danger` for the barrel, a near-black glossy splat for oil. Death causes `spikes` and
-`spread` (fire over oil or bushes). Not yet: the almanac's **Board** tab, the spikes' turn count.
+`spread` (fire over oil or bushes). *Also 2026-10-26:* being **boxed in** (nowhere to step or put a bomb, nothing left to change it)
+costs a life. *Also built the same day:* the almanac's **Board** tab (crates,
+pillars, the five objects and rubble, with their first levels and clips) and a demo scene for each
+object (a bush hiding you, a bush fire, metal stopping flame, oil, spikes, a barrel). Not yet: the
+spikes' turn count.
 
 ### 19.4 The Acid bomb: weakening pillars, bringing the roof down
 
diff --git a/README.md b/README.md
index 62aeb17..1aabddb 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ seven charges (one carried at a time, used when you choose), a hidden gem on eve
 mid-run work. Curses and the Reaper enemy are planned —
 see [PLAN.md](PLAN.md).
 
-Version: **38**, shown at the bottom of the menu with the commit the game was built from (a link
+Version: **39**, 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
@@ -49,8 +49,10 @@ narrow escape, chain reactions, lingering flame, the first three enemy types, dr
 power-up you can see in action (Bomb +1, Fire +1, Fuse +1, Run, Kick — a tap on a bomb next to
 you —, Throw, Diagonal, Slide, RPG, the Detonator — a tap on your bomb —, Proximity
 alert, Wall / Bomb Pass,
-Star) and every charge (Pierce, Power, Nuke, Shield,
-Freeze, Decoy, ICBM — each used with the charge button), **the charge swap** (carrying a
+Star), every charge (Pierce, Power, Nuke, Acid, Shield, Freeze, Decoy, ICBM, Flame Pass —
+the ones you use, with the charge button), **the board's objects** (hiding in a bush, a bush fire
+creeping on, flame stopping at a metal crate, an oil spill burning all at once, crossing spikes
+while they're down, an oil barrel bursting and going off), **the charge swap** (carrying a
 Pierce, the player picks up a Nuke; the choice opens over the scene and the finger taps Take
 Nuke), and the exit (walking out once it opens; blasting it lets enemies out). Heart and
 Coin bag only change numbers, so they have no scene; nor does the shop. Every other scene shows
@@ -141,8 +143,9 @@ a still picture of its moment instead.
 and seed, the **Sound** and **Vibration** switches (see *Sound and vibration* below), and its
 buttons, kept compact: **Resume** and **New** side by side (New starts a new run and asks first,
 since the current run can't be got back), then one row of small ones — **Almanac** (every
-power-up, charge and enemy type, with its picture and what it does, in three tabs; an entry with
-a green ▶ opens its clip), **Scores** (the high scores), **Demo** (the demo again) and **Bug** (an
+power-up, charge and enemy type, and everything on the board (crates, pillars, bushes, metal
+crates, oil, spikes, oil barrels, rubble, with the level each first turns up on), with its picture
+and what it does, in four tabs; an entry with a green ▶ opens its clip), **Scores** (the high scores), **Demo** (the demo again) and **Bug** (an
 email to the game's GitLab Service Desk, which needs no GitLab account, filled in with the
 game's version, your device and the run, so it can be replayed; a long run's record goes on your
 clipboard to paste in).
@@ -213,6 +216,9 @@ will end up, a faded bomb with its blast, a kicked bomb's stopping place, a chan
   (a Flame Pass carried, a Star), then you can walk through it like floor (with Flame Pass, using one
   of its hits). It goes out after that turn.
 - Bombs block movement, for you and for enemies.
+- **Bombs go on a tile next to you**, not under you as in the original. Both were measured with
+  the balance lab; see *Measured: bombs under the player* below for how the original rule would
+  change the game.
 - Bombs can't be placed on an enemy.
 - Pierce, Power, Nuke and Acid bombs are put down with the charge button (see Charges above); such a
   bomb wears its charge's icon as a badge.
@@ -354,7 +360,9 @@ runs: leftover coins only count on the high-score board.
 ### Lives
 
 You have **3 lives**. Caught in a blast or touched by an enemy (or on raised spikes, under a
-falling ceiling, or walled in by rubble), you lose one and the level starts
+falling ceiling, walled in by rubble, or **boxed in** — nowhere to step and nowhere to put a
+bomb, with nothing left on the board that could change that, as after a Teleport into a gap
+between two crates), 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). **The charge you carry
 drops where you fell** (since 2026-10-25), with what was left of it: walk back over it on the
@@ -459,7 +467,7 @@ npm run balance -- help   # the balance lab: bots play many games to measure the
 
 **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
+(checks every turn that it can survive what the enemies and bombs will do next; wins about 90% 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/`:
@@ -467,12 +475,47 @@ 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 -- underfoot` — bombs next to the player (the rule) against under them (the
+  original's rule; a lab-only switch). Results below.
 - `npm run balance -- runs` and `npm run balance -- trace <seed> <level>` — the bot's play, run by
-  run or board by board.
+  run or board by board (`--arm <name>` plays a power-up or rule variant).
 
 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.
 
+#### Measured: bombs under the player (2026-10-26)
+
+In the original, a bomb goes down on your own tile; here it goes on a tile next to you. To see what
+the original's rule would change, the lab played both on the same 200 seeds (reports:
+[careful](docs/balance/underfoot-careful.md), [average](docs/balance/underfoot-average.md)). Under
+the original's rule you walk off your bomb and can't walk back; enemies can't step onto it, so
+not onto you while you stand there. Differences from the current rule (✱: larger than chance):
+
+| Bot | Rule | Levels cleared | Won | Lives lost | Turns per level | Turns in danger | Stuck |
+|---|---|---|---|---|---|---|---|
+| careful | next to you (current) | 19.3 | 93% | 0.24 | 83 | 5.4% | 4% |
+| careful | under you (original) | −0.6 | −6% ✱ | +0.12 | +12 ✱ | +5.0 points ✱ | 8% |
+| average | next to you (current) | 4.1 | 0% | 3.00 | 76 | 3.4% | 0% |
+| average | under you (original) | +0.6 ✱ | 0% | ±0 | +9 ✱ | +9.3 points ✱ | 1% |
+
+What it would change:
+
+1. **Slower:** 10–15% more turns per level. Every bomb starts with you inside its blast, and
+   getting out takes moves; next to you, you start a tile away.
+2. **Riskier:** about twice the turns spent on tiles about to burn, and the careful bot dies to its
+   own blasts about twice as often.
+3. **A little kinder to weaker play:** the average bot clears 0.6 more levels. Standing on your
+   bomb keeps enemies off you, and you can't shut your only way out by dropping a bomb into it.
+4. **Cornered enemies get harder:** to bomb an enemy at the end of a dead end, you have to stand in
+   the corridor's blast line and still get out in time. Half of the careful bot's lost wins are
+   such levels; the other half are it refusing to spend a life when boxed in (a person would bomb
+   their way out).
+
+**Decided (2026-10-26): bombs stay next to you.** The original's rule is a fair choice, not a
+balance risk — it trades pace for the classic feel — but the current rule keeps turns quick.
+What the original's rule did have going for it, never being stuck with nowhere to put a bomb, is
+covered by **boxed in** (see *Lives*): it costs a life instead of leaving you stuck.
+
 URL parameters: `?seed=123` plays a fixed board; `?level=4` starts at a later level (level 19
 has all seven enemy types); `?charge=nuke` (or any charge) starts carrying that charge, for
 trying it out; `?level=3&shop` opens the shop after that level straight away, with 9 coins; `?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
diff --git a/decisions.md b/decisions.md
index 2cb5b92..f28d10e 100644
--- a/decisions.md
+++ b/decisions.md
@@ -2150,3 +2150,55 @@ the player waiting and enemies left where they are, every tile burned (chains, o
 or crushed until nothing is left to go off (at most 12 more turns), minus what is already `now` or
 `next`. Drawn as a red outline, so it reads as "not yet" beside the two fills. Not used by
 auto-move's safety check or the bots, which still look at `now` only. No rule change.
+
+### 2026-10-25 — Board scenes, the almanac's Board tab, and the lab's stalls
+
+User request: work on the open items.
+
+1. **Demo scenes for the objects:** `bush` (stepping into one), `bushFire` (a smouldering bush
+   catching), `metal` (a flame stopped short at one), `oil` (a spill burning at once), `spikes`
+   (stepping on them while down), `barrel` (a burst barrel going off). A room gets a few of the
+   object by the player (`placeObjects` in demo.ts); a scene's reset to turn 0 shifts the spikes'
+   phase so they keep their rhythm. Found for every test seed like the rest.
+2. **The almanac's Board tab:** `BOARD_TEXT` in ui/info.ts, shared with "What is that?" so the two
+   can't disagree. Four tabs don't fit a phone's row: two rows of two below 440 px.
+3. **The lab's stalls (15% of runs since the objects):** the traces (the board printout now shows
+   the objects) found two causes. (a) The bot never bombed an oil barrel, so barrels shutting in the
+   exit or the last enemy (with a metal crate on the other side) stopped it: a bot fault, fixed in
+   the bot (barrels count as crates in its plans). (b) Its last Teleport jump landed in a one-tile
+   pocket between two crates: nowhere to step, nowhere to put a bomb, stuck for good — a trap a
+   person could fall into too. **Rule:** the last jump of a Teleport charge may only land on a tile
+   with a passable tile next to it (or next to the player's own); earlier jumps may, since the next
+   gets out (*replaced the next day by "boxed in costs a life", below*). `SIM_VERSION` 39. Now: stalls 3% (1 of 40, the bot dithering by an enemy, its old
+   weakness), wins 90%, 19.1 levels, 0.53 lives lost.
+
+### 2026-10-26 — Measured: bombs under the player (the NES rule)
+
+User question: how would the original's placement — the bomb on the player's own tile — change
+things? A lab-only rule switch (`RunOptions.lab.underfoot`, never set in play): Bomb (and a charged
+bomb) goes on the player's tile; the player walks off it and can't walk back, enemies can't enter
+it. The bots stand on the spot instead of next to it; nothing else of theirs changed.
+`npm run balance -- underfoot` (reports in docs/balance/underfoot-*.md), 200 seeds:
+
+| Bot | Levels | Won | Lives lost | Turns per level | Turns in danger | Stalled |
+|---|---|---|---|---|---|---|
+| careful, next to (now) | 19.32 | 93% | 0.24 | 83.4 | 5.4% | 4% |
+| careful, under (NES) | −0.56 ± 0.61 | −6 ± 5% ✱ | +0.12 ± 0.13 | +12.3 ✱ | +5.0 ✱ | 8% |
+| average, next to (now) | 4.11 | 0% | 3.00 | 75.5 | 3.4% | 0% |
+| average, under (NES) | +0.62 ± 0.47 ✱ | 0 | −0.02 | +9.2 ✱ | +9.3 ✱ | 1% |
+
+Read: slower (a bomb starts with you in its blast, and getting out takes turns), about twice the
+turns spent in danger, own-blast deaths up for the careful bot; the weaker bot does a little better
+(enemies can't step onto you while you stand on your bomb, and a bomb can't shut your only way
+out). Half the careful bot's extra stalls are its refusal to spend a life: walled in by rubble
+next to a crate it waited for ever, where a person would bomb out. That case is a **soft-lock under
+the current rule** too: no free tile to put a bomb on, and the crate means it doesn't count as
+trapped. Left to the user.
+- **Decided (user, same day): bombs stay next to the player**, and the summary goes in the README
+  (*Measured: bombs under the player*). The variant stays in the lab. With it: **boxed in costs a
+  life** (`boxedIn` in actions.ts, death cause `boxedIn`): at the end of a turn, nothing available
+  but Wait, and no bomb, flame, falling ceiling, smouldering bush or crumbling crate left that could
+  change it. A Shield or Star doesn't help. It replaces the Teleport restriction of the day before
+  (the last jump couldn't land in a dead end): the user would rather the player pay for the
+  mistake, and it covers rubble boxing the player in next to a crate too. Lab: stalls 0% (40
+  seeds), wins 93%.
diff --git a/docs/balance/runs-careful.md b/docs/balance/runs-careful.md
index f9c715d..8cfcef7 100644
--- a/docs/balance/runs-careful.md
+++ b/docs/balance/runs-careful.md
@@ -1,6 +1,6 @@
 # Balance lab: runs (careful bot)
 
-2026-10-25 · rules version 37 · 40 seeds per arm · 29 s.
+2026-10-26 · rules version 39 · 40 seeds per arm · 34 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
@@ -8,7 +8,7 @@ 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** | 19.45 | 80% | 0.45 | 91.1 | 5.1% | 15% |
+| **baseline** | 19.23 | 93% | 0.47 | 87.1 | 5.0% | 0% |
 
 ## The baseline, level by level
 
@@ -16,23 +16,23 @@ gained. The bots answer a gem by taking the first thing offered and never shop.
 |---|---|---|---|---|
 | 1 | 100% | 0.03 | 73 | 13% |
 | 2 | 100% | 0.00 | 76 | 20% |
-| 3 | 100% | 0.00 | 62 | 33% |
-| 4 | 100% | 0.00 | 91 | 30% |
-| 5 | 100% | 0.03 | 96 | 30% |
-| 6 | 100% | 0.03 | 95 | 23% |
-| 7 | 100% | 0.05 | 85 | 38% |
-| 8 | 100% | 0.05 | 107 | 33% |
-| 9 | 100% | 0.05 | 100 | 48% |
-| 10 | 100% | 0.03 | 99 | 40% |
-| 11 | 100% | 0.00 | 100 | 58% |
-| 12 | 100% | 0.00 | 100 | 63% |
-| 13 | 100% | 0.00 | 91 | 68% |
-| 14 | 100% | 0.05 | 87 | 75% |
-| 15 | 100% | 0.03 | 87 | 88% |
-| 16 | 98% | 0.00 | 100 | 77% |
-| 17 | 98% | 0.00 | 103 | 87% |
-| 18 | 93% | 0.00 | 84 | 95% |
-| 19 | 93% | 0.11 | 95 | 92% |
-| 20 | 85% | 0.03 | 81 | 94% |
+| 3 | 100% | 0.00 | 63 | 30% |
+| 4 | 100% | 0.00 | 92 | 30% |
+| 5 | 100% | 0.03 | 93 | 28% |
+| 6 | 100% | 0.07 | 84 | 30% |
+| 7 | 98% | 0.05 | 89 | 31% |
+| 8 | 98% | 0.05 | 101 | 36% |
+| 9 | 98% | 0.05 | 94 | 59% |
+| 10 | 98% | 0.00 | 96 | 41% |
+| 11 | 98% | 0.00 | 92 | 51% |
+| 12 | 98% | 0.00 | 93 | 74% |
+| 13 | 98% | 0.10 | 85 | 72% |
+| 14 | 93% | 0.05 | 92 | 76% |
+| 15 | 93% | 0.00 | 79 | 86% |
+| 16 | 93% | 0.00 | 101 | 81% |
+| 17 | 93% | 0.00 | 86 | 86% |
+| 18 | 93% | 0.03 | 89 | 89% |
+| 19 | 93% | 0.03 | 79 | 97% |
+| 20 | 93% | 0.00 | 75 | 100% |
 
-Lives lost in the baseline, by cause: walkedIntoEnemy 7, caughtByEnemy 4, blast 5, trapped 1, chain 1.
+Lives lost in the baseline, by cause: walkedIntoEnemy 9, blast 5, trapped 1, caughtByEnemy 3, chain 1.
diff --git a/docs/balance/underfoot-average.md b/docs/balance/underfoot-average.md
new file mode 100644
index 0000000..5cd5c56
--- /dev/null
+++ b/docs/balance/underfoot-average.md
@@ -0,0 +1,36 @@
+# Balance lab: underfoot (average bot)
+
+2026-10-26 · rules version 39 · 200 seeds per arm · 28 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** | 4.11 | 0% | 3.00 | 75.5 | 3.4% | 0% |
+| underfoot (NES rule) | +0.62 ± 0.47 ✱ |  0 ± 0% | −0.02 ± 0.03 | +9.2 ± 5.1 ✱ | +9.3 ± 0.4 ✱ | 1% |
+
+## The baseline, level by level
+
+| Level | Runs reaching it | Lives lost there | Turns there | Within par |
+|---|---|---|---|---|
+| 1 | 100% | 0.21 | 75 | 14% |
+| 2 | 97% | 0.45 | 75 | 26% |
+| 3 | 87% | 0.63 | 65 | 23% |
+| 4 | 69% | 0.90 | 90 | 20% |
+| 5 | 49% | 0.86 | 86 | 17% |
+| 6 | 34% | 0.64 | 86 | 25% |
+| 7 | 25% | 0.66 | 80 | 26% |
+| 8 | 18% | 0.80 | 87 | 17% |
+| 9 | 11% | 0.73 | 85 | 36% |
+| 10 | 8% | 0.87 | 83 | 60% |
+| 11 | 6% | 0.45 | 74 | 27% |
+| 12 | 4% | 0.63 | 83 | 63% |
+| 13 | 3% | 1.67 | 76 | 33% |
+| 14 | 1% | 0.50 | 57 | 0% |
+| 15 | 1% | 0.00 | 62 | 100% |
+| 16 | 1% | 0.00 | 67 | 100% |
+| 17 | 1% | 1.00 | 5 | 0% |
+
+Lives lost in the baseline, by cause: blast 496, lingering 11, chain 16, walkedIntoEnemy 47, caughtByEnemy 24, spikes 1, spread 5.
diff --git a/docs/balance/underfoot-careful.md b/docs/balance/underfoot-careful.md
new file mode 100644
index 0000000..87397c5
--- /dev/null
+++ b/docs/balance/underfoot-careful.md
@@ -0,0 +1,39 @@
+# Balance lab: underfoot (careful bot)
+
+2026-10-26 · rules version 39 · 200 seeds per arm · 287 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** | 19.32 | 93% | 0.24 | 83.4 | 5.4% | 4% |
+| underfoot (NES rule) | −0.56 ± 0.61 | −6 ± 5 ✱% | +0.12 ± 0.13 | +12.3 ± 2.9 ✱ | +5.0 ± 0.3 ✱ | 8% |
+
+## The baseline, level by level
+
+| Level | Runs reaching it | Lives lost there | Turns there | Within par |
+|---|---|---|---|---|
+| 1 | 100% | 0.02 | 66 | 19% |
+| 2 | 100% | 0.01 | 69 | 25% |
+| 3 | 100% | 0.01 | 65 | 32% |
+| 4 | 100% | 0.00 | 85 | 34% |
+| 5 | 100% | 0.01 | 81 | 36% |
+| 6 | 100% | 0.02 | 80 | 38% |
+| 7 | 99% | 0.01 | 85 | 38% |
+| 8 | 99% | 0.02 | 100 | 45% |
+| 9 | 98% | 0.02 | 96 | 47% |
+| 10 | 98% | 0.03 | 85 | 59% |
+| 11 | 97% | 0.01 | 85 | 63% |
+| 12 | 96% | 0.00 | 93 | 73% |
+| 13 | 96% | 0.01 | 84 | 74% |
+| 14 | 96% | 0.04 | 84 | 78% |
+| 15 | 95% | 0.01 | 77 | 88% |
+| 16 | 95% | 0.01 | 94 | 85% |
+| 17 | 94% | 0.00 | 86 | 87% |
+| 18 | 94% | 0.01 | 87 | 94% |
+| 19 | 94% | 0.02 | 79 | 96% |
+| 20 | 93% | 0.02 | 75 | 98% |
+
+Lives lost in the baseline, by cause: walkedIntoEnemy 25, blast 14, trapped 1, caughtByEnemy 6, chain 2, spikes 1.
diff --git a/src/main.ts b/src/main.ts
index b97402b..57fb09e 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -880,6 +880,7 @@ const SCENE_ICONS: Record<SceneKind, string> = {
   dieDetonator: POWER_UP_ICONS.detonator, dieRun: POWER_UP_ICONS.run,
   rpg: POWER_UP_ICONS.rpg, proximity: POWER_UP_ICONS.proximity, slide: POWER_UP_ICONS.slide, icbm: CHARGE_ICONS.icbm,
   acid: CHARGE_ICONS.acid,
+  bush: 'bush', bushFire: 'bush', metal: 'block_metal', oil: 'oil', spikes: 'spikes', barrel: 'block_strong_danger',
 }
 
 
diff --git a/src/sim/actions.ts b/src/sim/actions.ts
index 4e15d4b..476b847 100644
--- a/src/sim/actions.ts
+++ b/src/sim/actions.ts
@@ -113,6 +113,18 @@ export function teleportSteps(s: GameState, dx: number, dy: number): number {
   return s.powers.diagonal ? Math.max(Math.abs(dx), Math.abs(dy)) : Math.abs(dx) + Math.abs(dy)
 }
 
+/**
+ * Boxed in for good (user's choice, 2026-10-26): nothing the player can do but wait, and nothing
+ * left that could change that — no bomb on the board, no flame, no ceiling about to fall, no bush
+ * smouldering, no crates crumbling. Nowhere to step and nowhere to put a bomb: a
+ * jump into a pocket between crates, or rubble falling round the player. It costs a life, as
+ * walled in by rubble does, rather than leaving the player waiting for ever.
+ */
+export function boxedIn(s: GameState): boolean {
+  if (s.bombs.length || s.burning.length || s.collapsing.length || s.smoldering.length || s.crumbling.length) return false
+  return ACTION_ORDER.every((id) => id === 'wait' || !isAvailable(s, id))
+}
+
 function hasCapacity(s: GameState): boolean {
   // A burst oil barrel's blast isn't one of the player's bombs.
   return s.bombs.filter((b) => !b.barrel).length < s.stats.capacity
@@ -120,7 +132,10 @@ function hasCapacity(s: GameState): boolean {
 
 /** The adjacent tiles a bomb can go on, if the player may place one. */
 function bombTargets(s: GameState): Vec[] {
-  return hasCapacity(s) ? neighbours(s.player).filter((p) => canHoldBomb(s, p)) : []
+  if (!hasCapacity(s)) return []
+  // The lab's `underfoot` variant: the NES rule, on the player's own tile.
+  if (s.options.lab?.underfoot) return canHoldBomb(s, s.player) ? [s.player] : []
+  return neighbours(s.player).filter((p) => canHoldBomb(s, p))
 }
 
 function walkTo(s: GameState, target: Vec, events: GameEvent[]): void {
diff --git a/src/sim/demo.ts b/src/sim/demo.ts
index 97c373f..301eb69 100644
--- a/src/sim/demo.ts
+++ b/src/sim/demo.ts
@@ -42,6 +42,7 @@ export type SceneKind =
   | 'pierce' | 'power' | 'nuke' | 'acid' | 'shield' | 'freeze' | 'decoy' | 'chargeSwap'
   | 'exit' | 'doorBlast'
   | 'lastEnemy' | 'crate' | 'pillar' | 'rainbowGem' | 'immortal'
+  | 'bush' | 'bushFire' | 'metal' | 'oil' | 'spikes' | 'barrel'
   | 'dieIntoBlast' | 'dieNotFar' | 'dieBlockedIn' | 'dieChain' | 'dieEnemy' | 'dieDetonator' | 'dieRun'
 
 /**
@@ -469,6 +470,54 @@ export const SCENES: Record<SceneKind, SceneDef> = {
     clipOnly: true,
     shows: (t) => explosions(t).some((e) => DIR_LIST.some((d) => t.prev.cells[idx(t.prev, { x: e.pos.x + DIRS[d].x, y: e.pos.y + DIRS[d].y })] === 'wall' && !isEdge(t.prev, { x: e.pos.x + DIRS[d].x, y: e.pos.y + DIRS[d].y }))),
   },
+  // The board's objects (PLAN.md §19.3; 2026-10-25), each in a room with a few of them by the player.
+  bush: {
+    caption: 'Bushes: step into one to hide. Enemies chasing you head for where they last saw you.',
+    enemies: ['hunter', 'seeker'],
+    prepare: (s, rng) => placeObjects(s, rng, 'bush', 2, 3),
+    shows: (t) =>
+      t.next.status === 'playing' && t.next.bushes.includes(idx(t.next, t.next.player)) && !t.prev.bushes.includes(idx(t.prev, t.prev.player)),
+  },
+  bushFire: {
+    caption: 'Bushes burn, and the fire creeps on to the next bush a turn later.',
+    enemies: ['blob'],
+    prepare: (s, rng) => placeObjects(s, rng, 'bush', 2, 4),
+    shows: (t) => t.next.status === 'playing' && t.prev.smoldering.length > 0 && t.events.some((e) => e.type === 'fireSpread'),
+  },
+  metal: {
+    caption: 'Metal crates: nothing breaks them, and flame stops before them.',
+    enemies: ['blob'],
+    prepare: (s, rng) => ((s.stats.fire = 2), placeObjects(s, rng, 'metal', 2, 2)),
+    // A flame that stopped short of its reach at a metal crate.
+    shows: (t) =>
+      explosions(t).some((e) =>
+        DIR_LIST.some((d) => {
+          let p = e.pos
+          while (e.tiles.some((q) => at(q, { x: p.x + DIRS[d].x, y: p.y + DIRS[d].y }))) p = { x: p.x + DIRS[d].x, y: p.y + DIRS[d].y }
+          const next = { x: p.x + DIRS[d].x, y: p.y + DIRS[d].y }
+          return inBounds(t.prev, next) && t.prev.cells[idx(t.prev, next)] === 'metal'
+        }),
+      ),
+  },
+  oil: {
+    caption: 'Oil: safe to walk on, but flame on any of it burns the whole spill at once.',
+    enemies: ['blob', 'blob'],
+    prepare: (s, rng) => placeObjects(s, rng, 'oil', 2, 4),
+    shows: (t) => t.next.status === 'playing' && t.events.some((e) => e.type === 'fireSpread' && e.tiles.length >= 2),
+  },
+  spikes: {
+    caption: 'Spikes: up one turn, down the next. Cross while they are down; enemies never step on them.',
+    enemies: ['blob'],
+    prepare: (s, rng) => placeObjects(s, rng, 'spikes', 1, 3),
+    shows: (t) => t.next.status === 'playing' && moved(t) && t.prev.spikes.some((sp) => sp.cell === idx(t.next, t.next.player)),
+  },
+  barrel: {
+    caption: 'Oil barrel: flame bursts it, spilling oil, and the next turn it explodes.',
+    enemies: ['blob'],
+    // Fire 2 and two barrels, so random play bursts one often enough.
+    prepare: (s, rng) => ((s.stats.fire = 2), placeObjects(s, rng, 'barrel', 2, 2)),
+    shows: (t) => t.next.status === 'playing' && explosions(t).some((e) => t.prev.bombs.some((b) => b.barrel && at(b.pos, e.pos))),
+  },
   // The ways to die.
   dieIntoBlast: {
     caption: 'Ouch! Walked straight into a blast. Check the red tiles before you step.',
@@ -588,7 +637,9 @@ function ending(kind: SceneKind, game: GameTurn[]): Scene | null {
     if (s.bombs.length || s.burning.length || first.gesture.kind === 'go' || first.walking) continue
     if (s.drops.some((d) => !room.drops.some((r) => at(r.pos, d.pos) && r.kind === d.kind))) continue
     const turns = game.slice(-n)
-    const scene = { kind, start: { ...s, turn: 0, coins: 0 }, actions: turns.map((t) => t.action), gestures: turns.map((t) => t.gesture) }
+    // Spikes keep their rhythm across the reset to turn 0.
+    const spikes = s.spikes.map((sp) => ({ ...sp, phase: (sp.phase + s.turn) % 2 }))
+    const scene = { kind, start: { ...s, turn: 0, coins: 0, spikes }, actions: turns.map((t) => t.action), gestures: turns.map((t) => t.gesture) }
     if (showsAtEnd(scene)) return scene
   }
   return null
@@ -613,7 +664,7 @@ function makeRoom(kind: SceneKind, rng: Rng): GameState {
     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,
     charge: null, chargeOffer: null, frozen: 0, decoy: null, burning: [],
-    powers: noPowers(), star: 0, gems: [], offer: null, shop: null, death: null, runTurns: 0, runKills: 0, deaths: 0, crumbling: [], bushes: [], smoldering: [], oil: [], spikes: [], cracked: [], collapsing: [], lastSeen: { ...player }, 
+    powers: noPowers(), star: 0, gems: [], offer: null, shop: null, death: null, runTurns: 0, runKills: 0, deaths: 0, crumbling: [], bushes: [], smoldering: [], oil: [], spikes: [], cracked: [], collapsing: [], lastSeen: { ...player },
     aiRng: rng.int(2 ** 31), dropRng: rng.int(2 ** 31),
   }
   const open = floor().filter((p) => steps(p, player) >= 3)
@@ -682,6 +733,32 @@ function placeExit(s: GameState, rng: Rng, covered: boolean): void {
   if (covered) s.cells[idx(s, s.exit)] = 'block'
 }
 
+/**
+ * `count` of a board object round a free floor tile `n` steps from the player: bushes and oil in a
+ * touching clump, spikes, metal crates and barrels on tiles of their own (never next to the player,
+ * for crates and barrels, so there is room to move).
+ */
+function placeObjects(s: GameState, rng: Rng, kind: 'bush' | 'oil' | 'spikes' | 'metal' | 'barrel', n: number, count: number): void {
+  const near = tilesAt(s, n)
+  if (!near.length) return
+  const placed: Vec[] = [near[rng.int(near.length)]!]
+  const free = (p: Vec) =>
+    inBounds(s, p) && s.cells[idx(s, p)] === 'floor' && !at(p, s.player) && !placed.some((q) => at(q, p)) && !s.enemies.some((e) => at(e.pos, p))
+  while (placed.length < count) {
+    const from = kind === 'bush' || kind === 'oil' ? placed : tilesAt(s, n).concat(tilesAt(s, n + 1))
+    const next = from.flatMap((p) => (kind === 'bush' || kind === 'oil' ? DIR_LIST.map((d) => ({ x: p.x + DIRS[d].x, y: p.y + DIRS[d].y })) : [p])).filter(free)
+    if (!next.length) break
+    placed.push(next[rng.int(next.length)]!)
+  }
+  for (const p of placed) {
+    const i = idx(s, p)
+    if (kind === 'bush') s.bushes.push(i)
+    else if (kind === 'oil') s.oil.push(i)
+    else if (kind === 'spikes') s.spikes.push({ cell: i, phase: rng.int(2) })
+    else if (steps(p, s.player) > 1) s.cells[i] = kind
+  }
+}
+
 /** Put a charge `n` steps from the player. */
 function dropNear(s: GameState, rng: Rng, kind: ChargeId, n = 2): void {
   const near = tilesAt(s, n)
diff --git a/src/sim/record.ts b/src/sim/record.ts
index 283d3f3..0321dc1 100644
--- a/src/sim/record.ts
+++ b/src/sim/record.ts
@@ -6,7 +6,7 @@ import { DEFAULT_OPTIONS, type Action, type ChargeId, type GameEvent, type GameS
  * 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 = 38
+export const SIM_VERSION = 39
 
 /** Everything needed to reproduce a run exactly: mid-run saves, replays, certificates. */
 export interface RunRecord {
diff --git a/src/sim/turn.ts b/src/sim/turn.ts
index dbb27d0..9ed251a 100644
--- a/src/sim/turn.ts
+++ b/src/sim/turn.ts
@@ -1,4 +1,4 @@
-import { ACTIONS, isAvailable, isValidTarget, slidePassed } from './actions'
+import { ACTIONS, boxedIn, isAvailable, isValidTarget, slidePassed } from './actions'
 import { holds, sellOffered, spendCharge, swapOffered, takeCharge } from './charges'
 import { enemyAt, moveEnemies, planIntents, rollDrops } from './enemies'
 import { enemyKinds, LAST_LEVEL } from './levelgen'
@@ -107,7 +107,7 @@ export function step(prev: GameState, action: Action): StepResult {
   let shielded = false
   let flameBlocked = false
   const hurt = (how: Death): void => {
-    const cause = how.cause === 'walkedIntoEnemy' || how.cause === 'caughtByEnemy' ? 'enemy' : how.cause === 'spikes' ? 'spikes' : how.cause === 'crushed' || how.cause === 'trapped' ? 'rubble' : 'flame'
+    const cause = how.cause === 'walkedIntoEnemy' || how.cause === 'caughtByEnemy' ? 'enemy' : how.cause === 'spikes' ? 'spikes' : how.cause === 'crushed' || how.cause === 'trapped' || how.cause === 'boxedIn' ? 'rubble' : 'flame'
     if (s.status !== 'playing' || s.options.immortal) return
     // Nothing saves the player from a fallen ceiling: not a Shield, nor a Star.
     if (cause !== 'rubble' && (shielded || s.star > 0 || (cause === 'flame' && flameBlocked))) return
@@ -245,6 +245,9 @@ export function step(prev: GameState, action: Action): StepResult {
   planIntents(s)
   if (s.star > 0) s.star--
 
+  // Boxed in for good: nowhere to step or put a bomb, and nothing left that could change it.
+  if (s.status === 'playing' && !s.offer && !s.chargeOffer && boxedIn(s)) hurt({ cause: 'boxedIn', turn: s.turn + 1 })
+
   // 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') {
diff --git a/src/sim/types.ts b/src/sim/types.ts
index 9c28523..b1c5284 100644
--- a/src/sim/types.ts
+++ b/src/sim/types.ts
@@ -95,8 +95,10 @@ export interface RunOptions {
   /**
    * Rule variants for the balance lab to compare (PLAN.md §19.1), never set in play. `squeeze`: the
    * old Diagonal, which could step between two blocked tiles (the rule until 2026-10-25).
+   * `underfoot`: the NES rule, a bomb goes on the player's own tile, not one next to them (to
+   * measure, 2026-10-25; the player walks off it, and nothing walks onto it).
    */
-  lab?: { squeeze?: boolean }
+  lab?: { squeeze?: boolean; underfoot?: boolean }
 }
 
 export const DEFAULT_OPTIONS: RunOptions = { dev: false, immortal: false }
@@ -347,7 +349,8 @@ export interface Shop {
  */
 export interface Death {
   /** `spread`: flame over oil or through bushes, with no blast there (PLAN.md §19.3). */
-  cause: 'walkedIntoEnemy' | 'caughtByEnemy' | 'blast' | 'chain' | 'lingering' | 'spread' | 'spikes' | 'crushed' | 'trapped'
+  /** `boxedIn`: nowhere to step or put a bomb, for good (2026-10-26). */
+  cause: 'walkedIntoEnemy' | 'caughtByEnemy' | 'blast' | 'chain' | 'lingering' | 'spread' | 'spikes' | 'crushed' | 'trapped' | 'boxedIn'
   /** The enemy, for the two enemy causes. */
   enemy?: EnemyKind
   /** The turn it happened on (counted from 1, as the top bar shows after it). */
diff --git a/src/style.css b/src/style.css
index 15d64c8..5500094 100644
--- a/src/style.css
+++ b/src/style.css
@@ -624,8 +624,15 @@ img {
   background: #ece3cc;
 }
 
-/* Three equal tabs that always fit the row (user report, 2026-10-25: on a phone they ran off both
-   ends, cut off). */
+/* Equal tabs that always fit the row (user report, 2026-10-25: on a phone they ran off both
+   ends, cut off). Four since the Board tab: two rows of two on a phone. */
+@media (max-width: 440px) {
+  .almanac-tabs {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+  }
+}
+
 .almanac-tabs button {
   flex: 1 1 0;
   min-width: 0;
diff --git a/src/ui/almanac.ts b/src/ui/almanac.ts
index 5b073b8..a8562bb 100644
--- a/src/ui/almanac.ts
+++ b/src/ui/almanac.ts
@@ -5,18 +5,20 @@ import { CHARGE_ICONS, ENEMY_SPRITES, POWER_UP_ICONS, spriteUrl } from '../rende
 import type { SceneKind } from '../sim/demo'
 import { openDialog } from './dialog'
 import { startClip } from './clip'
-import { CHARGE_TEXT, ENEMY_TEXT, POWER_UP_CLIP, POWER_UP_TEXT } from './info'
+import { BOARD_TEXT, CHARGE_TEXT, ENEMY_TEXT, POWER_UP_CLIP, POWER_UP_TEXT } from './info'
 
 /**
  * The almanac (user request, 2026-10-22): every power-up, action charge and enemy type, with its
- * picture and what it does, opened from the menu. Its tabs swap the list in place.
+ * picture and what it does, opened from the menu. Its tabs swap the list in place. Since 2026-10-25
+ * a Board tab too: crates, pillars and the board's objects (PLAN.md §19.3).
  */
-export type AlmanacTab = 'powerUps' | 'charges' | 'enemies'
+export type AlmanacTab = 'powerUps' | 'charges' | 'enemies' | 'board'
 
 const TABS: [AlmanacTab, string][] = [
   ['powerUps', 'Power-ups'],
   ['charges', 'Charges'],
   ['enemies', 'Enemies'],
+  ['board', 'Board'],
 ]
 
 /** The power-ups in the order they make sense to read: stats, abilities, passes, the rest. */
@@ -56,6 +58,12 @@ function body(tab: AlmanacTab): string {
     list =
       '<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 if (tab === 'board') {
+    list =
+      '<p class="almanac-intro">What you find on the board. New things turn up a few levels apart, so each is met on its own first. Tap one on the board to read about it there too.</p>' +
+      Object.values(BOARD_TEXT)
+        .map((t) => entry(t.icon, t.name, t.from ? [...t.text, `From level ${t.from}.`] : t.text, t.clip))
+        .join('')
   } else {
     list =
       '<p class="almanac-intro">Every enemy shows its next 3 moves as arrows. Flame kills them; touching one costs you a life.</p>' +
diff --git a/src/ui/info.ts b/src/ui/info.ts
index 85d0f2b..b558901 100644
--- a/src/ui/info.ts
+++ b/src/ui/info.ts
@@ -1,6 +1,6 @@
 import { DECOY_RANGE, FREEZE_TURNS, NUKE_RANGE, TELEPORT_RANGE } from '../sim/actions'
 import { BOMB_BOOST, CHARGE_AMOUNT, FLAME_PASS_HITS, PASSIVE_CHARGES, salePrice } from '../sim/charges'
-import { LAST_LEVEL } from '../sim/levelgen'
+import { LAST_LEVEL, OBJECTS_FROM } from '../sim/levelgen'
 import { bombAt, cellAt, eq, exitOpen, isBurning, CLEAR_BONUS, PAR_BONUS } from '../sim/state'
 import { BARREL_FIRE, COLLAPSE_TURNS, spikesUp } from '../sim/explosions'
 import { COIN_BAG, EXIT_SPAWN, OFFER_SIZE, STAR_TURNS, STAT_MAX, STAT_STEP } from '../sim/turn'
@@ -156,6 +156,7 @@ export function deathLine(level: number, d: Death | null): string {
     spikes: 'you stepped on raised spikes',
     crushed: 'the ceiling fell on you',
     trapped: 'fallen rubble walled you in, away from the exit or an enemy',
+    boxedIn: 'you were boxed in, with nowhere to step and nowhere to put a bomb',
   }[d.cause]
   return `Level ${level}, turn ${d.turn}: ${how}.`
 }
@@ -256,6 +257,68 @@ export function hudInfo(item: HudItem, s: GameState): Info {
   }
 }
 
+/** The things on the board, for "What is that?" and the almanac's Board tab (2026-10-25). */
+export type BoardThing = 'crate' | 'pillar' | 'bush' | 'metal' | 'oil' | 'spikes' | 'barrel' | 'rubble'
+
+export const BOARD_TEXT: Record<BoardThing, { icon: string; name: string; text: string[]; clip?: SceneKind; from?: number }> = {
+  crate: { icon: 'block_planks', name: 'Crate', text: ['Flame destroys it (and stops there). On every level one crate hides the exit and another hides a gem.'], clip: 'crate' },
+  pillar: {
+    icon: 'block_blue',
+    name: 'Pillar',
+    text: ['Indestructible: nothing gets through, not even flame. Pillars change colour from level to level; Acid can crack them.'],
+    clip: 'pillar',
+  },
+  bush: {
+    icon: 'bush',
+    name: 'Bush',
+    text: [
+      'Walk into it to hide: enemies can’t see you there, and head for where they last saw you instead. Anything lying in it is hidden too; an enemy in one shows faint.',
+      'Flame burns it, and sets the bushes next to it smouldering: they catch fire the next turn.',
+    ],
+    clip: 'bush',
+    from: OBJECTS_FROM.bush,
+  },
+  metal: {
+    icon: 'block_metal',
+    name: 'Metal crate',
+    text: ['Nothing breaks it, and nothing gets through, not even flame (not a Pierce bomb’s either). Go round.'],
+    clip: 'metal',
+    from: OBJECTS_FROM.metal,
+  },
+  oil: {
+    icon: 'oil',
+    name: 'Oil',
+    text: ['Safe to walk on, until flame touches any part of the spill: then the whole spill burns at once, and sets off any bomb lying in it.'],
+    clip: 'oil',
+    from: OBJECTS_FROM.oil,
+  },
+  spikes: {
+    icon: 'spikes',
+    name: 'Spikes',
+    text: ['Up one turn, down the next. Up, they cost a life to whoever stands on them, and show red in the danger overlay. Enemies never step on them.'],
+    clip: 'spikes',
+    from: OBJECTS_FROM.spikes,
+  },
+  barrel: {
+    icon: 'block_strong_danger',
+    name: 'Oil barrel',
+    text: [`It blocks the way like a crate. Flame bursts it: oil spills round it, and the next turn it explodes with a blast of ${BARREL_FIRE}, lighting the spill. Keep clear!`],
+    clip: 'barrel',
+    from: OBJECTS_FROM.barrel,
+  },
+  rubble: {
+    icon: 'rock',
+    name: 'Rubble',
+    text: [`A fallen ceiling: Acid cracked a pillar, a later blast broke it, and ${COLLAPSE_TURNS} turns on the ceiling round it came down. Nothing gets through rubble, not even flame, and nothing clears it.`],
+    clip: 'acid',
+  },
+}
+
+function boardInfo(thing: BoardThing): Info {
+  const t = BOARD_TEXT[thing]
+  return { icon: t.icon, title: t.name, body: t.text, clip: t.clip }
+}
+
 /**
  * What is on a tile, most important first, or null for plain empty floor (a tap there is
  * auto-move, not info).
@@ -339,52 +402,25 @@ export function tileInfo(s: GameState, p: Vec): Info | null {
       clip: cracked ? 'acid' : 'pillar',
     }
   }
-  if (cell === 'metal') {
-    return { icon: 'block_metal', title: 'Metal crate', body: ['Nothing breaks it, and nothing gets through, not even flame (not a Pierce bomb’s either). Go round.'] }
-  }
-  if (cell === 'barrel') {
-    return {
-      icon: 'block_strong_danger',
-      title: 'Oil barrel',
-      body: [`It blocks the way like a crate. Flame bursts it: oil spills round it, and the next turn it explodes with a blast of ${BARREL_FIRE}, lighting the spill. Keep clear!`],
-    }
-  }
-  if (cell === 'rubble') {
-    return { icon: 'rock', title: 'Rubble', body: ['A fallen ceiling. Nothing gets through it, not even flame, and nothing clears it.'] }
-  }
+  if (cell === 'metal') return boardInfo('metal')
+  if (cell === 'barrel') return boardInfo('barrel')
+  if (cell === 'rubble') return boardInfo('rubble')
   const spike = s.spikes.find((sp) => sp.cell === i)
   if (spike) {
     const up = spikesUp(s, i)
+    const info = boardInfo('spikes')
     return {
-      icon: 'spikes',
+      ...info,
       title: up ? 'Spikes: up' : 'Spikes: down',
-      body: [
-        `They go up and down every turn: ${up ? 'up now, so stepping or standing here this turn costs a life' : 'down now, safe to step on this turn, but up the next'}. They show red in the danger overlay when up.`,
-        'Enemies never step on them.',
-      ],
+      body: [up ? 'Up now: stepping or standing here this turn costs a life.' : 'Down now: safe to step on this turn, but up the next.', ...info.body],
     }
   }
   if (s.bushes.includes(i)) {
-    return {
-      icon: 'bush',
-      title: 'Bush',
-      body: [
-        'Walk into it to hide: enemies can’t see you there, and head for where they last saw you instead. Anything lying in it is hidden too.',
-        `Flame burns it, and sets the bushes next to it smouldering: they catch fire the next turn${s.smoldering.includes(i) ? ' — this one is smouldering now' : ''}.`,
-      ],
-    }
-  }
-  if (s.oil.includes(i)) {
-    return { icon: 'oil', title: 'Oil', body: ['Safe to walk on, until flame touches any part of the spill: then the whole spill burns at once, and sets off any bomb lying in it.'] }
-  }
-  if (cell === 'block') {
-    return {
-      icon: 'block_planks',
-      title: 'Crate',
-      body: ['Flame destroys it (and stops there). On every level one crate hides the exit and another hides a gem.'],
-      clip: 'crate',
-    }
+    const info = boardInfo('bush')
+    return s.smoldering.includes(i) ? { ...info, title: 'Bush: smouldering', body: [...info.body, 'This one is smouldering: it catches fire next turn.'] } : info
   }
+  if (s.oil.includes(i)) return boardInfo('oil')
+  if (cell === 'block') return boardInfo('crate')
   const gem = s.gems.find((g) => eq(g.pos, p))
   if (gem) {
     return {
diff --git a/test/charges.test.ts b/test/charges.test.ts
index 56912fc..923b8f9 100644
--- a/test/charges.test.ts
+++ b/test/charges.test.ts
@@ -315,6 +315,36 @@ describe('Flame Pass and Teleport, charges since 2026-10-25', () => {
     expect(createLevel(s.seed, 2, s.options, carryOf(s)).charge).toEqual({ kind: 'flamePass', left: 2 })
   })
 
+  it('the lab’s NES rule (`underfoot`): the bomb goes on the player’s own tile, and they walk off it', () => {
+    const s = board(`
+      #####
+      #P..#
+      #####`)
+    s.options = { ...s.options, lab: { underfoot: true } }
+    expect(ACTIONS.bomb.validTargets(s)).toEqual([v(1, 1)])
+    const placed = step(s, { id: 'bomb', target: v(1, 1) }).state
+    expect(placed.bombs[0]!.pos).toEqual(v(1, 1))
+    const off = step(placed, { id: 'move', target: v(2, 1) }).state
+    expect(off.player).toEqual(v(2, 1))
+    // Not back onto it.
+    expect(ACTIONS.move.validTargets(off)).not.toContainEqual(v(1, 1))
+  })
+
+  it('a jump into a pocket with nowhere to step or put a bomb costs a life: boxed in (2026-10-26)', () => {
+    const s = board(`
+      #######
+      #.P.+.#
+      ###.#+#
+      #######`)
+    // (5, 1): crates left and below, walls round the rest.
+    s.charge = { kind: 'teleport', left: 1 }
+    const r = step(s, { id: 'teleport', target: v(5, 1) })
+    expect(r.state.death?.cause).toBe('boxedIn')
+    // With a jump left, there is a way out: no death.
+    s.charge = { kind: 'teleport', left: 2 }
+    expect(step(s, { id: 'teleport', target: v(5, 1) }).state.status).toBe('playing')
+  })
+
   it('Teleport: 3 jumps from the charge button, then it is gone', () => {
     let s = board(`
       ##########
diff --git a/test/new-enemies.test.ts b/test/new-enemies.test.ts
index 3cbcd59..1a8fce7 100644
--- a/test/new-enemies.test.ts
+++ b/test/new-enemies.test.ts
@@ -29,7 +29,9 @@ describe('Ghosts and Wisps go through crates', () => {
     const s = board(`
       #######
       #P++.G#
+      #.#####
       #######`)
+    // (A tile below the player, so they aren't boxed in.)
     let s2 = s
     planIntents(s2)
     const been: Vec[] = []
diff --git a/tools/balance/bot.ts b/tools/balance/bot.ts
index 9fb49f7..78d4b3c 100644
--- a/tools/balance/bot.ts
+++ b/tools/balance/bot.ts
@@ -162,7 +162,7 @@ function scoreOf(s: GameState, next: GameState, events: GameEvent[], goals: Int3
   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 === 'blockDestroyed' || e.type === 'barrelLit') 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
@@ -187,7 +187,9 @@ function bombValue(s: GameState, bomb: GameState['bombs'][number]): number {
     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
+  // Crates, and oil barrels (2026-10-25: the bot never burst one, and stalled where barrels shut
+  // in the exit or the last enemy).
+  for (const t of tiles) if (cellAt(s, t) === 'block' || cellAt(s, t) === 'barrel') 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
@@ -216,10 +218,13 @@ function goalDistances(s: GameState): Int32Array {
         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))
+        const worth = reach.some((t) => cellAt(s, t) === 'block' || cellAt(s, t) === 'barrel' || 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)
+        // Somewhere to stand next to the bomb with a walk out of the blast before it goes off; with
+        // the lab's NES rule (`underfoot`), the bomb's own tile.
+        if (s.options.lab?.underfoot) {
+          if (canGetClear(s, spot, spot, reach)) goals.push(spot)
+        } else for (const from of near(spot)) if (canGetClear(s, from, spot, reach)) goals.push(from)
       }
     }
   }
diff --git a/tools/balance/lab.ts b/tools/balance/lab.ts
index 6e7b04f..75befef 100644
--- a/tools/balance/lab.ts
+++ b/tools/balance/lab.ts
@@ -20,6 +20,7 @@ import { measureShape, samplePositions, type Shape } from './structure'
  *
  *   npm run balance -- powers [--seeds 200] [--skill careful|sloppy]   each power-up held from level 1
  *   npm run balance -- diagonal [--seeds 200] [--skill …]              Diagonal now, and with the old squeeze
+ *   npm run balance -- underfoot [--seeds 200] [--skill …]             bombs next to the player, or under them (NES)
  *   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
@@ -44,6 +45,18 @@ function powerArms(): Arms {
  * Diagonal as it is (no squeezing, since 2026-10-25) against the old rule that let it step between
  * two blocked tiles: without it, with it, and with the squeeze back.
  */
+/**
+ * Where a bomb goes (user question, 2026-10-25): next to the player, as now, against the NES rule,
+ * on the player's own tile.
+ */
+function underfootArms(): Arms {
+  const underfoot: Setup = (s) => void (s.options = { ...s.options, lab: { underfoot: true } })
+  return {
+    baseline: () => {},
+    'underfoot (NES rule)': underfoot,
+  }
+}
+
 function diagonalArms(): Arms {
   return {
     baseline: () => {},
@@ -58,8 +71,9 @@ function diagonalArms(): Arms {
 const EXPERIMENTS: Record<string, () => Arms> = {
   powers: powerArms,
   diagonal: diagonalArms,
-  // The baseline alone, or with one power-up (`--arm <power-up>`), run by run.
-  runs: () => ({ baseline: powerArms()[process.env.BALANCE_ARM ?? 'baseline'] ?? (() => {}) }),
+  underfoot: underfootArms,
+  // The baseline alone, or with one power-up or rule variant (`--arm <name>`), run by run.
+  runs: () => ({ baseline: { ...powerArms(), ...underfootArms() }[process.env.BALANCE_ARM ?? 'baseline'] ?? (() => {}) }),
 }
 
 interface Task {
@@ -100,7 +114,7 @@ async function main(): Promise<void> {
   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, diagonal, structure, runs, trace <seed> <level> [turns]. Options: --seeds N, --skill careful|average|sloppy, --levels N.')
+    console.log('Commands: powers, diagonal, underfoot, 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'))
@@ -225,7 +239,7 @@ function writeReport(experiment: string, skill: string, seeds: number, byArm: Ma
 /** 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)
+  ;({ ...powerArms(), ...underfootArms() })[process.env.BALANCE_ARM ?? 'baseline']?.(s)
   const bot = makeBot(seed)
   let shown = 0
   while (s.status !== 'gameOver' && s.status !== 'won' && shown < turns) {
diff --git a/tools/balance/show.ts b/tools/balance/show.ts
index 508cd22..ed3a68b 100644
--- a/tools/balance/show.ts
+++ b/tools/balance/show.ts
@@ -1,6 +1,9 @@
 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. */
+/**
+ * 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; M metal crate, O oil barrel, r rubble, * bush, ~ oil, ^ spikes (test/helpers.ts's letters).
+ */
 export function show(s: GameState): string {
   const rows: string[] = []
   for (let y = 0; y < s.height; y++) {
@@ -15,7 +18,11 @@ export function show(s: GameState): string {
         : 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' ? '+' : '.'
+        : c === 'wall' ? '#' : c === 'block' ? '+' : c === 'metal' ? 'M' : c === 'barrel' ? 'O' : c === 'rubble' ? 'r'
+        : s.bushes.includes(y * s.width + x) ? '*'
+        : s.oil.includes(y * s.width + x) ? '~'
+        : s.spikes.some((sp) => sp.cell === y * s.width + x) ? '^'
+        : '.'
     }
     rows.push(r)
   }