fuzzyworld

explodorama / commit

Touch shortcuts, all action charges, lingering flame, 3-move enemy plans

82adee1813a85a3c301cff4a3fbcc458d42ae091 · Pavel Safronov · 2026-10-20 10:33:04 -0700 · parents c37a2b0b52cb

Claude Code usage: this commit ~196.4K out / ~46.2M cached · cost unknown (no per-model trailer) · session 1 tally 678 in / 395.1K out / 73.1M cached · usage by commit and session

Touch shortcuts, all action charges, lingering flame, 3-move enemy plans

Auto-move: tap a far tile, then Go or tap the flag for one safe step
(never onto an enemy, its next tile, or this turn's flame); tap a wall
or crate to cancel. Hold a neighbouring tile to bomb it; tap the player
to wait. New charges: Fuse +1 / -1, Diagonal, Teleport, Pierce, Power,
Line, Decoy, with a generic commit preview. Flame now lingers one turn,
killing enemies that walk in. Enemies commit to 3 moves, shown as a
fading column of arrows beside them. SIM_VERSION 3.

Usage: 678 in / 395.1K out / 73.1M cached (session tally)
rtk:   1.7M saved, 25.2% over 2791 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 PLAN.md                                   |  65 ++++++++---
 README.md                                 |  47 ++++++--
 assets/kenney/bridge_logs.png             | Bin 0 -> 2043 bytes
 assets/kenney/hud_player_helmet_green.png | Bin 0 -> 2994 bytes
 assets/kenney/ramp.png                    | Bin 0 -> 1069 bytes
 assets/kenney/spikes.png                  | Bin 0 -> 1469 bytes
 assets/kenney/torch_on_a.png              | Bin 0 -> 2724 bytes
 assets/kenney/weight.png                  | Bin 0 -> 2271 bytes
 assets/kenney/window.png                  | Bin 0 -> 1843 bytes
 decisions.md                              |  66 +++++++++++-
 docs/screenshots/auto-move.png            | Bin 0 -> 239199 bytes
 docs/screenshots/bomb-picked.png          | Bin 238948 -> 238931 bytes
 docs/screenshots/charges.png              | Bin 261017 -> 269004 bytes
 docs/screenshots/countdown.png            | Bin 244041 -> 244028 bytes
 docs/screenshots/death.png                | Bin 197380 -> 199288 bytes
 docs/screenshots/desktop.png              | Bin 586370 -> 585942 bytes
 docs/screenshots/enemies.png              | Bin 252615 -> 250386 bytes
 docs/screenshots/lingering.png            | Bin 0 -> 248179 bytes
 docs/screenshots/long-press.png           | Bin 0 -> 242461 bytes
 docs/screenshots/pierce-decoy.png         | Bin 0 -> 277755 bytes
 docs/screenshots/scrolled.png             | Bin 253517 -> 253258 bytes
 docs/screenshots/start.png                | Bin 237858 -> 237840 bytes
 src/main.ts                               | 115 +++++++++++++++++---
 src/render/board.ts                       | 126 +++++++++++++++++-----
 src/render/sprites.ts                     |  16 +++
 src/sim/actions.ts                        | 173 +++++++++++++++++++++++++-----
 src/sim/danger.ts                         |  10 +-
 src/sim/enemies.ts                        | 108 ++++++++++++++-----
 src/sim/explosions.ts                     |   5 +-
 src/sim/path.ts                           |  30 ++++++
 src/sim/record.ts                         |   2 +-
 src/sim/state.ts                          |  30 ++++--
 src/sim/turn.ts                           |  37 +++++--
 src/sim/types.ts                          |  42 ++++++--
 src/style.css                             |  19 +++-
 src/ui/controls.ts                        |  25 +++--
 test/charges.test.ts                      | 171 +++++++++++++++++++++++++++++
 test/danger.test.ts                       |   3 +-
 test/enemies.test.ts                      | 114 ++++++++++++++++++--
 test/helpers.ts                           |   8 +-
 test/path.test.ts                         |  42 ++++++++
 tools/screenshots.mjs                     |  31 ++++++
 42 files changed, 1111 insertions(+), 174 deletions(-)

diff --git a/PLAN.md b/PLAN.md
index 4c7ade7..f7fae95 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -3,7 +3,7 @@
 A turn-based, mobile-first, roguelike take on the 1985 NES *Bomberman*: 50 levels and an ending.
 Built with **LittleJS + TypeScript + Vite**, tested with **Vitest**, deployed to **GitLab Pages**.
 
-Status: **M0–M4 done, M5 well under way** (see §18). This document is the design and the hand-off
+Status: **M0–M5 done** (see §18); M6 next. This document is the design and the hand-off
 for whoever continues the implementation. Read it fully before writing code, especially §1 (the
 turn), §15 (notes for the implementer) and §18 (milestones). Reasons for decisions are in
 `decisions.md`.
@@ -38,16 +38,17 @@ One turn, in this exact order:
 
 1. **Player acts.** The player picks one action and a target; it resolves immediately
    (move, run, place bomb, kick, …). Picking up whatever is on the destination tile happens here.
-2. **Enemies execute their announced intents.** Each enemy moves as its intent arrow showed.
-   An enemy whose intended tile became blocked this turn (e.g. the player put a bomb there)
-   stays put — it does **not** re-plan mid-turn.
+2. **Enemies execute their announced intents.** Each enemy takes the first of its planned moves
+   (it shows the next 3, §4). An enemy whose next tile became blocked this turn (e.g. the player
+   put a bomb there) stays put — it does **not** re-plan mid-turn.
 3. **Bombs tick.** Every bomb's timer decreases by 1. Bombs reaching **0** explode.
    Chain reactions resolve **within the same phase** (a bomb caught in a blast explodes now,
    regardless of its timer).
 4. **Damage.** Flame kills the player (unless protected) and enemies, destroys soft blocks
    (revealing anything under them), and destroys pickups lying in the open (see §5, NES rule).
+   **Last turn's flame, still burning, also kills** the player and enemies in it.
    An enemy sharing the player's tile kills the player.
-5. **Enemies plan.** Every surviving enemy computes and displays its next intent.
+5. **Enemies plan.** Every surviving enemy tops its plan up to **3 moves** and displays them.
    Curse and Star/Shield turn counters advance. Input unlocks.
 
 Rules that fall out of this:
@@ -55,8 +56,14 @@ Rules that fall out of this:
 - **Bomb number = how many more player actions until it explodes.** A newly placed bomb shows
   its fuse value (default **3**) and is **not** ticked on the turn it is placed. A bomb showing
   **1** explodes at the end of the player's next action.
-- **Flame is instantaneous.** It exists only during phases 3–4 (and visually during the
-  animation). It does not persist on the board into the next turn.
+- **Flame lingers one extra turn** (*changed 2026-10-20 at the user's request*; it used to be
+  instantaneous). A blast burns in phase 4 of its turn, then stays burning through the whole
+  next turn: enemies that step into it in that turn's phase 2 die in its phase 4, as does a
+  player who waits in it. The player can't step, teleport or put a bomb into a burning tile.
+  Lingering flame doesn't burn crates, drops, the exit or bombs again (so drops from a kill
+  survive), and it doesn't set off bombs. This is the turn-based version of the NES flames
+  staying on screen for a moment, which is where most early kills come from: with instant
+  flame, a kill needed the enemy on the exact tile at the exact turn.
 - **Contact is checked twice:** after phase 1 (player walked into an enemy — death) and after
   phase 2 (enemy walked into player — death). Player and enemy swapping tiles counts as contact.
 - **Bombs block movement** for the player and for (most) enemies. Flame stops at the first hard
@@ -91,6 +98,22 @@ Starting values can be raised by Workshop tiers (§7).
 | **Place bomb** | On any **adjacent** free tile (orthogonal). Limited by bomb capacity. | Valid tiles. Hovering/pressing one shows the faded **blast cross** and the starting timer. |
 | **Wait** | Do nothing; the world advances one turn. | Nothing (or a subtle pulse on the player). |
 
+**Touch shortcuts (user request, 2026-10-20; built):**
+
+- **Tap the player** = Wait.
+- **Press and hold** (450 ms) a free tile next to the player, with no action picked = Place bomb
+  there. The bomb and its blast preview appear when the hold registers; release places it,
+  sliding off cancels.
+- **Auto-move:** tap a reachable tile that isn't adjacent, with no action picked, to set it as a
+  destination (flag + dotted path, next step highlighted). The Move button turns into **Go**;
+  each press takes one step along the shortest walk (`sim/path.ts`, over free tiles, ignoring
+  enemies), so it is still one turn per step and fully recorded as ordinary Move actions.
+  **Tapping the flag also takes one step** (user request, 2026-10-20). Tapping another far tile
+  moves the flag. Recomputed every turn; cleared on arrival, when unreachable, on Esc, by tapping
+  a wall or crate, or on death / level change. Neither Go nor the flag takes a known-deadly step
+  (onto an enemy, onto an enemy's intent tile, or into flame this turn): the next step shows red
+  and Go falls back to picking Move.
+
 ### 3.2 Abilities (permanent once gained — no charges)
 
 | Action | Source | Rule |
@@ -106,15 +129,15 @@ The button shows a count, e.g. "Kick ×2". Using one spends one charge and the t
 |---|---|
 | **Kick** | Push an adjacent bomb; it slides until the next tile is not free. It keeps its timer. The escape hatch for boxing yourself in. |
 | **Throw** | Place a bomb **exactly 2 tiles away** in a straight line, over a wall/soft block/bomb. Target must be free. |
-| **Fuse +1 / Fuse −1** | Change any visible bomb's timer by 1 (minimum 1). One action; the direction is chosen at target time. |
+| **Fuse +1 / Fuse −1** | Change any visible bomb's timer by 1 (minimum 1). One action; the direction is chosen at target time. *Built:* one Fuse charge shows two buttons, Fuse +1 and Fuse −1; either spends it. Maximum 9. |
 | **Shield** | Absorb the next hit (flame or enemy). Shown as a ring around the player. Arming it costs the turn. *Built:* it also absorbs any further hit in the same turn it breaks (e.g. an enemy and then a flame), so one shield never fails halfway through a turn. |
 | **Diagonal step** | Move 1 tile diagonally. Both orthogonal neighbours may be blocked — it's a squeeze. |
-| **Teleport** (rare) | Jump to any free tile within Manhattan distance 3. |
+| **Teleport** (rare) | Jump to any free tile within Manhattan distance 3. *Built:* drops at half the weight of each other charge. |
 | **Pierce bomb** | Place a bomb whose flame passes **through** soft blocks (destroying all in range). From later Bomberman games. |
-| **Power bomb** | Place a bomb with maximum fire (whole row/column up to hard walls). |
+| **Power bomb** | Place a bomb with maximum fire (whole row/column up to hard walls). *Built:* soft blocks still stop it (Pierce is the one for those). |
 | **Line bomb** | Place all remaining bomb capacity in a straight line from the player, in one action. From *Super Bomberman*. |
 | **Freeze** | All enemies skip their next move. *Built:* that is the move their arrows show when Freeze is used, i.e. this same turn's phase 2, so there is never a greyed-out state to show. |
-| **Decoy** | Place a decoy on an adjacent tile; chasing enemies target it instead of the player for 3 turns. Flame destroys it. |
+| **Decoy** | Place a decoy on an adjacent tile; chasing enemies target it instead of the player for 3 turns. Flame destroys it. *Built:* it blocks nothing; "3 turns" = 3 enemy plannings; one decoy at a time (a new one replaces it). |
 
 All charge actions can also appear in the shop (§8).
 
@@ -125,7 +148,15 @@ All charge actions can also appear in the shop (§8).
 - **Danger overlay (always on, unless cursed with Fog):** tiles that will burn **this turn** =
   strong red; **next turn** = light red. Includes chain reactions. Computed by a pure function
   in `sim/danger.ts` from the current state.
-- **Enemy intent arrows** on every enemy (phase 5). A 2-tile mover shows a 2-step arrow.
+- **Enemy plans, 3 moves ahead** (*changed 2026-10-20 at the user's request*; it used to be
+  1). Every enemy commits to its next 3 moves and shows them as a **vertical stack of see-through
+  arrows down the right side of its own tile** (the enemy drawn slightly smaller and to the
+  left to make room): the next move at the top, strongest; the 2nd and 3rd below it, fading. Each arrow is a single polygon, so a see-through
+  arrow is evenly tinted (a shaft + head drawn separately showed a darker overlap). It makes
+  exactly those moves unless the board blocks one (then it stays put that turn and plans
+  afresh). Planned moves a new bomb blocks are dropped and re-planned in phase 5. Freeze keeps
+  plans. Reason: bombs take 3 turns, so the player needs to see 3 turns ahead to set traps.
+  (A 2-tile mover will show one arrow per tile.)
 - **Bomb timers:** large number on each bomb; pulses at 1.
 - **Commit preview:** after picking an action and pressing a target, show the outcome
   (player ghost at destination, faded blast cross, slide path for Kick) before release/confirm.
@@ -396,7 +427,9 @@ Sprite assignments (✅ = drawn now; the rest are copied and waiting for their f
 | ✅ banners | `character_green_hit` (lost a life), `hud_heart_empty` (game over), `flag_green_a` (level cleared); favicon `bomb` |
 | ✅ exit (locked / open) | `door_closed` + `door_closed_top`, `door_open` + `door_open_top`, squeezed into one tile. (`lock_yellow` spare.) |
 | ✅ coins | `coin_bronze` (1), `coin_silver` (2), `coin_gold` (5), bobbing; `hud_coin` in the HUD |
-| ✅ action charges | Kick `spring_out`, Throw `sign_right`, Shield `switch_blue`, Freeze `chain` (drops and buttons) |
+| ✅ action charges | Kick `spring_out`, Throw `sign_right`, Shield `switch_blue`, Freeze `chain`, Fuse ± `torch_on_a`, Diagonal `ramp`, Teleport `window`, Pierce `spikes`, Power `weight`, Line `bridge_logs`, Decoy `hud_player_helmet_green` (drops, buttons; Pierce / Power also as a badge on their bombs) |
+| ✅ decoy | `character_green_front`, faded |
+| ✅ auto-move flag | `flag_green_a` |
 | ✅ intent arrows | drawn red lines with a head, not sprites |
 | pickups (power-ups) | gems: `gem_red` Fire +1, `gem_blue` Bomb +1, `gem_yellow` Fuse +1, `gem_green` Skates (suggested; final mapping when M6 is built); `heart` Heart; `star` Star |
 | ✅ Blob | `slime_normal_rest` (nowhere to go), `_walk_a` / `_walk_b` alternating; mirrored when heading right. `_flat` spare for a death animation. |
@@ -552,9 +585,9 @@ tools/                 icons.mjs (PWA icons), screenshots.mjs (Playwright)
 5. **M4 – World:** ✅ levelgen (31 × 13), side-scrolling camera, exit door under a soft block
    (opens when all enemies are dead; flaming it spawns 3 enemies), par + coin bonus (2026-10-20).
 6. **M5 – Enemies:** ✅ Blob, Seeker, Drifter with intent arrows, both contact checks, coin and
-   charge drops, coins, charge inventory (buttons with counts), and the first 4 charge actions:
-   Kick, Throw, Shield, Freeze (2026-10-20). ⏳ The other 7 charge actions (Fuse ±, Diagonal,
-   Teleport, Pierce, Power, Line, Decoy).
+   charge drops, coins, charge inventory (buttons with counts), and all 11 charge actions
+   (2026-10-20). Touch shortcuts from the user's playtest: tap self = Wait, hold = Bomb,
+   tap far = auto-move (§3.1).
 7. **M6 – Run loop:** NES pickups, coins, shop after each level, Keep/Fresh death choice, curses
    from level 2, 50-level progression and ending, mid-run save.
 8. **M7 – Look & feel:** animations, sounds (Kenney pack, §13), PWA icons, remaining
diff --git a/README.md b/README.md
index a5f0b93..002899c 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@ counts down with *your* moves, so nothing happens until you act — and you can
 what will burn before you commit.
 
 Status: **early development.** The board, moving, bombs, chain reactions, lives, the first three
-enemy types, coins, action charges and the exit to the next level work. Power-ups, the shop,
+enemy types, coins, all eleven action charges and the exit to the next level work. Power-ups, the shop,
 more enemies and the 50-level ending are planned — see [PLAN.md](PLAN.md).
 
 ![The board on a phone, with a bomb counting down](docs/screenshots/countdown.png)
@@ -21,8 +21,21 @@ where their arrows pointed, bombs count down and explode, and enemies show their
 | Action | Phone | Keyboard | What it does |
 |---|---|---|---|
 | **Move** | Tap a tile next to you | W A S D or arrow keys | Step one tile up, down, left or right onto an empty tile. |
-| **Bomb** | Tap **Bomb**, then a highlighted tile | **B**, then a direction | Place a bomb on an empty tile next to you. You can have 1 bomb on the board at a time. |
-| **Wait** | Tap **Wait** | Space | Do nothing; the world takes its turn. |
+| **Bomb** | Tap **Bomb**, then a highlighted tile — or **press and hold** a tile next to you | **B**, then a direction | Place a bomb on an empty tile next to you. You can have 1 bomb on the board at a time. |
+| **Wait** | Tap **Wait**, or tap **yourself** | Space | Do nothing; the world takes its turn. |
+
+**Auto-move:** tap an empty tile further away. A flag marks it, dots show the way, and the
+**Move** button becomes **Go**: each press of Go — or each tap on the flag itself — takes **one
+step** along the way (one turn, as always — the world still moves between steps). The way is
+worked out again every turn. Tapping another far tile moves the flag there. Auto-move ends when
+you arrive, when the flag can no longer be reached, when you tap a wall or crate, or on **Esc**.
+It **won't take a deadly step**: onto an enemy, onto a tile an enemy's arrow points at, or into
+flame this turn. The next step then shows red, and Go / the flag wait for you to deal with it
+(Go falls back to picking Move, so you can step aside yourself).
+
+**Press and hold** a tile next to you (about half a second, with a short buzz on phones that
+support it) to arm a bomb there: its blast shows, and letting go places it. Sliding off the
+tile first cancels it.
 
 **Action charges** are one-use actions that enemies sometimes drop. Their buttons appear in a
 second row, with a badge showing how many you hold; using one spends one and takes your turn.
@@ -33,6 +46,16 @@ second row, with a badge showing how many you hold; using one spends one and tak
 | **Throw** | arrow sign | Tap **Throw**, then a highlighted tile | **T**, then a direction | Place a bomb exactly 2 tiles away in a straight line, over anything in between. Uses your bomb limit like a normal bomb. |
 | **Shield** | blue dome | Tap **Shield** | **H** | A ring appears round you. The next hit (flame or enemy) is absorbed, and so is anything else that hits you in that same turn. |
 | **Freeze** | chain | Tap **Freeze** | **F** | Every enemy skips the step its arrow shows this turn. |
+| **Fuse +1 / Fuse −1** | torch | Tap **Fuse +1** or **Fuse −1**, then any bomb | **=** or **−**, then tap a bomb | Add 1 to a bomb's number (up to 9) or take 1 off (down to 1). One Fuse charge gives both buttons; using either spends it. |
+| **Diagonal** | ramp | Tap **Diagonal**, then a highlighted tile | **G**, then **Q E Z C** (up-left, up-right, down-left, down-right) | Step one tile diagonally — even squeezing between two blocked tiles. |
+| **Teleport** (rare) | stone archway | Tap **Teleport**, then a highlighted tile | **P**, then tap a tile | Jump to any empty tile within 3 steps (counting across and down), over anything. |
+| **Pierce** | spikes | Tap **Pierce**, then a highlighted tile | **I**, then a direction | A bomb whose flame goes **through** crates, burning every one in reach. Marked with a spikes badge. |
+| **Power** | weight | Tap **Power**, then a highlighted tile | **O**, then a direction | A bomb whose flame reaches all the way to the walls (crates still stop it). Marked with a weight badge. |
+| **Line** | logs | Tap **Line**, then a highlighted tile | **L**, then a direction | Places as many bombs as you're still allowed, in a row starting at that tile, until the row is blocked. |
+| **Decoy** | helmet | Tap **Decoy**, then a highlighted tile | **Y**, then a direction | A pale copy of you. Enemies that chase you (Seekers) chase it instead for 3 turns. Flame destroys it; it doesn't block anyone. |
+
+With an action picked, holding or hovering a highlighted tile previews what it will do: where you
+will end up, a faded bomb with its blast, a kicked bomb's stopping place, a changed number.
 
 - Picking an action highlights the tiles you can choose, in blue. Tapping the action again,
   pressing **Esc**, or tapping anywhere else cancels it.
@@ -48,21 +71,29 @@ second row, with a badge showing how many you hold; using one spends one and tak
 - The blast is a cross reaching **1 tile** in each direction. It stops at walls; it destroys
   the first soft block (wooden crate) in each direction and stops there.
 - A bomb caught in a blast **explodes at once**, whatever its number (a chain reaction).
+- **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. It goes out after that turn.
 - Bombs block movement, for you and for enemies.
 - Bombs can't be placed on an enemy.
 
 ### Danger
 
-- **Strong red** tiles will burn at the end of this turn if you wait.
-- **Light red** tiles will burn at the end of the next turn.
+- **Strong red** tiles will burn at the end of this turn if you wait (including flame still
+  burning from last turn).
+- **Light red** tiles will burn at the end of the next turn (including flame that will still be
+  burning then).
 
 ### Enemies
 
 <img src="docs/screenshots/enemies.png" alt="Level 13: a Drifter saw, a Blob and a Seeker fly, the moving ones with red arrows" width="260" align="right">
 
-Every enemy shows a **red arrow** toward the tile it will step onto next turn. No arrow (and
-closed eyes): it has nowhere to go and stays put. If you put a bomb on the tile an arrow
-points at, that enemy stays put this turn. Touching an enemy costs a life — walking into it, it
+Every enemy shows its **next 3 moves** as a column of see-through red arrows on the right side
+of its tile: the top arrow is its next move (strongest), the ones below are the 2nd and 3rd
+moves (fainter). It makes exactly
+those moves, one per turn, so you can time a bomb for where it will be. No arrows (and closed
+eyes): it has nowhere to go and stays put. If you put a bomb on a tile it planned to walk to,
+it stops there and plans a new route. Touching an enemy costs a life — walking into it, it
 walking into you, or the two of you swapping places. Flame kills enemies.
 
 | Enemy | Looks like | Appears from | Moves |
diff --git a/assets/kenney/bridge_logs.png b/assets/kenney/bridge_logs.png
new file mode 100644
index 0000000..3c23e04
Binary files /dev/null and b/assets/kenney/bridge_logs.png differ
diff --git a/assets/kenney/hud_player_helmet_green.png b/assets/kenney/hud_player_helmet_green.png
new file mode 100644
index 0000000..cbc9d66
Binary files /dev/null and b/assets/kenney/hud_player_helmet_green.png differ
diff --git a/assets/kenney/ramp.png b/assets/kenney/ramp.png
new file mode 100644
index 0000000..ab44665
Binary files /dev/null and b/assets/kenney/ramp.png differ
diff --git a/assets/kenney/spikes.png b/assets/kenney/spikes.png
new file mode 100644
index 0000000..5cbaad6
Binary files /dev/null and b/assets/kenney/spikes.png differ
diff --git a/assets/kenney/torch_on_a.png b/assets/kenney/torch_on_a.png
new file mode 100644
index 0000000..13256d9
Binary files /dev/null and b/assets/kenney/torch_on_a.png differ
diff --git a/assets/kenney/weight.png b/assets/kenney/weight.png
new file mode 100644
index 0000000..73cac7d
Binary files /dev/null and b/assets/kenney/weight.png differ
diff --git a/assets/kenney/window.png b/assets/kenney/window.png
new file mode 100644
index 0000000..462cdc8
Binary files /dev/null and b/assets/kenney/window.png differ
diff --git a/decisions.md b/decisions.md
index 97329a6..a81d06a 100644
--- a/decisions.md
+++ b/decisions.md
@@ -89,6 +89,70 @@ the README credits Kenney anyway.
   only a punishment, never a coin farm.
 - **Par = 50 + 10 per enemy, bonus 5 coins.** A first guess, to tune by playtesting.
 - **`?charges=N` is part of the run record** (`startCharges`), since it changes the starting
-  state; replays stay exact. `SIM_VERSION` is now 2.
+  state; replays stay exact. `SIM_VERSION` is now 2 (3 after the lingering-flame change below).
 - **Death banner says "Ouch!"**, not "Boom.", since enemies can kill too now.
 
+### 2026-10-20 — Touch shortcuts: tap self to wait, hold to bomb, tap far to auto-move
+
+Asked for by the user after playing. All three live in the UI layer (`main.ts`); the sim only
+ever sees ordinary Wait, Bomb and Move actions, so the run record and replays are unchanged.
+
+- **Auto-move is one step per press, not a walk that plays out by itself.** The game is
+  turn-based and the world moves between steps, so the player keeps the chance to stop. The
+  path ignores enemies (they move), but Go never steps onto one.
+- **Long press is 450 ms**, a common long-press length that doesn't feel slow. It only arms a
+  bomb on a tile next to the player with nothing picked, so it can't clash with the two-tap
+  targeting or with the press-and-slide preview.
+
+### 2026-10-20 — The other seven action charges
+
+- **Fuse ± is one charge with two buttons** (Fuse +1, Fuse −1) rather than one button that asks
+  which way afterwards: the action log stays a plain `{ id, target }`, and the choice is
+  visible up front. Timers go from 1 to 9.
+- **Pierce flame still stops at bombs** (setting them off), like any flame; it only ignores
+  crates.
+- **Power bomb fire = the level's width**, so it always reaches the walls; crates stop it.
+- **Line places bombs one per tile from the tile picked**, until capacity or a blocked tile
+  ends the row. With the starting capacity of 1 it is just a bomb; it gets better with Bomb+.
+- **Decoy blocks nothing** and only Seekers chase (the other types don't target the player).
+  It lasts 3 enemy plannings. One at a time.
+- **Teleport is rare** by drop weight (½ of each other charge), as the plan asked.
+- Keys: `=` / `−` Fuse, G Diagonal (then Q E Z C), P Teleport, I Pierce, O Power, L Line,
+  Y Decoy.
+
+### 2026-10-20 — Tapping the auto-move flag steps toward it
+
+The user asked for the flag itself to act like Go, so the thumb can stay on the board. That
+took over "tap the flag again to clear it", so clearing moved to tapping a wall or crate (a tap
+that otherwise does nothing), besides Esc and the existing automatic cases.
+
+Testing the flag tap in a browser walked the player straight into a Blob's announced step. So
+auto-move now refuses any step it knows is deadly — onto an enemy, onto a tile an enemy's arrow
+points at, or into flame this turn — and shows that next step in red. Manual moves are not
+restricted: the player can still choose a risky step on purpose.
+
+### 2026-10-20 — Lingering flame, and enemies planning 3 moves ahead
+
+The user found kills too hard: flame lasted one turn, and an enemy had to be on the exact tile at
+that moment. The cause: bombs take 3 turns, but enemies only showed 1 move, and Blobs keep
+their direction only 75% of the time, so a 3-turn guess was right about 42% of the time. Agreed
+fix, two parts:
+
+- **Flame lingers one extra turn** (option 1 of the ones offered). It is the turn-based version
+  of the NES flames staying on screen briefly, which is where most early kills come from there.
+  Lingering flame kills creatures (enemies, player, decoy) but not crates, drops, the exit or
+  bombs, so the drops a kill leaves survive. The player can't enter it, since walking into fire
+  you can see would only ever be a mistake. Changes the PLAN.md §1 rule "flame is instantaneous".
+- **Enemies commit to 3 moves and show them** (instead of option 3, making Blobs steadier): the
+  user asked for the next 2–3 moves to be shown, stacked vertically, future ones fading. First
+  drawn above the enemy's head on a white backing; the user found that blocky and spilling into
+  the tile above, so they are now see-through arrows down the right side of the enemy's own
+  tile, with the enemy drawn a little smaller and to the left to make room (the user briefly
+  tried full-size enemies under the arrows, then chose the smaller enemy).
+  Each arrow is one polygon, so it has no darker band where shaft and head would overlap. The moves are a real plan, not a forecast: the enemy makes exactly those moves unless
+  the board blocks one, so the player can plan a 3-turn bomb against them. Moves a newly placed
+  bomb blocks are dropped and re-planned in phase 5; the blocked-this-turn rule for the next move
+  is unchanged. Layout chosen by the user over a chain of arrows along the path.
+- Auto-move's safety check uses each enemy's next planned move.
+- Starting fire stays at 1, so Fire+ pickups (M6) still have room to matter.
+
diff --git a/docs/screenshots/auto-move.png b/docs/screenshots/auto-move.png
new file mode 100644
index 0000000..dd0735b
Binary files /dev/null and b/docs/screenshots/auto-move.png differ
diff --git a/docs/screenshots/bomb-picked.png b/docs/screenshots/bomb-picked.png
index b4b2f2d..a760034 100644
Binary files a/docs/screenshots/bomb-picked.png and b/docs/screenshots/bomb-picked.png differ
diff --git a/docs/screenshots/charges.png b/docs/screenshots/charges.png
index 0d2d81e..4ef5d94 100644
Binary files a/docs/screenshots/charges.png and b/docs/screenshots/charges.png differ
diff --git a/docs/screenshots/countdown.png b/docs/screenshots/countdown.png
index f0abe4b..8e78746 100644
Binary files a/docs/screenshots/countdown.png and b/docs/screenshots/countdown.png differ
diff --git a/docs/screenshots/death.png b/docs/screenshots/death.png
index f009bae..d4adf48 100644
Binary files a/docs/screenshots/death.png and b/docs/screenshots/death.png differ
diff --git a/docs/screenshots/desktop.png b/docs/screenshots/desktop.png
index ff4b38d..ebcea15 100644
Binary files a/docs/screenshots/desktop.png and b/docs/screenshots/desktop.png differ
diff --git a/docs/screenshots/enemies.png b/docs/screenshots/enemies.png
index 413e83f..7eba8cf 100644
Binary files a/docs/screenshots/enemies.png and b/docs/screenshots/enemies.png differ
diff --git a/docs/screenshots/lingering.png b/docs/screenshots/lingering.png
new file mode 100644
index 0000000..9e1053e
Binary files /dev/null and b/docs/screenshots/lingering.png differ
diff --git a/docs/screenshots/long-press.png b/docs/screenshots/long-press.png
new file mode 100644
index 0000000..443c5c0
Binary files /dev/null and b/docs/screenshots/long-press.png differ
diff --git a/docs/screenshots/pierce-decoy.png b/docs/screenshots/pierce-decoy.png
new file mode 100644
index 0000000..4ce5d41
Binary files /dev/null and b/docs/screenshots/pierce-decoy.png differ
diff --git a/docs/screenshots/scrolled.png b/docs/screenshots/scrolled.png
index 119129c..b3585c8 100644
Binary files a/docs/screenshots/scrolled.png and b/docs/screenshots/scrolled.png differ
diff --git a/docs/screenshots/start.png b/docs/screenshots/start.png
index 0a69656..68bac0c 100644
Binary files a/docs/screenshots/start.png and b/docs/screenshots/start.png differ
diff --git a/src/main.ts b/src/main.ts
index 8c590e5..7b368ce 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -14,9 +14,11 @@ import { BACKGROUND, drawBoard, FLAME_SECONDS, toTile, type Flame } from './rend
 import { updateCamera } from './render/camera'
 import { TEXTURES } from './render/sprites'
 import { isAvailable, isValidTarget, ACTIONS } from './sim/actions'
+import { enemyAt } from './sim/enemies'
+import { playerPath } from './sim/path'
 import { predictDanger, type Danger } from './sim/danger'
 import { newRecord, type RunRecord } from './sim/record'
-import { inBounds, newRun } from './sim/state'
+import { eq, idx, inBounds, newRun } from './sim/state'
 import { step } from './sim/turn'
 import { DIRS, type Action, type ActionId, type Dir, type GameState, type Vec } from './sim/types'
 import { createControls } from './ui/controls'
@@ -36,6 +38,14 @@ let hover: Vec | null = null
 let flames: Flame[] = []
 let facingLeft = false
 let snapCamera = true
+/** Auto-move: a far tile the player tapped. Each press of Move takes one step along `path` toward it. */
+let destination: Vec | null = null
+let path: Vec[] | null = null
+/** Long press: the adjacent tile a held finger has armed for a bomb, placed on release. */
+let armed: Vec | null = null
+let pressTimer: number | undefined
+let pressTile: Vec | null = null
+const LONG_PRESS_MS = 450
 
 function startRun(seed: number): void {
   record = newRecord(seed, startLevel, startCharges)
@@ -54,6 +64,7 @@ function act(action: Action): void {
   for (const e of result.events) {
     if (e.type === 'exploded') flames.push({ tiles: e.tiles, born: time })
     if (e.type === 'levelRestarted' || e.type === 'levelStarted') snapCamera = true
+    if (e.type === 'levelRestarted' || e.type === 'levelStarted' || e.type === 'levelCleared') destination = null
     if (e.type === 'moved' && e.to.x !== e.from.x) facingLeft = e.to.x < e.from.x
   }
   selected = null
@@ -62,10 +73,20 @@ function act(action: Action): void {
 
 function afterChange(): void {
   danger = predictDanger(state)
-  controls.update(state, selected)
+  // The walk is worked out again every turn, since bombs and crates change; reaching the
+  // destination, or it becoming unreachable, ends auto-move.
+  if (destination && (eq(state.player, destination) || state.status !== 'playing')) destination = null
+  path = destination ? playerPath(state, destination) : null
+  if (!path) destination = null
+  controls.update(state, selected, destination !== null)
 }
 
-/** Two taps: pick an action, then a highlighted tile. With nothing picked, a tap next to the player moves. */
+/**
+ * Two taps: pick an action, then a highlighted tile. With nothing picked: a tap next to the
+ * player moves there, a tap on the player waits, a tap on a reachable tile further away sets it
+ * as the auto-move destination, a tap on that flag takes one step toward it (like Go), and a tap
+ * on a wall or crate clears it.
+ */
 function tapTile(p: Vec): void {
   if (state.status !== 'playing') return
   if (selected) {
@@ -74,11 +95,39 @@ function tapTile(p: Vec): void {
       selected = null
       afterChange()
     }
-  } else if (isValidTarget(state, 'move', p)) act({ id: 'move', target: p })
+  } else if (eq(p, state.player)) pick('wait')
+  // The flag first: even when it is next to the player, tapping it is an auto-move step, with
+  // auto-move's safety check, not a plain move.
+  else if (destination && eq(destination, p)) stepTowardDestination()
+  else if (isValidTarget(state, 'move', p)) act({ id: 'move', target: p })
+  else if (playerPath(state, p)) {
+    destination = p
+    afterChange()
+  } else if (destination) {
+    destination = null
+    afterChange()
+  }
+}
+
+/**
+ * Whether auto-move may take this step: not onto an enemy, not onto the tile an enemy moves to
+ * next, and not into flame this turn. Those would be deaths the player didn't choose.
+ */
+function safeStep(p: Vec): boolean {
+  return !enemyAt(state, p) && !state.enemies.some((e) => e.plan[0] && eq(e.plan[0], p)) && !danger.now.has(idx(state, p))
+}
+
+/** One step of auto-move, if the next step is safe. True if it stepped. */
+function stepTowardDestination(): boolean {
+  const next = path?.[0]
+  if (!next || !safeStep(next) || !isValidTarget(state, 'move', next)) return false
+  act({ id: 'move', target: next })
+  return true
 }
 
 function pick(id: ActionId): void {
   if (!isAvailable(state, id)) return
+  if (id === 'move' && destination && !selected && stepTowardDestination()) return
   if (!ACTIONS[id].needsTarget) act({ id })
   else {
     selected = selected === id ? null : id
@@ -113,20 +162,43 @@ const KEY_DIRS: Record<string, Dir> = {
   KeyD: 'right', ArrowRight: 'right',
 }
 
-const ACTION_KEYS: Record<string, ActionId> = { KeyB: 'bomb', KeyK: 'kick', KeyT: 'throw', KeyH: 'shield', KeyF: 'freeze' }
+const ACTION_KEYS: Record<string, ActionId> = {
+  KeyB: 'bomb', KeyK: 'kick', KeyT: 'throw', KeyH: 'shield', KeyF: 'freeze', Equal: 'fuseUp', Minus: 'fuseDown',
+  KeyG: 'diagonal', KeyP: 'teleport', KeyI: 'pierce', KeyO: 'power', KeyL: 'line', KeyY: 'decoy',
+}
+
+/** With Diagonal picked: Q / E / Z / C step up-left, up-right, down-left, down-right. */
+const DIAGONAL_KEYS: Record<string, Vec> = { KeyQ: { x: -1, y: -1 }, KeyE: { x: 1, y: -1 }, KeyZ: { x: -1, y: 1 }, KeyC: { x: 1, y: 1 } }
 
 window.addEventListener('keydown', (e) => {
   if (e.repeat) return
   const d = KEY_DIRS[e.code]
-  if (d) direction(d)
+  const diag = selected === 'diagonal' ? DIAGONAL_KEYS[e.code] : undefined
+  if (diag) {
+    const p = { x: state.player.x + diag.x, y: state.player.y + diag.y }
+    if (isValidTarget(state, 'diagonal', p)) act({ id: 'diagonal', target: p })
+  } else if (d) direction(d)
   else if (e.code === 'Space') pick('wait')
   else if (ACTION_KEYS[e.code]) pick(ACTION_KEYS[e.code]!)
   else if (e.code === 'Escape') {
     selected = null
+    destination = null
     afterChange()
   } else if (e.code === 'Enter') controls.pressBanner()
 })
 
+/** Start timing a long press on `p`, or with null, cancel any press in progress. */
+function startPress(p: Vec | null): void {
+  clearTimeout(pressTimer)
+  armed = null
+  pressTile = p
+  if (!p || selected || state.status !== 'playing' || !isValidTarget(state, 'bomb', p)) return
+  pressTimer = window.setTimeout(() => {
+    armed = p
+    navigator.vibrate?.(15)
+  }, LONG_PRESS_MS)
+}
+
 function tileUnder(e: PointerEvent): Vec | null {
   const r = mainCanvas.getBoundingClientRect()
   const p = toTile(state, screenToWorld(vec2(e.clientX - r.left, e.clientY - r.top)))
@@ -149,17 +221,32 @@ await engineInit(
     setShowSplashScreen(false)
     setCanvasClearColor(BACKGROUND)
     // Pressing shows the preview on the tile under the finger; releasing commits to it.
-    // Sliding to another tile before letting go changes the choice.
-    mainCanvas.addEventListener('pointerdown', (e) => (hover = tileUnder(e)))
+    // Sliding to another tile before letting go changes the choice. Holding still on a tile
+    // next to the player, with nothing picked, arms a bomb there instead (placed on release).
+    mainCanvas.addEventListener('pointerdown', (e) => {
+      hover = tileUnder(e)
+      startPress(hover)
+    })
     mainCanvas.addEventListener('pointermove', (e) => {
-      if (e.pointerType === 'mouse' || e.buttons) hover = tileUnder(e)
+      if (e.pointerType !== 'mouse' && !e.buttons) return
+      const p = tileUnder(e)
+      if (e.buttons && !(p && pressTile && eq(p, pressTile))) startPress(null)
+      hover = p
     })
     mainCanvas.addEventListener('pointerup', (e) => {
       const p = tileUnder(e)
       if (e.pointerType !== 'mouse') hover = null
-      if (p) tapTile(p)
+      const bombAt = armed
+      startPress(null)
+      if (bombAt && p && eq(p, bombAt)) act({ id: 'bomb', target: bombAt })
+      else if (p) tapTile(p)
+    })
+    mainCanvas.addEventListener('pointerleave', () => {
+      hover = null
+      startPress(null)
     })
-    mainCanvas.addEventListener('pointerleave', () => (hover = null))
+    // A long press shouldn't open the browser's context menu.
+    mainCanvas.addEventListener('contextmenu', (e) => e.preventDefault())
   },
   () => {
     updateCamera(state, controls.insets(), snapCamera)
@@ -167,7 +254,11 @@ await engineInit(
     flames = flames.filter((f) => time - f.born < FLAME_SECONDS)
   },
   () => {},
-  () => drawBoard({ state, danger, selected, hover, flames, facingLeft }),
+  () =>
+    drawBoard({
+      state, danger, selected, hover, flames, facingLeft, destination, path, armed,
+      pathBlocked: !!path?.[0] && !safeStep(path[0]),
+    }),
   () => {},
   TEXTURES,
 )
diff --git a/src/render/board.ts b/src/render/board.ts
index b7f2d9e..d51af79 100644
--- a/src/render/board.ts
+++ b/src/render/board.ts
@@ -1,9 +1,10 @@
-import { drawCircle, drawLine, drawPoly, drawRect, drawTile, rgb, time, vec2, type Color, type Vector2 } from 'littlejsengine'
-import { ACTIONS, kickDestination } from '../sim/actions'
+import { drawCircle, drawPoly, drawRect, drawTile, rgb, time, vec2, type Color, type Vector2 } from 'littlejsengine'
+import { ACTIONS } from '../sim/actions'
 import type { Danger } from '../sim/danger'
 import { blastTiles } from '../sim/explosions'
-import { cellAt, eq, exitOpen, idx } from '../sim/state'
-import type { ActionId, Enemy, GameState, Vec } from '../sim/types'
+import { dirBetween } from '../sim/enemies'
+import { cellAt, cloneState, eq, exitOpen, idx } from '../sim/state'
+import type { ActionId, Bomb, Enemy, GameState, Vec } from '../sim/types'
 import { CHARGE_ICONS, coinSprite, digitSprite, ENEMY_SPRITES, sprite, type BoardSprite } from './sprites'
 
 /**
@@ -33,6 +34,13 @@ export interface View {
   flames: Flame[]
   /** The way the player last stepped. The character sprite faces right; this mirrors it. */
   facingLeft: boolean
+  /** Auto-move: the tapped far tile, and the walk there (next step first). */
+  destination: Vec | null
+  path: Vec[] | null
+  /** The next auto-move step is unsafe, so Go and the flag won't take it this turn. */
+  pathBlocked: boolean
+  /** A tile a long press has armed for a bomb. */
+  armed: Vec | null
 }
 
 export const FLAME_SECONDS = 0.45
@@ -49,9 +57,12 @@ const C = {
   flame: rgb(1, 0.6, 0.1),
   white: rgb(1, 1, 1),
   ghost: rgb(1, 1, 1, 0.45),
-  intent: rgb(0.85, 0.2, 0.2, 0.85),
+  intent: rgb(0.85, 0.2, 0.2),
   shield: rgb(0.25, 0.62, 0.87, 0.9),
   clear: rgb(0, 0, 0, 0),
+  pathDot: rgb(0.25, 0.62, 0.87, 0.7),
+  burning: rgb(1, 0.55, 0.1, 0.5),
+  blocked: rgb(0.85, 0.2, 0.2, 0.35),
 }
 
 export const BACKGROUND: Color = C.sky
@@ -89,8 +100,29 @@ export function drawBoard(view: View): void {
       drawRect(toWorld(s, t), vec2(0.94), hovered && eq(t, hovered) ? C.targetHover : C.target)
     }
   }
-  if (hovered && selected === 'bomb') {
-    for (const t of blastTiles(s, { pos: hovered, fire: s.stats.fire })) drawRect(toWorld(s, t), vec2(0.7), C.preview)
+  if (view.destination && view.path) {
+    for (const [i, t] of view.path.entries()) {
+      if (i === 0) drawRect(toWorld(s, t), vec2(0.94), view.pathBlocked ? C.blocked : C.target)
+      else drawCircle(toWorld(s, t), 0.16, C.pathDot)
+    }
+    drawTile(toWorld(s, view.destination), vec2(0.8), sprite('flag_green_a'))
+  }
+  // The commit preview: the picked action played on a copy, then whatever it changed drawn faded.
+  const after = view.armed
+    ? previewOf(s, 'bomb', view.armed)
+    : selected && hovered
+      ? previewOf(s, selected, hovered)
+      : null
+  const changedBombs = after ? after.bombs.filter((b) => !s.bombs.some((o) => eq(o.pos, b.pos) && o.timer === b.timer)) : []
+  for (const b of changedBombs) {
+    for (const t of blastTiles(after!, b)) drawRect(toWorld(s, t), vec2(0.7), C.preview)
+  }
+
+  // Flame from last turn's explosions still burning (it lingers one turn).
+  for (const i of s.burning) {
+    const pos = toWorld(s, tileOf(s, i))
+    drawRect(pos, vec2(0.94), C.burning)
+    drawTile(pos, vec2(0.55 + 0.05 * Math.sin(time * 9 + i)), sprite('fireball'), C.white.scale(1, 0.8), time * 4 + i)
   }
 
   for (const f of view.flames) {
@@ -106,13 +138,11 @@ export function drawBoard(view: View): void {
   for (const b of s.bombs) {
     // The last turn before it goes off, it flashes.
     const hot = b.timer <= 1 && Math.sin(time * 14) > 0
-    drawBomb(toWorld(s, b.pos), b.timer, hot ? 'bomb_active' : 'bomb', C.white)
-  }
-  if (hovered && selected === 'kick') {
-    const bomb = s.bombs.find((b) => eq(b.pos, hovered))
-    if (bomb) drawBomb(toWorld(s, kickDestination(s, hovered)), bomb.timer, 'bomb', C.ghost)
+    drawBomb(toWorld(s, b.pos), b.timer, hot ? 'bomb_active' : 'bomb', C.white, b.special)
   }
-  if (hovered && (selected === 'bomb' || selected === 'throw')) drawBomb(toWorld(s, hovered), s.stats.fuse, 'bomb', C.ghost)
+  for (const b of changedBombs) drawBomb(toWorld(s, b.pos), b.timer, 'bomb', C.ghost, b.special)
+  if (s.decoy) drawDecoy(toWorld(s, s.decoy.pos), C.white)
+  if (after?.decoy && !s.decoy) drawDecoy(toWorld(s, after.decoy.pos), C.ghost)
 
   // Drops bob gently, so they read as things to pick up rather than part of the floor.
   for (const [i, d] of s.drops.entries()) {
@@ -123,7 +153,9 @@ export function drawBoard(view: View): void {
   for (const e of s.enemies) drawEnemy(s, e)
 
   if (s.shield) drawCircle(toWorld(s, s.player), 1.08, C.clear, 0.07, C.shield)
-  if (hovered && selected === 'move') drawTile(toWorld(s, hovered), tileSize, sprite('character_green_idle'), C.ghost, 0, view.facingLeft)
+  if (after && !eq(after.player, s.player)) {
+    drawTile(toWorld(s, after.player), tileSize, sprite('character_green_idle'), C.ghost, 0, view.facingLeft)
+  }
   const hit = s.status === 'dead' || s.status === 'gameOver'
   drawTile(toWorld(s, s.player), tileSize, sprite(hit ? 'character_green_hit' : 'character_green_idle'), C.white, 0, view.facingLeft)
 }
@@ -135,29 +167,69 @@ function drawExit(pos: Vector2, open: boolean): void {
   drawTile(pos.add(vec2(0, 0.34)), half, sprite(open ? 'door_open_top' : 'door_closed_top'))
 }
 
-/** An enemy, and its intent: an arrow toward the tile it will step onto next turn. */
+/** How strongly each planned move's arrow shows: the next one solid, later ones fading. */
+const PLAN_ALPHA = [0.85, 0.55, 0.3]
+
+/**
+ * An enemy, and its plan: a see-through column of arrows down the right side of its own tile,
+ * one per planned move, the next move at the top (the user's choice of layout, decisions.md).
+ * The enemy is drawn a little smaller and to the left to make room.
+ */
 function drawEnemy(s: GameState, e: Enemy): void {
   const pos = toWorld(s, e.pos)
   const art = ENEMY_SPRITES[e.kind]
-  const frame = !e.intent ? art.rest : Math.floor(time * 2.5 + e.id) % 2 ? art.a : art.b
-  const angle = art.spins && e.intent ? time * 3 : 0
-  const mirror = art.facesLeft && e.dir === 'right'
-  drawTile(pos, vec2(0.9), sprite(frame), C.white, angle, mirror)
-  if (!e.intent) return
-  const d = vec2(e.intent.x - e.pos.x, e.pos.y - e.intent.y) // world y is up
-  const tip = pos.add(d.scale(0.82))
-  drawLine(pos.add(d.scale(0.5)), tip, 0.08, C.intent)
-  const side = vec2(-d.y, d.x).scale(0.13)
-  drawPoly([tip.add(d.scale(0.12)), tip.add(side), tip.subtract(side)], C.intent)
+  const moving = e.plan.length > 0
+  const frame = !moving ? art.rest : Math.floor(time * 2.5 + e.id) % 2 ? art.a : art.b
+  const angle = art.spins && moving ? time * 3 : 0
+  const heading = moving ? dirBetween(e.pos, e.plan[0]!) : e.dir
+  const mirror = art.facesLeft && heading === 'right'
+  drawTile(pos.add(vec2(-0.09, 0)), vec2(0.8), sprite(frame), C.white, angle, mirror)
+  if (!moving) return
+
+  const gap = 0.29
+  const top = pos.add(vec2(0.35, gap))
+  let from = e.pos
+  for (const [i, to] of e.plan.entries()) {
+    const d = vec2(to.x - from.x, from.y - to.y) // world y is up
+    drawArrow(top.add(vec2(0, -gap * i)), d, 0.26, C.intent.scale(1, PLAN_ALPHA[i] ?? 0.3))
+    from = to
+  }
 }
 
-/** The bomb sprite with its timer on the body, which sits a little below and right of the tile's centre. */
-function drawBomb(pos: Vector2, timer: number, body: BoardSprite, color: Color): void {
+/**
+ * A small arrow centred on `pos`, pointing along the unit vector `d`. One polygon (shaft and
+ * head together), so a see-through arrow has no darker band where two shapes would overlap.
+ */
+function drawArrow(pos: Vector2, d: Vector2, size: number, color: Color): void {
+  const n = vec2(-d.y, d.x)
+  // Along the arrow (a) and across it (b), in units of `size`.
+  const at = (a: number, b: number) => pos.add(d.scale(a * size)).add(n.scale(b * size))
+  drawPoly([at(0.5, 0), at(0.02, 0.32), at(0.02, 0.15), at(-0.45, 0.15), at(-0.45, -0.15), at(0.02, -0.15), at(0.02, -0.32)], color)
+}
+
+/** The picked action played on a copy of the state, for the preview. Nothing else happens. */
+function previewOf(s: GameState, id: ActionId, target: Vec): GameState {
+  const copy = cloneState(s)
+  ACTIONS[id].apply(copy, target, [])
+  return copy
+}
+
+/** The decoy: a pale copy of the player, facing out. */
+function drawDecoy(pos: Vector2, color: Color): void {
+  drawTile(pos, vec2(0.9), sprite('character_green_front'), color.scale(1, 0.75))
+}
+
+/**
+ * The bomb sprite with its timer on the body, which sits a little below and right of the
+ * tile's centre. A Pierce or Power bomb wears its charge's icon as a badge at the top left.
+ */
+function drawBomb(pos: Vector2, timer: number, body: BoardSprite, color: Color, special?: Bomb['special']): void {
   drawTile(pos, vec2(0.9), sprite(body), color)
   const digits = String(timer)
   const w = 0.32
   const start = pos.add(vec2(0.04 - ((digits.length - 1) * w) / 2, -0.06))
   for (let i = 0; i < digits.length; i++) drawTile(start.add(vec2(i * w, 0)), vec2(0.42), digitSprite(Number(digits[i])), color)
+  if (special) drawTile(pos.add(vec2(-0.28, 0.28)), vec2(0.4), sprite(CHARGE_ICONS[special]), color)
 }
 
 function tileOf(s: GameState, i: number): Vec {
diff --git a/src/render/sprites.ts b/src/render/sprites.ts
index d0a97ac..5c474e8 100644
--- a/src/render/sprites.ts
+++ b/src/render/sprites.ts
@@ -37,6 +37,7 @@ const BOARD = [
   'hud_character_7',
   'hud_character_8',
   'hud_character_9',
+  'flag_green_a',
   'door_closed',
   'door_closed_top',
   'door_open',
@@ -48,6 +49,14 @@ const BOARD = [
   'sign_right',
   'switch_blue',
   'chain',
+  'torch_on_a',
+  'ramp',
+  'window',
+  'spikes',
+  'weight',
+  'bridge_logs',
+  'hud_player_helmet_green',
+  'character_green_front',
   'slime_normal_rest',
   'slime_normal_walk_a',
   'slime_normal_walk_b',
@@ -83,6 +92,13 @@ export const CHARGE_ICONS: Record<ChargeId, BoardSprite> = {
   throw: 'sign_right',
   shield: 'switch_blue',
   freeze: 'chain',
+  fuse: 'torch_on_a',
+  diagonal: 'ramp',
+  teleport: 'window',
+  pierce: 'spikes',
+  power: 'weight',
+  line: 'bridge_logs',
+  decoy: 'hud_player_helmet_green',
 }
 
 /**
diff --git a/src/sim/actions.ts b/src/sim/actions.ts
index 015ab0b..9dfad12 100644
--- a/src/sim/actions.ts
+++ b/src/sim/actions.ts
@@ -1,6 +1,6 @@
 import { enemyAt } from './enemies'
-import { add, bombAt, eq, isFree } from './state'
-import { CHARGE_IDS, DIRS, DIR_LIST, type ActionId, type ChargeId, type GameEvent, type GameState, type Vec } from './types'
+import { add, bombAt, eq, isBurning, isFree, playerCanEnter } from './state'
+import { DIRS, DIR_LIST, type ActionId, type Bomb, type ChargeId, type GameEvent, type GameState, type Vec } from './types'
 
 /**
  * The board actions (PLAN.md §3). Adding one = an entry here, its tests, and a button.
@@ -11,8 +11,8 @@ export interface ActionDef {
   label: string
   /** False for actions like Wait that happen as soon as they are picked. */
   needsTarget: boolean
-  /** A consumable action charge (PLAN.md §3.3): only offered while the player holds one, and spends one. */
-  charge?: boolean
+  /** The action charge it spends (PLAN.md §3.3); only offered while the player holds one. */
+  charge?: ChargeId
   validTargets(s: GameState): Vec[]
   apply(s: GameState, target: Vec | undefined, events: GameEvent[]): void
 }
@@ -21,16 +21,42 @@ function neighbours(p: Vec): Vec[] {
   return DIR_LIST.map((d) => add(p, DIRS[d]))
 }
 
-/** Where a bomb can go: free, and no enemy standing there. */
+/** Where a bomb can go: free, not on fire, and no enemy standing there. */
 function canHoldBomb(s: GameState, p: Vec): boolean {
-  return isFree(s, p) && !enemyAt(s, p)
+  return isFree(s, p) && !isBurning(s, p) && !enemyAt(s, p)
 }
 
-function placeBomb(s: GameState, pos: Vec, events: GameEvent[]): void {
-  s.bombs.push({ pos: { ...pos }, timer: s.stats.fuse, fire: s.stats.fire, placedTurn: s.turn })
+function placeBomb(s: GameState, pos: Vec, events: GameEvent[], special?: Bomb['special']): void {
+  // A Power bomb's flame reaches as far as the level is wide: in practice, to the walls.
+  const fire = special === 'power' ? Math.max(s.width, s.height) : s.stats.fire
+  const bomb: Bomb = { pos: { ...pos }, timer: s.stats.fuse, fire, placedTurn: s.turn }
+  if (special) bomb.special = special
+  s.bombs.push(bomb)
   events.push({ type: 'bombPlaced', pos: { ...pos }, timer: s.stats.fuse })
 }
 
+function hasCapacity(s: GameState): boolean {
+  return s.bombs.length < s.stats.capacity
+}
+
+/** The adjacent tiles a bomb can go on, if the player may place one. */
+function bombTargets(s: GameState): Vec[] {
+  return hasCapacity(s) ? neighbours(s.player).filter((p) => canHoldBomb(s, p)) : []
+}
+
+function walkTo(s: GameState, target: Vec, events: GameEvent[]): void {
+  const from = s.player
+  s.player = { ...target }
+  events.push({ type: 'moved', from, to: s.player })
+}
+
+/** The furthest a Fuse +1 takes a timer: one digit. */
+const MAX_TIMER = 9
+/** How far Teleport reaches (Manhattan distance). */
+export const TELEPORT_RANGE = 3
+/** How many enemy plannings a Decoy lasts. */
+export const DECOY_TURNS = 3
+
 /** Where a bomb kicked by the player slides to: on, away from them, until the next tile can't hold it. */
 export function kickDestination(s: GameState, from: Vec): Vec {
   const d = { x: from.x - s.player.x, y: from.y - s.player.y }
@@ -44,18 +70,14 @@ export const ACTIONS: Record<ActionId, ActionDef> = {
     id: 'move',
     label: 'Move',
     needsTarget: true,
-    validTargets: (s) => neighbours(s.player).filter((p) => isFree(s, p)),
-    apply(s, target, events) {
-      const from = s.player
-      s.player = { ...target! }
-      events.push({ type: 'moved', from, to: s.player })
-    },
+    validTargets: (s) => neighbours(s.player).filter((p) => playerCanEnter(s, p)),
+    apply: (s, target, events) => walkTo(s, target!, events),
   },
   bomb: {
     id: 'bomb',
     label: 'Bomb',
     needsTarget: true,
-    validTargets: (s) => (s.bombs.length >= s.stats.capacity ? [] : neighbours(s.player).filter((p) => canHoldBomb(s, p))),
+    validTargets: bombTargets,
     apply: (s, target, events) => placeBomb(s, target!, events),
   },
   wait: {
@@ -71,7 +93,7 @@ export const ACTIONS: Record<ActionId, ActionDef> = {
     id: 'kick',
     label: 'Kick',
     needsTarget: true,
-    charge: true,
+    charge: 'kick',
     // An adjacent bomb with room to slide at least one tile away from the player.
     validTargets: (s) =>
       DIR_LIST.map((d) => DIRS[d]).flatMap((d) => {
@@ -89,10 +111,10 @@ export const ACTIONS: Record<ActionId, ActionDef> = {
     id: 'throw',
     label: 'Throw',
     needsTarget: true,
-    charge: true,
+    charge: 'throw',
     // Exactly 2 tiles away in a straight line, over anything in between.
     validTargets: (s) =>
-      s.bombs.length >= s.stats.capacity
+      !hasCapacity(s)
         ? []
         : DIR_LIST.map((d) => add(s.player, { x: 2 * DIRS[d].x, y: 2 * DIRS[d].y })).filter((p) => canHoldBomb(s, p)),
     apply: (s, target, events) => placeBomb(s, target!, events),
@@ -101,7 +123,7 @@ export const ACTIONS: Record<ActionId, ActionDef> = {
     id: 'shield',
     label: 'Shield',
     needsTarget: false,
-    charge: true,
+    charge: 'shield',
     validTargets: () => [],
     apply(s) {
       s.shield = true
@@ -111,18 +133,118 @@ export const ACTIONS: Record<ActionId, ActionDef> = {
     id: 'freeze',
     label: 'Freeze',
     needsTarget: false,
-    charge: true,
+    charge: 'freeze',
     validTargets: () => [],
     apply(s) {
       s.frozen = true
     },
   },
+  fuseUp: {
+    id: 'fuseUp',
+    label: 'Fuse +1',
+    needsTarget: true,
+    charge: 'fuse',
+    validTargets: (s) => s.bombs.filter((b) => b.timer < MAX_TIMER).map((b) => b.pos),
+    apply: (s, target, events) => changeFuse(s, target!, 1, events),
+  },
+  fuseDown: {
+    id: 'fuseDown',
+    label: 'Fuse −1',
+    needsTarget: true,
+    charge: 'fuse',
+    validTargets: (s) => s.bombs.filter((b) => b.timer > 1).map((b) => b.pos),
+    apply: (s, target, events) => changeFuse(s, target!, -1, events),
+  },
+  diagonal: {
+    id: 'diagonal',
+    label: 'Diagonal',
+    needsTarget: true,
+    charge: 'diagonal',
+    // A squeeze: the two orthogonal tiles in between may both be blocked.
+    validTargets: (s) =>
+      [
+        { x: -1, y: -1 },
+        { x: 1, y: -1 },
+        { x: -1, y: 1 },
+        { x: 1, y: 1 },
+      ]
+        .map((d) => add(s.player, d))
+        .filter((p) => playerCanEnter(s, p)),
+    apply: (s, target, events) => walkTo(s, target!, events),
+  },
+  teleport: {
+    id: 'teleport',
+    label: 'Teleport',
+    needsTarget: true,
+    charge: 'teleport',
+    validTargets(s) {
+      const tiles: Vec[] = []
+      for (let dy = -TELEPORT_RANGE; dy <= TELEPORT_RANGE; dy++) {
+        for (let dx = -TELEPORT_RANGE; dx <= TELEPORT_RANGE; dx++) {
+          const p = add(s.player, { x: dx, y: dy })
+          if ((dx || dy) && Math.abs(dx) + Math.abs(dy) <= TELEPORT_RANGE && playerCanEnter(s, p)) tiles.push(p)
+        }
+      }
+      return tiles
+    },
+    apply: (s, target, events) => walkTo(s, target!, events),
+  },
+  pierce: {
+    id: 'pierce',
+    label: 'Pierce',
+    needsTarget: true,
+    charge: 'pierce',
+    validTargets: bombTargets,
+    apply: (s, target, events) => placeBomb(s, target!, events, 'pierce'),
+  },
+  power: {
+    id: 'power',
+    label: 'Power',
+    needsTarget: true,
+    charge: 'power',
+    validTargets: bombTargets,
+    apply: (s, target, events) => placeBomb(s, target!, events, 'power'),
+  },
+  line: {
+    id: 'line',
+    label: 'Line',
+    needsTarget: true,
+    charge: 'line',
+    // The tile picked sets the direction: bombs go there and on beyond it, one per free tile,
+    // until capacity runs out or the line is blocked.
+    validTargets: bombTargets,
+    apply(s, target, events) {
+      const d = { x: target!.x - s.player.x, y: target!.y - s.player.y }
+      for (let p = target!; hasCapacity(s) && canHoldBomb(s, p); p = add(p, d)) placeBomb(s, p, events)
+    },
+  },
+  decoy: {
+    id: 'decoy',
+    label: 'Decoy',
+    needsTarget: true,
+    charge: 'decoy',
+    validTargets: (s) => neighbours(s.player).filter((p) => canHoldBomb(s, p)),
+    apply(s, target, events) {
+      s.decoy = { pos: { ...target! }, turns: DECOY_TURNS }
+      events.push({ type: 'decoyPlaced', pos: s.decoy.pos })
+    },
+  },
+}
+
+function changeFuse(s: GameState, at: Vec, delta: number, events: GameEvent[]): void {
+  const bomb = bombAt(s, at)!
+  bomb.timer += delta
+  events.push({ type: 'fuseChanged', pos: bomb.pos, timer: bomb.timer })
 }
 
-export const ACTION_ORDER: readonly ActionId[] = ['move', 'bomb', 'wait', ...CHARGE_IDS]
+export const ACTION_ORDER: readonly ActionId[] = [
+  'move', 'bomb', 'wait', 'kick', 'throw', 'shield', 'freeze', 'fuseUp', 'fuseDown', 'diagonal', 'teleport',
+  'pierce', 'power', 'line', 'decoy',
+]
 
-export function isCharge(id: ActionId): id is ChargeId {
-  return ACTIONS[id].charge === true
+/** The action charge an action spends, if it is a charge action. */
+export function chargeOf(id: ActionId): ChargeId | undefined {
+  return ACTIONS[id].charge
 }
 
 export function isValidTarget(s: GameState, id: ActionId, target: Vec): boolean {
@@ -132,8 +254,9 @@ export function isValidTarget(s: GameState, id: ActionId, target: Vec): boolean
 /** Whether the action can be picked right now. */
 export function isAvailable(s: GameState, id: ActionId): boolean {
   if (s.status !== 'playing') return false
-  if (isCharge(id)) {
-    if (s.charges[id] <= 0) return false
+  const charge = chargeOf(id)
+  if (charge) {
+    if (s.charges[charge] <= 0) return false
     if (id === 'shield' && s.shield) return false
     if (id === 'freeze' && s.enemies.length === 0) return false
   }
diff --git a/src/sim/danger.ts b/src/sim/danger.ts
index 6f03643..4ccf13d 100644
--- a/src/sim/danger.ts
+++ b/src/sim/danger.ts
@@ -10,15 +10,17 @@ export interface Danger {
 
 /**
  * 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.
+ * 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.
  */
 export function predictDanger(state: GameState): Danger {
   const s = cloneState(state)
   const ignored: GameEvent[] = []
   tickBombs(s, ignored)
-  const now = detonate(s, ignored)
+  const exploding = detonate(s, ignored)
   s.turn++
   tickBombs(s, ignored)
-  const next = detonate(s, ignored)
-  return { now, next }
+  const later = detonate(s, ignored)
+  return { now: new Set([...exploding, ...state.burning]), next: new Set([...later, ...exploding]) }
 }
diff --git a/src/sim/enemies.ts b/src/sim/enemies.ts
index e0154a2..715aa0b 100644
--- a/src/sim/enemies.ts
+++ b/src/sim/enemies.ts
@@ -1,6 +1,6 @@
 import { Rng } from './rng'
 import { add, bombAt, cellAt, eq } from './state'
-import { CHARGE_IDS, DIRS, DIR_LIST, type Dir, type Drop, type Enemy, type EnemyKind, type GameEvent, type GameState, type Vec } from './types'
+import { CHARGE_IDS, DIRS, DIR_LIST, type ChargeId, type Dir, type Drop, type Enemy, type EnemyKind, type GameEvent, type GameState, type Vec } from './types'
 
 /** Enemies walk on floor and are stopped by bombs. They don't block each other; they can share a tile. */
 export function enemyCanEnter(s: GameState, p: Vec): boolean {
@@ -16,31 +16,49 @@ interface Step {
   to: Vec
 }
 
-function openSteps(s: GameState, e: Enemy): Step[] {
-  return DIR_LIST.map((dir) => ({ dir, to: add(e.pos, DIRS[dir]) })).filter((st) => enemyCanEnter(s, st.to))
+/** Where an enemy is, or will be, while its moves are planned ahead. */
+interface Walker {
+  pos: Vec
+  dir: Dir
+}
+
+/** The direction of a one-tile step. */
+export function dirBetween(from: Vec, to: Vec): Dir {
+  return DIR_LIST.find((d) => eq(add(from, DIRS[d]), to))!
+}
+
+/** How many moves ahead every enemy plans, and shows (PLAN.md §4). */
+export const PLAN_AHEAD = 3
+
+function openSteps(s: GameState, w: Walker): Step[] {
+  return DIR_LIST.map((dir) => ({ dir, to: add(w.pos, DIRS[dir]) })).filter((st) => enemyCanEnter(s, st.to))
 }
 
 /** Blob: carries on the way it was going three times in four, otherwise any open way. */
-function wander(s: GameState, e: Enemy, rng: Rng): Step | null {
-  const steps = openSteps(s, e)
-  const ahead = steps.find((st) => st.dir === e.dir)
+function wander(s: GameState, w: Walker, rng: Rng): Step | null {
+  const steps = openSteps(s, w)
+  const ahead = steps.find((st) => st.dir === w.dir)
   if (ahead && rng.next() < 0.75) return ahead
   return steps.length ? steps[rng.int(steps.length)]! : null
 }
 
 /** Drifter: straight on until blocked, then any open way. */
-function drift(s: GameState, e: Enemy, rng: Rng): Step | null {
-  const steps = openSteps(s, e)
-  return steps.find((st) => st.dir === e.dir) ?? (steps.length ? steps[rng.int(steps.length)]! : null)
+function drift(s: GameState, w: Walker, rng: Rng): Step | null {
+  const steps = openSteps(s, w)
+  return steps.find((st) => st.dir === w.dir) ?? (steps.length ? steps[rng.int(steps.length)]! : null)
 }
 
-/** The first step of a shortest path to the player over tiles enemies can enter, if there is one. */
-export function stepToward(s: GameState, e: Enemy): Step | null {
+/**
+ * The first step of a shortest path over tiles enemies can enter toward what they chase: the
+ * decoy while there is one, otherwise the player. Null if there is no way.
+ */
+export function stepToward(s: GameState, w: Walker): Step | null {
+  const goal = s.decoy?.pos ?? s.player
   const key = (p: Vec) => p.y * s.width + p.x
   const first = new Map<number, Step>()
   const queue: Vec[] = []
-  for (const st of openSteps(s, e)) {
-    if (eq(st.to, s.player)) return st
+  for (const st of openSteps(s, w)) {
+    if (eq(st.to, goal)) return st
     first.set(key(st.to), st)
     queue.push(st.to)
   }
@@ -48,36 +66,58 @@ export function stepToward(s: GameState, e: Enemy): Step | null {
     const p = queue[i]!
     for (const d of DIR_LIST) {
       const q = add(p, DIRS[d])
-      if (first.has(key(q)) || eq(q, e.pos) || !enemyCanEnter(s, q)) continue
+      if (first.has(key(q)) || eq(q, w.pos) || !enemyCanEnter(s, q)) continue
       first.set(key(q), first.get(key(p))!)
-      if (eq(q, s.player)) return first.get(key(q))!
+      if (eq(q, goal)) return first.get(key(q))!
       queue.push(q)
     }
   }
   return null
 }
 
-const PLANNERS: Record<EnemyKind, (s: GameState, e: Enemy, rng: Rng) => Step | null> = {
+const PLANNERS: Record<EnemyKind, (s: GameState, w: Walker, rng: Rng) => Step | null> = {
   blob: wander,
   // Seeker: half the time a step along the shortest path to the player, otherwise a Blob's wander.
-  seeker: (s, e, rng) => (rng.next() < 0.5 ? stepToward(s, e) : null) ?? wander(s, e, rng),
+  seeker: (s, w, rng) => (rng.next() < 0.5 ? stepToward(s, w) : null) ?? wander(s, w, rng),
   drifter: drift,
 }
 
-/** Phase 5: every enemy announces where it will step next turn. */
+/**
+ * Phase 5: every enemy tops its plan up to PLAN_AHEAD moves, and those are the moves it will
+ * make. Planned steps the board no longer allows (a bomb now in the way) are dropped first,
+ * along with everything after them, and planned again from there. The steps are worked out on
+ * the board as it is now, so a later bomb can still block one.
+ */
 export function planIntents(s: GameState): void {
   const rng = new Rng(s.aiRng)
   for (const e of s.enemies) {
-    const st = PLANNERS[e.kind](s, e, rng)
-    e.intent = st ? st.to : null
-    if (st) e.dir = st.dir
+    let from = e.pos
+    const kept: Vec[] = []
+    for (const p of e.plan) {
+      if (!enemyCanEnter(s, p) || Math.abs(p.x - from.x) + Math.abs(p.y - from.y) !== 1) break
+      kept.push(p)
+      from = p
+    }
+    // Carry on from the last kept step, heading the way that step went.
+    const last = kept.at(-1)
+    const w: Walker = { pos: from, dir: last ? dirBetween(kept.at(-2) ?? e.pos, last) : e.dir }
+    e.plan = kept
+    while (e.plan.length < PLAN_AHEAD) {
+      const st = PLANNERS[e.kind](s, w, rng)
+      if (!st) break
+      e.plan.push(st.to)
+      w.pos = st.to
+      w.dir = st.dir
+    }
+    e.dir = w.dir
   }
   s.aiRng = rng.state
 }
 
 /**
- * Phase 2: every enemy steps where it announced. One whose tile was blocked this turn (a bomb
- * placed there) stays put and does not re-plan. Frozen, they all stay put once.
+ * Phase 2: every enemy takes the first move of its plan. One whose tile was blocked this turn
+ * (a bomb placed there) stays put and does not re-plan until phase 5, which plans it afresh.
+ * Frozen, they all stay put once and keep their plans.
  */
 export function moveEnemies(s: GameState, events: GameEvent[]): void {
   if (s.frozen) {
@@ -85,9 +125,15 @@ export function moveEnemies(s: GameState, events: GameEvent[]): void {
     return
   }
   for (const e of s.enemies) {
-    if (!e.intent || !enemyCanEnter(s, e.intent)) continue
-    events.push({ type: 'enemyMoved', id: e.id, from: e.pos, to: e.intent })
-    e.pos = e.intent
+    const next = e.plan[0]
+    if (!next) continue
+    if (!enemyCanEnter(s, next)) {
+      e.plan = []
+      continue
+    }
+    events.push({ type: 'enemyMoved', id: e.id, from: e.pos, to: next })
+    e.pos = next
+    e.plan.shift()
   }
 }
 
@@ -98,6 +144,14 @@ const DROP_CHANCE: Record<EnemyKind, { coins: number; charge: number }> = {
   drifter: { coins: 0.2, charge: 0.5 },
 }
 
+/** Teleport is rare (PLAN.md §3.3): half as likely as each other charge. */
+function pickCharge(rng: Rng): ChargeId {
+  const weight = (id: ChargeId) => (id === 'teleport' ? 0.5 : 1)
+  let r = rng.next() * CHARGE_IDS.reduce((sum, id) => sum + weight(id), 0)
+  for (const id of CHARGE_IDS) if ((r -= weight(id)) < 0) return id
+  return CHARGE_IDS[CHARGE_IDS.length - 1]!
+}
+
 /** What a slain enemy leaves on its tile. Coins are worth 1 (60%), 2 (30%) or 5 (10%). */
 export function rollDrops(s: GameState, e: Enemy): Drop[] {
   if (e.spawned) return []
@@ -109,7 +163,7 @@ export function rollDrops(s: GameState, e: Enemy): Drop[] {
     drops.push({ pos: e.pos, kind: 'coins', amount: r < 0.6 ? 1 : r < 0.9 ? 2 : 5 })
   }
   if (rng.next() < chance.charge) {
-    drops.push({ pos: e.pos, kind: CHARGE_IDS[rng.int(CHARGE_IDS.length)]! })
+    drops.push({ pos: e.pos, kind: pickCharge(rng) })
   }
   s.dropRng = rng.state
   return drops
diff --git a/src/sim/explosions.ts b/src/sim/explosions.ts
index 641c5b7..0f44f06 100644
--- a/src/sim/explosions.ts
+++ b/src/sim/explosions.ts
@@ -4,8 +4,9 @@ import { DIRS, DIR_LIST, type Bomb, type GameEvent, type GameState, type Vec } f
 /**
  * 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.
  */
-export function blastTiles(s: GameState, bomb: Pick<Bomb, 'pos' | 'fire'>): Vec[] {
+export function blastTiles(s: GameState, bomb: Pick<Bomb, 'pos' | 'fire' | 'special'>): Vec[] {
   const tiles = [bomb.pos]
   for (const d of DIR_LIST) {
     let p = bomb.pos
@@ -14,7 +15,7 @@ export function blastTiles(s: GameState, bomb: Pick<Bomb, 'pos' | 'fire'>): Vec[
       const cell = cellAt(s, p)
       if (cell === 'wall') break
       tiles.push(p)
-      if (cell === 'block' || bombAt(s, p)) break
+      if ((cell === 'block' && bomb.special !== 'pierce') || bombAt(s, p)) break
     }
   }
   return tiles
diff --git a/src/sim/path.ts b/src/sim/path.ts
new file mode 100644
index 0000000..ad3f456
--- /dev/null
+++ b/src/sim/path.ts
@@ -0,0 +1,30 @@
+import { add, eq, isFree } from './state'
+import { DIRS, DIR_LIST, type GameState, type Vec } from './types'
+
+/**
+ * The shortest walk for the player to `to` over free tiles (floor, no bomb), for auto-move. The
+ * result leaves out the player's own tile and ends at `to`; null if `to` can't be reached.
+ * Enemies don't block the path, because they will have moved by the time the player gets there;
+ * the UI refuses a step onto an enemy instead.
+ */
+export function playerPath(s: GameState, to: Vec): Vec[] | null {
+  if (eq(s.player, to) || !isFree(s, to)) return null
+  const key = (p: Vec) => p.y * s.width + p.x
+  const prev = new Map<number, Vec>([[key(s.player), s.player]])
+  const queue = [s.player]
+  for (let i = 0; i < queue.length; i++) {
+    const p = queue[i]!
+    for (const d of DIR_LIST) {
+      const q = add(p, DIRS[d])
+      if (prev.has(key(q)) || !isFree(s, q)) continue
+      prev.set(key(q), p)
+      if (eq(q, to)) {
+        const path = [q]
+        for (let r = p; !eq(r, s.player); r = prev.get(key(r))!) path.unshift(r)
+        return path
+      }
+      queue.push(q)
+    }
+  }
+  return null
+}
diff --git a/src/sim/record.ts b/src/sim/record.ts
index 4205197..dc582a5 100644
--- a/src/sim/record.ts
+++ b/src/sim/record.ts
@@ -6,7 +6,7 @@ import type { Action, GameState } from './types'
  * Bumped whenever a rule change would make an old record replay differently. Records from
  * another version can't be trusted to replay (PLAN.md §11).
  */
-export const SIM_VERSION = 2
+export const SIM_VERSION = 3
 
 /** Everything needed to reproduce a run exactly: mid-run saves, replays, certificates. */
 export interface RunRecord {
diff --git a/src/sim/state.ts b/src/sim/state.ts
index fe03ce8..87b7d7e 100644
--- a/src/sim/state.ts
+++ b/src/sim/state.ts
@@ -1,7 +1,7 @@
 import { planIntents } from './enemies'
 import { generateLevel } from './levelgen'
 import { hashSeed } from './rng'
-import type { Bomb, Cell, ChargeId, GameState, Stats, Vec } from './types'
+import { CHARGE_IDS, type Bomb, type Cell, type ChargeId, type GameState, type Stats, type Vec } from './types'
 
 export const START_LIVES = 3
 export const START_STATS: Stats = { capacity: 1, fire: 1, fuse: 3 }
@@ -17,14 +17,18 @@ export interface Carry {
   charges: Record<ChargeId, number>
 }
 
+/** `n` of every action charge. */
+export function charges(n: number): Record<ChargeId, number> {
+  return Object.fromEntries(CHARGE_IDS.map((id) => [id, n])) as Record<ChargeId, number>
+}
+
 export function noCharges(): Record<ChargeId, number> {
-  return { kick: 0, throw: 0, shield: 0, freeze: 0 }
+  return charges(0)
 }
 
-/** `charges`: start with this many of every action charge (a testing aid, `?charges=` in the URL). */
-export function newRun(seed: number, level = 1, charges = 0): GameState {
-  const all = { kick: charges, throw: charges, shield: charges, freeze: charges }
-  return createLevel(seed, level, { lives: START_LIVES, stats: { ...START_STATS }, coins: 0, charges: all })
+/** `n`: start with this many of every action charge (a testing aid, `?charges=` in the URL). */
+export function newRun(seed: number, level = 1, n = 0): GameState {
+  return createLevel(seed, level, { lives: START_LIVES, stats: { ...START_STATS }, coins: 0, charges: charges(n) })
 }
 
 /**
@@ -37,9 +41,9 @@ export function createLevel(seed: number, level: number, carry: Carry): GameStat
     seed, level, turn: 0, width, height, cells, player: start,
     lives: carry.lives, stats: { ...carry.stats }, coins: carry.coins, charges: { ...carry.charges },
     bombs: [], status: 'playing',
-    enemies: enemies.map((e, id) => ({ id, kind: e.kind, pos: e.pos, dir: e.dir, intent: null, spawned: false })),
+    enemies: enemies.map((e, id) => ({ id, kind: e.kind, pos: e.pos, dir: e.dir, plan: [], spawned: false })),
     nextEnemyId: enemies.length,
-    drops: [], exit, par, shield: false, frozen: false,
+    drops: [], exit, par, shield: false, frozen: false, decoy: null, burning: [],
     aiRng: hashSeed(seed, 'ai', level), dropRng: hashSeed(seed, 'drops', level),
   }
   planIntents(s)
@@ -88,3 +92,13 @@ export function bombAt(s: GameState, p: Vec): Bomb | undefined {
 export function isFree(s: GameState, p: Vec): boolean {
   return cellAt(s, p) === 'floor' && !bombAt(s, p)
 }
+
+/** Still on fire from last turn's explosion (see `GameState.burning`). */
+export function isBurning(s: GameState, p: Vec): boolean {
+  return inBounds(s, p) && s.burning.includes(idx(s, p))
+}
+
+/** Where the player can step or land: free and not on fire. */
+export function playerCanEnter(s: GameState, p: Vec): boolean {
+  return isFree(s, p) && !isBurning(s, p)
+}
diff --git a/src/sim/turn.ts b/src/sim/turn.ts
index 454fa75..de845cc 100644
--- a/src/sim/turn.ts
+++ b/src/sim/turn.ts
@@ -1,4 +1,4 @@
-import { ACTIONS, isAvailable, isCharge, isValidTarget } from './actions'
+import { ACTIONS, chargeOf, isAvailable, isValidTarget } from './actions'
 import { enemyAt, moveEnemies, planIntents, rollDrops } from './enemies'
 import { enemyKinds } from './levelgen'
 import { detonate, tickBombs } from './explosions'
@@ -39,9 +39,10 @@ export function step(prev: GameState, action: Action): StepResult {
   if (def.needsTarget && (!action.target || !isValidTarget(s, action.id, action.target))) {
     throw new Error(`invalid target for ${action.id}: ${JSON.stringify(action.target)}`)
   }
-  if (isCharge(action.id)) {
-    s.charges[action.id]--
-    events.push({ type: 'chargeUsed', id: action.id })
+  const charge = chargeOf(action.id)
+  if (charge) {
+    s.charges[charge]--
+    events.push({ type: 'chargeUsed', id: charge })
   }
   def.apply(s, action.target, events)
   pickUp(s, events)
@@ -78,20 +79,23 @@ export function step(prev: GameState, action: Action): StepResult {
     if (enemyAt(s, s.player)) hurt()
   }
 
-  // Phase 3: bombs tick; those at 0 explode, in chains.
+  // Phase 3: bombs tick; those at 0 explode, in chains. Last turn's flame is still burning too.
+  const lingering = new Set(s.burning)
   const exitWasOpen = cellAt(s, s.exit) === 'floor'
   const enemiesBefore = s.enemies.length
   tickBombs(s, events)
   const burned = detonate(s, events)
 
-  // Phase 4: damage. Flame burns the player, enemies, and drops lying in the open; slain enemies
-  // then leave their own drops, so those survive the flame that made them.
-  if (s.status === 'playing' && burned.has(idx(s, s.player))) hurt()
+  // Phase 4: damage. New flame and lingering flame both burn creatures: the player, enemies and
+  // the decoy. Only new flame burns drops lying in the open; slain enemies then leave their own
+  // drops, so those survive the flame that made them (and its lingering).
+  const fire = new Set([...burned, ...lingering])
+  if (s.status === 'playing' && fire.has(idx(s, s.player))) hurt()
   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 })
   }
-  for (const e of s.enemies.filter((e) => burned.has(idx(s, e.pos)))) {
+  for (const e of s.enemies.filter((e) => fire.has(idx(s, e.pos)))) {
     s.enemies.splice(s.enemies.indexOf(e), 1)
     events.push({ type: 'enemyDied', id: e.id, pos: e.pos })
     for (const drop of rollDrops(s, e)) {
@@ -99,19 +103,30 @@ export function step(prev: GameState, action: Action): StepResult {
       events.push({ type: 'dropSpawned', drop })
     }
   }
+  if (s.decoy && fire.has(idx(s, s.decoy.pos))) {
+    events.push({ type: 'decoyGone', pos: s.decoy.pos, burned: true })
+    s.decoy = null
+  }
   if (!exitWasOpen && cellAt(s, s.exit) === 'floor') events.push({ type: 'exitRevealed', pos: s.exit })
   // Flaming an uncovered exit lets out a group of the level's toughest type (NES rule, PLAN.md §5).
   if (exitWasOpen && burned.has(idx(s, s.exit))) {
     const kinds = enemyKinds(s.level)
     for (let n = 0; n < EXIT_SPAWN; n++) {
-      s.enemies.push({ id: s.nextEnemyId++, kind: kinds[kinds.length - 1]!, pos: { ...s.exit }, dir: 'up', intent: null, spawned: true })
+      s.enemies.push({ id: s.nextEnemyId++, kind: kinds[kinds.length - 1]!, pos: { ...s.exit }, dir: 'up', plan: [], spawned: true })
     }
     events.push({ type: 'enemiesSpawned', pos: s.exit, count: EXIT_SPAWN })
   }
   if (enemiesBefore > 0 && s.enemies.length === 0) events.push({ type: 'exitOpened', pos: s.exit })
 
-  // Phase 5: enemies announce their next intents.
+  // This turn's flame lingers through the next turn; last turn's goes out.
+  s.burning = [...burned]
+
+  // Phase 5: enemies announce their next intents. A decoy lasts a fixed number of these.
   planIntents(s)
+  if (s.decoy && --s.decoy.turns <= 0) {
+    events.push({ type: 'decoyGone', pos: s.decoy.pos, burned: false })
+    s.decoy = null
+  }
 
   s.turn++
   return { state: s, events }
diff --git a/src/sim/types.ts b/src/sim/types.ts
index 8ab9c96..c6012b1 100644
--- a/src/sim/types.ts
+++ b/src/sim/types.ts
@@ -31,6 +31,8 @@ export interface Bomb {
   fire: number
   /** The turn it was placed on. A bomb is not ticked on the turn it is placed. */
   placedTurn: number
+  /** Pierce: its flame goes through soft blocks, burning them all. Power: fire to the walls. */
+  special?: 'pierce' | 'power'
 }
 
 export interface Stats {
@@ -55,18 +57,34 @@ export interface Enemy {
   id: number
   kind: EnemyKind
   pos: Vec
-  /** The way it last moved or meant to move. Blobs and Drifters keep going this way. */
+  /** The way its last planned move goes. Blobs and Drifters keep going this way. */
   dir: Dir
-  /** Where it will step next turn (phase 2), announced in phase 5. Null: it stays put. */
-  intent: Vec | null
+  /**
+   * Its next moves, up to PLAN_AHEAD tiles, first one first (announced in phase 5, taken one
+   * per turn in phase 2). Empty: it has nowhere to go and stays put.
+   */
+  plan: Vec[]
   /** Spawned as a punishment (the exit was flamed): drops nothing. */
   spawned: boolean
 }
 
-/** The consumable actions dropped by enemies (PLAN.md §3.3). The first four of the plan's list. */
-export type ChargeId = 'kick' | 'throw' | 'shield' | 'freeze'
+/** The consumable action charges dropped by enemies (PLAN.md §3.3). */
+export type ChargeId =
+  | 'kick' | 'throw' | 'shield' | 'freeze' | 'fuse' | 'diagonal' | 'teleport' | 'pierce' | 'power' | 'line' | 'decoy'
 
-export const CHARGE_IDS: readonly ChargeId[] = ['kick', 'throw', 'shield', 'freeze']
+export const CHARGE_IDS: readonly ChargeId[] = [
+  'kick', 'throw', 'shield', 'freeze', 'fuse', 'diagonal', 'teleport', 'pierce', 'power', 'line', 'decoy',
+]
+
+/** The actions charges give: one each, except a Fuse charge, which is spent by Fuse +1 or Fuse −1. */
+export type ChargeActionId = Exclude<ChargeId, 'fuse'> | 'fuseUp' | 'fuseDown'
+
+/** A stand-in for the player that chasing enemies go after instead, for a few turns. */
+export interface Decoy {
+  pos: Vec
+  /** Enemy plannings left before it disappears. */
+  turns: number
+}
 
 /** Something lying on a floor tile to walk over and collect: coins, or one action charge. */
 export type Drop = { pos: Vec; kind: 'coins'; amount: number } | { pos: Vec; kind: ChargeId }
@@ -98,13 +116,20 @@ export interface GameState {
   shield: boolean
   /** Enemies skip their next move (Freeze). */
   frozen: boolean
+  /**
+   * Tile indices still on fire from last turn's explosions. Flame lingers one extra turn: it
+   * kills creatures that are in it or walk into it this turn, and the player can't step or put
+   * a bomb there. It doesn't burn crates, drops or bombs a second time.
+   */
+  burning: number[]
+  decoy: Decoy | null
   /** The AI and drop random streams' positions (`Rng.state`), so a turn is a pure function of the state. */
   aiRng: number
   dropRng: number
 }
 
 /** The actions the player can pick on the board. */
-export type ActionId = 'move' | 'bomb' | 'wait' | ChargeId
+export type ActionId = 'move' | 'bomb' | 'wait' | ChargeActionId
 
 export type Action = { id: ActionId; target?: Vec } | { id: 'continue' }
 
@@ -131,3 +156,6 @@ export type GameEvent =
   | { type: 'chargeUsed'; id: ChargeId }
   | { type: 'bombKicked'; from: Vec; to: Vec }
   | { type: 'shieldBroke'; pos: Vec }
+  | { type: 'fuseChanged'; pos: Vec; timer: number }
+  | { type: 'decoyPlaced'; pos: Vec }
+  | { type: 'decoyGone'; pos: Vec; burned: boolean }
diff --git a/src/style.css b/src/style.css
index 3d8d85e..98005ad 100644
--- a/src/style.css
+++ b/src/style.css
@@ -148,7 +148,17 @@ img {
   font-size: 13px;
 }
 
+/* Icon above the name, so names like "Teleport" fit four to a row on a phone. */
+#actions button.charge {
+  grid-template-columns: auto;
+  justify-items: center;
+  row-gap: 0;
+  padding: 2px 2px 4px;
+  white-space: nowrap;
+}
+
 #actions button.charge .icon {
+  grid-row: auto;
   width: 26px;
   height: 26px;
 }
@@ -181,9 +191,6 @@ img {
     display: none;
   }
 
-  #actions button.charge .icon {
-    grid-row: auto;
-  }
 }
 
 #actions button:active:not(:disabled) {
@@ -191,6 +198,12 @@ img {
   box-shadow: none;
 }
 
+/* Auto-move: Move has become "Go", one step toward the flag per press. */
+#actions button.auto {
+  border-color: var(--accent-dark);
+  box-shadow: 0 4px 0 var(--accent-dark);
+}
+
 #actions button[aria-pressed='true'] {
   background: var(--accent);
   color: #fff;
diff --git a/src/ui/controls.ts b/src/ui/controls.ts
index 8f0595b..a040d03 100644
--- a/src/ui/controls.ts
+++ b/src/ui/controls.ts
@@ -1,4 +1,4 @@
-import { ACTIONS, ACTION_ORDER, isAvailable, isCharge } from '../sim/actions'
+import { ACTIONS, ACTION_ORDER, chargeOf, isAvailable } from '../sim/actions'
 import { PAR_BONUS, START_LIVES } from '../sim/state'
 import type { ActionId, GameState } from '../sim/types'
 import { CHARGE_ICONS, spriteUrl } from '../render/sprites'
@@ -12,10 +12,12 @@ export interface Handlers {
 }
 
 export const KEYS: Record<ActionId, string> = {
-  move: 'WASD', bomb: 'B', wait: 'Space', kick: 'K', throw: 'T', shield: 'H', freeze: 'F',
+  move: 'WASD', bomb: 'B', wait: 'Space', kick: 'K', throw: 'T', shield: 'H', freeze: 'F', fuseUp: '=', fuseDown: '−',
+  diagonal: 'G', teleport: 'P', pierce: 'I', power: 'O', line: 'L', decoy: 'Y',
 }
 const ICONS: Record<ActionId, string> = {
   move: 'character_green_walk_a', bomb: 'bomb', wait: 'character_green_duck', ...CHARGE_ICONS,
+  fuseUp: CHARGE_ICONS.fuse, fuseDown: CHARGE_ICONS.fuse,
 }
 
 function icon(name: string, className = 'icon'): string {
@@ -34,9 +36,9 @@ export function createControls(handlers: Handlers) {
     const b = document.createElement('button')
     b.type = 'button'
     b.dataset.action = id
-    if (isCharge(id)) b.classList.add('charge')
+    if (chargeOf(id)) b.classList.add('charge')
     b.innerHTML = `${icon(ICONS[id])}<span class="label">${ACTIONS[id].label}</span><kbd>${KEYS[id]}</kbd>`
-    if (isCharge(id)) b.insertAdjacentHTML('beforeend', '<span class="count"></span>')
+    if (chargeOf(id)) b.insertAdjacentHTML('beforeend', '<span class="count"></span>')
     b.addEventListener('click', () => handlers.pick(id))
     bar.append(b)
     buttons.set(id, b)
@@ -58,14 +60,21 @@ export function createControls(handlers: Handlers) {
   }
 
   return {
-    update(s: GameState, selected: ActionId | null): void {
+    /** `autoMove`: a destination is set, so Move takes one step toward it. */
+    update(s: GameState, selected: ActionId | null, autoMove: boolean): void {
       for (const [id, b] of buttons) {
         b.disabled = !isAvailable(s, id)
         b.setAttribute('aria-pressed', String(selected === id))
+        if (id === 'move') {
+          b.querySelector('.label')!.textContent = autoMove ? 'Go' : ACTIONS.move.label
+          b.querySelector('.icon')!.setAttribute('src', spriteUrl(autoMove ? 'flag_green_a' : ICONS.move))
+          b.classList.toggle('auto', autoMove)
+        }
         // Charges only get a button while the player holds some; a badge shows how many.
-        if (isCharge(id)) {
-          b.hidden = s.charges[id] === 0
-          b.querySelector('.count')!.textContent = `×${s.charges[id]}`
+        const charge = chargeOf(id)
+        if (charge) {
+          b.hidden = s.charges[charge] === 0
+          b.querySelector('.count')!.textContent = `×${s.charges[charge]}`
         }
       }
       // Lives lost from the starting three show as empty hearts; extra lives add full ones.
diff --git a/test/charges.test.ts b/test/charges.test.ts
new file mode 100644
index 0000000..25489e7
--- /dev/null
+++ b/test/charges.test.ts
@@ -0,0 +1,171 @@
+import { describe, expect, it } from 'vitest'
+import { ACTIONS, DECOY_TURNS, isAvailable, TELEPORT_RANGE } from '../src/sim/actions'
+import { stepToward } from '../src/sim/enemies'
+import { blastTiles } from '../src/sim/explosions'
+import { step } from '../src/sim/turn'
+import { board, v } from './helpers'
+
+describe('Fuse +1 / Fuse −1', () => {
+  it('share one Fuse charge and change any bomb by one', () => {
+    const s = board(`
+      #######
+      #P..3.#
+      #######`)
+    s.charges.fuse = 1
+    expect(isAvailable(s, 'fuseUp') && isAvailable(s, 'fuseDown')).toBe(true)
+    const up = step(s, { id: 'fuseUp', target: v(4, 1) })
+    // 3 + 1, then the turn's tick takes 1 off again.
+    expect(up.events).toContainEqual({ type: 'fuseChanged', pos: v(4, 1), timer: 4 })
+    expect(up.state.bombs[0]!.timer).toBe(3)
+    expect(up.state.charges.fuse).toBe(0)
+    expect(isAvailable(up.state, 'fuseDown')).toBe(false)
+  })
+
+  it('−1 cannot take a bomb below 1', () => {
+    const s = board(`
+      ######
+      #P.1.#
+      ######`)
+    s.charges.fuse = 1
+    expect(ACTIONS.fuseDown.validTargets(s)).toEqual([])
+    expect(ACTIONS.fuseUp.validTargets(s)).toEqual([v(3, 1)])
+  })
+})
+
+describe('Diagonal', () => {
+  it('squeezes between two blocked tiles', () => {
+    const s = board(`
+      #####
+      #P+.#
+      #+..#
+      #####`)
+    s.charges.diagonal = 1
+    expect(ACTIONS.diagonal.validTargets(s)).toEqual([v(2, 2)])
+    expect(step(s, { id: 'diagonal', target: v(2, 2) }).state.player).toEqual(v(2, 2))
+  })
+})
+
+describe('Teleport', () => {
+  it(`reaches any free tile within ${TELEPORT_RANGE} steps as the crow walks, over anything`, () => {
+    const s = board(`
+      #########
+      #P#+...$#
+      #########`)
+    s.charges.teleport = 1
+    expect(ACTIONS.teleport.validTargets(s)).toEqual([v(4, 1)])
+    const { state } = step(s, { id: 'teleport', target: v(4, 1) })
+    expect(state.player).toEqual(v(4, 1))
+  })
+
+  it('picks up what is on the tile it lands on', () => {
+    const s = board(`
+      ######
+      #P#$.#
+      ######`)
+    s.charges.teleport = 1
+    expect(step(s, { id: 'teleport', target: v(3, 1) }).state.coins).toBe(1)
+  })
+})
+
+describe('Pierce and Power bombs', () => {
+  it('Pierce: the flame goes through soft blocks and burns them all', () => {
+    const s = board(`
+      ########
+      #P.++..#
+      ########`, { fire: 3 })
+    const pierce = { pos: v(2, 1), fire: 3, special: 'pierce' as const }
+    expect(blastTiles(s, pierce)).toEqual([v(2, 1), v(1, 1), v(3, 1), v(4, 1), v(5, 1)])
+    expect(blastTiles(s, { pos: v(2, 1), fire: 3 })).toEqual([v(2, 1), v(1, 1), v(3, 1)])
+  })
+
+  it('Pierce: placed like a bomb, marked, and burns every block in reach', () => {
+    let s = board(`
+      ########
+      #P.++..#
+      ########`, { fire: 3 })
+    s.charges.pierce = 1
+    s = step(s, { id: 'pierce', target: v(2, 1) }).state
+    expect(s.bombs[0]!.special).toBe('pierce')
+    s = step(s, { id: 'wait' }).state
+    s = step(s, { id: 'wait' }).state
+    const r = step(s, { id: 'wait' })
+    expect(r.events.filter((e) => e.type === 'blockDestroyed')).toHaveLength(2)
+  })
+
+  it('Power: the flame reaches all the way to the walls', () => {
+    const s = board(`
+      ##########
+      #P.......#
+      ##########`)
+    s.charges.power = 1
+    const { state } = step(s, { id: 'power', target: v(2, 1) })
+    expect(state.bombs[0]!.special).toBe('power')
+    expect(blastTiles(state, state.bombs[0]!)).toHaveLength(8)
+  })
+})
+
+describe('Line', () => {
+  it('places all remaining capacity in a row, starting next to the player', () => {
+    const s = board(`
+      ########
+      #P.....#
+      ########`, { capacity: 3 })
+    s.charges.line = 1
+    const { state } = step(s, { id: 'line', target: v(2, 1) })
+    expect(state.bombs.map((b) => b.pos)).toEqual([v(2, 1), v(3, 1), v(4, 1)])
+  })
+
+  it('stops where the row is blocked', () => {
+    const s = board(`
+      ######
+      #P..+#
+      ######`, { capacity: 5 })
+    s.charges.line = 1
+    expect(step(s, { id: 'line', target: v(2, 1) }).state.bombs).toHaveLength(2)
+  })
+})
+
+describe('Decoy', () => {
+  it('is placed next to the player and starts counting down', () => {
+    const s = board(`
+      #######
+      #P....#
+      #######`)
+    s.charges.decoy = 1
+    const { state, events } = step(s, { id: 'decoy', target: v(2, 1) })
+    expect(events).toContainEqual({ type: 'decoyPlaced', pos: v(2, 1) })
+    expect(state.decoy).toEqual({ pos: v(2, 1), turns: DECOY_TURNS - 1 })
+  })
+
+  it('chasing enemies head for it instead of the player', () => {
+    const s = board(`
+      #######
+      #P.S..#
+      #######`)
+    expect(stepToward(s, s.enemies[0]!)?.to).toEqual(v(2, 1))
+    s.decoy = { pos: v(5, 1), turns: 2 }
+    expect(stepToward(s, s.enemies[0]!)?.to).toEqual(v(4, 1))
+  })
+
+  it('disappears after its turns, or when flame reaches it', () => {
+    let s = board(`
+      #######
+      #P....#
+      #######`)
+    s.charges.decoy = 1
+    s = step(s, { id: 'decoy', target: v(2, 1) }).state
+    s = step(s, { id: 'wait' }).state
+    const last = step(s, { id: 'wait' })
+    expect(last.state.decoy).toBeNull()
+    expect(last.events).toContainEqual({ type: 'decoyGone', pos: v(2, 1), burned: false })
+
+    const burning = board(`
+      ######
+      #P.1.#
+      ######`)
+    burning.decoy = { pos: v(2, 1), turns: 3 }
+    const r = step(burning, { id: 'wait' })
+    expect(r.state.decoy).toBeNull()
+    expect(r.events).toContainEqual({ type: 'decoyGone', pos: v(2, 1), burned: true })
+  })
+})
diff --git a/test/danger.test.ts b/test/danger.test.ts
index ed6ece8..6bb4eec 100644
--- a/test/danger.test.ts
+++ b/test/danger.test.ts
@@ -16,8 +16,9 @@ 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. */
 function burnedBy(s: GameState): Set<number> {
-  const tiles = new 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)))
   return tiles
 }
diff --git a/test/enemies.test.ts b/test/enemies.test.ts
index 5458470..b51bcef 100644
--- a/test/enemies.test.ts
+++ b/test/enemies.test.ts
@@ -1,6 +1,6 @@
 import { describe, expect, it } from 'vitest'
 import { ACTIONS, isAvailable } from '../src/sim/actions'
-import { planIntents, stepToward } from '../src/sim/enemies'
+import { PLAN_AHEAD, planIntents, stepToward } from '../src/sim/enemies'
 import { enemyCount, enemyKinds, generateLevel, SAFE_DISTANCE, START } from '../src/sim/levelgen'
 import { newRun, PAR_BONUS } from '../src/sim/state'
 import { EXIT_SPAWN, step } from '../src/sim/turn'
@@ -35,16 +35,17 @@ describe('levelgen: enemies and exit', () => {
     expect(enemyKinds(50)).toEqual(['blob', 'seeker', 'drifter'])
   })
 
-  it('a new level already shows every enemy intent', () => {
+  it('a new level already shows every enemy plan, up to 3 moves', () => {
     const s = newRun(5)
     expect(s.enemies.length).toBeGreaterThan(0)
-    expect(s.enemies.some((e) => e.intent)).toBe(true)
+    expect(s.enemies.some((e) => e.plan.length === PLAN_AHEAD)).toBe(true)
+    expect(s.enemies.every((e) => e.plan.length <= PLAN_AHEAD)).toBe(true)
   })
 })
 
-/** Give every enemy an intent of one step in a direction. */
+/** Give every enemy a one-move plan: the nth enemy steps to the nth target next turn. */
 function intend(s: GameState, ...targets: Vec[]): GameState {
-  s.enemies.forEach((e, i) => (e.intent = targets[i]!))
+  s.enemies.forEach((e, i) => (e.plan = [targets[i]!]))
   return s
 }
 
@@ -124,12 +125,12 @@ describe('enemy movement', () => {
       #####`)
     s.enemies[0]!.kind = 'drifter'
     planIntents(s)
-    expect(s.enemies[0]!.intent).toEqual(v(2, 1))
+    expect(s.enemies[0]!.plan.slice(0, 2)).toEqual([v(2, 1), v(3, 1)])
     s.enemies[0]!.pos = v(3, 1)
+    s.enemies[0]!.plan = []
     s.cells[1 * s.width + 2] = 'block' // behind it, so the only way on is down
     planIntents(s)
-    expect(s.enemies[0]!.intent).toEqual(v(3, 2))
-    expect(s.enemies[0]!.dir).toBe('down')
+    expect(s.enemies[0]!.plan.slice(0, 2)).toEqual([v(3, 2), v(3, 3)])
   })
 
   it('an enemy with nowhere to go stays put', () => {
@@ -138,7 +139,7 @@ describe('enemy movement', () => {
       #B#P#
       #####`)
     planIntents(s)
-    expect(s.enemies[0]!.intent).toBeNull()
+    expect(s.enemies[0]!.plan).toEqual([])
   })
 
   it('the same state always plans the same intents', () => {
@@ -149,6 +150,101 @@ describe('enemy movement', () => {
   })
 })
 
+describe('plans three moves ahead', () => {
+  it('an enemy makes exactly the moves it showed', () => {
+    let s = newRun(5)
+    const e = s.enemies.find((e) => e.plan.length === PLAN_AHEAD)!
+    const shown = [...e.plan]
+    for (let t = 0; t < PLAN_AHEAD; t++) {
+      s = step(s, { id: 'wait' }).state
+      if (s.status !== 'playing') return
+      expect(s.enemies.find((x) => x.id === e.id)!.pos).toEqual(shown[t])
+    }
+  })
+
+  it('tops the plan up to 3 every turn', () => {
+    let s = newRun(5)
+    s = step(s, { id: 'wait' }).state
+    expect(s.enemies.some((e) => e.plan.length === PLAN_AHEAD)).toBe(true)
+  })
+
+  it('a later move the player blocks is dropped, with everything after it, and planned again', () => {
+    const s = board(`
+      ########
+      #P.....#
+      ########`)
+    s.enemies.push({ id: 0, kind: 'drifter', pos: v(6, 1), dir: 'left', plan: [v(5, 1), v(4, 1), v(3, 1)], spawned: false })
+    s.cells[1 * s.width + 4] = 'block' // the second move is now blocked
+    planIntents(s)
+    // Keeps (5,1); the Drifter, blocked going left, bounces back and forth from there.
+    expect(s.enemies[0]!.plan).toEqual([v(5, 1), v(6, 1), v(5, 1)])
+  })
+
+  it('frozen enemies keep their plans', () => {
+    const s = intend(board(`
+      ######
+      #P..B#
+      ######`), v(3, 1))
+    s.charges.freeze = 1
+    const { state } = step(s, { id: 'freeze' })
+    expect(state.enemies[0]!.plan[0]).toEqual(v(3, 1))
+  })
+})
+
+describe('lingering flame', () => {
+  it('stays burning for the next turn, then goes out', () => {
+    let s = board(`
+      #######
+      #P..1.#
+      #######`)
+    s = step(s, { id: 'wait' }).state
+    expect(s.burning.sort((a, b) => a - b)).toEqual([7 + 3, 7 + 4, 7 + 5])
+    s = step(s, { id: 'wait' }).state
+    expect(s.burning).toEqual([])
+  })
+
+  it('kills an enemy that walks into it the turn after', () => {
+    let s = board(`
+      ########
+      #P..1.B#
+      ########`)
+    s = step(s, { id: 'wait' }).state // explodes: tiles 3-5 burn; the Blob at 6 is untouched
+    expect(s.enemies).toHaveLength(1)
+    s.enemies[0]!.plan = [v(5, 1)]
+    const { state, events } = step(s, { id: 'wait' })
+    expect(state.enemies).toEqual([])
+    expect(events.map((e) => e.type)).toContain('enemyDied')
+  })
+
+  it('the player cannot step or put a bomb into it', () => {
+    let s = board(`
+      #######
+      #P.1..#
+      #######`)
+    s = step(s, { id: 'wait' }).state
+    expect(s.status).toBe('playing')
+    expect(ACTIONS.move.validTargets(s)).toEqual([])
+    expect(ACTIONS.bomb.validTargets(s)).toEqual([])
+  })
+
+  it('does not burn the drops its own kills left', () => {
+    let found = false
+    for (let r = 1; r < 200 && !found; r++) {
+      let s = board(`
+        #######
+        #P..1B#
+        #######`)
+      s.dropRng = r
+      s = step(s, { id: 'wait' }).state
+      if (!s.drops.length) continue
+      found = true
+      const after = step(s, { id: 'wait' })
+      expect(after.state.drops.length).toBe(s.drops.length)
+    }
+    expect(found).toBe(true)
+  })
+})
+
 describe('drops', () => {
   it('walking onto coins collects them', () => {
     const s = board(`
diff --git a/test/helpers.ts b/test/helpers.ts
index c59abc7..178debc 100644
--- a/test/helpers.ts
+++ b/test/helpers.ts
@@ -6,8 +6,8 @@ const ENEMIES: Record<string, EnemyKind> = { B: 'blob', S: 'seeker', D: 'drifter
 /**
  * A state from a picture: `#` wall, `+` soft block, `.` floor, `P` player (on floor),
  * `1`–`9` a bomb with that timer (on floor, placed on an earlier turn),
- * `B` / `S` / `D` a Blob / Seeker / Drifter facing right with no intent (it stays put unless the
- * test sets one), `E` the exit uncovered, `e` the exit under a soft block, `$` a 1-coin drop.
+ * `B` / `S` / `D` a Blob / Seeker / Drifter 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.
  * Without `E` or `e` the exit is off the board, so it never opens.
  */
 export function board(picture: string, stats: Partial<Stats> = {}): GameState {
@@ -20,7 +20,7 @@ export function board(picture: string, stats: Partial<Stats> = {}): GameState {
     seed: 1, level: 1, turn: 5, width, height, cells, player: { x: 0, y: 0 },
     lives: START_LIVES, stats: { ...START_STATS, ...stats }, bombs: [], status: 'playing',
     enemies: [], nextEnemyId: 0, drops: [], exit: { x: -1, y: -1 }, par: 100, coins: 0,
-    charges: noCharges(), shield: false, frozen: false, aiRng: 1, dropRng: 1,
+    charges: noCharges(), shield: false, frozen: false, decoy: null, burning: [], aiRng: 1, dropRng: 1,
   }
   rows.forEach((row, y) => {
     if (row.length !== width) throw new Error(`row ${y} is ${row.length} wide, not ${width}`)
@@ -29,7 +29,7 @@ export function board(picture: string, stats: Partial<Stats> = {}): GameState {
       if (ch === 'P') player = { x, y }
       if (/[1-9]/.test(ch)) s.bombs.push({ pos: { x, y }, timer: Number(ch), fire: s.stats.fire, placedTurn: 0 })
       const kind = ENEMIES[ch]
-      if (kind) s.enemies.push({ id: s.nextEnemyId++, kind, pos: { x, y }, dir: 'right', intent: null, spawned: false })
+      if (kind) s.enemies.push({ id: s.nextEnemyId++, kind, pos: { x, y }, dir: 'right', plan: [], spawned: false })
       if (ch === 'E' || ch === 'e') s.exit = { x, y }
       if (ch === '$') s.drops.push({ pos: { x, y }, kind: 'coins', amount: 1 })
     })
diff --git a/test/path.test.ts b/test/path.test.ts
new file mode 100644
index 0000000..a6a7a01
--- /dev/null
+++ b/test/path.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest'
+import { playerPath } from '../src/sim/path'
+import { board, v } from './helpers'
+
+describe('auto-move path', () => {
+  it('is the shortest walk round walls and crates, ending at the destination', () => {
+    const s = board(`
+      #######
+      #P.+..#
+      #.#.#.#
+      #.....#
+      #######`)
+    expect(playerPath(s, v(5, 1))).toEqual([v(1, 2), v(1, 3), v(2, 3), v(3, 3), v(4, 3), v(5, 3), v(5, 2), v(5, 1)])
+  })
+
+  it('goes round bombs', () => {
+    const s = board(`
+      #####
+      #P3.#
+      #...#
+      #####`)
+    expect(playerPath(s, v(3, 1))).toEqual([v(1, 2), v(2, 2), v(3, 2), v(3, 1)])
+  })
+
+  it('is null when the destination is walled off, blocked, or where the player stands', () => {
+    const s = board(`
+      ######
+      #P.+.#
+      ######`)
+    expect(playerPath(s, v(4, 1))).toBeNull()
+    expect(playerPath(s, v(3, 1))).toBeNull()
+    expect(playerPath(s, v(1, 1))).toBeNull()
+  })
+
+  it('goes through enemies, which will have moved by then', () => {
+    const s = board(`
+      ######
+      #PB..#
+      ######`)
+    expect(playerPath(s, v(4, 1))).toEqual([v(2, 1), v(3, 1), v(4, 1)])
+  })
+})
diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs
index f341cee..56f2232 100644
--- a/tools/screenshots.mjs
+++ b/tools/screenshots.mjs
@@ -19,12 +19,29 @@ const press = (...keys) => async (page) => {
   }
 }
 
+/** The screen position of a tile's centre, while the camera is at the left edge of the level. */
+async function tileCentre(page, x, y) {
+  return page.evaluate(([x, y]) => {
+    const top = document.getElementById('hud').getBoundingClientRect().height
+    const bottom = document.getElementById('actions').getBoundingClientRect().height
+    const size = (innerHeight - top - bottom) / 13
+    return { x: (x + 0.5) * size, y: top + (y + 0.5) * size }
+  }, [x, y])
+}
+
+const tap = (x, y) => async (page) => {
+  const p = await tileCentre(page, x, y)
+  await page.mouse.click(p.x, p.y)
+}
+
 const SHOTS = [
   { name: 'start', act: async () => {} },
   // Bomb picked: the tiles it can go on are highlighted.
   { name: 'bomb-picked', act: async (page) => { await page.click('[data-action=bomb]') } },
   // A bomb placed to the right, then a step down out of its way: its countdown and the danger overlay.
   { name: 'countdown', act: press('KeyB', 'KeyD', 'KeyS') },
+  // The bomb has gone off: its flame lingers on the board through the next turn.
+  { name: 'lingering', act: press('KeyB', 'KeyD', 'KeyS', 'Space', 'Space') },
   // Walked to the right a little way: the camera follows.
   { name: 'scrolled', act: press('KeyD', 'KeyD', 'KeyD', 'KeyD', 'KeyD', 'KeyD', 'KeyD', 'KeyD') },
   // Standing next to our own bomb as it goes off.
@@ -32,6 +49,20 @@ const SHOTS = [
   { name: 'desktop', viewport: DESKTOP, act: press('KeyB', 'KeyD', 'KeyS') },
   // Level 13 has all three enemy types so far, each with its intent arrow.
   { name: 'enemies', query: 'level=13', act: press('KeyD', 'KeyD', 'KeyD', 'KeyD', 'KeyD', 'KeyD') },
+  // Tapped a far tile: the flag, the walk there, and Move turned into Go; then one Go.
+  { name: 'auto-move', act: async (page) => { await tap(3, 3)(page); await page.click('[data-action=move]') } },
+  // Held a finger on the tile to the right: a bomb is armed there, with its blast, until release.
+  {
+    name: 'long-press',
+    act: async (page) => {
+      const p = await tileCentre(page, 2, 1)
+      await page.mouse.move(p.x, p.y)
+      await page.mouse.down()
+      await page.waitForTimeout(600)
+    },
+  },
+  // A Pierce bomb (spikes badge) placed to the right, then a Decoy (pale copy of the player) below.
+  { name: 'pierce-decoy', query: 'charges=2', act: press('KeyI', 'KeyD', 'KeyY', 'KeyS') },
   // Holding action charges: their buttons appear; Throw picked shows where a bomb can land.
   { name: 'charges', query: 'charges=2', act: async (page) => { await page.click('[data-action=throw]') } },
 ]