fuzzyworld

explodorama / commit

Board objects, the Acid charge, passive Shield and Flame Pass, danger outline

5f06696031f7ea6a36df9bc6e0072f6d7821d376 · Pavel Safronov · 2026-10-25 18:44:16 -0700 · parents a313eb7dd389

Claude Code usage: this commit ~1.2M out / ~541.5M cached · cost unknown (no per-model trailer) · session 4 tally 6.5K in / 3.0M out / 1.5G cached · usage by commit and session

Board objects, the Acid charge, passive Shield and Flame Pass, danger outline

- Bushes (hide the player from chasers, burn a bush a turn), metal crates,
  oil spills (a whole spill burns at once), spikes (up every other turn) and
  oil barrels (burst into oil, blast the next turn), from levels 2, 3, 5, 8, 11.
- Acid charge: cracks pillars; a later blast breaks one and the ceiling round
  it falls as rubble 4 turns on. New deaths: crushed, trapped, spikes, spread.
- Shield and Flame Pass work by themselves while carried and are used up by
  their hits; a Shield pushes the player out from under a falling ceiling.
- Danger overlay: a red outline marks every tile a bomb will hit later.
- The Splat Pack worn look is built but switched off (user's call).
- SIM_VERSION 38.

Usage: 6.5K in / 3.0M out / 1.5G cached (session tally)
rtk:   2.0M saved, 39.7% over 4763 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 CHANGELOG.md                             |   4 +
 PLAN.md                                  |  34 ++++-
 README.md                                |  49 +++++--
 assets/composed/block_metal.png          | Bin 0 -> 925 bytes
 assets/composed/charge_acid.png          | Bin 0 -> 7043 bytes
 assets/composed/oil.png                  | Bin 0 -> 3732 bytes
 assets/kenney-board-icons/flask_full.png | Bin 0 -> 1211 bytes
 assets/kenney-splat/License.txt          |  22 +++
 assets/kenney-splat/splat00.png          | Bin 0 -> 2728 bytes
 assets/kenney-splat/splat02.png          | Bin 0 -> 3381 bytes
 assets/kenney-splat/splat03.png          | Bin 0 -> 2940 bytes
 assets/kenney-splat/splat05.png          | Bin 0 -> 2720 bytes
 assets/kenney-splat/splat08.png          | Bin 0 -> 2517 bytes
 assets/kenney-splat/splat11.png          | Bin 0 -> 2795 bytes
 assets/kenney-splat/splat13.png          | Bin 0 -> 2490 bytes
 assets/kenney-splat/splat15.png          | Bin 0 -> 2410 bytes
 assets/kenney-splat/splat19.png          | Bin 0 -> 3312 bytes
 assets/kenney-splat/splat21.png          | Bin 0 -> 2469 bytes
 assets/kenney-splat/splat24.png          | Bin 0 -> 2804 bytes
 assets/kenney-splat/splat27.png          | Bin 0 -> 3074 bytes
 assets/kenney-splat/splat30.png          | Bin 0 -> 3155 bytes
 assets/kenney-splat/splat33.png          | Bin 0 -> 3630 bytes
 assets/kenney/block_strong_danger.png    | Bin 0 -> 1352 bytes
 assets/kenney/block_strong_empty.png     | Bin 0 -> 735 bytes
 assets/kenney/bush.png                   | Bin 0 -> 2145 bytes
 assets/kenney/rock.png                   | Bin 0 -> 1650 bytes
 decisions.md                             |  47 ++++++
 docs/balance/runs-careful.md             |  38 +++++
 src/main.ts                              |   9 +-
 src/render/animate.ts                    |   3 +-
 src/render/board.ts                      |  90 ++++++++++--
 src/render/decor.ts                      |  92 ++++++++++++
 src/render/effects.ts                    |   3 -
 src/render/sprites.ts                    |  25 +++-
 src/sim/actions.ts                       |  39 +----
 src/sim/charges.ts                       |  20 ++-
 src/sim/danger.ts                        |  36 ++++-
 src/sim/demo.ts                          |  20 ++-
 src/sim/enemies.ts                       |  15 +-
 src/sim/explosions.ts                    | 201 ++++++++++++++++++++++---
 src/sim/levelgen.ts                      |  95 +++++++++++-
 src/sim/picks.ts                         |   2 +-
 src/sim/record.ts                        |   2 +-
 src/sim/shop.ts                          |   1 +
 src/sim/state.ts                         |  27 ++--
 src/sim/turn.ts                          |  65 ++++++--
 src/sim/types.ts                         |  70 ++++++---
 src/ui/controls.ts                       |   2 +-
 src/ui/feedback.ts                       |   8 +-
 src/ui/info.ts                           |  91 ++++++++++--
 test/charges.test.ts                     |  51 +++----
 test/danger.test.ts                      |  29 +++-
 test/enemies.test.ts                     |   8 +-
 test/hazards.test.ts                     | 244 +++++++++++++++++++++++++++++++
 test/helpers.ts                          |  16 +-
 test/new-powers.test.ts                  |   2 +-
 test/powerups.test.ts                    |   6 +-
 tools/compose-icons.sh                   |  14 ++
 58 files changed, 1256 insertions(+), 224 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index f0e5501..4192c26 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
 
+- **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.
 - **Bigger boards for later levels:** from 9 × 11 up to 15 × 21, seen through the same 9 × 13 tiles on every screen, scrolling with you, with arrows at the edge pointing to enemies, bombs, the exit and the gem out of sight. Enemy counts and par grow with the board. Version 32: runs saved before don't resume.
 - **Bigger boards for later levels:** from 9 × 11 up to 15 × 21, seen through the same 9 × 13 tiles on every screen, scrolling with you, with arrows at the edge pointing to enemies, bombs, the exit and the gem out of sight. Enemy counts and par grow with the board. Version 32: runs saved before don't resume.
@@ -93,6 +95,8 @@ saved before the change is dropped and the next visit starts a new run.
 
 ### Changed
 
+- **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.
 - **Flame Pass and Teleport are charges now**, not power-ups: Flame Pass, raised with the charge button, takes the next 3 fire hits; Teleport gives 3 jumps from the charge button. Each alone used to win the game.
 - **A death drops the charge you carry** where you fell, to pick up again on the retry.
diff --git a/PLAN.md b/PLAN.md
index 5ca043f..ecf51e7 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -191,7 +191,11 @@ it, or flame burns it. (For one round both lasted 3 turns; the user found that f
 `SIM_VERSION` 31.
 *Also 2026-10-25 (user request):* **Shield and Freeze need a confirmation**: the charge button
 picks them, lighting the player's own tile (their only target); a tap on yourself, or the button
-pressed again, uses them. `SIM_VERSION` 33. `boosts` and `shield` are gone from the state; `?charges=n` became
+pressed again, uses them. `SIM_VERSION` 33.
+*Changed again 2026-10-25 (user request):* **Shield and Flame Pass work by themselves while in the
+charge slot**, no raising: a hit of their kind uses them up (Flame Pass: one of its 3 uses per fire
+hit), and the slot is empty again. The `shield` and `flamePass` actions and `shieldUp`/`flameHits`
+are gone; only Freeze still takes the confirming tap. `SIM_VERSION` 38. `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.
 
@@ -1028,7 +1032,9 @@ tools/                 compose-icons.sh (composed sprites and the app icons), sc
 
 ### Backlog
 
-- **A worn dungeon (user request, 2026-10-25; UI, to build):** use the Splat Pack (approved) to make
+- **A worn dungeon (user request, 2026-10-25; built, then switched off the same day):** *the user:
+  the splats looked too alike, and not the look wanted* — `render/decor.ts` stays, off
+  (`ENABLED`), until a new approach is chosen. The first idea: use the Splat Pack (approved) to make
   each level look dilapidated, old, tarnished, wet or dirty — stains, puddles, soot, moss, rust — in
   a random mix and amount drawn from the seed and level, so a level always looks the same and
   levels differ. Decoration only: drawn on the floor (and walls) under everything, never in the
@@ -1049,8 +1055,8 @@ tools/                 compose-icons.sh (composed sprites and the app icons), sc
   (§19.1). Decided by the user:
   - **Flame Pass → a charge counted in flame hits, not turns:** it saves the player from **3 fire
     hits** (a blast, a chain, lingering flame), one used up each time flame would have hurt them,
-    then runs out. **Raised with the charge button first**, as the Shield is (user, 2026-10-25),
-    with the same confirming tap on yourself.
+    then runs out. ~~Raised with the charge button first~~ — since later that day (user) it works
+    while carried, its 3 hits being the charge's uses.
   - **Teleport → a charge of 3 jumps**, as ICBM has 3 launches, with its reach as now (Run adds a
     step; Diagonal makes it a square).
   - **Wall Pass and Bomb Pass stay power-ups for now**: the user wants to playtest them more (the lab
@@ -1328,6 +1334,18 @@ and all the objects below are wanted.*
 find) and hide the player from chasers (something to use), while an enemy inside shows faint,
 arrows still visible — so nothing that can kill you is ever hidden.
 
+*Built 2026-10-25 (the first five; `SIM_VERSION` 37):* from level 2 bushes, 3 metal crates, 5 oil,
+8 spikes, 11 oil barrels (`OBJECTS_FROM`, first guesses), from their own random stream after the
+rest of the level, so crates and enemies are where they were. As built, where the table above left
+it open: a burning bush is flame that turn, and the bushes touching it **smoulder** and catch the
+next; oil burns once (then it is gone) and lingers a turn like flame; a barrel's blast is 2 and its
+spill covers its tile and the floor round it; it shows a 1 and shakes; **enemies never step on
+spikes**, up or down (simpler to read than planning round the raised ones, and spikes then never
+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.
+
 ### 19.4 The Acid bomb: weakening pillars, bringing the roof down
 
 **Idea:** a bomb that eats into the pillars (the fixed grid of walls), so that enough damage brings
@@ -1361,6 +1379,14 @@ part of the ceiling down — a way to reshape the board, with a risk.
 Still to settle when building: whether the second hit must be any blast (as written) or another
 acid bomb, and how many acid bombs a charge gives.
 
+*Built 2026-10-25:* **any later blast** breaks a cracked pillar (one cracked before that turn; an
+acid flame and a second blast in the same turn only crack it), **1 bomb** per charge (price 6),
+in the gem's offers, the shop and enemies' drops like the other charges. The ceiling never falls on
+the exit or a gem. Crushed or trapped, a Star doesn't help; a Shield carried pushes the player out to an empty tile next to them (straight before diagonal), and is used up — with none, it doesn't help (user request, 2026-10-25). Trapped: the exit, or any
+enemy, unreachable from the player through all but walls, metal and rubble (flame can't cross those
+either, so "reachable by flame" is the same test). Not yet: the acid puddle the bomb leaves (it
+comes with 19.3's second batch), the floor-collapse alternative.
+
 ### 19.5 Gauntlet mode
 
 **Idea:** a second way to play. You start with a random loadout and nothing more comes: survive as
diff --git a/README.md b/README.md
index bbad3e1..62aeb17 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: **36**, shown at the bottom of the menu with the commit the game was built from (a link
+Version: **38**, 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
@@ -154,8 +154,10 @@ tile first cancels it.
 **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)
+that?" on it explains charges and the choice below. Every charge is used with it **except the
+Shield and Flame Pass, which work by themselves while carried** and are used up by the hits they
+take, leaving the slot empty (user request, 2026-10-25; for a few hours that day they had to be
+raised with the button first)
 (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.
 
@@ -177,14 +179,15 @@ piled up). The top bar shows it too.
 | **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, then tap yourself — or press it again — to raise it (a confirmation, so it can't go up by a stray tap): 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. |
+| **Shield** | the Move figure in a ring | **Works by itself while you carry it**, level after level: a ring round you until something hits you. It absorbs that hit (flame, enemy or spikes); under a falling ceiling it **pushes you out** to an empty tile next to you, if there is one, and anything else that hits you that turn. Then it is used up and the charge slot is empty. |
 | **Freeze** | a padlock | Press the charge button, then tap yourself — or press it again: 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. |
-| **Flame Pass** | flame with a shield badge | Press the charge button, then tap yourself: flame can't hurt you for the next **3 fire hits** (an orange ring round you; the top bar counts them), level after level until used. Enemies still can. A power-up that worked for good until 2026-10-25, when the balance lab found it won the game alone. |
+| **Flame Pass** | flame with a shield badge | **Works by itself while you carry it:** flame can't hurt you for the next **3 fire hits** (an orange ring round you; the charge button and the top bar count them), level after level; when they are used the charge slot is empty. Enemies still can. A power-up that worked for good until 2026-10-25, when the balance lab found it won the game alone. |
 | **Teleport** | the Move figure with an outbox badge | **3 jumps**: press the charge button (or **P**), then tap any empty tile within 3 steps (counting across and down), over anything. **With Run, 4 steps;** **with Diagonal, a square** (a diagonal step counts as one). A power-up until 2026-10-25, for the same reason. |
+| **Acid** | bomb with a flask badge | **1 bomb** whose flame also **cracks** every pillar it reaches (cracks show on it). **Any later blast** that reaches a cracked pillar **breaks** it: its tile becomes floor, dust trickles from the ceiling over the 8 tiles round it, and **4 turns later** it falls as **rubble**, for good — nothing gets through rubble, not even flame. Whatever is under it is crushed: enemies (a trap to set), drops, bombs, and **you** — unless you carry a Shield, which pushes you out to an empty tile next to you (with none, nothing saves you; a Star doesn't). If the rubble cuts you off from the exit or from an enemy, the level can't be won: that costs a life at once (**trapped**). The outer wall never breaks. Since 2026-10-25. |
 
-A Pierce, Power or Nuke bomb wears its charge's icon as a small badge. **Bomb** always puts down a
+A Pierce, Power, Nuke or Acid 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
@@ -207,11 +210,11 @@ will end up, a faded bomb with its blast, a kicked bomb's stopping place, a chan
 - **Flame lingers for one more turn.** After a blast, its tiles keep burning (orange, with
   flickering fireballs) through the next turn: any enemy that walks in dies, and so do you if
   you wait in it. You can't step or put a bomb into burning tiles — unless flame can't hurt you
-  (a raised Flame Pass, a Star), then you can walk through it like floor (with Flame Pass, using one
+  (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 can't be placed on an enemy.
-- Pierce, Power and Nuke bombs are put down with the charge button (see Charges above); such a
+- 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.
 
 ### Danger
@@ -220,6 +223,12 @@ will end up, a faded bomb with its blast, a kicked bomb's stopping place, a chan
   burning from last turn).
 - **Light red** tiles will burn at the end of the next turn (including flame that will still be
   burning then).
+- **A red outline** marks every tile a bomb on the board will hit on a later turn, however long
+  its fuse (with the chains and spreading fire it will set off, if you wait), and where a ceiling
+  will fall: it fills in light red, then strong red, as the moment comes (since 2026-10-25; until
+  then a bomb's tiles showed only in its last two turns).
+- Fire that spreads over oil or through bushes, **raised spikes**, and a **ceiling about to fall**
+  show the same way (see The board).
 
 ### Enemies
 
@@ -344,9 +353,10 @@ runs: leftover coins only count on the high-score board.
 
 ### Lives
 
-You have **3 lives**. Caught in a blast or touched by an enemy, you lose one and the level starts
+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
 again with the same layout and enemies. **You keep everything else**: coins, stats, every
-power-up (abilities and passes included), a raised Shield or Flame Pass. **The charge you carry
+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
 retry to pick it up again, before a blast burns it. (The original took the passes and the
 Detonator away; since 2026-10-23 a death otherwise costs only the life.) The banner says how and where you died — "Level 3, turn 12: you walked into a Blob." — and so
@@ -422,6 +432,18 @@ blocks (destructible). **Once the last enemy is gone, every crate crumbles away*
 (shaking first), revealing the exit and the gem — not flame, so nothing is punished. (The
 original's 31 × 13 board with 50 levels was an option until 2026-10-23; so was the crumbling.)
 
+**Board objects** (since 2026-10-25), each from its own level on, one new kind every few levels,
+placed from the seed (never on the exit's or the gem's crate, nor near the start). Tap one to read
+what it does.
+
+| Object | From level | What it does |
+|---|---|---|
+| **Bush** | 2 | Walk into it. **It hides you:** enemies chasing you head for where they last saw you instead. It hides drops lying in it too; an enemy in one shows faint, arrows and all, so nothing that can hurt you is hidden. Flame burns it, and the bushes touching it **smoulder** (glowing) and catch fire the next turn: a slow fire front. |
+| **Metal crate** | 3 | Grey. Like a pillar, but anywhere: nothing breaks it, nothing gets through (not a Pierce bomb's flame, not Wall Pass); a Nuke's blast goes round it. Placed only where every tile stays reachable. |
+| **Oil** | 5 | A dark, glossy spill, safe to walk on — until flame touches any part of it: then **the whole spill burns at once** (and lingers a turn, like flame), setting off any bomb lying in it. |
+| **Spikes** | 8 | Up one turn, down the next. Up, they cost a life to whoever stands on them (they show in the danger overlay when up). Enemies never step on them. |
+| **Oil barrel** | 11 | A crate with a warning sign: it blocks like a crate. Flame **bursts** it: oil spills on it and the floor round it, and the **next turn** it explodes with a blast of 2, lighting the spill. It isn't your bomb: it doesn't count to your limit, and the Detonator can't set it off. |
+
 ## Running it
 
 ```sh
@@ -490,13 +512,14 @@ Art: [New Platformer Pack](https://kenney.nl/assets/new-platformer-pack) 1.1,
 [Game Icons](https://kenney.nl/assets/game-icons) and
 [Board Game Icons](https://kenney.nl/assets/board-game-icons), all by
 [Kenney](https://www.kenney.nl), under [CC0](http://creativecommons.org/publicdomain/zero/1.0/)
-(public domain). Kenney doesn't require credit, but the pack was chosen deliberately and the game
+(public domain), and the [Splat Pack](https://kenney.nl/assets/splat-pack) (the oil spills). Kenney doesn't require credit, but the pack was chosen deliberately and the game
 looks the way it does because of it, so it is credited here and in the game (in the menu). Thank you, Kenney! Support his work at <https://www.kenney.nl/donate>.
 
 The files used are copied, unchanged and under their original file names, into
 [`assets/kenney/`](assets/kenney/), [`assets/kenney-ui/`](assets/kenney-ui/) (keeping the pack's
-colour folders) and [`assets/kenney-ui-rpg/`](assets/kenney-ui-rpg/), each with its pack's
-licence file. One exception: the demo's pointing hand, `assets/kenney-ui-rpg/cursorHand_beige.svg`,
+colour folders), [`assets/kenney-ui-rpg/`](assets/kenney-ui-rpg/) and
+[`assets/kenney-splat/`](assets/kenney-splat/) (a few of the Splat Pack's splats, scaled down to
+128 px), each with its pack's licence file. One exception: the demo's pointing hand, `assets/kenney-ui-rpg/cursorHand_beige.svg`,
 is the RPG Expansion's `cursorHand_beige` cut out of the pack's vector sheet
 (`Vector/uipack_rpg_vector.svg`: just its three shapes, unchanged), since the PNG is only 27 px
 across. They
diff --git a/assets/composed/block_metal.png b/assets/composed/block_metal.png
new file mode 100644
index 0000000..090a1c8
Binary files /dev/null and b/assets/composed/block_metal.png differ
diff --git a/assets/composed/charge_acid.png b/assets/composed/charge_acid.png
new file mode 100644
index 0000000..eb7e595
Binary files /dev/null and b/assets/composed/charge_acid.png differ
diff --git a/assets/composed/oil.png b/assets/composed/oil.png
new file mode 100644
index 0000000..5e262d5
Binary files /dev/null and b/assets/composed/oil.png differ
diff --git a/assets/kenney-board-icons/flask_full.png b/assets/kenney-board-icons/flask_full.png
new file mode 100644
index 0000000..3a2d18e
Binary files /dev/null and b/assets/kenney-board-icons/flask_full.png differ
diff --git a/assets/kenney-splat/License.txt b/assets/kenney-splat/License.txt
new file mode 100644
index 0000000..7544d91
--- /dev/null
+++ b/assets/kenney-splat/License.txt
@@ -0,0 +1,22 @@
+	

+

+	Splat Pack (1.0)

+

+	Created/distributed by Kenney (www.kenney.nl)

+	Creation date: 16-03-2022

+

+			------------------------------

+

+	License: (Creative Commons Zero, CC0)

+	http://creativecommons.org/publicdomain/zero/1.0/

+

+	This content is free to use in personal, educational and commercial projects.


+	Support us by crediting Kenney or www.kenney.nl (this is not mandatory)

+

+			------------------------------

+

+	Donate:   http://support.kenney.nl

+	Patreon:  http://patreon.com/kenney/

+

+	Follow on Twitter for updates:

+	http://twitter.com/KenneyNL
\ No newline at end of file
diff --git a/assets/kenney-splat/splat00.png b/assets/kenney-splat/splat00.png
new file mode 100644
index 0000000..5373e33
Binary files /dev/null and b/assets/kenney-splat/splat00.png differ
diff --git a/assets/kenney-splat/splat02.png b/assets/kenney-splat/splat02.png
new file mode 100644
index 0000000..14c7ec0
Binary files /dev/null and b/assets/kenney-splat/splat02.png differ
diff --git a/assets/kenney-splat/splat03.png b/assets/kenney-splat/splat03.png
new file mode 100644
index 0000000..1bd383d
Binary files /dev/null and b/assets/kenney-splat/splat03.png differ
diff --git a/assets/kenney-splat/splat05.png b/assets/kenney-splat/splat05.png
new file mode 100644
index 0000000..ddb77fd
Binary files /dev/null and b/assets/kenney-splat/splat05.png differ
diff --git a/assets/kenney-splat/splat08.png b/assets/kenney-splat/splat08.png
new file mode 100644
index 0000000..ebedb8c
Binary files /dev/null and b/assets/kenney-splat/splat08.png differ
diff --git a/assets/kenney-splat/splat11.png b/assets/kenney-splat/splat11.png
new file mode 100644
index 0000000..ca45b96
Binary files /dev/null and b/assets/kenney-splat/splat11.png differ
diff --git a/assets/kenney-splat/splat13.png b/assets/kenney-splat/splat13.png
new file mode 100644
index 0000000..8192748
Binary files /dev/null and b/assets/kenney-splat/splat13.png differ
diff --git a/assets/kenney-splat/splat15.png b/assets/kenney-splat/splat15.png
new file mode 100644
index 0000000..f09f713
Binary files /dev/null and b/assets/kenney-splat/splat15.png differ
diff --git a/assets/kenney-splat/splat19.png b/assets/kenney-splat/splat19.png
new file mode 100644
index 0000000..ed6aec6
Binary files /dev/null and b/assets/kenney-splat/splat19.png differ
diff --git a/assets/kenney-splat/splat21.png b/assets/kenney-splat/splat21.png
new file mode 100644
index 0000000..ba3d0ad
Binary files /dev/null and b/assets/kenney-splat/splat21.png differ
diff --git a/assets/kenney-splat/splat24.png b/assets/kenney-splat/splat24.png
new file mode 100644
index 0000000..6063bba
Binary files /dev/null and b/assets/kenney-splat/splat24.png differ
diff --git a/assets/kenney-splat/splat27.png b/assets/kenney-splat/splat27.png
new file mode 100644
index 0000000..ba85d95
Binary files /dev/null and b/assets/kenney-splat/splat27.png differ
diff --git a/assets/kenney-splat/splat30.png b/assets/kenney-splat/splat30.png
new file mode 100644
index 0000000..a2b719f
Binary files /dev/null and b/assets/kenney-splat/splat30.png differ
diff --git a/assets/kenney-splat/splat33.png b/assets/kenney-splat/splat33.png
new file mode 100644
index 0000000..e3c328d
Binary files /dev/null and b/assets/kenney-splat/splat33.png differ
diff --git a/assets/kenney/block_strong_danger.png b/assets/kenney/block_strong_danger.png
new file mode 100644
index 0000000..5f37ebe
Binary files /dev/null and b/assets/kenney/block_strong_danger.png differ
diff --git a/assets/kenney/block_strong_empty.png b/assets/kenney/block_strong_empty.png
new file mode 100644
index 0000000..ba9fa9a
Binary files /dev/null and b/assets/kenney/block_strong_empty.png differ
diff --git a/assets/kenney/bush.png b/assets/kenney/bush.png
new file mode 100644
index 0000000..62fed53
Binary files /dev/null and b/assets/kenney/bush.png differ
diff --git a/assets/kenney/rock.png b/assets/kenney/rock.png
new file mode 100644
index 0000000..ae80ccb
Binary files /dev/null and b/assets/kenney/rock.png differ
diff --git a/decisions.md b/decisions.md
index 7bb9398..2cb5b92 100644
--- a/decisions.md
+++ b/decisions.md
@@ -2103,3 +2103,50 @@ Left to the user to decide.
 - **Decided (user, same day): no squeezing is the rule.** A diagonal step needs one of the two
   tiles beside it passable, for the action and auto-move alike. The lab keeps the old rule as a
   variant (`options.lab.squeeze`) to compare against. `SIM_VERSION` 36.
+
+### 2026-10-25 — Board objects (first batch) and the Acid bomb; the worn look switched off
+
+The user's order: "implement 2, 1, 3" — the worn dungeon, the first hazards, the Acid bomb.
+
+1. **The worn dungeon** was built (`render/decor.ts`: Splat Pack stains in two of six looks per
+   level, denser along walls) and then **switched off** at the user's word: the splats looked too
+   alike and not the look wanted. The code stays, off, until another approach is chosen.
+2. **Bushes, metal crates, oil, spikes, oil barrels** (PLAN.md §19.3), from levels 2, 3, 5, 8 and
+   11. Choices made in building (the plan left them open): bushes smoulder a turn before catching;
+   enemies avoid spikes entirely rather than planning round raised ones; barrels burst into oil
+   and a reach-2 blast the next turn, not counting to the player's bombs; metal is placed only
+   where everything stays reachable.
+3. **Acid** (PLAN.md §19.4): one bomb per charge; any later blast breaks a cracked pillar; the pillar's
+   tile becomes floor and the 8 round it fall 4 turns later; crushed and trapped ignore Shield and
+   Star (rubble would leave the player inside a wall).
+
+`SIM_VERSION` 37. The balance lab (40 seeds, careful bot): wins 80%, 19.5 levels, 0.45 lives lost;
+stalled runs 15% (9% before), no deaths on spikes; one trapped (the bot used a dropped Acid).
+
+### 2026-10-25 — Shield and Flame Pass work while carried
+
+User request: "while they are in the charge slot, they are active. No activation needed. When the
+appropriate damage hits the player, the charge is used up, and the charge slot is once again empty."
+So the `shield` and `flamePass` actions, `shieldUp` and `flameHits` are gone: the charge held is
+the protection. A Shield's use goes on any hit but a falling ceiling; a Flame Pass carries
+FLAME_PASS_HITS (3) uses, one per fire hit (and per turn walked in flame), the slot emptying with
+the last — the 3 hits the user chose earlier the same day, kept. A death can still drop one (only a
+ceiling or being trapped kills through a Shield). The charge button is greyed out while one is
+carried; its "What is that?" says it works by itself. `SIM_VERSION` 38.
+
+### 2026-10-25 — A Shield pushes the player out from under a falling ceiling
+
+User request: under a falling ceiling, the Shield pushes the player to an empty spot; with none,
+they die. Empty: open floor with no bomb or enemy, among the 8 tiles round them, the 4 straight ones
+first (up, down, left, right), then the diagonals. The Shield is used up, and
+absorbs the rest of that turn's hits as usual. A Star still doesn't help. Within `SIM_VERSION` 38
+(not yet released).
+
+### 2026-10-25 — Danger "later": a bomb's tiles shown from the start
+
+User report: with a long fuse, a bomb's tiles weren't marked until its last turns (the overlay showed
+only this turn and the next). `predictDanger` now also returns `later`: playing on from the next turn,
+the player waiting and enemies left where they are, every tile burned (chains, oil, bushes included)
+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.
diff --git a/docs/balance/runs-careful.md b/docs/balance/runs-careful.md
new file mode 100644
index 0000000..f9c715d
--- /dev/null
+++ b/docs/balance/runs-careful.md
@@ -0,0 +1,38 @@
+# Balance lab: runs (careful bot)
+
+2026-10-25 · rules version 37 · 40 seeds per arm · 29 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.45 | 80% | 0.45 | 91.1 | 5.1% | 15% |
+
+## The baseline, level by level
+
+| Level | Runs reaching it | Lives lost there | Turns there | Within par |
+|---|---|---|---|---|
+| 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% |
+
+Lives lost in the baseline, by cause: walkedIntoEnemy 7, caughtByEnemy 4, blast 5, trapped 1, chain 1.
diff --git a/src/main.ts b/src/main.ts
index ac37915..b97402b 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -460,7 +460,7 @@ function showMenu(): void {
       (modes.length ? `<p class="mode">${modes.length > 1 ? 'Modes' : 'Mode'}: ${modes.join(', ')}.</p>` : '') +
       feedbackChoices() +
       `<p class="credit">Art and sounds by <a href="https://www.kenney.nl" target="_blank" rel="noopener">Kenney</a>: ` +
-      `New Platformer Pack, UI Pack, UI Pack: RPG Expansion, Game Icons and Board Game Icons (CC0). Thank you!</p>` +
+      `New Platformer Pack, UI Pack, UI Pack: RPG Expansion, Game Icons, Board Game Icons and Splat Pack (CC0). Thank you!</p>` +
       // The version is the rules version: it changes exactly when old saves stop resuming.
       `<p class="credit">Version ${SIM_VERSION}${commitLine()}. ` +
       `<a href="${REPO_URL}" target="_blank" rel="noopener">Source code</a></p>`,
@@ -583,10 +583,10 @@ function pick(id: ActionId, pressed = selected === id): void {
   // Wait happens 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) return act({ id: now })
-  // A Freeze, Shield or Flame Pass picked, whose only tile is the player's own: pressing the button again
-  // confirms it (as tapping yourself does), rather than putting it away.
+  // A Freeze picked, whose only tile is the player's own: pressing the button again confirms it (as
+  // tapping yourself does), rather than putting it away.
   const self = pressed && selected ? resolvePick(state, selected, state.player) : null
-  if (self && (self === 'freeze' || self === 'shield' || self === 'flamePass')) return act({ id: self, target: state.player })
+  if (self === 'freeze') return act({ id: self, target: state.player })
   selected = pressed ? null : id
   afterChange()
 }
@@ -879,6 +879,7 @@ const SCENE_ICONS: Record<SceneKind, string> = {
   dieIntoBlast: 'fireball', dieNotFar: 'fireball', dieBlockedIn: 'bomb', dieChain: 'bomb', dieEnemy: 'slime_normal_rest',
   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,
 }
 
 
diff --git a/src/render/animate.ts b/src/render/animate.ts
index 8f87c09..9e528c8 100644
--- a/src/render/animate.ts
+++ b/src/render/animate.ts
@@ -61,7 +61,8 @@ export function animateTurn(a: Animation, prev: GameState, result: ReturnType<ty
     a.enemySteps.clear()
   }
   // Flame shows when the blast does (an ICBM's, when it lands).
-  for (const e of result.events) if (e.type === 'exploded') a.flames.push({ tiles: e.tiles, born: Math.max(now, a.fx.blastAt) })
+  // Flame spread over oil or through bushes shows with it.
+  for (const e of result.events) if (e.type === 'exploded' || e.type === 'fireSpread') a.flames.push({ tiles: e.tiles, born: Math.max(now, a.fx.blastAt) })
   // Every enemy that changed tile steps from where it was: from the state, like the player.
   if (!newLevel) {
     for (const enemy of next.enemies) {
diff --git a/src/render/board.ts b/src/render/board.ts
index 157a57d..6955fb0 100644
--- a/src/render/board.ts
+++ b/src/render/board.ts
@@ -2,11 +2,12 @@ import { drawCircle, drawLine, drawPoly, drawRect, drawTile, hsl, rgb, time, vec
 import { ACTIONS, isAvailable } from '../sim/actions'
 import { pickTargets, resolvePick } from '../sim/picks'
 import type { Danger } from '../sim/danger'
-import { blastTiles } from '../sim/explosions'
+import { blastTiles, spikesUp } from '../sim/explosions'
 import { dirBetween } from '../sim/enemies'
 import { cellAt, cloneState, eq, exitOpen, idx } from '../sim/state'
 import type { ActionId, Bomb, Enemy, GameState, Vec } from '../sim/types'
 import type { ViewRect } from './camera'
+import { drawFloorStains, drawWallStains } from './decor'
 import {
   appearScale, dropSprite, frostCrack, frostLevel, GONE_SECONDS, isSlide, key, moveProgress, moveSeconds, progress, pulseScale, TELEPORT_SECONDS, WALK_SECONDS,
   type Effects, type Gone, type PlayerMove, type PlayerPose,
@@ -85,6 +86,7 @@ const C = {
   floorAlt: rgb(0.9, 0.94, 0.98),
   dangerNow: rgb(0.9, 0.15, 0.1, 0.45),
   dangerNext: rgb(1, 0.35, 0.2, 0.18),
+  dangerLater: rgb(0.92, 0.25, 0.15, 0.65),
   target: rgb(0.25, 0.62, 0.87, 0.4),
   /** Auto-move's way, drawn as a line of arrows over its blue tiles. */
   pathLine: rgb(0.13, 0.42, 0.72, 0.9),
@@ -113,6 +115,10 @@ const C = {
   armed: rgb(1, 0.3, 0.15, 0.45),
   /** An ICBM's smoke: grey, to show on the pale floor and the red of the danger overlay alike. */
   smoke: rgb(0.62, 0.63, 0.68),
+  // Board objects (PLAN.md §19.3, §19.4): a cracked pillar's cracks, a falling ceiling's dust, a smouldering bush.
+  crack: rgb(0.1, 0.08, 0.1, 0.85),
+  dust: rgb(0.75, 0.7, 0.62),
+  smoulder: rgb(1, 0.45, 0.15),
 }
 
 /** An ICBM's smoke trail: how many puffs, how far apart in time. */
@@ -136,12 +142,27 @@ export function drawBoard(view: View): void {
   for (let y = 0; y < s.height; y++) {
     for (let x = 0; x < s.width; x++) drawRect(toWorld(s, { x, y }), wallSize, (x + y) % 2 ? C.floorAlt : C.floor)
   }
+  // The worn floor: stains from the seed (render/decor.ts).
+  drawFloorStains(s)
+  // Oil spills: near-black and glossy, each tile's splat turned its own way (PLAN.md §19.3).
+  for (const i of s.oil) drawTile(toWorld(s, tileOf(s, i)), vec2(1.3), sprite('oil'), C.white, (i * 1.7) % (Math.PI * 2))
+  // Spikes: standing up when they hurt this turn; down, short and faint.
+  for (const sp of s.spikes) {
+    const pos = toWorld(s, tileOf(s, sp.cell))
+    if (spikesUp(s, sp.cell)) drawTile(pos, vec2(0.9), sprite('spikes'))
+    else drawTile(pos.add(vec2(0, -0.28)), vec2(0.9, 0.3), sprite('spikes'), C.white.scale(1, 0.55))
+  }
   for (let y = 0; y < s.height; y++) {
     for (let x = 0; x < s.width; x++) {
       const p = { x, y }
       const pos = toWorld(s, p)
       const cell = s.cells[idx(s, p)]
-      if (cell === 'wall') drawTile(pos, wallSize, sprite(wallSprite(s.level, s.width, s.height, p)))
+      if (cell === 'wall') {
+        drawTile(pos, wallSize, sprite(wallSprite(s.level, s.width, s.height, p)))
+        if (s.cracked.includes(idx(s, p))) drawCracks(pos, idx(s, p))
+      } else if (cell === 'metal') drawTile(pos, tileSize, sprite('block_metal'))
+      else if (cell === 'barrel') drawTile(pos, tileSize, sprite('block_strong_danger'))
+      else if (cell === 'rubble') drawTile(pos, wallSize, sprite('rock'))
       else if (cell === 'block') {
         // A crumbling crate shakes, harder and fainter the closer it is to gone.
         const left = crumbling.get(idx(s, p))
@@ -153,6 +174,17 @@ export function drawBoard(view: View): void {
       }
     }
   }
+  drawWallStains(s)
+  // A ceiling about to fall: dust trickling down, more as it nears.
+  for (const c of s.collapsing) {
+    for (const i of c.cells) {
+      const pos = toWorld(s, tileOf(s, i))
+      for (let k = 0; k < 6 - c.turns; k++) {
+        const fall = (time * 0.8 + k * 0.37 + i * 0.13) % 1
+        drawCircle(pos.add(vec2(((k * 0.29 + i * 0.07) % 0.8) - 0.4, 0.45 - fall * 0.9)), 0.05, C.dust.scale(1, 1 - fall))
+      }
+    }
+  }
   // The stone frame has no inner-corner piece, so its outline gets a square where the sides meet,
   // lined up with the oversized wall sprites' outline.
   const outline = wallSize.x * STONE_OUTLINE
@@ -177,6 +209,8 @@ export function drawBoard(view: View): void {
   // A destroyed crate bursts over whatever it uncovered.
   for (const g of fx.gone) if (g.kind === 'crate') drawGone(s, g)
 
+  // Hit on a later turn: an outline only, so it reads as "not yet" (user request, 2026-10-25).
+  for (const i of danger.later) drawOutline(toWorld(s, tileOf(s, i)), 0.86, 0.07, C.dangerLater)
   for (const i of danger.next) if (!danger.now.has(i)) drawRect(toWorld(s, tileOf(s, i)), vec2(0.94), C.dangerNext)
   for (const i of danger.now) drawRect(toWorld(s, tileOf(s, i)), vec2(0.94), C.dangerNow)
 
@@ -261,6 +295,13 @@ export function drawBoard(view: View): void {
     drawTile(pos, vec2(0.62 * appearScale(fx, key.drop(d.pos), time)), sprite(dropSprite(d)))
   }
 
+  // Bushes over what lies in them, hiding it (PLAN.md §19.3). A smouldering one glows: it
+  // catches fire next turn.
+  for (const i of s.bushes) {
+    const smoulder = s.smoldering.includes(i)
+    drawTile(toWorld(s, tileOf(s, i)).add(vec2(0, 0.04)), vec2(1.02), sprite('bush'), smoulder ? C.smoulder.lerp(C.white, 0.5 + 0.5 * Math.sin(time * 6 + i)) : C.white)
+  }
+
   for (const e of s.enemies) drawEnemy(s, e, view.enemySteps.get(e.id), fx)
   for (const g of fx.gone) if (g.kind !== 'crate' && g.kind !== 'flame') drawGone(s, g)
 
@@ -268,14 +309,16 @@ 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.shieldUp) drawCircle(at, 0.88 * appearScale(fx, key.shield, time), C.clear, 0.06, C.shield)
-  // A raised Flame Pass (2026-10-25): a flickering orange ring, just outside the Shield's.
-  if (s.flameHits > 0) drawCircle(at, 0.98, C.clear, 0.05, C.flameRing.scale(1, 0.7 + 0.25 * Math.sin(time * 9)))
+  if (s.charge?.kind === 'shield') drawCircle(at, 0.88 * appearScale(fx, key.shield, time), C.clear, 0.06, C.shield)
+  // A Flame Pass carried (2026-10-25): a flickering orange ring, just outside the Shield's.
+  if (s.charge?.kind === 'flamePass') drawCircle(at, 0.98, C.clear, 0.05, C.flameRing.scale(1, 0.7 + 0.25 * Math.sin(time * 9)))
   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)
   }
   drawPlayer(s, view.playerMove, view.playerPose, view.facingLeft)
+  // Hidden in a bush: leaves over the player too, see-through, so they still know where they are.
+  if (s.bushes.includes(idx(s, s.player)) && at.distance(toWorld(s, s.player)) < 0.01) drawTile(toWorld(s, s.player).add(vec2(0, 0.04)), vec2(1.02), sprite('bush'), C.white.scale(1, 0.55))
 }
 
 /** Where the player is on screen right now: sliding between tiles while walking, otherwise on their tile. */
@@ -424,7 +467,8 @@ function drawEnemy(s: GameState, e: Enemy, stepped: { from: Vec; at: number } |
   const size = 0.8 * appearScale(fx, key.enemy(e.id), time)
   const at = pos.add(vec2(-0.09 + frostCrack(fx, time), 0))
   // Inside a crate (a Ghost or a Wisp): see-through, so the crate shows it is in there.
-  const inCrate = cellAt(s, e.pos) === 'block'
+  // In a bush, only glimpsed through the leaves.
+  const inCrate = cellAt(s, e.pos) === 'block' || s.bushes.includes(idx(s, e.pos))
   drawTile(at, vec2(size), sprite(frame), C.white.lerp(C.ice, frost).scale(1, inCrate ? 0.6 : 1), angle, mirror, C.frostGlow.scale(frost, 1))
   if (frost > 0) drawIce(at, size, frost, e.id)
   if (!moving) return
@@ -513,17 +557,45 @@ function drawDecoy(pos: Vector2, color: Color, scale = 1): void {
  * tile's centre. A Pierce, Power or Nuke bomb wears that charge's icon as a small badge along
  * the top, one for each it has.
  */
-function drawBomb(pos: Vector2, timer: number, body: BoardSprite, color: Color, kinds: Pick<Bomb, 'pierce' | 'power' | 'nuke'>, scale = 1): void {
+function drawBomb(pos: Vector2, timer: number, body: BoardSprite, color: Color, kinds: Pick<Bomb, 'pierce' | 'power' | 'nuke' | 'acid' | 'barrel'>, scale = 1): void {
   if (scale <= 0) return
-  drawTile(pos, vec2(0.9 * scale), sprite(body), color)
+  // A burst oil barrel, about to go: it shakes.
+  if (kinds.barrel) {
+    const shake = 0.06 * Math.sin(time * 40)
+    drawTile(pos.add(vec2(shake, 0)), vec2(0.95 * scale), sprite('block_strong_danger'), color, shake)
+  } else drawTile(pos, vec2(0.9 * scale), sprite(body), color)
   const digits = String(timer)
   const w = 0.32 * scale
   const start = pos.add(vec2(0.04 * scale - ((digits.length - 1) * w) / 2, -0.06 * scale))
   for (let i = 0; i < digits.length; i++) drawTile(start.add(vec2(i * w, 0)), vec2(0.42 * scale), digitSprite(Number(digits[i])), color)
-  const badges = (['pierce', 'power', 'nuke'] as const).filter((k) => kinds[k])
+  const badges = (['pierce', 'power', 'nuke', 'acid'] as const).filter((k) => kinds[k])
   badges.forEach((k, i) => drawTile(pos.add(vec2(-0.3 + 0.3 * i, 0.32).scale(scale)), vec2(0.36 * scale), sprite(CHARGE_ICONS[k]), color))
 }
 
+/** A square outline `size` across, lines `width` thick, inside a tile. */
+function drawOutline(pos: Vector2, size: number, width: number, color: Color): void {
+  const h = size / 2
+  const w = width / 2
+  drawRect(pos.add(vec2(0, h - w)), vec2(size, width), color)
+  drawRect(pos.add(vec2(0, -h + w)), vec2(size, width), color)
+  drawRect(pos.add(vec2(-h + w, 0)), vec2(width, size - 2 * width), color)
+  drawRect(pos.add(vec2(h - w, 0)), vec2(width, size - 2 * width), color)
+}
+
+/** Cracks over a pillar Acid reached: a few jagged dark lines, the same each time for a tile. */
+function drawCracks(pos: Vector2, seed: number): void {
+  for (let k = 0; k < 3; k++) {
+    const a = seed * 2.3 + k * 2.1
+    let p = pos.add(vec2(Math.cos(a) * 0.1, Math.sin(a) * 0.1))
+    for (let j = 0; j < 3; j++) {
+      const turn = a + (j % 2 ? 0.6 : -0.5)
+      const q = p.add(vec2(Math.cos(turn) * 0.14, Math.sin(turn) * 0.14))
+      drawLine(p, q, 0.045, C.crack)
+      p = q
+    }
+  }
+}
+
 function tileOf(s: GameState, i: number): Vec {
   return { x: i % s.width, y: Math.floor(i / s.width) }
 }
diff --git a/src/render/decor.ts b/src/render/decor.ts
new file mode 100644
index 0000000..ff120df
--- /dev/null
+++ b/src/render/decor.ts
@@ -0,0 +1,92 @@
+import { drawTile, rgb, vec2, type Color } from 'littlejsengine'
+import { stream } from '../sim/rng'
+import type { GameState, Vec } from '../sim/types'
+import { toWorld } from './board'
+import { SPLATS, sprite } from './sprites'
+
+/**
+ * A worn dungeon (user request, 2026-10-25): stains on the floor and the walls from Kenney's Splat
+ * Pack — damp puddles, moss, dirt, dust, soot, rust — each level its own mix and amount, from the
+ * seed and level, so a level always looks the same and levels differ. Decoration only: not in the
+ * sim, drawn faint under everything, so it never reads as a hazard (oil is dark and glossy, PLAN.md
+ * §19.3). Heavier along the walls and in corners, as dirt gathers there.
+ */
+interface Stain {
+  at: Vec
+  /** Offset within the tile, size and turn, in tiles and radians. */
+  dx: number
+  dy: number
+  size: number
+  angle: number
+  splat: (typeof SPLATS)[number]
+  color: Color
+  /** On a wall (rust, moss on stone) rather than the floor. */
+  wall: boolean
+}
+
+/** The looks a level can mix: each a few colours (faint: alpha about 0.15–0.25). */
+const THEMES: Record<string, Color[]> = {
+  damp: [rgb(0.32, 0.42, 0.58, 0.22), rgb(0.4, 0.5, 0.62, 0.18)],
+  mossy: [rgb(0.3, 0.52, 0.25, 0.2), rgb(0.42, 0.55, 0.3, 0.16)],
+  dirty: [rgb(0.42, 0.32, 0.2, 0.2), rgb(0.55, 0.45, 0.3, 0.16)],
+  dusty: [rgb(0.72, 0.64, 0.48, 0.2), rgb(0.62, 0.58, 0.5, 0.16)],
+  scorched: [rgb(0.14, 0.13, 0.13, 0.2), rgb(0.25, 0.22, 0.2, 0.16)],
+  rusty: [rgb(0.62, 0.32, 0.14, 0.22), rgb(0.5, 0.28, 0.16, 0.18)],
+}
+const THEME_NAMES = Object.keys(THEMES)
+
+/** Off for now (user, 2026-10-25: the splats look too alike, and not the look wanted). */
+const ENABLED = false
+
+let cached: { key: string; stains: Stain[] } | null = null
+
+/** The level's stains, worked out once per level (and board size). */
+export function stainsOf(s: GameState): Stain[] {
+  const key = `${s.seed}:${s.level}:${s.width}x${s.height}`
+  if (cached?.key === key) return cached.stains
+  const rng = stream(s.seed, 'decor', s.level)
+  // Two looks per level, and how worn it is: from lightly to heavily.
+  const first = THEME_NAMES[rng.int(THEME_NAMES.length)]!
+  const second = THEME_NAMES[rng.int(THEME_NAMES.length)]!
+  const colors = [...THEMES[first]!, ...THEMES[second]!]
+  const wear = 0.12 + rng.next() * 0.25
+  const wall = (x: number, y: number) => x < 0 || y < 0 || x >= s.width || y >= s.height || s.cells[y * s.width + x] === 'wall'
+  const stains: Stain[] = []
+  for (let y = 0; y < s.height; y++) {
+    for (let x = 0; x < s.width; x++) {
+      const onWall = wall(x, y)
+      // Next to walls more stains gather; walls themselves get a few, smaller.
+      const walls = [wall(x - 1, y), wall(x + 1, y), wall(x, y - 1), wall(x, y + 1)].filter(Boolean).length
+      const chance = onWall ? wear * 0.35 : wear * (1 + walls * 0.6)
+      if (rng.next() >= chance) continue
+      stains.push({
+        at: { x, y },
+        dx: (rng.next() - 0.5) * 0.5,
+        dy: (rng.next() - 0.5) * 0.5,
+        size: onWall ? 0.35 + rng.next() * 0.35 : 0.5 + rng.next() * 0.8,
+        angle: rng.next() * Math.PI * 2,
+        splat: SPLATS[rng.int(SPLATS.length)]!,
+        color: colors[rng.int(colors.length)]!,
+        wall: onWall,
+      })
+    }
+  }
+  cached = { key, stains }
+  return stains
+}
+
+/** The floor's stains: after the floor, before the walls and everything on the board. */
+export function drawFloorStains(s: GameState): void {
+  if (!ENABLED) return
+  for (const st of stainsOf(s)) if (!st.wall) drawStain(s, st)
+}
+
+/** The walls' stains: over the walls, before anything that moves. */
+export function drawWallStains(s: GameState): void {
+  if (!ENABLED) return
+  for (const st of stainsOf(s)) if (st.wall) drawStain(s, st)
+}
+
+function drawStain(s: GameState, st: Stain): void {
+  drawTile(toWorld(s, st.at).add(vec2(st.dx, st.dy)), vec2(st.size), sprite(st.splat), st.color, st.angle)
+}
diff --git a/src/render/effects.ts b/src/render/effects.ts
index 2bf426e..0d5cbb0 100644
--- a/src/render/effects.ts
+++ b/src/render/effects.ts
@@ -194,9 +194,6 @@ 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/render/sprites.ts b/src/render/sprites.ts
index 0dacf0f..230cff3 100644
--- a/src/render/sprites.ts
+++ b/src/render/sprites.ts
@@ -5,16 +5,18 @@ import type { ChargeId, EnemyKind, GemColor, PowerUpKind } from '../sim/types'
  * Art from Kenney's New Platformer Pack 1.1 (CC0), copied into `assets/kenney/` under the pack's
  * own file names, so a sprite can always be found again in the pack. Every file is 128 × 128:
  * tiles and enemies from the pack's `Double` folders, characters from `Default`. Also
- * `assets/composed/`: a sprite plus a modifier badge, built by `tools/compose-icons.sh`.
+ * `assets/composed/`: a sprite plus a modifier badge, built by `tools/compose-icons.sh`; and
+ * `assets/kenney-splat/`: white splats from Kenney's Splat Pack (CC0; approved by the user,
+ * 2026-10-24), scaled to 128, tinted when drawn (the worn floor, oil).
  */
-const URLS = import.meta.glob<string>(['../../assets/kenney/*.png', '../../assets/composed/*.png'], {
+const URLS = import.meta.glob<string>(['../../assets/kenney/*.png', '../../assets/composed/*.png', '../../assets/kenney-splat/*.png'], {
   eager: true,
   query: '?url',
   import: 'default',
 })
 
 export function spriteUrl(name: string): string {
-  const url = URLS[`../../assets/kenney/${name}.png`] ?? URLS[`../../assets/composed/${name}.png`]
+  const url = URLS[`../../assets/kenney/${name}.png`] ?? URLS[`../../assets/composed/${name}.png`] ?? URLS[`../../assets/kenney-splat/${name}.png`]
   if (!url) throw new Error(`Missing sprite: ${name}.png in assets/kenney or assets/composed`)
   return url
 }
@@ -25,6 +27,9 @@ export function spriteUrl(name: string): string {
  */
 export const MONO_ICONS: ReadonlySet<string> = new Set(['icon_move', 'icon_wait', 'icon_fuse', 'charge_shield', 'charge_freeze', 'charge_decoy'])
 
+/** The splats copied from the Splat Pack (assets/kenney-splat/), by name. */
+export const SPLATS = ['splat00', 'splat02', 'splat03', 'splat05', 'splat08', 'splat11', 'splat13', 'splat15', 'splat19', 'splat21', 'splat24', 'splat27', 'splat30', 'splat33'] as const
+
 /** The sprites drawn on the canvas. Their order is their LittleJS texture index. */
 const BOARD = [
   'terrain_stone_horizontal_middle',
@@ -115,6 +120,15 @@ const BOARD = [
   'powerup_proximity',
   'powerup_slide',
   'charge_icbm',
+  // The Splat Pack's splats (render/decor.ts), and the board objects (PLAN.md §19.3).
+  ...SPLATS,
+  'bush',
+  'block_metal',
+  'block_strong_danger',
+  'rock',
+  'spikes',
+  'charge_acid',
+  'oil',
 ] as const
 
 export type BoardSprite = (typeof BOARD)[number]
@@ -169,8 +183,8 @@ export function wallSprite(level: number, width: number, height: number, p: { x:
  * The icon for each charge, on the board (as a drop), in the top bar while it lasts, and as a
  * bomb's badge (user's choices, 2026-10-23): Pierce is the Wall Pass icon, Power the Fire+ icon;
  * Nuke a bomb with a skull badge; Shield the
- * figure in an empty ring; Freeze a locked padlock; Decoy three pawns; ICBM a bomb with a target
- * (tools/compose-icons.sh).
+ * figure in an empty ring; Freeze a locked padlock; Decoy three pawns; ICBM a bomb with a target;
+ * Acid a bomb with a flask (tools/compose-icons.sh).
  */
 export const CHARGE_ICONS: Record<ChargeId, BoardSprite> = {
   pierce: 'powerup_wall_pass',
@@ -183,6 +197,7 @@ export const CHARGE_ICONS: Record<ChargeId, BoardSprite> = {
   // Power-ups until 2026-10-25, with the icons they had.
   flamePass: 'powerup_flame_pass',
   teleport: 'powerup_teleport',
+  acid: 'charge_acid',
 }
 
 /**
diff --git a/src/sim/actions.ts b/src/sim/actions.ts
index 21ae1e8..4e15d4b 100644
--- a/src/sim/actions.ts
+++ b/src/sim/actions.ts
@@ -1,4 +1,4 @@
-import { BOMB_CHARGES, FLAME_PASS_HITS, holds, spendCharge } from './charges'
+import { BOMB_CHARGES, holds, spendCharge } from './charges'
 import { enemyAt } from './enemies'
 import { add, bombAt, cellAt, eq, exitOpen, isBurning, isFree, playerCanEnter, playerCanPass } from './state'
 import { DIRS, DIR_LIST, type ActionId, type Bomb, type GameEvent, type GameState, type Powers, type Vec } from './types'
@@ -33,7 +33,7 @@ function canHoldBomb(s: GameState, p: Vec): boolean {
  * 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 {
+function placeBomb(s: GameState, pos: Vec, events: GameEvent[], boost?: 'pierce' | 'power' | 'nuke' | 'acid'): Bomb {
   const bomb: Bomb = { pos: { ...pos }, timer: s.stats.fuse, fire: s.stats.fire, placedTurn: s.turn }
   if (boost) bomb[boost] = true
   // A Power bomb's flame reaches as far as the level is wide: in practice, to the walls.
@@ -114,7 +114,8 @@ export function teleportSteps(s: GameState, dx: number, dy: number): number {
 }
 
 function hasCapacity(s: GameState): boolean {
-  return s.bombs.length < s.stats.capacity
+  // 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
 }
 
 /** The adjacent tiles a bomb can go on, if the player may place one. */
@@ -193,7 +194,7 @@ export const ACTIONS: Record<ActionId, ActionDef> = {
     needsTarget: true,
     ability: 'detonate',
     // Any of your bombs: it explodes this turn, in phase 3, with everything it sets off.
-    validTargets: (s) => s.bombs.filter((b) => b.timer > 0).map((b) => b.pos),
+    validTargets: (s) => s.bombs.filter((b) => b.timer > 0 && !b.barrel).map((b) => b.pos),
     apply(s, target, events) {
       bombAt(s, target!)!.timer = 0
       events.push({ type: 'detonated', pos: { ...target! } })
@@ -307,7 +308,7 @@ export const ACTIONS: Record<ActionId, ActionDef> = {
     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'
+      const kind = s.charge!.kind as 'pierce' | 'power' | 'nuke' | 'acid'
       spendCharge(s)
       placeBomb(s, target!, events, kind)
     },
@@ -352,32 +353,6 @@ export const ACTIONS: Record<ActionId, ActionDef> = {
       events.push({ type: 'decoyPlaced', pos: s.decoy.pos })
     },
   },
-  shield: {
-    id: 'shield',
-    label: 'Shield',
-    needsTarget: true,
-    available: (s) => holds(s, 'shield') && !s.shieldUp,
-    validTargets: (s) => [s.player],
-    apply(s, _target, events) {
-      spendCharge(s)
-      s.shieldUp = true
-      events.push({ type: 'shieldRaised' })
-    },
-  },
-  // Flame Pass, a charge since 2026-10-25: raised like the Shield (a tap on yourself), it saves the
-  // player from FLAME_PASS_HITS fire hits.
-  flamePass: {
-    id: 'flamePass',
-    label: 'Flame Pass',
-    needsTarget: true,
-    available: (s) => holds(s, 'flamePass') && s.flameHits === 0,
-    validTargets: (s) => [s.player],
-    apply(s, _target, events) {
-      spendCharge(s)
-      s.flameHits = FLAME_PASS_HITS
-      events.push({ type: 'flamePassRaised', hits: FLAME_PASS_HITS })
-    },
-  },
   slide: {
     id: 'slide',
     label: 'Slide',
@@ -399,7 +374,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', 'charged', 'freeze', 'decoy', 'shield', 'flamePass',
+  'move', 'bomb', 'wait', 'run', 'detonate', 'kick', 'throw', 'diagonal', 'teleport', 'rpg', 'icbm', 'slide', 'charged', 'freeze', 'decoy',
 ]
 
 export function isValidTarget(s: GameState, id: ActionId, target: Vec): boolean {
diff --git a/src/sim/charges.ts b/src/sim/charges.ts
index 4193d3a..79c5152 100644
--- a/src/sim/charges.ts
+++ b/src/sim/charges.ts
@@ -11,18 +11,28 @@ import type { ChargeId, GameEvent, GameState } from './types'
 /** 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
 
+/** How many fire hits a Flame Pass saves the player from (user's choice, 2026-10-25). */
+export const FLAME_PASS_HITS = 3
+
+/**
+ * Shield and Flame Pass work by themselves while in the charge slot (user request, 2026-10-25:
+ * they had to be raised with the charge button): a hit of their kind uses one of their uses, and
+ * the slot empties when none is left.
+ */
+export const PASSIVE_CHARGES: readonly ChargeId[] = ['shield', 'flamePass']
+
 /** 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,
-  // Teleport: 3 jumps (user's choice, 2026-10-25); Flame Pass: one raise, worth FLAME_PASS_HITS.
-  teleport: 3, flamePass: 1,
+  // Teleport: 3 jumps (user's choice, 2026-10-25); Flame Pass: its fire hits.
+  teleport: 3, flamePass: FLAME_PASS_HITS,
+  // Acid: one bomb (PLAN.md §19.4).
+  acid: 1,
 }
 
-/** How many fire hits a raised Flame Pass saves the player from (user's choice, 2026-10-25). */
-export const FLAME_PASS_HITS = 3
 
 /** The charges used as a bomb from the charge button. */
-export const BOMB_CHARGES: readonly ChargeId[] = ['pierce', 'power', 'nuke']
+export const BOMB_CHARGES: readonly ChargeId[] = ['pierce', 'power', 'nuke', 'acid']
 
 /** 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 {
diff --git a/src/sim/danger.ts b/src/sim/danger.ts
index af55132..42bc8dc 100644
--- a/src/sim/danger.ts
+++ b/src/sim/danger.ts
@@ -1,4 +1,4 @@
-import { detonate, proximityAlert, tickBombs } from './explosions'
+import { collapse, detonate, proximityAlert, tickBombs } from './explosions'
 import { cloneState } from './state'
 import type { GameEvent, GameState } from './types'
 
@@ -6,13 +6,22 @@ import type { GameEvent, GameState } from './types'
 export interface Danger {
   now: Set<number>
   next: Set<number>
+  /**
+   * What the bombs on the board, and ceilings falling, will hit on any later turn, until the last
+   * goes off, if the player waits (user request, 2026-10-25: a bomb with a long fuse showed nothing
+   * until its last two turns). Not in `now` or `next`.
+   */
+  later: Set<number>
 }
 
+/** How many turns past the next `later` looks ahead: more than the longest fuse or ceiling countdown. */
+const LATER_TURNS = 12
+
 /**
  * The danger overlay (PLAN.md §4), assuming the player waits. It runs the same countdown and
  * chain code as `step()`, so it can't disagree with what actually happens. Flame lingers one
  * extra turn, so "now" includes what is still burning from last turn, and "next" includes what
- * explodes now.
+ * explodes now. Raised spikes and a falling ceiling are danger too.
  */
 export function predictDanger(state: GameState): Danger {
   const s = cloneState(state)
@@ -20,12 +29,31 @@ export function predictDanger(state: GameState): Danger {
   // Proximity bombs: set off by where the enemies will be after their announced moves (frozen
   // ones stay put), this turn and the next.
   const enemiesAfter = (k: number) => state.enemies.map((e) => (state.frozen > k ? e.pos : (e.plan[k] ?? e.plan[e.plan.length - 1] ?? e.pos)))
+  // Spikes up, and ceilings falling, this turn and the next (PLAN.md §19.3, §19.4).
+  const hazards = (turn: number, k: number) => [
+    ...state.spikes.filter((sp) => (turn + sp.phase) % 2 === 0).map((sp) => sp.cell),
+    ...state.collapsing.filter((c) => c.turns === k + 1).flatMap((c) => c.cells),
+  ]
   proximityAlert(s, ignored, enemiesAfter(0))
   tickBombs(s, ignored)
+  collapse(s, ignored)
   const exploding = detonate(s, ignored)
   s.turn++
   proximityAlert(s, ignored, enemiesAfter(1))
   tickBombs(s, ignored)
-  const later = detonate(s, ignored)
-  return { now: new Set([...exploding, ...state.burning]), next: new Set([...later, ...exploding]) }
+  collapse(s, ignored)
+  const following = detonate(s, ignored)
+  const now = new Set([...exploding, ...state.burning, ...hazards(state.turn, 0)])
+  const next = new Set([...following, ...exploding, ...hazards(state.turn + 1, 1)])
+  // Then on, until nothing is left to go off: what each bomb (and the chains and spreading fire it
+  // sets off) burns, and where a ceiling comes down. Enemies are left where they are.
+  const later = new Set<number>()
+  for (let k = 0; k < LATER_TURNS && (s.bombs.length || s.collapsing.length || s.smoldering.length); k++) {
+    s.turn++
+    tickBombs(s, ignored)
+    for (const i of collapse(s, ignored)) later.add(i)
+    for (const i of detonate(s, ignored)) later.add(i)
+  }
+  for (const i of [...now, ...next]) later.delete(i)
+  return { now, next, later }
 }
diff --git a/src/sim/demo.ts b/src/sim/demo.ts
index 5148279..97c373f 100644
--- a/src/sim/demo.ts
+++ b/src/sim/demo.ts
@@ -39,7 +39,7 @@ export type SceneKind =
   | 'bombPlus' | 'firePlus' | 'fusePlus' | 'run' | 'kick' | 'throw' | 'teleport' | 'diagonal' | 'detonate'
   | 'rpg' | 'proximity' | 'slide' | 'icbm'
   | 'wallPass' | 'bombPass' | 'flamePass' | 'star'
-  | 'pierce' | 'power' | 'nuke' | 'shield' | 'freeze' | 'decoy' | 'chargeSwap'
+  | 'pierce' | 'power' | 'nuke' | 'acid' | 'shield' | 'freeze' | 'decoy' | 'chargeSwap'
   | 'exit' | 'doorBlast'
   | 'lastEnemy' | 'crate' | 'pillar' | 'rainbowGem' | 'immortal'
   | 'dieIntoBlast' | 'dieNotFar' | 'dieBlockedIn' | 'dieChain' | 'dieEnemy' | 'dieDetonator' | 'dieRun'
@@ -338,9 +338,9 @@ export const SCENES: Record<SceneKind, SceneDef> = {
     shows: (t) => t.events.some((e) => e.type === 'moved' && t.prev.bombs.some((b) => at(b.pos, e.to))),
   },
   flamePass: {
-    caption: 'Flame Pass: raise it with the charge button, and the next 3 fire hits can’t hurt you.',
+    caption: 'Flame Pass: while you carry it, the next 3 fire hits can’t hurt you.',
     enemies: ['blob'],
-    prepare: (s) => (s.charge = { kind: 'flamePass', left: 1 }),
+    prepare: (s) => (s.charge = { kind: 'flamePass', left: 3 }),
     shows: (t) => explosions(t).some((e) => e.tiles.some((p) => at(p, t.next.player))),
   },
   star: {
@@ -374,8 +374,14 @@ export const SCENES: Record<SceneKind, SceneDef> = {
     // 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)),
   },
+  acid: {
+    caption: 'Acid: the charge button puts down a bomb whose flame cracks pillars. Blast a cracked one and the ceiling round it falls.',
+    enemies: ['blob', 'drifter'],
+    prepare: (s, rng) => ((s.stats.fire = 2), dropping('acid', 1)(s, rng)),
+    shows: (t, seen) => seen.picked.has('acid') && t.events.some((e) => e.type === 'pillarCracked'),
+  },
   shield: {
-    caption: 'Shield: press the charge button to raise it, and it absorbs the next hit.',
+    caption: 'Shield: while you carry it, it absorbs the next hit.',
     enemies: ['seeker', 'seeker'],
     prepare: dropping('shield'),
     shows: (t) => t.events.some((e) => e.type === 'shieldBroke'),
@@ -606,8 +612,8 @@ 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,
-    charge: null, chargeOffer: null, shieldUp: false, flameHits: 0, frozen: 0, decoy: null, burning: [],
-    powers: noPowers(), star: 0, gems: [], offer: null, shop: null, death: null, runTurns: 0, runKills: 0, deaths: 0, crumbling: [],
+    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 }, 
     aiRng: rng.int(2 ** 31), dropRng: rng.int(2 ** 31),
   }
   const open = floor().filter((p) => steps(p, player) >= 3)
@@ -789,7 +795,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, flamePass: 3,
+  charged: 3, freeze: 2, decoy: 2,
 }
 
 function randomAction(s: GameState, rng: Rng): Action {
diff --git a/src/sim/enemies.ts b/src/sim/enemies.ts
index 2eb6e85..7130b8d 100644
--- a/src/sim/enemies.ts
+++ b/src/sim/enemies.ts
@@ -1,5 +1,5 @@
 import { Rng } from './rng'
-import { add, bombAt, cellAt, eq } from './state'
+import { add, bombAt, cellAt, eq, idx } from './state'
 import { CHARGE_IDS, DIRS, DIR_LIST, type ChargeId, type Dir, type Drop, type Enemy, type EnemyKind, type GameEvent, type GameState, type Vec } from './types'
 
 /** Ghosts and Wisps go through crates (soft blocks), as the NES's Kondoria and Ovapi did. */
@@ -17,7 +17,16 @@ export const RESTS: ReadonlySet<EnemyKind> = new Set(['ghost', 'sprinter'])
  */
 export function enemyCanEnter(s: GameState, p: Vec, kind?: EnemyKind): boolean {
   const cell = cellAt(s, p)
-  return (cell === 'floor' || (cell === 'block' && !!kind && PASSES_CRATES.has(kind))) && !bombAt(s, p)
+  // Spikes: every enemy keeps off them, up or down (PLAN.md §19.3).
+  return (cell === 'floor' || (cell === 'block' && !!kind && PASSES_CRATES.has(kind))) && !bombAt(s, p) && !s.spikes.some((sp) => sp.cell === idx(s, p))
+}
+
+/**
+ * Where enemies chase: the player, or while the player hides in a bush, where they were last seen
+ * out of one (PLAN.md §19.3).
+ */
+export function chased(s: GameState): Vec {
+  return s.bushes.includes(idx(s, s.player)) ? s.lastSeen : s.player
 }
 
 export function enemyAt(s: GameState, p: Vec): Enemy | undefined {
@@ -102,7 +111,7 @@ function drift(s: GameState, w: Walker, rng: Rng): Step | null {
  * there is no way.
  */
 export function stepToward(s: GameState, w: Pick<Walker, 'pos' | 'kind'>): Step | null {
-  const goal = s.decoy?.pos ?? s.player
+  const goal = s.decoy?.pos ?? chased(s)
   const key = (p: Vec) => p.y * s.width + p.x
   const first = new Map<number, Step>()
   const queue: Vec[] = []
diff --git a/src/sim/explosions.ts b/src/sim/explosions.ts
index 59e1cdb..7454afc 100644
--- a/src/sim/explosions.ts
+++ b/src/sim/explosions.ts
@@ -1,12 +1,13 @@
 import { NUKE_RANGE } from './actions'
-import { add, bombAt, cellAt, idx } from './state'
-import { DIRS, DIR_LIST, type Bomb, type GameEvent, type GameState, type Vec } from './types'
+import { add, bombAt, cellAt, eq, idx, inBounds } from './state'
+import { DIRS, DIR_LIST, isSolid, type Bomb, type GameEvent, type GameState, type Vec } from './types'
 
 /**
  * The tiles a bomb's flame reaches: its own tile, then up to `fire` tiles in each direction.
- * Flame stops before a hard wall, and stops *on* a soft block or another bomb (burning it).
- * A Pierce bomb's flame carries on through soft blocks. A Nuke bomb also burns every tile within
- * NUKE_RANGE steps (across plus down), through anything, except walls, which still stand.
+ * Flame stops before a wall, metal crate or rubble, and stops *on* a crate, an oil barrel or
+ * another bomb (burning it). A Pierce bomb's flame carries on through crates, not barrels. A
+ * Nuke bomb also burns every tile within NUKE_RANGE steps (across plus down), through anything,
+ * except what is solid, which still stands.
  */
 export function blastTiles(s: GameState, bomb: Pick<Bomb, 'pos' | 'fire' | 'pierce' | 'nuke'>): Vec[] {
   const tiles = [bomb.pos]
@@ -15,16 +16,16 @@ export function blastTiles(s: GameState, bomb: Pick<Bomb, 'pos' | 'fire' | 'pier
     for (let i = 0; i < bomb.fire; i++) {
       p = add(p, DIRS[d])
       const cell = cellAt(s, p)
-      if (cell === 'wall') break
+      if (isSolid(cell)) break
       tiles.push(p)
-      if ((cell === 'block' && !bomb.pierce) || bombAt(s, p)) break
+      if ((cell === 'block' && !bomb.pierce) || cell === 'barrel' || bombAt(s, p)) break
     }
   }
   if (bomb.nuke) {
     for (let dy = -NUKE_RANGE; dy <= NUKE_RANGE; dy++) {
       for (let dx = -NUKE_RANGE; dx <= NUKE_RANGE; dx++) {
         const p = add(bomb.pos, { x: dx, y: dy })
-        if (Math.abs(dx) + Math.abs(dy) > NUKE_RANGE || cellAt(s, p) === 'wall') continue
+        if (Math.abs(dx) + Math.abs(dy) > NUKE_RANGE || isSolid(cellAt(s, p))) continue
         if (!tiles.some((t) => t.x === p.x && t.y === p.y)) tiles.push(p)
       }
     }
@@ -32,34 +33,192 @@ export function blastTiles(s: GameState, bomb: Pick<Bomb, 'pos' | 'fire' | 'pier
   return tiles
 }
 
+/** A pillar: a wall inside the board, not its outer wall. */
+export function isPillar(s: GameState, p: Vec): boolean {
+  return cellAt(s, p) === 'wall' && p.x > 0 && p.y > 0 && p.x < s.width - 1 && p.y < s.height - 1
+}
+
+/** The pillars a bomb's flame runs into: where a flame, still within reach, stops at one. */
+export function pillarsReached(s: GameState, bomb: Pick<Bomb, 'pos' | 'fire' | 'pierce'>): Vec[] {
+  const pillars: Vec[] = []
+  for (const d of DIR_LIST) {
+    let p = bomb.pos
+    for (let i = 0; i < bomb.fire; i++) {
+      p = add(p, DIRS[d])
+      const cell = cellAt(s, p)
+      if (isPillar(s, p)) pillars.push(p)
+      if (isSolid(cell) || (cell === 'block' && !bomb.pierce) || cell === 'barrel' || bombAt(s, p)) break
+    }
+  }
+  return pillars
+}
+
+/** Turns from a cracked pillar breaking to the ceiling round it falling (user's choice, 2026-10-25). */
+export const COLLAPSE_TURNS = 4
+/** An oil barrel's blast, the turn after flame reaches it. */
+export const BARREL_FIRE = 2
+
 /**
  * Explode every bomb whose timer is at 0 or below, and every bomb their flame reaches, in a
- * chain, all in this one call. Soft blocks are destroyed only once the whole chain is done, so
- * a block stops every flame that reaches it this turn. Returns the burned tiles' indices.
+ * chain, all in this one call. Crates are destroyed only once the whole chain is done, so a crate
+ * stops every flame that reaches it this turn. Returns the burned tiles' indices.
+ *
+ * Since 2026-10-25 (PLAN.md §19.3, §19.4) flame also spreads without a blast, in the same chain:
+ * flame on any tile of an oil spill burns the whole spill at once, setting off any bomb on it;
+ * bushes smouldering since last turn (next to one that burned) catch now. A burned bush is gone,
+ * and the bushes next to it smoulder, to catch next turn. An oil barrel reached bursts: oil on its
+ * tile and the floor round it, and a blast of BARREL_FIRE the next turn. Acid flame cracks each
+ * pillar it reaches; any flame reaching a pillar cracked before this turn breaks it, and the
+ * ceiling round it falls COLLAPSE_TURNS turns later.
  */
 export function detonate(s: GameState, events: GameEvent[]): Set<number> {
   const burned = new Set<number>()
   const hitBlocks = new Set<number>()
+  const hitBarrels = new Set<number>()
+  const crackedBefore = new Set(s.cracked)
+  const cracks = new Set<number>()
+  const breaks = new Set<number>()
+  const spread: Vec[] = []
+  const at = (i: number): Vec => ({ x: i % s.width, y: Math.floor(i / s.width) })
+  const burn = (i: number): void => {
+    burned.add(i)
+    if (s.cells[i] === 'block') hitBlocks.add(i)
+    if (s.cells[i] === 'barrel') hitBarrels.add(i)
+    const other = bombAt(s, at(i))
+    if (other) other.timer = 0
+  }
+  // Flame without a blast: on a tile not already burning this turn.
+  const catchFire = (i: number): void => {
+    if (burned.has(i)) return
+    burn(i)
+    spread.push(at(i))
+  }
+  for (const i of s.smoldering) if (s.bushes.includes(i)) catchFire(i)
+  s.smoldering = []
+  const oil = new Set(s.oil)
   let bomb: Bomb | undefined
-  while ((bomb = s.bombs.find((b) => b.timer <= 0))) {
-    s.bombs.splice(s.bombs.indexOf(bomb), 1)
-    const tiles = blastTiles(s, bomb)
-    for (const t of tiles) {
-      const i = idx(s, t)
-      burned.add(i)
-      if (s.cells[i] === 'block') hitBlocks.add(i)
-      const other = bombAt(s, t)
-      if (other) other.timer = 0
+  for (;;) {
+    while ((bomb = s.bombs.find((b) => b.timer <= 0))) {
+      s.bombs.splice(s.bombs.indexOf(bomb), 1)
+      const tiles = blastTiles(s, bomb)
+      for (const t of tiles) burn(idx(s, t))
+      for (const p of pillarsReached(s, bomb)) {
+        const i = idx(s, p)
+        if (crackedBefore.has(i)) breaks.add(i)
+        else if (bomb.acid) cracks.add(i)
+      }
+      events.push({ type: 'exploded', pos: bomb.pos, tiles })
     }
-    events.push({ type: 'exploded', pos: bomb.pos, tiles })
+    // A spill touched by flame burns whole (and may set off more bombs: round again).
+    const lit = [...oil].filter((i) => burned.has(i))
+    if (!lit.length) break
+    const queue = [...lit]
+    for (const i of lit) oil.delete(i)
+    for (let k = 0; k < queue.length; k++) {
+      catchFire(queue[k]!)
+      for (const j of neighbours(s, queue[k]!)) if (oil.delete(j)) queue.push(j)
+    }
+    if (!s.bombs.some((b) => b.timer <= 0)) break
   }
+  s.oil = [...oil]
+  // Burned bushes are gone; those next to them smoulder.
+  const burnedBushes = s.bushes.filter((i) => burned.has(i))
+  s.bushes = s.bushes.filter((i) => !burned.has(i))
+  s.smoldering = s.bushes.filter((i) => burnedBushes.some((b) => neighbours(s, b).includes(i)))
+  if (spread.length) events.push({ type: 'fireSpread', tiles: spread })
+
   for (const i of hitBlocks) {
     s.cells[i] = 'floor'
-    events.push({ type: 'blockDestroyed', pos: { x: i % s.width, y: Math.floor(i / s.width) } })
+    events.push({ type: 'blockDestroyed', pos: at(i) })
+  }
+  for (const i of hitBarrels) {
+    s.cells[i] = 'floor'
+    for (const j of [i, ...neighbours(s, i)]) if (s.cells[j] === 'floor' && !s.oil.includes(j)) s.oil.push(j)
+    s.bombs.push({ pos: at(i), timer: 1, fire: BARREL_FIRE, placedTurn: s.turn, barrel: true })
+    events.push({ type: 'barrelLit', pos: at(i) })
+  }
+  for (const i of cracks) {
+    if (breaks.has(i) || s.cracked.includes(i)) continue
+    s.cracked.push(i)
+    events.push({ type: 'pillarCracked', pos: at(i) })
   }
+  for (const i of breaks) breakPillar(s, i, events)
   return burned
 }
 
+/** The 4 tiles next to tile `i` that are on the board. */
+function neighbours(s: GameState, i: number): number[] {
+  const x = i % s.width
+  return [x > 0 ? i - 1 : -1, x < s.width - 1 ? i + 1 : -1, i - s.width, i + s.width].filter((j) => j >= 0 && j < s.cells.length)
+}
+
+/**
+ * A cracked pillar broken: its tile becomes floor, and the ceiling over the 8 tiles round it will
+ * fall (never on the outer wall, the exit or a gem, so a level can always be finished).
+ */
+function breakPillar(s: GameState, i: number, events: GameEvent[]): void {
+  const p = { x: i % s.width, y: Math.floor(i / s.width) }
+  s.cells[i] = 'floor'
+  s.cracked = s.cracked.filter((c) => c !== i)
+  const cells: number[] = []
+  for (let dy = -1; dy <= 1; dy++) {
+    for (let dx = -1; dx <= 1; dx++) {
+      const q = { x: p.x + dx, y: p.y + dy }
+      if ((!dx && !dy) || q.x <= 0 || q.y <= 0 || q.x >= s.width - 1 || q.y >= s.height - 1) continue
+      if (eq(q, s.exit) || s.gems.some((g) => eq(g.pos, q)) || isSolid(cellAt(s, q))) continue
+      cells.push(idx(s, q))
+    }
+  }
+  s.collapsing.push({ cells, turns: COLLAPSE_TURNS })
+  events.push({ type: 'pillarBroke', pos: p, tiles: cells.map((c) => ({ x: c % s.width, y: Math.floor(c / s.width) })), turns: COLLAPSE_TURNS })
+}
+
+/**
+ * Phase 3, before the blasts: falling ceilings count down, and those at 0 come down as rubble,
+ * for good. What is under it is crushed: enemies (no drops), drops, bombs, bushes, oil, spikes.
+ * Returns the tiles that fell this turn (the player under one is crushed: `step` decides).
+ */
+export function collapse(s: GameState, events: GameEvent[]): Set<number> {
+  const fell = new Set<number>()
+  for (const c of s.collapsing) if (--c.turns <= 0) for (const i of c.cells) fell.add(i)
+  s.collapsing = s.collapsing.filter((c) => c.turns > 0)
+  if (!fell.size) return fell
+  for (const i of fell) s.cells[i] = 'rubble'
+  const under = (p: Vec) => fell.has(idx(s, p))
+  s.enemies = s.enemies.filter((e) => !under(e.pos))
+  s.drops = s.drops.filter((d) => !under(d.pos))
+  s.bombs = s.bombs.filter((b) => !under(b.pos))
+  s.bushes = s.bushes.filter((i) => !fell.has(i))
+  s.smoldering = s.smoldering.filter((i) => !fell.has(i))
+  s.oil = s.oil.filter((i) => !fell.has(i))
+  s.spikes = s.spikes.filter((sp) => !fell.has(sp.cell))
+  s.cracked = s.cracked.filter((i) => !fell.has(i))
+  events.push({ type: 'ceilingFell', tiles: [...fell].map((i) => ({ x: i % s.width, y: Math.floor(i / s.width) })) })
+  return fell
+}
+
+/**
+ * Whether the player is walled in for good by rubble: the exit, or an enemy (that must die for it
+ * to open), is past what can be got through or burned away. Crates and barrels can be.
+ */
+export function walledIn(s: GameState): boolean {
+  const seen = new Set([idx(s, s.player)])
+  const queue = [idx(s, s.player)]
+  for (let k = 0; k < queue.length; k++) {
+    for (const j of neighbours(s, queue[k]!)) {
+      if (seen.has(j) || isSolid(s.cells[j]!)) continue
+      seen.add(j)
+      queue.push(j)
+    }
+  }
+  return (inBounds(s, s.exit) && !seen.has(idx(s, s.exit))) || s.enemies.some((e) => !seen.has(idx(s, e.pos)))
+}
+
+/** Whether spikes are up on tile `i` at turn `turn` (they hurt then). */
+export function spikesUp(s: GameState, i: number, turn = s.turn): boolean {
+  return s.spikes.some((sp) => sp.cell === i && (turn + sp.phase) % 2 === 0)
+}
+
 /**
  * Proximity alert (user request, 2026-10-24): each of the player's proximity bombs with an enemy
  * **next to it** (one of its 4 neighbouring tiles) goes off this turn. `enemyTiles`: where the
diff --git a/src/sim/levelgen.ts b/src/sim/levelgen.ts
index d2504c3..fe3d04d 100644
--- a/src/sim/levelgen.ts
+++ b/src/sim/levelgen.ts
@@ -1,5 +1,5 @@
 import { stream } from './rng'
-import { DIR_LIST, type Cell, type Dir, type EnemyKind, type GemColor, type PowerUp, type Vec } from './types'
+import { DIR_LIST, isSolid, type Cell, type Dir, type EnemyKind, type GemColor, type PowerUp, type Spike, type Vec } from './types'
 
 /**
  * The board: a wall all round and a pillar at every even (x, y). It grows with the level (PLAN.md
@@ -98,6 +98,10 @@ export interface Layout {
   powerUp: PowerUp
   enemies: EnemyStart[]
   par: number
+  /** The board's objects on floor (PLAN.md §19.3): cell indices, and spikes with their phase. */
+  bushes: number[]
+  oil: number[]
+  spikes: Spike[]
 }
 
 export function generateLevel(seed: number, level: number): Layout {
@@ -149,7 +153,94 @@ export function generateLevel(seed: number, level: number): Layout {
   // Any other block, near the start or not, so an early gem is possible.
   const others = cells.flatMap((c, i) => (c === 'block' && !(i % width === exit.x && Math.floor(i / width) === exit.y) ? [i] : []))
   const powerUp = { pos: at(others[rng.int(others.length)]!), color: GEM_COLORS[rng.int(GEM_COLORS.length)]! }
-  return { width, height, cells, start: { ...START }, exit, powerUp, enemies, par: parFor(openTiles, enemies.length) }
+  const objects = placeObjects(seed, level, width, height, cells, [exit, powerUp.pos], enemies.map((e) => e.pos))
+  return { width, height, cells, start: { ...START }, exit, powerUp, enemies, par: parFor(openTiles, enemies.length), ...objects }
+}
+
+/**
+ * The first level each board object can turn up on (PLAN.md §19.3; first guesses, 2026-10-25):
+ * one new kind every few levels, as enemies come, so each is met on its own first.
+ */
+export const OBJECTS_FROM = { bush: 2, metal: 3, oil: 5, spikes: 8, barrel: 11 } as const
+
+/**
+ * The board's objects, from their own random stream, after everything else, so a level's crates
+ * and enemies are where they would be without them: bushes in clumps, metal crates and oil barrels
+ * in place of some crates (never the exit's or the gem's, and a metal crate only where every tile
+ * stays reachable), oil spills, single spikes; none in the start's corner, nor under an enemy.
+ */
+function placeObjects(seed: number, level: number, width: number, height: number, cells: Cell[], keep: Vec[], enemies: Vec[]) {
+  const rng = stream(seed, 'objects', level)
+  const fromStart = (i: number) => Math.abs((i % width) - START.x) + Math.abs(Math.floor(i / width) - START.y)
+  const kept = new Set(keep.map((p) => p.y * width + p.x))
+  const taken = new Set(enemies.map((p) => p.y * width + p.x))
+  const free = (i: number) => cells[i] === 'floor' && !taken.has(i) && fromStart(i) > 2
+  const neighbours = (i: number) => [i - 1, i + 1, i - width, i + width].filter((j) => j >= 0 && j < cells.length && Math.abs((j % width) - (i % width)) <= 1)
+  const freeTiles = () => cells.flatMap((_, i) => (free(i) ? [i] : []))
+  const pick = (from: number[]) => from.splice(rng.int(from.length), 1)[0]
+  const floorCount = cells.filter((c) => c === 'floor').length
+  const bushes: number[] = []
+  const oil: number[] = []
+  const spikes: Spike[] = []
+
+  // A clump: a tile, then a few touching ones.
+  const clump = (into: number[], size: number) => {
+    const start = pick(freeTiles())
+    if (start === undefined) return
+    const group = [start]
+    taken.add(start)
+    for (let k = 1; k < size; k++) {
+      const next = group.flatMap(neighbours).filter(free)
+      const j = pick(next)
+      if (j === undefined) break
+      group.push(j)
+      taken.add(j)
+    }
+    into.push(...group)
+  }
+  if (level >= OBJECTS_FROM.bush) for (let n = Math.round((floorCount * 0.06) / 3); n > 0; n--) clump(bushes, 2 + rng.int(3))
+  if (level >= OBJECTS_FROM.oil) for (let n = 1 + Math.floor(level / 7); n > 0; n--) clump(oil, 3 + rng.int(3))
+  if (level >= OBJECTS_FROM.spikes) {
+    for (let n = 2 + Math.floor(level / 8); n > 0; n--) {
+      const i = pick(freeTiles())
+      if (i === undefined) break
+      taken.add(i)
+      spikes.push({ cell: i, phase: rng.int(2) })
+    }
+  }
+  // Crates that may become something else: not the exit's or the gem's, not by the start.
+  const crates = () => cells.flatMap((c, i) => (c === 'block' && !kept.has(i) && fromStart(i) > 3 ? [i] : []))
+  if (level >= OBJECTS_FROM.metal) {
+    const pool = crates()
+    for (let n = Math.round(pool.length * 0.12); n > 0 && pool.length; n--) {
+      const i = pick(pool)!
+      cells[i] = 'metal'
+      // Every open tile must stay reachable from the start, crates burnt away.
+      if (!allReachable(cells, width, height)) cells[i] = 'block'
+    }
+  }
+  if (level >= OBJECTS_FROM.barrel) {
+    const pool = crates()
+    for (let n = 1 + Math.floor(level / 8); n > 0 && pool.length; n--) cells[pick(pool)!] = 'barrel'
+  }
+  return { bushes, oil, spikes }
+}
+
+/** Whether every tile that isn't solid can be reached from the start, crates and barrels gone. */
+function allReachable(cells: Cell[], width: number, height: number): boolean {
+  const open = (i: number) => !isSolid(cells[i]!)
+  const start = START.y * width + START.x
+  const seen = new Set([start])
+  const queue = [start]
+  for (let k = 0; k < queue.length; k++) {
+    const i = queue[k]!
+    for (const j of [i - 1, i + 1, i - width, i + width]) {
+      if (j < 0 || j >= width * height || seen.has(j) || !open(j)) continue
+      seen.add(j)
+      queue.push(j)
+    }
+  }
+  return seen.size === cells.filter((_, i) => open(i)).length
 }
 
 function key(p: Vec): string {
diff --git a/src/sim/picks.ts b/src/sim/picks.ts
index ff9a9dc..f1de6cb 100644
--- a/src/sim/picks.ts
+++ b/src/sim/picks.ts
@@ -20,7 +20,7 @@ import type { ActionId, GameState, Vec } from './types'
 const GROUPS: Partial<Record<ActionId, readonly ActionId[]>> = {
   move: ['kick', 'move', 'run', 'diagonal', 'slide'],
   bomb: ['bomb', 'throw', 'rpg', 'detonate'],
-  charged: ['charged', 'icbm', 'freeze', 'decoy', 'shield', 'flamePass', 'teleport'],
+  charged: ['charged', 'icbm', 'freeze', 'decoy', 'teleport'],
 }
 
 /** 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 b261477..283d3f3 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 = 36
+export const SIM_VERSION = 38
 
 /** Everything needed to reproduce a run exactly: mid-run saves, replays, certificates. */
 export interface RunRecord {
diff --git a/src/sim/shop.ts b/src/sim/shop.ts
index 80c1eb2..0d18a14 100644
--- a/src/sim/shop.ts
+++ b/src/sim/shop.ts
@@ -25,6 +25,7 @@ export const PRICES: Record<OfferKind, number> = {
   pierce: 4, power: 4, nuke: 6, shield: 5, freeze: 4, decoy: 3, icbm: 6,
   // Charges since 2026-10-25 (power-ups at 9 and 10 until then).
   flamePass: 7, teleport: 7,
+  acid: 6,
 }
 
 /** How often each charge comes up in the shop: as often as an uncommon power-up. */
diff --git a/src/sim/state.ts b/src/sim/state.ts
index 581ee07..82f72bf 100644
--- a/src/sim/state.ts
+++ b/src/sim/state.ts
@@ -18,8 +18,8 @@ export interface Carry {
   stats: Stats
   coins: number
   /**
-   * 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).
+   * The charge carried goes on to the next level. A Shield or Flame Pass carried works by itself
+   * (since 2026-10-25), so it goes on with whatever uses it has left.
    */
   charge: HeldCharge | null
   /**
@@ -27,8 +27,6 @@ export interface Carry {
    * has it lying there, to pick up again.
    */
   fallen?: { pos: Vec; charge: HeldCharge } | null
-  shieldUp: boolean
-  flameHits: number
   powers: Powers
   star: number
   runTurns: number
@@ -49,7 +47,7 @@ export function noPowers(): Powers {
  */
 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, charge: charge && { kind: charge, left: CHARGE_AMOUNT[charge] }, shieldUp: false, flameHits: 0,
+    lives: START_LIVES, stats: { ...START_STATS }, coins: 0, charge: charge && { kind: charge, left: CHARGE_AMOUNT[charge] },
     powers: noPowers(), star: 0, runTurns: 0, runKills: 0, deaths: 0,
   })
 }
@@ -59,15 +57,16 @@ export function newRun(seed: number, level = 1, charge: ChargeId | null = null,
  * is the same board. What the player has comes with them (`carryOf` decides what a death costs).
  */
 export function createLevel(seed: number, level: number, options: RunOptions, carry: Carry): GameState {
-  const { width, height, cells, start, exit, powerUp, enemies, par } = generateLevel(seed, level)
+  const { width, height, cells, start, exit, powerUp, enemies, par, bushes, oil, spikes } = 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, 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, shieldUp: carry.shieldUp, flameHits: carry.flameHits, frozen: 0, decoy: null, burning: [],
+    drops: [], exit, par, 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: [],
+    bushes, smoldering: [], oil, spikes, lastSeen: { ...start }, cracked: [], collapsing: [],
     aiRng: hashSeed(seed, 'ai', level), dropRng: hashSeed(seed, 'drops', level),
   }
   // Developer mode: only this (user request, 2026-10-23; it used to give every ability, stats and
@@ -92,7 +91,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, powers: s.powers, star: s.star, shieldUp: s.shieldUp, flameHits: s.flameHits,
+    lives: s.lives, stats: s.stats, coins: s.coins, powers: s.powers, star: s.star,
     // A death leaves the charge where the player fell; otherwise it goes on with them.
     charge: s.status === 'dead' ? null : s.charge,
     fallen: s.status === 'dead' && s.charge ? { pos: { ...s.player }, charge: { ...s.charge } } : null,
@@ -102,10 +101,10 @@ export function carryOf(s: GameState): Carry {
 
 /**
  * `p` if it is open floor (no wall or crate), else the nearest
- * open floor to it, by steps over the board; a fallen charge can't lie inside a crate (with Wall
- * Pass the player can die in one).
+ * open floor to it, by steps over the board (through anything but walls); a fallen charge can't
+ * lie inside a crate (with Wall Pass the player can die in one), nor an immortal player in rubble.
  */
-function nearestFloor(s: GameState, p: Vec): Vec {
+export function nearestFloor(s: GameState, p: Vec): Vec {
   const seen = new Set([idx(s, p)])
   const queue = [p]
   for (let i = 0; i < queue.length; i++) {
@@ -173,11 +172,11 @@ export function playerCanPass(s: GameState, p: Vec): boolean {
 }
 
 /**
- * The player may go into flame this turn: a Star or Immortal (nothing hurts), or a raised Flame
- * Pass (each turn in flame then uses one of its hits).
+ * The player may go into flame this turn: a Star or Immortal (nothing hurts), or a Flame Pass
+ * carried (each turn in flame then uses one of its hits).
  */
 export function flameSafe(s: GameState): boolean {
-  return s.flameHits > 0 || s.star > 0 || s.options.immortal
+  return s.charge?.kind === 'flamePass' || s.star > 0 || s.options.immortal
 }
 
 /**
diff --git a/src/sim/turn.ts b/src/sim/turn.ts
index ac563d1..dbb27d0 100644
--- a/src/sim/turn.ts
+++ b/src/sim/turn.ts
@@ -1,9 +1,9 @@
 import { ACTIONS, isAvailable, isValidTarget, slidePassed } from './actions'
-import { sellOffered, swapOffered, takeCharge } from './charges'
+import { holds, sellOffered, spendCharge, swapOffered, takeCharge } from './charges'
 import { enemyAt, moveEnemies, planIntents, rollDrops } from './enemies'
 import { enemyKinds, LAST_LEVEL } from './levelgen'
-import { detonate, proximityAlert, tickBombs } from './explosions'
-import { add, bombAt, carryOf, cellAt, cloneState, createLevel, eq, exitOpen, idx, CLEAR_BONUS, PAR_BONUS } from './state'
+import { collapse, detonate, proximityAlert, spikesUp, tickBombs, walledIn } from './explosions'
+import { add, bombAt, carryOf, cellAt, cloneState, createLevel, eq, exitOpen, idx, nearestFloor, CLEAR_BONUS, PAR_BONUS } from './state'
 import { stream } from './rng'
 import { buy, openShop, reroll, shopAfter } from './shop'
 import { CHARGE_IDS, DIRS, DIR_LIST, isCharge, type Action, type Death, type GameEvent, type GameState, type OfferKind, type Powers, type PowerUpKind, type Stats, type Vec } from './types'
@@ -107,17 +107,20 @@ 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' : 'flame'
-    if (s.options.immortal || shielded || s.star > 0 || (cause === 'flame' && flameBlocked)) return
-    // A raised Flame Pass takes a fire hit (and every other flame that turn), using one of its hits.
-    if (cause === 'flame' && s.flameHits > 0) {
-      s.flameHits--
+    const cause = how.cause === 'walkedIntoEnemy' || how.cause === 'caughtByEnemy' ? 'enemy' : how.cause === 'spikes' ? 'spikes' : how.cause === 'crushed' || how.cause === 'trapped' ? '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
+    // A Flame Pass carried takes a fire hit (and every other flame that turn), using one of its hits.
+    if (cause === 'flame' && holds(s, 'flamePass')) {
+      spendCharge(s)
       flameBlocked = true
-      events.push({ type: 'flameBlocked', pos: s.player, hitsLeft: s.flameHits })
+      events.push({ type: 'flameBlocked', pos: s.player, hitsLeft: s.charge?.left ?? 0 })
       return
     }
-    if (s.shieldUp) {
-      s.shieldUp = false
+    // A Shield carried takes any other hit, and is used up.
+    if (holds(s, 'shield') && cause !== 'rubble') {
+      spendCharge(s)
       shielded = true
       events.push({ type: 'shieldBroke', pos: s.player })
       return
@@ -158,6 +161,23 @@ export function step(prev: GameState, action: Action): StepResult {
   tickBombs(s, events)
   // The bombs going off by themselves (or by the Detonator); the rest of what explodes, a chain set off.
   const due = new Set(s.bombs.filter((b) => b.timer <= 0).map((b) => idx(s, b.pos)))
+  // A ceiling falling now comes down before the blasts: what it crushes doesn't burn.
+  const fell = collapse(s, events)
+  if (fell.has(idx(s, s.player))) {
+    // A Shield carried pushes the player out to an empty tile next to them, and is used up
+    // (user request, 2026-10-25); with none, nothing saves them.
+    const out = holds(s, 'shield') ? pushOut(s) : null
+    if (out) {
+      spendCharge(s)
+      shielded = true
+      events.push({ type: 'shieldBroke', pos: s.player })
+      s.player = out
+    } else {
+      hurt({ cause: 'crushed', turn: s.turn + 1 })
+      // Immortal (a testing aid): out from under it.
+      if (s.status === 'playing') s.player = nearestFloor(s, s.player)
+    }
+  }
   const firstEvent = events.length
   const burned = detonate(s, events)
   crumble(s, events)
@@ -169,9 +189,11 @@ export function step(prev: GameState, action: Action): StepResult {
   if (s.status === 'playing' && fire.has(idx(s, s.player))) {
     const at = idx(s, s.player)
     const hits = events.slice(firstEvent).filter((e) => e.type === 'exploded' && e.tiles.some((t) => idx(s, t) === at))
-    const cause = !burned.has(at) ? 'lingering' : hits.every((e) => e.type === 'exploded' && !due.has(idx(s, e.pos))) ? 'chain' : 'blast'
+    const cause = !burned.has(at) ? 'lingering' : !hits.length ? 'spread' : hits.every((e) => e.type === 'exploded' && !due.has(idx(s, e.pos))) ? 'chain' : 'blast'
     hurt({ cause, turn: s.turn + 1 })
   }
+  // Spikes up under the player (PLAN.md §19.3).
+  if (spikesUp(s, idx(s, s.player))) hurt({ cause: 'spikes', turn: s.turn + 1 })
   for (const d of s.drops.filter((d) => burned.has(idx(s, d.pos)))) {
     s.drops.splice(s.drops.indexOf(d), 1)
     events.push({ type: 'dropBurned', drop: d })
@@ -207,6 +229,8 @@ export function step(prev: GameState, action: Action): StepResult {
       events.push({ type: 'powerUpRevealed', powerUp: gem })
     }
   }
+  // Walled in by fallen rubble, with the exit or an enemy out of reach: the level can't be finished.
+  if (fell.size && walledIn(s)) hurt({ cause: 'trapped', turn: s.turn + 1 })
   if (enemiesBefore > 0 && s.enemies.length === 0) {
     events.push({ type: 'exitOpened', pos: s.exit })
     startCrumbling(s, events)
@@ -215,7 +239,9 @@ 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 Star lasts a fixed number of these.
+  // Phase 5: enemies announce their next intents, toward where they last saw the player if
+  // the player is hidden in a bush (PLAN.md §19.3). A Star lasts a fixed number of these.
+  if (!s.bushes.includes(idx(s, s.player))) s.lastSeen = { ...s.player }
   planIntents(s)
   if (s.star > 0) s.star--
 
@@ -231,6 +257,19 @@ export function step(prev: GameState, action: Action): StepResult {
   return { state: s, events }
 }
 
+/**
+ * Where a Shield pushes the player from under a falling ceiling: the first empty tile next to them
+ * (open floor, no bomb, no enemy), straight before diagonal; null if there is none.
+ */
+function pushOut(s: GameState): Vec | null {
+  const steps = [...DIR_LIST.map((d) => DIRS[d]), { x: -1, y: -1 }, { x: 1, y: -1 }, { x: -1, y: 1 }, { x: 1, y: 1 }]
+  for (const d of steps) {
+    const p = add(s.player, d)
+    if (cellAt(s, p) === 'floor' && !bombAt(s, p) && !enemyAt(s, p)) return p
+  }
+  return null
+}
+
 /** How far (in steps) a drop may roll from where the enemy died to find a tile of its own. */
 export const DROP_SPREAD = 2
 
diff --git a/src/sim/types.ts b/src/sim/types.ts
index 31c0441..9c28523 100644
--- a/src/sim/types.ts
+++ b/src/sim/types.ts
@@ -20,8 +20,24 @@ export const DIRS: Record<Dir, Vec> = {
 
 export const DIR_LIST: readonly Dir[] = ['up', 'down', 'left', 'right']
 
-/** `wall` is a hard wall or pillar (indestructible); `block` is a soft block (flame destroys it). */
-export type Cell = 'floor' | 'wall' | 'block'
+/**
+ * `wall` is a hard wall or pillar (indestructible); `block` is a soft block (flame destroys it).
+ * Since 2026-10-25 (PLAN.md §19.3, §19.4): `metal`, a metal crate (indestructible, anywhere);
+ * `barrel`, an oil barrel (blocks like a crate; flame lights it and it goes off the next turn);
+ * `rubble`, a fallen ceiling (indestructible, for good). Bushes, oil and spikes lie on floor.
+ */
+export type Cell = 'floor' | 'wall' | 'block' | 'metal' | 'barrel' | 'rubble'
+
+/** Cells nothing goes through or into, and flame stops before: walls, metal crates, rubble. */
+export function isSolid(cell: Cell): boolean {
+  return cell === 'wall' || cell === 'metal' || cell === 'rubble'
+}
+
+/** Spikes on a floor tile: up on the turns where (turn + phase) is even, and then they hurt. */
+export interface Spike {
+  cell: number
+  phase: number
+}
 
 export interface Bomb {
   pos: Vec
@@ -39,6 +55,10 @@ export interface Bomb {
   nuke?: boolean
   /** Proximity alert: it goes off the turn an enemy is in its reach (the power-up, when placed). */
   proximity?: boolean
+  /** A lit oil barrel, not the player's: it doesn't count to their limit, nor can they set it off. */
+  barrel?: boolean
+  /** Acid (the charge, 2026-10-25): its flame also cracks each pillar it reaches. */
+  acid?: boolean
 }
 
 export interface Stats {
@@ -168,10 +188,10 @@ export interface Enemy {
  * until then, and Diagonal, which became a power-up the same day; Kick, Throw and Teleport until
  * 2026-10-22; Fuse ±1 was removed on 2026-10-22.)
  */
-export type ChargeId = 'pierce' | 'power' | 'nuke' | 'shield' | 'freeze' | 'decoy' | 'icbm' | 'flamePass' | 'teleport'
+export type ChargeId = 'pierce' | 'power' | 'nuke' | 'shield' | 'freeze' | 'decoy' | 'icbm' | 'flamePass' | 'teleport' | 'acid'
 
 /** Flame Pass and Teleport were power-ups until 2026-10-25, when the balance lab found each won the game alone. */
-export const CHARGE_IDS: readonly ChargeId[] = ['pierce', 'power', 'nuke', 'shield', 'freeze', 'decoy', 'icbm', 'flamePass', 'teleport']
+export const CHARGE_IDS: readonly ChargeId[] = ['pierce', 'power', 'nuke', 'shield', 'freeze', 'decoy', 'icbm', 'flamePass', 'teleport', 'acid']
 
 /**
  * What a gem can offer: a power-up, or a charge (only Developer mode's rainbow gem, to test them
@@ -241,16 +261,21 @@ export interface GameState {
    */
   chargeOffer: HeldCharge | 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
-  /**
-   * Flame Pass raised with the charge button (2026-10-25; a power-up until then): fire hits it
-   * still saves the player from. Each turn flame would hurt them uses one. Kept from level to level.
+   * The board's objects on floor (PLAN.md §19.3; 2026-10-25): bushes (walk through; they burn, and
+   * fire spreads to the bushes touching them a turn later), oil (flame on any of a spill burns all
+   * of it at once), spikes (up every other turn). Cell indices.
    */
-  flameHits: number
+  bushes: number[]
+  /** Bushes that catch fire next turn, from a bush that burned this turn. */
+  smoldering: number[]
+  oil: number[]
+  spikes: Spike[]
+  /** Where the enemies last saw the player: a bush hides them, and chasers head here instead. */
+  lastSeen: Vec
+  /** Acid (§19.4): pillars cracked by an acid bomb; a second blast breaks them. Cell indices. */
+  cracked: number[]
+  /** The ceiling round a broken pillar, coming down: these tiles, in this many turns. */
+  collapsing: { cells: number[]; turns: number }[]
   /** Freeze: how many more enemy moves are skipped (FREEZE_TURNS when used; the turn it is used counts). */
   frozen: number
   powers: Powers
@@ -293,8 +318,8 @@ 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' | 'flamePass'
+  /** The carried charge, used (2026-10-24): a Pierce, Power or Nuke bomb; a Freeze; a Decoy. */
+  | 'charged' | 'freeze' | 'decoy'
 
 /** `choose`: one of the gem's offer. It takes no turn. */
 export type Action =
@@ -321,7 +346,8 @@ export interface Shop {
  * one a chain set off; or by flame still burning from last turn.
  */
 export interface Death {
-  cause: 'walkedIntoEnemy' | 'caughtByEnemy' | 'blast' | 'chain' | 'lingering'
+  /** `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'
   /** The enemy, for the two enemy causes. */
   enemy?: EnemyKind
   /** The turn it happened on (counted from 1, as the top bar shows after it). */
@@ -358,10 +384,6 @@ export type GameEvent =
   | { 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' }
-  /** Flame Pass raised with the charge button, and when flame would have hurt: hits left. */
-  | { type: 'flamePassRaised'; hits: number }
   | { type: 'flameBlocked'; pos: Vec; hitsLeft: number }
   /** Freeze used: every enemy stops for FREEZE_TURNS turns. */
   | { type: 'frozen'; turns: number }
@@ -379,3 +401,11 @@ export type GameEvent =
   | { type: 'runWon'; turns: number; deaths: number }
   | { type: 'cratesCrumbling'; count: number }
   | { type: 'crateCrumbled'; pos: Vec }
+  /** Flame that spread without a blast: over an oil spill, or through bushes (PLAN.md §19.3). */
+  | { type: 'fireSpread'; tiles: Vec[] }
+  /** Flame reached an oil barrel: it bursts, spilling oil, and goes off next turn. */
+  | { type: 'barrelLit'; pos: Vec }
+  /** Acid (PLAN.md §19.4): a pillar cracked; a cracked one broken, and the ceiling round it to fall. */
+  | { type: 'pillarCracked'; pos: Vec }
+  | { type: 'pillarBroke'; pos: Vec; tiles: Vec[]; turns: number }
+  | { type: 'ceilingFell'; tiles: Vec[] }
diff --git a/src/ui/controls.ts b/src/ui/controls.ts
index 2b6163f..e3c4fea 100644
--- a/src/ui/controls.ts
+++ b/src/ui/controls.ts
@@ -43,7 +43,7 @@ interface ButtonDef {
 const BUTTONS: ButtonDef[] = [
   { id: 'move', label: 'Move', icon: 'icon_move', key: 'M', group: ['move', 'run', 'kick', 'diagonal', 'slide'] },
   { 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', 'flamePass', 'teleport'] },
+  { id: 'charged', label: 'Charge', icon: 'bomb', key: 'V', group: ['charged', 'icbm', 'freeze', 'decoy', 'teleport'] },
   { id: 'wait', label: 'Wait', icon: 'icon_wait', key: 'Space', group: ['wait'] },
 ]
 
diff --git a/src/ui/feedback.ts b/src/ui/feedback.ts
index ba9a4c2..ec60c2c 100644
--- a/src/ui/feedback.ts
+++ b/src/ui/feedback.ts
@@ -145,8 +145,6 @@ 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 'flamePassRaised': play('magic', 0.8, 0.9); break
       case 'flameBlocked': play('bump', 0.8, 1.3, boom); break
       case 'frozen': play('magic', 0.8, 0.7); break
       case 'enemiesSpawned': play('hurt', 0.8, 0.5, boom); break
@@ -154,6 +152,12 @@ export function turnFeedback(events: readonly GameEvent[], action: Action, anim:
       case 'playerDied': play('hurt', 1, 1, boom); vibrate([60, 40, 120], boom); break
       case 'levelCleared': play('cleared', 1, 1, arrive); vibrate([20, 40, 20], arrive); break
       case 'runWon': play('gem', 1, 1.2, arrive + 0.35); break
+      // Board objects (PLAN.md §19.3, §19.4).
+      case 'fireSpread': if (!blasts) play('boom', 0.6, 0.7, boom); break
+      case 'barrelLit': play('bump', 0.9, 0.6, boom); break
+      case 'pillarCracked': play('bump', 0.7, 1.4, boom); break
+      case 'pillarBroke': play('hurt', 0.7, 0.4, boom); vibrate(50, boom); break
+      case 'ceilingFell': play('boom', 1.4, 0.45); vibrate([80, 30, 80]); break
     }
   }
   if (pickedCoins) { play('coin', 1, 1, arrive); vibrate(10, arrive) }
diff --git a/src/ui/info.ts b/src/ui/info.ts
index 4f55681..85d0f2b 100644
--- a/src/ui/info.ts
+++ b/src/ui/info.ts
@@ -1,7 +1,8 @@
 import { DECOY_RANGE, FREEZE_TURNS, NUKE_RANGE, TELEPORT_RANGE } from '../sim/actions'
-import { BOMB_BOOST, CHARGE_AMOUNT, FLAME_PASS_HITS, salePrice } from '../sim/charges'
+import { BOMB_BOOST, CHARGE_AMOUNT, FLAME_PASS_HITS, PASSIVE_CHARGES, salePrice } from '../sim/charges'
 import { LAST_LEVEL } 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'
 import { SHOP_EVERY } from '../sim/shop'
 import { isCharge, type ActionId, type ChargeId, type Death, type Dir, type EnemyKind, type GameState, type OfferKind, type Powers, type PowerUpKind, type Vec } from '../sim/types'
@@ -68,12 +69,13 @@ export const CHARGE_TEXT: Record<ChargeId, { name: string; text: string }> = {
   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, then tap yourself (or press it again) 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.' },
+  shield: { name: 'Shield', text: 'Works by itself while you carry it, level after level, until something hits you: it absorbs that hit (flame, enemy or spikes), and anything else that hits you that turn; under a falling ceiling, it pushes you out to an empty tile next to you, if there is one. Then it is used up and the charge slot is empty.' },
   freeze: { name: 'Freeze', text: `Press the charge button, then tap yourself (or press it again): 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.` },
   // Power-ups until 2026-10-25 (the balance lab found each won the game alone): charges now.
-  flamePass: { name: 'Flame Pass', text: `Press the charge button, then tap yourself (or press it again): flame can’t hurt you for the next ${FLAME_PASS_HITS} fire hits, level after level until they are used. Enemies still can.` },
+  flamePass: { name: 'Flame Pass', text: `Works by itself while you carry it: flame can’t hurt you for the next ${FLAME_PASS_HITS} fire hits (and you may walk into flame), level after level. When they are used up the charge slot is empty. Enemies still can hurt you.` },
+  acid: { name: 'Acid', text: `One bomb whose flame cracks every pillar it reaches. Any blast that reaches a cracked pillar breaks it, and ${COLLAPSE_TURNS} turns later the ceiling round it comes down as rubble, for good: anything under it is crushed, you too (a Shield you carry pushes you out to an empty tile next to you). Don’t wall yourself in: if rubble cuts you off from the exit or an enemy, you lose a life. Put it down with the charge button.` },
   teleport: { name: 'Teleport', text: `${CHARGE_AMOUNT.teleport} jumps: press the charge button, then tap any empty tile up to ${TELEPORT_RANGE} steps away, over anything (${TELEPORT_RANGE + 1} with Run; with Diagonal, a diagonal step counts as one).` },
 }
 
@@ -120,9 +122,7 @@ export function chargeRule(kind: ChargeId): string {
  * 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 {
-  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 === 'flamePass') return s.flameHits
+  if (s.charge?.kind === id) return s.charge.left > 1 || ['pierce', 'power', 'nuke', 'icbm', 'flamePass'].includes(id) ? s.charge.left : true
   if (id === 'freeze') return s.frozen
   if (id === 'decoy') return s.decoy ? true : 0
   return 0
@@ -152,6 +152,10 @@ export function deathLine(level: number, d: Death | null): string {
     blast: 'you were caught in a bomb’s blast',
     chain: 'one bomb set off another, and its blast caught you',
     lingering: 'you were caught by flame still burning from the turn before',
+    spread: 'you were caught by fire spreading over oil or through bushes',
+    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',
   }[d.cause]
   return `Level ${level}, turn ${d.turn}: ${how}.`
 }
@@ -243,6 +247,7 @@ export function hudInfo(item: HudItem, s: GameState): Info {
     case 'icbm':
     case 'flamePass':
     case 'teleport':
+    case 'acid':
       return { icon: CHARGE_ICONS[item], title: chargeTitle(s, item), body: [CHARGE_TEXT[item].text], clip: item }
     default: {
       const kind = PASS_KIND[item]
@@ -282,15 +287,19 @@ export function tileInfo(s: GameState, p: Vec): Info | null {
   }
   const bomb = bombAt(s, p)
   if (bomb) {
-    const kinds = (['pierce', 'power', 'nuke'] as const).filter((k) => bomb[k]).map((k) => CHARGE_TEXT[k].name)
+    if (bomb.barrel) {
+      return { icon: 'block_strong_danger', title: 'Burst oil barrel', body: [`Explodes at the end of your next action, with a blast of ${bomb.fire}, lighting the oil it spilled.`] }
+    }
+    const kinds = (['pierce', 'power', 'nuke', 'acid'] as const).filter((k) => bomb[k]).map((k) => CHARGE_TEXT[k].name)
     const reach = bomb.power ? 'to the walls' : `${bomb.fire} ${bomb.fire === 1 ? 'tile' : 'tiles'}`
     const body = [
       `Explodes at the end of your ${bomb.timer === 1 ? 'next action' : `${bomb.timer}th action from now`}.`,
       `Its blast reaches ${reach} in each direction. Flame stops at walls${bomb.pierce ? ' (it goes through crates)' : ' and on the first crate'}, and sets off any bomb it reaches.`,
     ]
     if (bomb.nuke) body.push(`Nuke: it also burns every tile within ${NUKE_RANGE} steps, through crates and round corners.`)
+    if (bomb.acid) body.push('Acid: its flame cracks every pillar it reaches.')
     // Its clip: the charge that shapes its blast, or a bomb's countdown and blast.
-    const clip = bomb.nuke ? 'nuke' : bomb.power ? 'power' : bomb.pierce ? 'pierce' : 'kill'
+    const clip = bomb.nuke ? 'nuke' : bomb.power ? 'power' : bomb.pierce ? 'pierce' : bomb.acid ? 'acid' : 'kill'
     return { icon: 'bomb', title: `${kinds.length ? `${kinds.join(' + ')} bomb` : 'Bomb'}: ${bomb.timer}`, body, clip }
   }
   if (s.decoy && eq(s.decoy.pos, p)) {
@@ -305,16 +314,69 @@ export function tileInfo(s: GameState, p: Vec): Info | null {
     }
   }
   const cell = cellAt(s, p)
+  const i = p.y * s.width + p.x
+  const falling = s.collapsing.find((c) => c.cells.includes(i))
+  if (falling) {
+    return {
+      icon: 'rock',
+      title: `Ceiling falling: ${falling.turns} ${falling.turns === 1 ? 'turn' : 'turns'}`,
+      body: ['A cracked pillar here was broken. When the count runs out the ceiling comes down as rubble, for good, crushing whatever is under it — you too, unless a Shield you carry pushes you out to an empty tile next to you. Get out from under it, and don’t let it wall you in.'],
+      clip: 'acid',
+    }
+  }
   if (cell === 'wall') {
     const icon = wallSprite(s.level, s.width, s.height, p)
     const pillar = !isOuterWall(s.width, s.height, p)
+    const cracked = s.cracked.includes(i)
     return {
       icon,
-      title: pillar ? 'Pillar' : 'Outer wall',
-      body: [`Indestructible: nothing gets through, not even flame.${pillar ? ' Pillars change colour from level to level.' : ''}`],
-      clip: 'pillar',
+      title: cracked ? 'Cracked pillar' : pillar ? 'Pillar' : 'Outer wall',
+      body: [
+        cracked
+          ? `Acid cracked it. The next blast to reach it breaks it, and ${COLLAPSE_TURNS} turns later the ceiling round it falls as rubble.`
+          : `Indestructible: nothing gets through, not even flame.${pillar ? ' Pillars change colour from level to level; Acid can crack them.' : ''}`,
+      ],
+      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.'] }
+  }
+  const spike = s.spikes.find((sp) => sp.cell === i)
+  if (spike) {
+    const up = spikesUp(s, i)
+    return {
+      icon: 'spikes',
+      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.',
+      ],
+    }
+  }
+  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',
@@ -370,7 +432,7 @@ function chargeTitle(s: GameState, id: ChargeId): string {
   if (id === 'pierce' || id === 'power' || id === 'nuke') return `${name}: next ${left} ${left === 1 ? 'bomb' : 'bombs'}`
   if (id === 'icbm') return `${name}: ${left} ${left === 1 ? 'launch' : 'launches'} left`
   if (id === 'teleport') return `${name}: ${left} ${left === 1 ? 'jump' : 'jumps'} left`
-  if (id === 'flamePass') return s.charge?.kind === 'flamePass' ? `${name}: carried` : `${name}: ${left} fire ${left === 1 ? 'hit' : 'hits'} left`
+  if (id === 'flamePass') return `${name}: ${left} fire ${left === 1 ? 'hit' : 'hits'} left`
   return `${name}: ${turns(left)} left`
 }
 
@@ -417,8 +479,6 @@ export function actionInfo(s: GameState, picked: ActionId): Info {
     case 'icbm':
     case 'freeze':
     case 'decoy':
-    case 'shield':
-    case 'flamePass':
     case 'teleport': {
       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) {
@@ -434,7 +494,8 @@ export function actionInfo(s: GameState, picked: ActionId): Info {
       }
       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]
+      const passive = PASSIVE_CHARGES.includes(kind) ? ['It works by itself: nothing to press.'] : []
+      const body = [intro, `${CHARGE_TEXT[kind].name}: ${CHARGE_TEXT[kind].text}`, ...passive, `${left} ${left === 1 ? 'use' : 'uses'} left.`, CHOICE_TEXT]
       return { icon: CHARGE_ICONS[kind], title: `Charge: ${CHARGE_TEXT[kind].name}`, body, clip: kind }
     }
     case 'wait':
diff --git a/test/charges.test.ts b/test/charges.test.ts
index 017e9b9..56912fc 100644
--- a/test/charges.test.ts
+++ b/test/charges.test.ts
@@ -1,6 +1,6 @@
 import { describe, expect, it } from 'vitest'
 import { ACTIONS, DECOY_RANGE, FREEZE_TURNS, isAvailable, NUKE_RANGE, TELEPORT_RANGE } from '../src/sim/actions'
-import { BOMB_BOOST, FLAME_PASS_HITS, salePrice } from '../src/sim/charges'
+import { BOMB_BOOST, CHARGE_AMOUNT, FLAME_PASS_HITS, salePrice } from '../src/sim/charges'
 import { PRICES } from '../src/sim/shop'
 import { stepToward } from '../src/sim/enemies'
 import { blastTiles } from '../src/sim/explosions'
@@ -87,45 +87,40 @@ describe('charges are used on purpose', () => {
     expect(s.charge).toBeNull()
   })
 
-  it('Shield, raised with the charge button, absorbs the next hit (2026-10-25)', () => {
+  it('Shield works while carried: it absorbs the next hit, and the slot empties (user request, 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', target: s.player })
+    const { state, events } = step(s, { id: 'wait' })
     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)
+    // Nothing to press: the charge button has nothing to do with it.
+    expect(isAvailable({ ...s, bombs: [] }, 'charged')).toBe(false)
   })
 
-  it('Shield and Freeze need a tile, the player’s own: a confirmation, not a stray tap (2026-10-25)', () => {
+  it('Freeze needs a tile, the player’s own: a confirmation, not a stray tap (2026-10-25)', () => {
     const s = board(`
       #####
       #P..#
       #####`)
-    for (const kind of ['shield', 'freeze'] as const) {
-      s.charge = { kind, left: 1 }
-      expect(ACTIONS[kind].validTargets(s)).toEqual([s.player])
-      expect(() => step(s, { id: kind })).toThrow()
-    }
+    s.charge = { kind: 'freeze', left: 1 }
+    expect(ACTIONS.freeze.validTargets(s)).toEqual([s.player])
+    expect(() => step(s, { id: 'freeze' })).toThrow()
   })
 
-  it('a raised Shield stays up until a hit, level after level (user request, 2026-10-25)', () => {
+  it('a carried Shield lasts until a hit, level after level (user request, 2026-10-25)', () => {
     let s = board(`
       #####
       #P..#
       #####`)
     s.charge = { kind: 'shield', left: 1 }
-    s = step(s, { id: 'shield', target: s.player }).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)
+    expect(s.charge).toEqual({ kind: 'shield', left: 1 })
+    expect(createLevel(s.seed, 2, s.options, carryOf(s)).charge).toEqual({ kind: 'shield', left: 1 })
   })
 
   it(`Freeze, used, freezes every enemy for ${FREEZE_TURNS} turns, that one included`, () => {
@@ -292,36 +287,32 @@ describe('charged bombs', () => {
 })
 
 describe('Flame Pass and Teleport, charges since 2026-10-25', () => {
-  it('Flame Pass, raised, saves the player from 3 fire hits, then runs out', () => {
+  it('Flame Pass, carried, saves the player from 3 fire hits, then the slot empties', () => {
     let s = board(`
       #####
       #P..#
       #####`)
-    s.charge = { kind: 'flamePass', left: 1 }
-    s = step(s, { id: 'flamePass', target: s.player }).state
-    expect(s.flameHits).toBe(FLAME_PASS_HITS)
-    expect(s.charge).toBeNull()
+    s.charge = { kind: 'flamePass', left: CHARGE_AMOUNT.flamePass }
+    expect(CHARGE_AMOUNT.flamePass).toBe(FLAME_PASS_HITS)
     for (let hit = 1; hit <= FLAME_PASS_HITS; hit++) {
       s.bombs = [{ pos: v(2, 1), timer: 1, fire: 1, placedTurn: -9 }]
       const r = step(s, { id: 'wait' })
       expect(r.state.status).toBe('playing')
-      expect(r.state.flameHits).toBe(FLAME_PASS_HITS - hit)
+      expect(r.state.charge?.left ?? 0).toBe(FLAME_PASS_HITS - hit)
       s = { ...r.state, burning: [] }
     }
+    expect(s.charge).toBeNull()
     s.bombs = [{ pos: v(2, 1), timer: 1, fire: 1, placedTurn: -9 }]
     expect(step(s, { id: 'wait' }).state.status).toBe('dead')
   })
 
-  it('carried but not raised, Flame Pass does nothing; raised, it lasts to the next level', () => {
+  it('a Flame Pass with hits left goes on to the next level', () => {
     const s = board(`
       #####
-      #P1.#
+      #P..#
       #####`)
-    s.charge = { kind: 'flamePass', left: 1 }
-    s.bombs[0]!.timer = 1
-    expect(step(s, { id: 'wait' }).state.status).toBe('dead')
-    const raised = { ...s, charge: null, flameHits: 2 }
-    expect(createLevel(raised.seed, 2, raised.options, carryOf(raised)).flameHits).toBe(2)
+    s.charge = { kind: 'flamePass', left: 2 }
+    expect(createLevel(s.seed, 2, s.options, carryOf(s)).charge).toEqual({ kind: 'flamePass', left: 2 })
   })
 
   it('Teleport: 3 jumps from the charge button, then it is gone', () => {
diff --git a/test/danger.test.ts b/test/danger.test.ts
index aafcfae..7671fcd 100644
--- a/test/danger.test.ts
+++ b/test/danger.test.ts
@@ -1,11 +1,13 @@
 import { describe, expect, it } from 'vitest'
 import { ACTIONS, ACTION_ORDER, isAvailable } from '../src/sim/actions'
 import { predictDanger } from '../src/sim/danger'
+import { spikesUp } from '../src/sim/explosions'
 import { newRecord, replay } from '../src/sim/record'
 import { idx, newRun } from '../src/sim/state'
 import { step } from '../src/sim/turn'
 import type { Action, GameState } from '../src/sim/types'
 import { Rng } from '../src/sim/rng'
+import { board, v } from './helpers'
 
 /** A random but legal action; `continue` when dead or the level is cleared. */
 function randomAction(s: GameState, rng: Rng): Action {
@@ -20,10 +22,16 @@ function randomAction(s: GameState, rng: Rng): Action {
   return ACTIONS[id].needsTarget ? { id, target: targets[rng.int(targets.length)]! } : { id }
 }
 
-/** What burns this turn if the player waits: new explosions, plus last turn's flame still burning. */
+/**
+ * What hurts this turn if the player waits: new explosions and flame spread over oil and bushes,
+ * last turn's flame still burning, raised spikes, and a ceiling coming down.
+ */
 function burnedBy(s: GameState): Set<number> {
   const tiles = new Set<number>(s.burning)
-  for (const e of step(s, { id: 'wait' }).events) if (e.type === 'exploded') e.tiles.forEach((t) => tiles.add(idx(s, t)))
+  for (const sp of s.spikes) if (spikesUp(s, sp.cell)) tiles.add(sp.cell)
+  for (const e of step(s, { id: 'wait' }).events) {
+    if (e.type === 'exploded' || e.type === 'fireSpread' || e.type === 'ceilingFell') e.tiles.forEach((t) => tiles.add(idx(s, t)))
+  }
   return tiles
 }
 
@@ -45,6 +53,23 @@ describe('danger overlay', () => {
   })
 })
 
+describe('danger later on', () => {
+  it('a long fuse: its blast is marked from the start, as "later", then "next", then "now"', () => {
+    let s = board(`
+      #######
+      #P....#
+      #######`, { fire: 1 })
+    s.bombs.push({ pos: v(4, 1), timer: 5, fire: 1, placedTurn: 0 })
+    const blast = [v(3, 1), v(4, 1), v(5, 1)].map((p) => idx(s, p))
+    const d = predictDanger(s)
+    expect([...d.later].sort()).toEqual(blast.sort())
+    expect(d.next.size + d.now.size).toBe(0)
+    for (let k = 0; k < 3; k++) s = step(s, { id: 'wait' }).state
+    expect(predictDanger(s).next).toEqual(new Set(blast))
+    expect(predictDanger(s).later.size).toBe(0)
+  })
+})
+
 describe('run record', () => {
   it('replays to the identical state', () => {
     const rng = new Rng(99)
diff --git a/test/enemies.test.ts b/test/enemies.test.ts
index 9d1ef52..8dc62ce 100644
--- a/test/enemies.test.ts
+++ b/test/enemies.test.ts
@@ -538,11 +538,11 @@ describe('action charges and abilities', () => {
       #####
       #P1.#
       #####`)
-    s.shieldUp = true
+    s.charge = { kind: 'shield', left: 1 }
     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.shieldUp).toBe(false)
+    expect(r.state.charge).toBeNull()
     expect(r.state.lives).toBe(3)
   })
 
@@ -552,7 +552,7 @@ describe('action charges and abilities', () => {
       #PB.#
       #1###
       #####`), v(1, 1))
-    s.shieldUp = true
+    s.charge = { kind: 'shield', left: 1 }
     const { state } = step(s, { id: 'wait' })
     expect(state.status).toBe('playing')
     expect(state.lives).toBe(3)
@@ -679,6 +679,6 @@ describe('charge drops are never wasted', () => {
   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')
-    expect(dropped(() => {})).toEqual(new Set(['pierce', 'power', 'nuke', 'shield', 'freeze', 'decoy', 'icbm', 'flamePass', 'teleport']))
+    expect(dropped(() => {})).toEqual(new Set(['pierce', 'power', 'nuke', 'shield', 'freeze', 'decoy', 'icbm', 'flamePass', 'teleport', 'acid']))
   })
 })
diff --git a/test/hazards.test.ts b/test/hazards.test.ts
new file mode 100644
index 0000000..f224dbe
--- /dev/null
+++ b/test/hazards.test.ts
@@ -0,0 +1,244 @@
+import { describe, expect, it } from 'vitest'
+import { predictDanger } from '../src/sim/danger'
+import { stepToward } from '../src/sim/enemies'
+import { COLLAPSE_TURNS } from '../src/sim/explosions'
+import { generateLevel, OBJECTS_FROM } from '../src/sim/levelgen'
+import { idx } from '../src/sim/state'
+import { step } from '../src/sim/turn'
+import { isSolid, type GameState } from '../src/sim/types'
+import { board, v } from './helpers'
+
+const wait = (s: GameState) => step(s, { id: 'wait' })
+
+describe('metal crates (PLAN.md §19.3)', () => {
+  it('stop flame like a wall, even a Pierce bomb’s; a Nuke’s goes round, but they never break', () => {
+    const s = board(`
+      ########
+      #P..1M.#
+      ########`, { fire: 3 })
+    s.bombs[0]!.pierce = true
+    s.bombs[0]!.nuke = true
+    const r = wait(s)
+    const tiles = r.events.flatMap((e) => (e.type === 'exploded' ? e.tiles : []))
+    expect(tiles).not.toContainEqual(v(5, 1))
+    expect(r.state.cells[idx(s, v(5, 1))]).toBe('metal')
+  })
+
+  it('can’t be walked through, even with Wall Pass', () => {
+    const s = board(`
+      #####
+      #PM.#
+      #####`)
+    s.powers.wallPass = true
+    expect(() => step(s, { id: 'move', target: v(2, 1) })).toThrow()
+  })
+})
+
+describe('oil barrels', () => {
+  it('flame bursts one: oil round it, and its own blast the next turn', () => {
+    const s = board(`
+      #########
+      #P..1O..#
+      #########`, { fire: 2 })
+    const lit = wait(s)
+    expect(lit.events.some((e) => e.type === 'barrelLit')).toBe(true)
+    const t = lit.state
+    expect(t.cells[idx(t, v(5, 1))]).toBe('floor')
+    expect(t.oil).toEqual(expect.arrayContaining([idx(t, v(5, 1)), idx(t, v(6, 1))]))
+    // It isn't the player's bomb: it doesn't count to their limit, nor can it be set off.
+    expect(t.bombs).toEqual([expect.objectContaining({ pos: v(5, 1), barrel: true, timer: 1 })])
+    const after = wait(t)
+    expect(after.events.some((e) => e.type === 'exploded' && e.pos.x === 5)).toBe(true)
+    // Its blast lights its own spill: all of it burns.
+    expect(after.state.oil).toEqual([])
+  })
+})
+
+describe('oil spills', () => {
+  it('flame on one tile burns the whole spill, and sets off a bomb lying in it', () => {
+    const s = board(`
+      ##########
+      #P.1~~~..#
+      #......4.#
+      ##########`)
+    s.oil.push(idx(s, v(6, 2)), idx(s, v(7, 2)))
+    const r = wait(s)
+    const spread = r.events.find((e) => e.type === 'fireSpread')
+    expect(spread && spread.type === 'fireSpread' && spread.tiles).toEqual(expect.arrayContaining([v(5, 1), v(6, 1), v(6, 2), v(7, 2)]))
+    // The bomb at (7, 2) went off with it.
+    expect(r.state.bombs).toEqual([])
+    expect(r.state.oil).toEqual([])
+  })
+
+  it('is safe to stand on until flame comes', () => {
+    const s = board(`
+      #####
+      #P~.#
+      #####`)
+    const r = step(s, { id: 'move', target: v(2, 1) })
+    expect(r.state.status).toBe('playing')
+  })
+})
+
+describe('bushes', () => {
+  it('burn, and the fire creeps on a bush a turn', () => {
+    const s = board(`
+      ########
+      #P.1**.#
+      ########`)
+    const first = wait(s).state
+    expect(first.bushes).toEqual([idx(s, v(5, 1))])
+    expect(first.smoldering).toEqual([idx(s, v(5, 1))])
+    const second = wait(first)
+    expect(second.events.some((e) => e.type === 'fireSpread' && e.tiles.some((t) => t.x === 5))).toBe(true)
+    expect(second.state.bushes).toEqual([])
+    expect(predictDanger(first).now.has(idx(s, v(5, 1)))).toBe(true)
+  })
+
+  it('hide the player: enemies go where they last saw them', () => {
+    const s = board(`
+      #########
+      #.......#
+      #H.P*...#
+      #.......#
+      #########`)
+    s.enemies[0]!.kind = 'hunter'
+    const hidden = step(s, { id: 'move', target: v(4, 2) }).state
+    // Last seen on (3, 2): the hunter heads there, not into the bush.
+    expect(hidden.lastSeen).toEqual(v(3, 2))
+    const w = { pos: v(3, 2), kind: 'hunter' as const }
+    expect(stepToward(hidden, w)).toBeNull()
+  })
+})
+
+describe('spikes', () => {
+  it('hurt the player when up, not when down; up and down by turns', () => {
+    const up = board(`
+      #####
+      #P^.#
+      #####`)
+    expect(step(up, { id: 'move', target: v(2, 1) }).state.death?.cause).toBe('spikes')
+    const down = board(`
+      #####
+      #Pv.#
+      #####`)
+    const r = step(down, { id: 'move', target: v(2, 1) })
+    expect(r.state.status).toBe('playing')
+    // Next turn they are up.
+    expect(wait(r.state).state.death?.cause).toBe('spikes')
+  })
+
+  it('keep every enemy off them', () => {
+    const s = board(`
+      ######
+      #P.vH#
+      ######`)
+    expect(stepToward(s, { pos: v(4, 1), kind: 'hunter' })).toBeNull()
+  })
+})
+
+describe('Acid bomb (PLAN.md §19.4)', () => {
+  // A pillar at (4, 2); the player far off; an enemy so the level goes on.
+  const room = () =>
+    board(`
+      #########
+      #P......#
+      #...#...#
+      #.......#
+      #......B#
+      #########`, { fire: 2 })
+
+  it('cracks a pillar; a later blast breaks it, and the ceiling round it falls 4 turns on', () => {
+    const s = room()
+    s.bombs.push({ pos: v(4, 3), timer: 1, fire: 2, placedTurn: 0, acid: true })
+    const cracked = wait(s).state
+    expect(cracked.cracked).toEqual([idx(s, v(4, 2))])
+    expect(cracked.cells[idx(s, v(4, 2))]).toBe('wall')
+    cracked.bombs.push({ pos: v(5, 2), timer: 1, fire: 1, placedTurn: 0 })
+    const broke = wait(cracked)
+    expect(broke.events.some((e) => e.type === 'pillarBroke')).toBe(true)
+    let t = broke.state
+    expect(t.cells[idx(t, v(4, 2))]).toBe('floor')
+    expect(t.collapsing[0]!.turns).toBe(COLLAPSE_TURNS)
+    for (let k = 1; k < COLLAPSE_TURNS; k++) t = wait(t).state
+    expect(predictDanger(t).now.has(idx(t, v(3, 2)))).toBe(true)
+    t = wait(t).state
+    for (const p of [v(3, 1), v(3, 2), v(5, 3)]) expect(t.cells[idx(t, p)]).toBe('rubble')
+    // The pillar's own tile stays floor (PLAN.md §19.4).
+    expect(t.cells[idx(t, v(4, 2))]).toBe('floor')
+    expect(isSolid('rubble')).toBe(true)
+  })
+
+  it('a plain bomb doesn’t crack a pillar', () => {
+    const s = room()
+    s.bombs.push({ pos: v(4, 3), timer: 1, fire: 2, placedTurn: 0 })
+    expect(wait(s).state.cracked).toEqual([])
+  })
+
+  it('crushes the player under it', () => {
+    const s = room()
+    s.player = v(3, 3)
+    s.collapsing.push({ cells: [idx(s, v(3, 3))], turns: 1 })
+    expect(wait(s).state.death?.cause).toBe('crushed')
+  })
+
+  it('a Shield pushes the player out to an empty tile next to them, and is used up (2026-10-25)', () => {
+    const s = room()
+    s.player = v(3, 3)
+    s.charge = { kind: 'shield', left: 1 }
+    s.collapsing.push({ cells: [idx(s, v(3, 3))], turns: 1 })
+    const r = wait(s)
+    expect(r.state.status).toBe('playing')
+    expect(r.state.player).toEqual(v(3, 2))
+    expect(r.state.charge).toBeNull()
+    expect(r.events.some((e) => e.type === 'shieldBroke')).toBe(true)
+  })
+
+  it('with nowhere to push to, the Shield doesn’t save the player', () => {
+    const s = room()
+    s.player = v(3, 3)
+    s.charge = { kind: 'shield', left: 1 }
+    const cells: number[] = []
+    for (let y = 2; y <= 4; y++) for (let x = 2; x <= 4; x++) if (s.cells[idx(s, v(x, y))] === 'floor') cells.push(idx(s, v(x, y)))
+    s.collapsing.push({ cells, turns: 1 })
+    expect(wait(s).state.death?.cause).toBe('crushed')
+  })
+
+  it('walls the player in for good: trapped', () => {
+    const s = board(`
+      #######
+      #P.#.B#
+      #######`)
+    s.collapsing.push({ cells: [idx(s, v(2, 1))], turns: 1 })
+    expect(wait(s).state.death?.cause).toBe('trapped')
+  })
+})
+
+describe('level generation places the objects', () => {
+  it('from their first levels, never on the exit or gem, and every open tile reachable', () => {
+    for (let seed = 1; seed <= 30; seed++) {
+      for (const level of [1, OBJECTS_FROM.bush, OBJECTS_FROM.metal, OBJECTS_FROM.oil, OBJECTS_FROM.spikes, OBJECTS_FROM.barrel, 20]) {
+        const l = generateLevel(seed, level)
+        const i = (p: { x: number; y: number }) => p.y * l.width + p.x
+        if (level < OBJECTS_FROM.bush) expect(l.bushes).toEqual([])
+        if (level < OBJECTS_FROM.metal) expect(l.cells).not.toContain('metal')
+        expect(l.cells[i(l.exit)]).toBe('block')
+        expect(l.cells[i(l.powerUp.pos)]).toBe('block')
+        for (const c of [...l.bushes, ...l.oil, ...l.spikes.map((sp) => sp.cell)]) expect(l.cells[c]).toBe('floor')
+        // Reachable from the start, crates burnt away.
+        const seen = new Set([i(l.start)])
+        const queue = [i(l.start)]
+        for (let k = 0; k < queue.length; k++) {
+          for (const j of [queue[k]! - 1, queue[k]! + 1, queue[k]! - l.width, queue[k]! + l.width]) {
+            if (!seen.has(j) && !isSolid(l.cells[j]!)) {
+              seen.add(j)
+              queue.push(j)
+            }
+          }
+        }
+        expect(seen.size).toBe(l.cells.filter((c) => !isSolid(c)).length)
+      }
+    }
+    expect(generateLevel(3, 20).cells).toContain('metal')
+  })
+})
diff --git a/test/helpers.ts b/test/helpers.ts
index dbe7c81..31ceb47 100644
--- a/test/helpers.ts
+++ b/test/helpers.ts
@@ -8,7 +8,9 @@ const ENEMIES: Record<string, EnemyKind> = { B: 'blob', S: 'seeker', D: 'drifter
  * `1`–`9` a bomb with that timer (on floor, placed on an earlier turn),
  * `B` / `S` / `D` / `R` / `G` / `W` / `H` a Blob / Seeker / Drifter / Sprinter (runner) / Ghost /
  * Wisp / Hunter facing right with an empty plan (it stays put unless the test gives it one),
- * `E` the exit uncovered, `e` the exit under a soft block, `$` a 1-coin drop.
+ * `E` the exit uncovered, `e` the exit under a soft block, `$` a 1-coin drop;
+ * `M` a metal crate, `O` an oil barrel, `r` rubble, `*` a bush, `~` oil, `^` spikes up this turn,
+ * `v` spikes down this turn (all on floor but the first three).
  * Without `E` or `e` the exit is off the board, so it never opens.
  */
 export function board(picture: string, stats: Partial<Stats> = {}): GameState {
@@ -21,13 +23,18 @@ 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,
-    charge: null, chargeOffer: null, shieldUp: false, flameHits: 0, 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,
+    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: { x: 0, y: 0 }, aiRng: 1, dropRng: 1,
   }
   rows.forEach((row, y) => {
     if (row.length !== width) throw new Error(`row ${y} is ${row.length} wide, not ${width}`)
     ;[...row].forEach((ch, x) => {
-      cells.push(ch === '#' ? 'wall' : ch === '+' || ch === 'e' ? 'block' : 'floor')
+      cells.push(ch === '#' ? 'wall' : ch === '+' || ch === 'e' ? 'block' : ch === 'M' ? 'metal' : ch === 'O' ? 'barrel' : ch === 'r' ? 'rubble' : 'floor')
+      const i = y * width + x
+      if (ch === '*') s.bushes.push(i)
+      if (ch === '~') s.oil.push(i)
+      // Up when (turn + phase) is even; the picture's turn is 5.
+      if (ch === '^' || ch === 'v') s.spikes.push({ cell: i, phase: ch === '^' ? 1 : 0 })
       if (ch === 'P') player = { x, y }
       if (/[1-9]/.test(ch)) s.bombs.push({ pos: { x, y }, timer: Number(ch), fire: s.stats.fire, placedTurn: 0 })
       const kind = ENEMIES[ch]
@@ -38,6 +45,7 @@ export function board(picture: string, stats: Partial<Stats> = {}): GameState {
   })
   if (!player) throw new Error('no P in the picture')
   s.player = player
+  s.lastSeen = { ...player }
   return s
 }
 
diff --git a/test/new-powers.test.ts b/test/new-powers.test.ts
index 0b518e9..8077298 100644
--- a/test/new-powers.test.ts
+++ b/test/new-powers.test.ts
@@ -172,7 +172,7 @@ describe('Slide', () => {
     s.powers.slide = true
     s.burning = [idx(s, v(3, 1))]
     expect(step(s, { id: 'slide', target: v(6, 1) }).state.status).toBe('dead')
-    s.flameHits = 99
+    s.charge = { kind: 'flamePass', left: 99 }
     expect(step(s, { id: 'slide', target: v(6, 1) }).state.status).toBe('playing')
   })
 
diff --git a/test/powerups.test.ts b/test/powerups.test.ts
index 093cbee..abf5d06 100644
--- a/test/powerups.test.ts
+++ b/test/powerups.test.ts
@@ -265,13 +265,13 @@ describe('passes', () => {
       #####
       #P1.#
       #####`)
-    s.flameHits = 99
+    s.charge = { kind: 'flamePass', left: 99 }
     expect(step(s, { id: 'wait' }).state.status).toBe('playing')
     const e = board(`
       #####
       #PB.#
       #####`)
-    e.flameHits = 99
+    e.charge = { kind: 'flamePass', left: 99 }
     expect(step(e, { id: 'move', target: v(2, 1) }).state.status).toBe('dead')
   })
 
@@ -285,7 +285,7 @@ describe('passes', () => {
     s.star = 2
     expect(ACTIONS.move.validTargets(s)).toEqual([v(2, 1)])
     s.star = 0
-    s.flameHits = 99
+    s.charge = { kind: 'flamePass', left: 99 }
     expect(ACTIONS.move.validTargets(s)).toEqual([v(2, 1)])
   })
 
diff --git a/tools/compose-icons.sh b/tools/compose-icons.sh
index 4704d48..0528a92 100644
--- a/tools/compose-icons.sh
+++ b/tools/compose-icons.sh
@@ -66,6 +66,20 @@ badge "$OUT/icon_move.png" "$B/arrow_horizontal.png" powerup_slide
 badge "$K/bomb.png" "$I/target.png" charge_icbm
 # TNT (user request, 2026-10-25): Fire +2 — the fireball itself with the Fire +1 "larger" badge.
 badge "$K/fireball.png" "$I/larger.png" powerup_tnt
+# Acid (PLAN.md §19.4, 2026-10-25): a bomb with a flask.
+badge "$K/bomb.png" "$B/flask_full.png" charge_acid
+
+# A metal crate (PLAN.md §19.3): the strong crate in steel grey.
+magick "$K/block_strong_empty.png" -modulate 165,6 -fill '#9fb4cc' -tint 70 "$OUT/block_metal.png"
+echo "  $OUT/block_metal.png"
+
+# Oil (PLAN.md §19.3): a splat from the Splat Pack, near black, with a glossy highlight, so it
+# can't be taken for a stain.
+magick assets/kenney-splat/splat05.png -fill '#2b2631' -colorize 100 \
+  -fill 'rgba(190,200,230,0.55)' -stroke none -draw 'ellipse 52,46 16,7 0,360' \
+  -fill 'rgba(190,200,230,0.35)' -draw 'ellipse 76,70 7,3 0,360' \
+  \( assets/kenney-splat/splat05.png -alpha extract \) -compose copyopacity -composite PNG32:"$OUT/oil.png"
+echo "  $OUT/oil.png"
 
 # Shield: the Move figure inside an empty ring (the board-game timer at 0).
 magick "$B/timer_0.png" -fill '#353541' -colorize 100 \