fuzzyworld

explodorama / commit

Exit door, par, enemies, drops and the first action charges (M4, M5)

c37a2b0b52cb11b308a62a4dad1db3694c50b4b1 · Pavel Safronov · 2026-10-20 09:46:59 -0700 · parents 644cfd0b6992

Claude Code usage: this commit ~118.2K out / ~19.0M cached · cost unknown (no per-model trailer) · session 1 tally 378 in / 198.7K out / 26.9M cached · usage by commit and session

Exit door, par, enemies, drops and the first action charges (M4, M5)

Exit hidden under a crate, locked until every enemy is dead; flaming it
releases 3 enemies. Par bonus of 5 coins. Blob, Seeker and Drifter with
intent arrows, both contact checks, coin and charge drops. Kick, Throw,
Shield and Freeze charges with count badges. HUD shows enemies left,
coins and turn / par. ?charges=N testing aid, kept in the run record.

Usage: 378 in / 198.7K out / 26.9M cached (session tally)
rtk:   1.7M saved, 25.2% over 2779 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 PLAN.md                          |  38 +++--
 README.md                        |  74 +++++++-
 assets/kenney/chain.png          | Bin 0 -> 1457 bytes
 assets/kenney/sign_right.png     | Bin 0 -> 1316 bytes
 assets/kenney/spring_out.png     | Bin 0 -> 2361 bytes
 assets/kenney/switch_blue.png    | Bin 0 -> 1490 bytes
 decisions.md                     |  18 ++
 docs/screenshots/bomb-picked.png | Bin 173420 -> 238948 bytes
 docs/screenshots/charges.png     | Bin 0 -> 261017 bytes
 docs/screenshots/countdown.png   | Bin 178477 -> 244041 bytes
 docs/screenshots/death.png       | Bin 165699 -> 197380 bytes
 docs/screenshots/desktop.png     | Bin 569343 -> 586370 bytes
 docs/screenshots/enemies.png     | Bin 0 -> 252615 bytes
 docs/screenshots/scrolled.png    | Bin 151868 -> 253517 bytes
 docs/screenshots/start.png       | Bin 172573 -> 237858 bytes
 index.html                       |   2 +
 src/main.ts                      |  26 ++-
 src/render/board.ts              |  56 +++++-
 src/render/sprites.ts            |  45 +++++
 src/sim/actions.ts               |  94 ++++++++++-
 src/sim/enemies.ts               | 116 +++++++++++++
 src/sim/levelgen.ts              |  48 +++++-
 src/sim/record.ts                |  10 +-
 src/sim/state.ts                 |  55 +++++-
 src/sim/turn.ts                  | 106 ++++++++++--
 src/sim/types.ts                 |  62 ++++++-
 src/style.css                    |  68 +++++++-
 src/ui/controls.ts               |  42 +++--
 test/danger.test.ts              |  11 +-
 test/enemies.test.ts             | 355 +++++++++++++++++++++++++++++++++++++++
 test/helpers.ts                  |  19 ++-
 tools/screenshots.mjs            |   8 +-
 32 files changed, 1151 insertions(+), 102 deletions(-)

diff --git a/PLAN.md b/PLAN.md
index 0c583eb..4c7ade7 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–M2 done, M3 mostly done** (see §18). This document is the design and the hand-off
+Status: **M0–M4 done, M5 well under way** (see §18). 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`.
@@ -107,13 +107,13 @@ 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. |
-| **Shield** | Absorb the next hit (flame or enemy). Shown as a ring around the player. Arming it costs the turn. |
+| **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. |
 | **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). |
 | **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 (intents greyed out). |
+| **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. |
 
 All charge actions can also appear in the shop (§8).
@@ -289,6 +289,11 @@ contact. Intent shown every turn. Adapted from the NES eight:
 
 - Until Reapers exist, "destroyed pickup/exit" spawns a group of the level's tougher types.
 - Base drop table: ~30–40% action charge, separate coin roll, rare skull from level 2. Tune.
+  *Built (2026-10-20):* per type — Blob 20% coins / 15% charge, Seeker 50% / 25%, Drifter
+  20% / 50%; a coin drop is worth 1 (60%), 2 (30%) or 5 (10%). Counts: 3 enemies on level 1,
+  +1 every 3 levels, at most 10. New type every 6 levels: Seeker from 7, Drifter from 13.
+  Enemies may share a tile (they don't block each other). Enemies spawned from a flamed exit
+  drop nothing, so the exit can't be farmed.
 - Drops land on the enemy's tile and stay until collected or flamed.
 - **Introduction schedule over 50 levels** (like the NES): Blob only at first; one new type
   every ~6 levels; later levels mix types. Exact table to be drafted in `design.md`.
@@ -305,7 +310,8 @@ contact. Intent shown every turn. Adapted from the NES eight:
 - **Camera:** all 13 rows always fit vertically (portrait phone is the primary target), about
   7 columns visible on a 390 × 844 phone (measured), since 13 rows must fill the height. Follows the player **horizontally**, eased between turns, clamped to the
   level edges. Landscape/desktop show more columns.
-- **Par:** each level has a turn budget; finishing under it pays a coin bonus.
+- **Par:** each level has a turn budget; finishing under it pays a coin bonus. *Built:* par =
+  50 + 10 × enemies, bonus 5 coins; the HUD shows `turn / par`.
   (Turn **limit** with Reapers: later milestone.)
 - **50 levels**, difficulty rising through enemy count, types, speed and soft-block density.
 - **Game over** at 0 lives → run summary (level reached, coins banked, kills, Keep/Fresh choices,
@@ -385,15 +391,17 @@ Sprite assignments (✅ = drawn now; the rest are copied and waiting for their f
 | ✅ soft block | `block_planks` (wooden crate) |
 | ✅ bomb | `bomb`; flashes to `bomb_active` on its last turn; timer drawn in `hud_character_0`–`9` |
 | ✅ flame | `fireball` (spinning, fading) over an orange tile |
-| ✅ HUD | `hud_heart` / `hud_heart_empty` (lives), `flag_green_a` (level), `bomb`, `fireball`, digits |
+| ✅ HUD | `hud_heart` / `hud_heart_empty` (lives), `flag_green_a` (level), `slime_normal_rest` (enemies left), `hud_coin` (coins), `bomb`, `fireball`, digits |
 | ✅ action buttons | Move `character_green_walk_a`, Bomb `bomb`, Wait `character_green_duck` |
-| ✅ banners | `character_green_hit` (lost a life), `hud_heart_empty` (game over); favicon `bomb` |
-| exit (closed / open) | `door_closed` + `door_closed_top`, `door_open` + `door_open_top`; `lock_yellow` |
-| coins | `coin_gold`, `coin_silver`, `coin_bronze`; `hud_coin` in the HUD |
+| ✅ 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) |
+| ✅ 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`, `_walk_a`, `_walk_b`, `_flat` (squashed when killed) |
-| Seeker | `fly_rest`, `fly_a`, `fly_b` |
-| Drifter | `saw_rest`, `saw_a`, `saw_b` |
+| ✅ Blob | `slime_normal_rest` (nowhere to go), `_walk_a` / `_walk_b` alternating; mirrored when heading right. `_flat` spare for a death animation. |
+| ✅ Seeker | `fly_rest`, `fly_a`, `fly_b`, as the Blob |
+| ✅ Drifter | `saw_rest`, `saw_a`, `saw_b`; spins while moving |
 | Sprinter | `mouse_rest`, `mouse_walk_a`, `mouse_walk_b` |
 | Ghost (passes soft blocks) | `slime_block_rest`, `_walk_a`, `_walk_b`, `_jump` |
 | Wisp | `slime_fire_rest`, `_walk_a`, `_walk_b`, `_flat` |
@@ -541,8 +549,12 @@ tools/                 icons.mjs (PWA icons), screenshots.mjs (Playwright)
 4. **M3 – Mobile UI:** ✅ action bar, two-tap targeting, highlights, danger overlay, commit preview
    (press-and-hold / hover), keyboard controls; Kenney sprites (2026-10-20). ⏳ No animation of moves yet
    (instant), only a flame flash.
-5. **M4 – World:** ✅ levelgen (31 × 13), side-scrolling camera. ⏳ Exit door, par.
-6. **M5 – Enemies:** Blob, Seeker, Drifter first; intents, drops, charge inventory, charge actions.
+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).
 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 ca216cb..a5f0b93 100644
--- a/README.md
+++ b/README.md
@@ -4,14 +4,19 @@ A turn-based, mobile-first, roguelike take on the 1985 NES bomb-laying classic.
 counts down with *your* moves, so nothing happens until you act — and you can see exactly
 what will burn before you commit.
 
-Status: **early development.** The board, moving, bombs, chain reactions and lives work.
-Enemies, pickups, the shop and the 50-level run are planned — see [PLAN.md](PLAN.md).
+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,
+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)
 
 ## How to play
 
-You are the green astronaut. Each turn you pick **one** action; then the world takes its turn.
+You are the green astronaut. **Goal of each level:** destroy every enemy, uncover the exit door
+hidden under one of the crates, and step onto it.
+
+Each turn you pick **one** action; then the world takes its turn, in this order: enemies step
+where their arrows pointed, bombs count down and explode, and enemies show their next step.
 
 | Action | Phone | Keyboard | What it does |
 |---|---|---|---|
@@ -19,11 +24,22 @@ You are the green astronaut. Each turn you pick **one** action; then the world t
 | **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. |
 
+**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.
+
+| Charge | Icon | Phone | Keyboard | What it does |
+|---|---|---|---|---|
+| **Kick** | spring | Tap **Kick**, then a bomb next to you | **K**, then a direction | The bomb slides away from you until the next tile is blocked (a wall, crate, bomb or enemy). It keeps its number. |
+| **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. |
+
 - Picking an action highlights the tiles you can choose, in blue. Tapping the action again,
   pressing **Esc**, or tapping anywhere else cancels it.
 - **Preview before you commit:** on a phone, press and hold a highlighted tile to see the
   result (for a bomb, its blast in orange); slide to another tile to change your mind; let go
-  to commit. With a mouse, hovering shows the same preview.
+  to commit. With a mouse, hovering shows the same preview. For a kick, the preview shows
+  where the bomb will stop.
 
 ### Bombs
 
@@ -32,17 +48,57 @@ You are the green astronaut. Each turn you pick **one** action; then the world t
 - 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).
-- Bombs block movement.
+- 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.
 
+### 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
+walking into you, or the two of you swapping places. Flame kills enemies.
+
+| Enemy | Looks like | Appears from | Moves |
+|---|---|---|---|
+| **Blob** | purple slime | level 1 | Wanders; usually keeps going the way it was heading. |
+| **Seeker** | fly | level 7 | Half the time takes the shortest way toward you; otherwise wanders like a Blob. |
+| **Drifter** | spinning saw | level 13 | Goes straight until blocked, then turns. |
+
+Level 1 has 3 enemies, with one more every 3 levels (at most 10). Enemies never start within
+4 tiles of you.
+
+### Drops and coins
+
+A slain enemy sometimes leaves **coins** (bronze = 1, silver = 2, gold = 5) or an **action
+charge** on its tile. Walk onto them to collect them. A later flame burns drops lying in the
+open, so pick them up soon. Coins will buy upgrades in the shop (not built yet).
+
+### The exit
+
+- The exit door is hidden under one of the crates. Burning that crate reveals it.
+- The door stays **locked** while any enemy is alive (top-bar enemy count). When the last one
+  dies, it opens; step onto it to finish the level.
+- **Don't blast the uncovered door:** flame on it lets out 3 more enemies (of the level's
+  toughest type). Those drop nothing.
+- **Par:** the top bar shows `turns taken / par`. Finish within par for a bonus of **5 coins**.
+  Par is 50 turns plus 10 per enemy on the level.
+
 ### Lives
 
-You have **3 lives**. Caught in a blast, you lose one and the level starts again with the same
-layout. With none left, the run is over and the seed is shown, so the same board can be replayed.
+You have **3 lives**. Caught in a blast or touched by an enemy, you lose one and the level starts
+again with the same layout and enemies. You keep your coins and charges. With none left, the run
+is over and the seed is shown, so the same board can be replayed.
+
+### The top bar
+
+Lives (hearts), level (flag), enemies left, coins, turn / par, and your bomb limit and blast size.
 
 ### The board
 
@@ -60,7 +116,9 @@ npm run build        # type-check and build into dist/
 npm run screenshots  # build, then photograph the game into docs/screenshots/ (Playwright)
 ```
 
-URL parameters: `?seed=123` plays a fixed board; `?level=4` starts at a later level.
+URL parameters: `?seed=123` plays a fixed board; `?level=4` starts at a later level (level 13
+has all three enemy types); `?charges=2` starts with 2 of every action charge, for trying them
+out.
 
 Deployed to GitLab Pages by `.gitlab-ci.yml` on every push to the default branch.
 
diff --git a/assets/kenney/chain.png b/assets/kenney/chain.png
new file mode 100644
index 0000000..b5763bf
Binary files /dev/null and b/assets/kenney/chain.png differ
diff --git a/assets/kenney/sign_right.png b/assets/kenney/sign_right.png
new file mode 100644
index 0000000..dbce9af
Binary files /dev/null and b/assets/kenney/sign_right.png differ
diff --git a/assets/kenney/spring_out.png b/assets/kenney/spring_out.png
new file mode 100644
index 0000000..8b98d0e
Binary files /dev/null and b/assets/kenney/spring_out.png differ
diff --git a/assets/kenney/switch_blue.png b/assets/kenney/switch_blue.png
new file mode 100644
index 0000000..3354688
Binary files /dev/null and b/assets/kenney/switch_blue.png differ
diff --git a/decisions.md b/decisions.md
index f26bba2..97329a6 100644
--- a/decisions.md
+++ b/decisions.md
@@ -74,3 +74,21 @@ the README credits Kenney anyway.
 - **Not pixelated.** `setTilesPixelated(false)`: the sprites are smooth art drawn at about
   100–110 device pixels from 128, which looks jagged without smoothing.
 - **Pale floor, not grass.** The player is mint green; a green floor hid them.
+
+### 2026-10-20 — Enemies, exit and charges: rules settled while building M4/M5
+
+- **Bombs can't be placed on an enemy's tile** (Bomb and Throw). Otherwise a bomb could sit
+  under an enemy, which the turn rules don't cover.
+- **Enemies don't block each other** and may share a tile. Simpler, and the NES did the same.
+- **Shield absorbs every hit for the rest of the turn it breaks in**, not just the first one, so
+  an enemy step followed by a flame in the same turn doesn't kill through a shield.
+- **Freeze cancels the move already announced**, in the same turn, rather than the one after.
+  The arrows the player sees are what Freeze stops, which is easier to read.
+- **Kicked bombs stop at enemies** as well as at walls, crates and bombs.
+- **Exit-spawned enemies drop nothing** (3 of the level's toughest type), so flaming the exit is
+  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.
+- **Death banner says "Ouch!"**, not "Boom.", since enemies can kill too now.
+
diff --git a/docs/screenshots/bomb-picked.png b/docs/screenshots/bomb-picked.png
index d5507da..b4b2f2d 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
new file mode 100644
index 0000000..0d2d81e
Binary files /dev/null and b/docs/screenshots/charges.png differ
diff --git a/docs/screenshots/countdown.png b/docs/screenshots/countdown.png
index 073e704..f0abe4b 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 54ee735..f009bae 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 947a818..ff4b38d 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
new file mode 100644
index 0000000..413e83f
Binary files /dev/null and b/docs/screenshots/enemies.png differ
diff --git a/docs/screenshots/scrolled.png b/docs/screenshots/scrolled.png
index 999adfd..119129c 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 c7b6252..0a69656 100644
Binary files a/docs/screenshots/start.png and b/docs/screenshots/start.png differ
diff --git a/index.html b/index.html
index a71dcf5..0ed27fa 100644
--- a/index.html
+++ b/index.html
@@ -15,6 +15,8 @@
 <header id="hud" aria-live="polite">
   <span id="hud-lives"></span>
   <span id="hud-level"></span>
+  <span id="hud-enemies"></span>
+  <span id="hud-coins"></span>
   <span id="hud-turn"></span>
   <span id="hud-stats"></span>
 </header>
diff --git a/src/main.ts b/src/main.ts
index 80fa620..8c590e5 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -16,7 +16,7 @@ import { TEXTURES } from './render/sprites'
 import { isAvailable, isValidTarget, ACTIONS } from './sim/actions'
 import { predictDanger, type Danger } from './sim/danger'
 import { newRecord, type RunRecord } from './sim/record'
-import { add, inBounds, newRun } from './sim/state'
+import { 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'
@@ -25,6 +25,8 @@ import { createControls } from './ui/controls'
 // seed — chosen here, never inside the sim.
 const params = new URLSearchParams(location.search)
 const startLevel = Number(params.get('level')) || 1
+// `?charges=2` starts with 2 of every action charge, to try them without hunting for drops.
+const startCharges = Number(params.get('charges')) || 0
 
 let record: RunRecord
 let state: GameState
@@ -36,8 +38,8 @@ let facingLeft = false
 let snapCamera = true
 
 function startRun(seed: number): void {
-  record = newRecord(seed, startLevel)
-  state = newRun(seed, startLevel)
+  record = newRecord(seed, startLevel, startCharges)
+  state = newRun(seed, startLevel, startCharges)
   afterChange()
 }
 
@@ -51,7 +53,7 @@ function act(action: Action): void {
   record.actions.push(action)
   for (const e of result.events) {
     if (e.type === 'exploded') flames.push({ tiles: e.tiles, born: time })
-    if (e.type === 'levelRestarted') snapCamera = true
+    if (e.type === 'levelRestarted' || e.type === 'levelStarted') snapCamera = true
     if (e.type === 'moved' && e.to.x !== e.from.x) facingLeft = e.to.x < e.from.x
   }
   selected = null
@@ -84,18 +86,22 @@ function pick(id: ActionId): void {
   }
 }
 
-/** A direction key: places a bomb that way if Bomb is picked, otherwise moves. */
+/**
+ * A direction key: uses the picked action that way (a bomb or kick next to the player, a throw
+ * 2 tiles away), or moves if nothing is picked.
+ */
 function direction(d: Dir): void {
   if (state.status !== 'playing') return
-  const p = add(state.player, DIRS[d])
-  const id: ActionId = selected === 'bomb' ? 'bomb' : 'move'
+  const id: ActionId = selected && ACTIONS[selected].needsTarget ? selected : 'move'
+  const reach = id === 'throw' ? 2 : 1
+  const p = { x: state.player.x + reach * DIRS[d].x, y: state.player.y + reach * DIRS[d].y }
   if (isValidTarget(state, id, p)) act({ id, target: p })
 }
 
 const controls = createControls({
   pick,
   banner() {
-    if (state.status === 'dead') act({ id: 'continue' })
+    if (state.status === 'dead' || state.status === 'cleared') act({ id: 'continue' })
     else if (state.status === 'gameOver') startRun(randomSeed())
   },
 })
@@ -107,12 +113,14 @@ 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' }
+
 window.addEventListener('keydown', (e) => {
   if (e.repeat) return
   const d = KEY_DIRS[e.code]
   if (d) direction(d)
-  else if (e.code === 'KeyB') pick('bomb')
   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
     afterChange()
diff --git a/src/render/board.ts b/src/render/board.ts
index 5277b15..b7f2d9e 100644
--- a/src/render/board.ts
+++ b/src/render/board.ts
@@ -1,10 +1,10 @@
-import { drawRect, drawTile, rgb, time, vec2, type Color, type Vector2 } from 'littlejsengine'
-import { ACTIONS } from '../sim/actions'
+import { drawCircle, drawLine, drawPoly, drawRect, drawTile, rgb, time, vec2, type Color, type Vector2 } from 'littlejsengine'
+import { ACTIONS, kickDestination } from '../sim/actions'
 import type { Danger } from '../sim/danger'
 import { blastTiles } from '../sim/explosions'
-import { eq, idx } from '../sim/state'
-import type { ActionId, GameState, Vec } from '../sim/types'
-import { digitSprite, sprite, type BoardSprite } from './sprites'
+import { cellAt, eq, exitOpen, idx } from '../sim/state'
+import type { ActionId, Enemy, GameState, Vec } from '../sim/types'
+import { CHARGE_ICONS, coinSprite, digitSprite, ENEMY_SPRITES, sprite, type BoardSprite } from './sprites'
 
 /**
  * The one place where sim tiles meet LittleJS world space. The sim's y grows downward
@@ -49,6 +49,9 @@ 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),
+  shield: rgb(0.25, 0.62, 0.87, 0.9),
+  clear: rgb(0, 0, 0, 0),
 }
 
 export const BACKGROUND: Color = C.sky
@@ -75,6 +78,7 @@ export function drawBoard(view: View): void {
       } else if (cell === 'block') drawTile(pos, tileSize, sprite('block_planks'))
     }
   }
+  if (cellAt(s, s.exit) === 'floor') drawExit(toWorld(s, s.exit), exitOpen(s))
 
   for (const i of danger.next) if (!danger.now.has(i)) drawRect(toWorld(s, tileOf(s, i)), vec2(0.94), C.dangerNext)
   for (const i of danger.now) drawRect(toWorld(s, tileOf(s, i)), vec2(0.94), C.dangerNow)
@@ -104,11 +108,47 @@ export function drawBoard(view: View): void {
     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 === 'bomb') drawBomb(toWorld(s, hovered), s.stats.fuse, 'bomb', C.ghost)
+  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)
+  }
+  if (hovered && (selected === 'bomb' || selected === 'throw')) drawBomb(toWorld(s, hovered), s.stats.fuse, 'bomb', 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()) {
+    const pos = toWorld(s, d.pos).add(vec2(0, 0.05 * Math.sin(time * 3 + i)))
+    drawTile(pos, vec2(0.62), sprite(d.kind === 'coins' ? coinSprite(d.amount) : CHARGE_ICONS[d.kind]))
+  }
+
+  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)
-  const alive = s.status === 'playing'
-  drawTile(toWorld(s, s.player), tileSize, sprite(alive ? 'character_green_idle' : 'character_green_hit'), C.white, 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)
+}
+
+/** The pack's door is two tiles tall; it is drawn narrower so both halves fit about one tile. */
+function drawExit(pos: Vector2, open: boolean): void {
+  const half = vec2(0.56)
+  drawTile(pos.add(vec2(0, -0.22)), half, sprite(open ? 'door_open' : 'door_closed'))
+  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. */
+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)
 }
 
 /** The bomb sprite with its timer on the body, which sits a little below and right of the tile's centre. */
diff --git a/src/render/sprites.ts b/src/render/sprites.ts
index 26e0571..d0a97ac 100644
--- a/src/render/sprites.ts
+++ b/src/render/sprites.ts
@@ -1,4 +1,5 @@
 import { tile, vec2, type TileInfo } from 'littlejsengine'
+import type { ChargeId, EnemyKind } from '../sim/types'
 
 /**
  * Art from Kenney's New Platformer Pack 1.1 (CC0), copied into `assets/kenney/` under the pack's
@@ -36,6 +37,26 @@ const BOARD = [
   'hud_character_7',
   'hud_character_8',
   'hud_character_9',
+  'door_closed',
+  'door_closed_top',
+  'door_open',
+  'door_open_top',
+  'coin_bronze',
+  'coin_silver',
+  'coin_gold',
+  'spring_out',
+  'sign_right',
+  'switch_blue',
+  'chain',
+  'slime_normal_rest',
+  'slime_normal_walk_a',
+  'slime_normal_walk_b',
+  'fly_rest',
+  'fly_a',
+  'fly_b',
+  'saw_rest',
+  'saw_a',
+  'saw_b',
 ] as const
 
 export type BoardSprite = (typeof BOARD)[number]
@@ -55,3 +76,27 @@ export function sprite(name: BoardSprite): TileInfo {
 export function digitSprite(d: number): TileInfo {
   return sprite(`hud_character_${d}` as BoardSprite)
 }
+
+/** The icon for each action charge, on the board (as a drop) and on its button. */
+export const CHARGE_ICONS: Record<ChargeId, BoardSprite> = {
+  kick: 'spring_out',
+  throw: 'sign_right',
+  shield: 'switch_blue',
+  freeze: 'chain',
+}
+
+/**
+ * Each enemy type's frames: `rest` (eyes shut) when it has nowhere to go, `a`/`b` alternating
+ * otherwise. `facesLeft`: the art looks left, so it is mirrored when the enemy heads right.
+ * `spins`: turned as it moves instead (the saw).
+ */
+export const ENEMY_SPRITES: Record<EnemyKind, { rest: BoardSprite; a: BoardSprite; b: BoardSprite; facesLeft: boolean; spins: boolean }> = {
+  blob: { rest: 'slime_normal_rest', a: 'slime_normal_walk_a', b: 'slime_normal_walk_b', facesLeft: true, spins: false },
+  seeker: { rest: 'fly_rest', a: 'fly_a', b: 'fly_b', facesLeft: true, spins: false },
+  drifter: { rest: 'saw_rest', a: 'saw_a', b: 'saw_b', facesLeft: false, spins: true },
+}
+
+/** Coin drops look like their value: bronze 1, silver 2, gold 5 or more. */
+export function coinSprite(amount: number): BoardSprite {
+  return amount >= 5 ? 'coin_gold' : amount >= 2 ? 'coin_silver' : 'coin_bronze'
+}
diff --git a/src/sim/actions.ts b/src/sim/actions.ts
index 32d96c4..015ab0b 100644
--- a/src/sim/actions.ts
+++ b/src/sim/actions.ts
@@ -1,5 +1,6 @@
-import { add, eq, isFree } from './state'
-import { DIRS, DIR_LIST, type ActionId, type GameEvent, type GameState, type Vec } from './types'
+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'
 
 /**
  * The board actions (PLAN.md §3). Adding one = an entry here, its tests, and a button.
@@ -10,6 +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
   validTargets(s: GameState): Vec[]
   apply(s: GameState, target: Vec | undefined, events: GameEvent[]): void
 }
@@ -18,6 +21,24 @@ 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. */
+function canHoldBomb(s: GameState, p: Vec): boolean {
+  return isFree(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 })
+  events.push({ type: 'bombPlaced', pos: { ...pos }, timer: s.stats.fuse })
+}
+
+/** 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 }
+  let p = from
+  while (canHoldBomb(s, add(p, d))) p = add(p, d)
+  return p
+}
+
 export const ACTIONS: Record<ActionId, ActionDef> = {
   move: {
     id: 'move',
@@ -34,12 +55,8 @@ export const ACTIONS: Record<ActionId, ActionDef> = {
     id: 'bomb',
     label: 'Bomb',
     needsTarget: true,
-    validTargets: (s) => (s.bombs.length >= s.stats.capacity ? [] : neighbours(s.player).filter((p) => isFree(s, p))),
-    apply(s, target, events) {
-      const pos = { ...target! }
-      s.bombs.push({ pos, timer: s.stats.fuse, fire: s.stats.fire, placedTurn: s.turn })
-      events.push({ type: 'bombPlaced', pos, timer: s.stats.fuse })
-    },
+    validTargets: (s) => (s.bombs.length >= s.stats.capacity ? [] : neighbours(s.player).filter((p) => canHoldBomb(s, p))),
+    apply: (s, target, events) => placeBomb(s, target!, events),
   },
   wait: {
     id: 'wait',
@@ -50,9 +67,63 @@ export const ACTIONS: Record<ActionId, ActionDef> = {
       events.push({ type: 'waited' })
     },
   },
+  kick: {
+    id: 'kick',
+    label: 'Kick',
+    needsTarget: true,
+    charge: true,
+    // 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) => {
+        const p = add(s.player, d)
+        return bombAt(s, p) && canHoldBomb(s, add(p, d)) ? [p] : []
+      }),
+    apply(s, target, events) {
+      const bomb = bombAt(s, target!)!
+      const from = bomb.pos
+      bomb.pos = kickDestination(s, from)
+      events.push({ type: 'bombKicked', from, to: bomb.pos })
+    },
+  },
+  throw: {
+    id: 'throw',
+    label: 'Throw',
+    needsTarget: true,
+    charge: true,
+    // Exactly 2 tiles away in a straight line, over anything in between.
+    validTargets: (s) =>
+      s.bombs.length >= s.stats.capacity
+        ? []
+        : DIR_LIST.map((d) => add(s.player, { x: 2 * DIRS[d].x, y: 2 * DIRS[d].y })).filter((p) => canHoldBomb(s, p)),
+    apply: (s, target, events) => placeBomb(s, target!, events),
+  },
+  shield: {
+    id: 'shield',
+    label: 'Shield',
+    needsTarget: false,
+    charge: true,
+    validTargets: () => [],
+    apply(s) {
+      s.shield = true
+    },
+  },
+  freeze: {
+    id: 'freeze',
+    label: 'Freeze',
+    needsTarget: false,
+    charge: true,
+    validTargets: () => [],
+    apply(s) {
+      s.frozen = true
+    },
+  },
 }
 
-export const ACTION_ORDER: readonly ActionId[] = ['move', 'bomb', 'wait']
+export const ACTION_ORDER: readonly ActionId[] = ['move', 'bomb', 'wait', ...CHARGE_IDS]
+
+export function isCharge(id: ActionId): id is ChargeId {
+  return ACTIONS[id].charge === true
+}
 
 export function isValidTarget(s: GameState, id: ActionId, target: Vec): boolean {
   return ACTIONS[id].validTargets(s).some((p) => eq(p, target))
@@ -61,6 +132,11 @@ 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
+    if (id === 'shield' && s.shield) return false
+    if (id === 'freeze' && s.enemies.length === 0) return false
+  }
   const def = ACTIONS[id]
   return !def.needsTarget || def.validTargets(s).length > 0
 }
diff --git a/src/sim/enemies.ts b/src/sim/enemies.ts
new file mode 100644
index 0000000..e0154a2
--- /dev/null
+++ b/src/sim/enemies.ts
@@ -0,0 +1,116 @@
+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'
+
+/** 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 {
+  return cellAt(s, p) === 'floor' && !bombAt(s, p)
+}
+
+export function enemyAt(s: GameState, p: Vec): Enemy | undefined {
+  return s.enemies.find((e) => eq(e.pos, p))
+}
+
+interface Step {
+  dir: Dir
+  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))
+}
+
+/** 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)
+  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)
+}
+
+/** 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 {
+  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
+    first.set(key(st.to), st)
+    queue.push(st.to)
+  }
+  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 (first.has(key(q)) || eq(q, e.pos) || !enemyCanEnter(s, q)) continue
+      first.set(key(q), first.get(key(p))!)
+      if (eq(q, s.player)) return first.get(key(q))!
+      queue.push(q)
+    }
+  }
+  return null
+}
+
+const PLANNERS: Record<EnemyKind, (s: GameState, e: Enemy, 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),
+  drifter: drift,
+}
+
+/** Phase 5: every enemy announces where it will step next turn. */
+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
+  }
+  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.
+ */
+export function moveEnemies(s: GameState, events: GameEvent[]): void {
+  if (s.frozen) {
+    s.frozen = false
+    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
+  }
+}
+
+/** Chance of a coin drop, and of an action-charge drop, per type (PLAN.md §10). To tune. */
+const DROP_CHANCE: Record<EnemyKind, { coins: number; charge: number }> = {
+  blob: { coins: 0.2, charge: 0.15 },
+  seeker: { coins: 0.5, charge: 0.25 },
+  drifter: { coins: 0.2, charge: 0.5 },
+}
+
+/** 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 []
+  const rng = new Rng(s.dropRng)
+  const drops: Drop[] = []
+  const chance = DROP_CHANCE[e.kind]
+  if (rng.next() < chance.coins) {
+    const r = rng.next()
+    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)]! })
+  }
+  s.dropRng = rng.state
+  return drops
+}
diff --git a/src/sim/levelgen.ts b/src/sim/levelgen.ts
index 324ab47..3c48cdf 100644
--- a/src/sim/levelgen.ts
+++ b/src/sim/levelgen.ts
@@ -1,5 +1,5 @@
 import { stream } from './rng'
-import type { Cell, Vec } from './types'
+import { DIR_LIST, type Cell, type Dir, type EnemyKind, type Vec } from './types'
 
 /** The NES layout: 31 × 13 with a wall all round and a pillar at every even (x, y). */
 export const LEVEL_WIDTH = 31
@@ -7,12 +7,43 @@ export const LEVEL_HEIGHT = 13
 export const START: Vec = { x: 1, y: 1 }
 /** Share of the open tiles that get a soft block. */
 export const BLOCK_DENSITY = 0.35
+/** No enemy starts this close (Manhattan distance) to the player, and the exit isn't this close either. */
+export const SAFE_DISTANCE = 4
+
+/**
+ * Which enemy types a level can have (PLAN.md §10): Blobs only at first, then one new type
+ * every 6 levels. Only the first three types exist so far.
+ */
+export function enemyKinds(level: number): EnemyKind[] {
+  const all: EnemyKind[] = ['blob', 'seeker', 'drifter']
+  return all.slice(0, 1 + Math.floor((level - 1) / 6))
+}
+
+/** 3 on level 1, one more every 3 levels, at most 10. To tune by playtesting. */
+export function enemyCount(level: number): number {
+  return Math.min(10, 3 + Math.floor((level - 1) / 3))
+}
+
+/** The turn budget for the par bonus: 50 plus 10 per enemy. To tune by playtesting. */
+export function parFor(enemies: number): number {
+  return 50 + 10 * enemies
+}
+
+export interface EnemyStart {
+  kind: EnemyKind
+  pos: Vec
+  dir: Dir
+}
 
 export interface Layout {
   width: number
   height: number
   cells: Cell[]
   start: Vec
+  /** Always under a soft block. */
+  exit: Vec
+  enemies: EnemyStart[]
+  par: number
 }
 
 export function generateLevel(seed: number, level: number): Layout {
@@ -36,7 +67,20 @@ export function generateLevel(seed: number, level: number): Layout {
       if (cells[i] === 'floor' && !clear.has(key({ x, y })) && rng.next() < BLOCK_DENSITY) cells[i] = 'block'
     }
   }
-  return { width, height, cells, start: { ...START } }
+
+  const far = (i: number) => Math.abs((i % width) - START.x) + Math.abs(Math.floor(i / width) - START.y) > SAFE_DISTANCE
+  const at = (i: number): Vec => ({ x: i % width, y: Math.floor(i / width) })
+  const blocks = cells.flatMap((c, i) => (c === 'block' && far(i) ? [i] : []))
+  const exit = at(blocks[rng.int(blocks.length)]!)
+
+  const open = cells.flatMap((c, i) => (c === 'floor' && far(i) ? [i] : []))
+  const kinds = enemyKinds(level)
+  const enemies: EnemyStart[] = []
+  for (let n = enemyCount(level); n > 0 && open.length; n--) {
+    const i = open.splice(rng.int(open.length), 1)[0]!
+    enemies.push({ kind: kinds[rng.int(kinds.length)]!, pos: at(i), dir: DIR_LIST[rng.int(4)]! })
+  }
+  return { width, height, cells, start: { ...START }, exit, enemies, par: parFor(enemies.length) }
 }
 
 function key(p: Vec): string {
diff --git a/src/sim/record.ts b/src/sim/record.ts
index 3824fa5..4205197 100644
--- a/src/sim/record.ts
+++ b/src/sim/record.ts
@@ -6,22 +6,24 @@ 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 = 1
+export const SIM_VERSION = 2
 
 /** Everything needed to reproduce a run exactly: mid-run saves, replays, certificates. */
 export interface RunRecord {
   simVersion: number
   seed: number
   startLevel: number
+  /** Action charges of each kind given at the start (testing aid; 0 in a normal run). */
+  startCharges: number
   actions: Action[]
 }
 
-export function newRecord(seed: number, startLevel = 1): RunRecord {
-  return { simVersion: SIM_VERSION, seed, startLevel, actions: [] }
+export function newRecord(seed: number, startLevel = 1, startCharges = 0): RunRecord {
+  return { simVersion: SIM_VERSION, seed, startLevel, startCharges, actions: [] }
 }
 
 export function replay(record: RunRecord): GameState {
-  let s = newRun(record.seed, record.startLevel)
+  let s = newRun(record.seed, record.startLevel, record.startCharges)
   for (const a of record.actions) s = step(s, a).state
   return s
 }
diff --git a/src/sim/state.ts b/src/sim/state.ts
index 325b6e1..fe03ce8 100644
--- a/src/sim/state.ts
+++ b/src/sim/state.ts
@@ -1,17 +1,58 @@
+import { planIntents } from './enemies'
 import { generateLevel } from './levelgen'
-import type { Bomb, Cell, GameState, Stats, Vec } from './types'
+import { hashSeed } from './rng'
+import type { Bomb, Cell, ChargeId, GameState, Stats, Vec } from './types'
 
 export const START_LIVES = 3
 export const START_STATS: Stats = { capacity: 1, fire: 1, fuse: 3 }
 
-export function newRun(seed: number, level = 1): GameState {
-  return createLevel(seed, level, START_LIVES, { ...START_STATS })
+/** Coins awarded for clearing a level within its par. */
+export const PAR_BONUS = 5
+
+/** What the player takes from one attempt at a level into the next. */
+export interface Carry {
+  lives: number
+  stats: Stats
+  coins: number
+  charges: Record<ChargeId, number>
+}
+
+export function noCharges(): Record<ChargeId, number> {
+  return { kick: 0, throw: 0, shield: 0, freeze: 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 })
+}
+
+/**
+ * A fresh attempt at a level: the layout and enemies come from the seed, so a retry after a death
+ * is the same board. Lives, stats, coins and charges come with the player.
+ */
+export function createLevel(seed: number, level: number, carry: Carry): GameState {
+  const { width, height, cells, start, exit, enemies, par } = generateLevel(seed, level)
+  const s: GameState = {
+    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 })),
+    nextEnemyId: enemies.length,
+    drops: [], exit, par, shield: false, frozen: false,
+    aiRng: hashSeed(seed, 'ai', level), dropRng: hashSeed(seed, 'drops', level),
+  }
+  planIntents(s)
+  return s
+}
+
+export function carryOf(s: GameState): Carry {
+  return { lives: s.lives, stats: s.stats, coins: s.coins, charges: s.charges }
 }
 
-/** A fresh attempt at a level: the layout comes from the seed, the player keeps lives and stats. */
-export function createLevel(seed: number, level: number, lives: number, stats: Stats): GameState {
-  const { width, height, cells, start } = generateLevel(seed, level)
-  return { seed, level, turn: 0, width, height, cells, player: start, lives, stats, bombs: [], status: 'playing' }
+/** The exit can be used once its block is gone and every enemy is dead. */
+export function exitOpen(s: GameState): boolean {
+  return s.enemies.length === 0 && cellAt(s, s.exit) === 'floor'
 }
 
 export function cloneState(s: GameState): GameState {
diff --git a/src/sim/turn.ts b/src/sim/turn.ts
index 5ae92a5..454fa75 100644
--- a/src/sim/turn.ts
+++ b/src/sim/turn.ts
@@ -1,6 +1,8 @@
-import { ACTIONS, isValidTarget } from './actions'
+import { ACTIONS, isAvailable, isCharge, isValidTarget } from './actions'
+import { enemyAt, moveEnemies, planIntents, rollDrops } from './enemies'
+import { enemyKinds } from './levelgen'
 import { detonate, tickBombs } from './explosions'
-import { cloneState, createLevel, idx } from './state'
+import { carryOf, cellAt, cloneState, createLevel, eq, exitOpen, idx, PAR_BONUS } from './state'
 import type { Action, GameEvent, GameState } from './types'
 
 export interface StepResult {
@@ -8,6 +10,9 @@ export interface StepResult {
   events: GameEvent[]
 }
 
+/** How many enemies come out of an exit caught in a flame. */
+export const EXIT_SPAWN = 3
+
 /**
  * One turn (PLAN.md §1). Never mutates `prev`. Throws on an action that isn't allowed right
  * now; the UI only offers allowed ones, so a throw is a bug.
@@ -17,34 +22,107 @@ export function step(prev: GameState, action: Action): StepResult {
   const events: GameEvent[] = []
 
   if (action.id === 'continue') {
-    if (s.status !== 'dead') throw new Error(`continue while ${s.status}`)
-    events.push({ type: 'levelRestarted' })
-    return { state: createLevel(s.seed, s.level, s.lives, s.stats), events }
+    if (s.status === 'dead') {
+      events.push({ type: 'levelRestarted' })
+      return { state: createLevel(s.seed, s.level, carryOf(s)), events }
+    }
+    if (s.status === 'cleared') {
+      events.push({ type: 'levelStarted', level: s.level + 1 })
+      return { state: createLevel(s.seed, s.level + 1, carryOf(s)), events }
+    }
+    throw new Error(`continue while ${s.status}`)
   }
-  if (s.status !== 'playing') throw new Error(`${action.id} while ${s.status}`)
+  if (!isAvailable(s, action.id)) throw new Error(`${action.id} is not available`)
 
-  // Phase 1: the player acts.
+  // Phase 1: the player acts, and picks up whatever is where they end up.
   const def = ACTIONS[action.id]
   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 })
+  }
   def.apply(s, action.target, events)
+  pickUp(s, events)
+  if (exitOpen(s) && eq(s.player, s.exit)) {
+    const parBonus = s.turn + 1 <= s.par ? PAR_BONUS : 0
+    s.coins += parBonus
+    s.turn++
+    s.status = 'cleared'
+    events.push({ type: 'levelCleared', level: s.level, turns: s.turn, parBonus })
+    return { state: s, events }
+  }
+
+  // A shield absorbs every hit for the rest of the turn it breaks in.
+  let shielded = false
+  const hurt = (): void => {
+    if (shielded) return
+    if (s.shield) {
+      s.shield = false
+      shielded = true
+      events.push({ type: 'shieldBroke', pos: s.player })
+      return
+    }
+    s.lives--
+    s.status = s.lives > 0 ? 'dead' : 'gameOver'
+    events.push({ type: 'playerDied', pos: s.player, livesLeft: s.lives })
+  }
+
+  // Contact, first check: the player walked into an enemy (or swapped places with one).
+  if (enemyAt(s, s.player)) hurt()
 
-  // Phase 2: enemies carry out their announced intents (M5).
+  // Phase 2: enemies carry out their announced intents. Second contact check.
+  if (s.status === 'playing') {
+    moveEnemies(s, events)
+    if (enemyAt(s, s.player)) hurt()
+  }
 
   // Phase 3: bombs tick; those at 0 explode, in chains.
+  const exitWasOpen = cellAt(s, s.exit) === 'floor'
+  const enemiesBefore = s.enemies.length
   tickBombs(s, events)
   const burned = detonate(s, events)
 
-  // Phase 4: damage.
-  if (burned.has(idx(s, s.player))) {
-    s.lives--
-    s.status = s.lives > 0 ? 'dead' : 'gameOver'
-    events.push({ type: 'playerDied', pos: s.player, livesLeft: s.lives })
+  // 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()
+  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)))) {
+    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)) {
+      s.drops.push(drop)
+      events.push({ type: 'dropSpawned', drop })
+    }
   }
+  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 })
+    }
+    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 (M5).
+  // Phase 5: enemies announce their next intents.
+  planIntents(s)
 
   s.turn++
   return { state: s, events }
 }
+
+/** Collect every drop on the player's tile. */
+function pickUp(s: GameState, events: GameEvent[]): void {
+  for (const d of s.drops.filter((d) => eq(d.pos, s.player))) {
+    s.drops.splice(s.drops.indexOf(d), 1)
+    if (d.kind === 'coins') s.coins += d.amount
+    else s.charges[d.kind]++
+    events.push({ type: 'pickedUp', drop: d })
+  }
+}
diff --git a/src/sim/types.ts b/src/sim/types.ts
index b6d0c66..8ab9c96 100644
--- a/src/sim/types.ts
+++ b/src/sim/types.ts
@@ -41,8 +41,35 @@ export interface Stats {
   fuse: number
 }
 
-/** `dead`: lost a life and waiting for `continue`. `gameOver`: no lives left. */
-export type Status = 'playing' | 'dead' | 'gameOver'
+/**
+ * `dead`: lost a life and waiting for `continue`. `gameOver`: no lives left.
+ * `cleared`: stepped onto the open exit; `continue` goes to the next level.
+ */
+export type Status = 'playing' | 'dead' | 'gameOver' | 'cleared'
+
+/** The enemy types so far (PLAN.md §10). */
+export type EnemyKind = 'blob' | 'seeker' | 'drifter'
+
+export interface Enemy {
+  /** Stable for the enemy's life, so the renderer can follow it between turns. */
+  id: number
+  kind: EnemyKind
+  pos: Vec
+  /** The way it last moved or meant to move. 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
+  /** 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'
+
+export const CHARGE_IDS: readonly ChargeId[] = ['kick', 'throw', 'shield', 'freeze']
+
+/** 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 }
 
 export interface GameState {
   seed: number
@@ -58,10 +85,26 @@ export interface GameState {
   stats: Stats
   bombs: Bomb[]
   status: Status
+  enemies: Enemy[]
+  nextEnemyId: number
+  drops: Drop[]
+  /** Under a soft block until that block burns. Open (walkable to win) once no enemies are left. */
+  exit: Vec
+  /** Finishing the level in at most this many turns pays PAR_BONUS coins. */
+  par: number
+  coins: number
+  charges: Record<ChargeId, number>
+  /** Shield armed: the next hit (flame or enemy) is absorbed. */
+  shield: boolean
+  /** Enemies skip their next move (Freeze). */
+  frozen: boolean
+  /** 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'
+export type ActionId = 'move' | 'bomb' | 'wait' | ChargeId
 
 export type Action = { id: ActionId; target?: Vec } | { id: 'continue' }
 
@@ -75,3 +118,16 @@ export type GameEvent =
   | { type: 'blockDestroyed'; pos: Vec }
   | { type: 'playerDied'; pos: Vec; livesLeft: number }
   | { type: 'levelRestarted' }
+  | { type: 'enemyMoved'; id: number; from: Vec; to: Vec }
+  | { type: 'enemyDied'; id: number; pos: Vec }
+  | { type: 'enemiesSpawned'; pos: Vec; count: number }
+  | { type: 'dropSpawned'; drop: Drop }
+  | { type: 'dropBurned'; drop: Drop }
+  | { type: 'pickedUp'; drop: Drop }
+  | { type: 'exitRevealed'; pos: Vec }
+  | { type: 'exitOpened'; pos: Vec }
+  | { type: 'levelCleared'; level: number; turns: number; parBonus: number }
+  | { type: 'levelStarted'; level: number }
+  | { type: 'chargeUsed'; id: ChargeId }
+  | { type: 'bombKicked'; from: Vec; to: Vec }
+  | { type: 'shieldBroke'; pos: Vec }
diff --git a/src/style.css b/src/style.css
index d74d693..3d8d85e 100644
--- a/src/style.css
+++ b/src/style.css
@@ -44,9 +44,10 @@ img {
   right: 0;
   z-index: 2;
   display: flex;
+  flex-wrap: wrap;
   justify-content: space-between;
   align-items: center;
-  gap: 8px;
+  gap: 4px 12px;
   padding: calc(env(safe-area-inset-top) + 6px) 16px 6px;
   background: var(--ink);
   color: #fff;
@@ -78,6 +79,12 @@ img {
   margin-left: 10px;
 }
 
+#hud small.par {
+  margin: 0 0 0 6px;
+  font-size: 12px;
+  opacity: 0.7;
+}
+
 #hud small {
   margin-right: 4px;
   font-size: 13px;
@@ -93,21 +100,22 @@ img {
   right: 0;
   z-index: 2;
   display: flex;
+  flex-wrap: wrap;
   gap: 8px;
-  overflow-x: auto;
   padding: 8px 16px calc(env(safe-area-inset-bottom) + 8px);
   background: var(--ink);
 }
 
 #actions button {
-  flex: 1 0 auto;
+  /* Three to a row, so Move / Bomb / Wait fill the first and any charges wrap below. */
+  flex: 1 0 calc(33.333% - 6px);
   display: grid;
   grid-template-columns: auto auto;
   grid-template-rows: auto auto;
   align-items: center;
   justify-content: center;
   column-gap: 6px;
-  min-width: 72px;
+  min-width: 0;
   min-height: 56px;
   margin-bottom: 4px;
   border: 3px solid var(--ink);
@@ -126,6 +134,58 @@ img {
   height: 36px;
 }
 
+#actions button[hidden] {
+  display: none;
+}
+
+/* Action charges: a second, smaller row under Move / Bomb / Wait. */
+#actions button.charge {
+  /* Quarter width whatever their number, so a single charge isn't a full-width bar. */
+  flex: 0 0 calc(25% - 6px);
+  min-width: 0;
+  min-height: 44px;
+  margin-top: 6px;
+  font-size: 13px;
+}
+
+#actions button.charge .icon {
+  width: 26px;
+  height: 26px;
+}
+
+#actions button {
+  position: relative;
+}
+
+#actions .count {
+  position: absolute;
+  top: -12px;
+  right: -6px;
+  min-width: 20px;
+  padding: 0 4px;
+  border: 2px solid var(--ink);
+  border-radius: 11px;
+  background: #ffc83d;
+  color: var(--ink);
+  font-size: 12px;
+  font-weight: 800;
+}
+
+/* On a phone, four charge buttons share a row: no room for their key hints. */
+@media (max-width: 600px) {
+  #actions button.charge {
+    grid-template-rows: auto;
+  }
+
+  #actions button.charge kbd {
+    display: none;
+  }
+
+  #actions button.charge .icon {
+    grid-row: auto;
+  }
+}
+
 #actions button:active:not(:disabled) {
   transform: translateY(4px);
   box-shadow: none;
diff --git a/src/ui/controls.ts b/src/ui/controls.ts
index c3be5e9..8f0595b 100644
--- a/src/ui/controls.ts
+++ b/src/ui/controls.ts
@@ -1,18 +1,22 @@
-import { ACTIONS, ACTION_ORDER, isAvailable } from '../sim/actions'
-import { START_LIVES } from '../sim/state'
+import { ACTIONS, ACTION_ORDER, isAvailable, isCharge } from '../sim/actions'
+import { PAR_BONUS, START_LIVES } from '../sim/state'
 import type { ActionId, GameState } from '../sim/types'
-import { spriteUrl } from '../render/sprites'
+import { CHARGE_ICONS, spriteUrl } from '../render/sprites'
 
-/** The HTML around the board: HUD, action bar, and the banner shown on death. */
+/** The HTML around the board: HUD, action bar, and the banner shown between attempts and levels. */
 export interface Handlers {
   /** An action button was pressed. */
   pick(id: ActionId): void
-  /** The banner's button: continue after losing a life, or a new run after game over. */
+  /** The banner's button: continue after losing a life or clearing a level, or a new run after game over. */
   banner(): void
 }
 
-const KEYS: Record<ActionId, string> = { move: 'WASD', bomb: 'B', wait: 'Space' }
-const ICONS: Record<ActionId, string> = { move: 'character_green_walk_a', bomb: 'bomb', wait: 'character_green_duck' }
+export const KEYS: Record<ActionId, string> = {
+  move: 'WASD', bomb: 'B', wait: 'Space', kick: 'K', throw: 'T', shield: 'H', freeze: 'F',
+}
+const ICONS: Record<ActionId, string> = {
+  move: 'character_green_walk_a', bomb: 'bomb', wait: 'character_green_duck', ...CHARGE_ICONS,
+}
 
 function icon(name: string, className = 'icon'): string {
   return `<img class="${className}" src="${spriteUrl(name)}" alt="" draggable="false">`
@@ -30,7 +34,9 @@ export function createControls(handlers: Handlers) {
     const b = document.createElement('button')
     b.type = 'button'
     b.dataset.action = id
-    b.innerHTML = `${icon(ICONS[id])}<span>${ACTIONS[id].label}</span><kbd>${KEYS[id]}</kbd>`
+    if (isCharge(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>')
     b.addEventListener('click', () => handlers.pick(id))
     bar.append(b)
     buttons.set(id, b)
@@ -46,6 +52,8 @@ export function createControls(handlers: Handlers) {
     lives: document.getElementById('hud-lives')!,
     level: document.getElementById('hud-level')!,
     turn: document.getElementById('hud-turn')!,
+    enemies: document.getElementById('hud-enemies')!,
+    coins: document.getElementById('hud-coins')!,
     stats: document.getElementById('hud-stats')!,
   }
 
@@ -54,6 +62,11 @@ export function createControls(handlers: Handlers) {
       for (const [id, b] of buttons) {
         b.disabled = !isAvailable(s, id)
         b.setAttribute('aria-pressed', String(selected === id))
+        // 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]}`
+        }
       }
       // Lives lost from the starting three show as empty hearts; extra lives add full ones.
       const hearts = Array.from({ length: Math.max(s.lives, START_LIVES) }, (_, i) =>
@@ -61,19 +74,28 @@ export function createControls(handlers: Handlers) {
       )
       hud.lives.innerHTML = `<span class="sr-only">${s.lives} lives</span>${hearts.join('')}`
       hud.level.innerHTML = icon('flag_green_a') + number(s.level, 'Level ')
-      hud.turn.innerHTML = `<small aria-hidden="true">Turn</small>` + number(s.turn, 'Turn ')
+      hud.enemies.innerHTML = icon('slime_normal_rest') + number(s.enemies.length, 'Enemies left ')
+      hud.coins.innerHTML = icon('hud_coin') + number(s.coins, 'Coins ')
+      // Turns taken, and the par to beat for the bonus.
+      hud.turn.innerHTML =
+        `<small aria-hidden="true">Turn</small>` + number(s.turn, 'Turn ') + `<small class="par">/${s.par}</small>`
       hud.stats.innerHTML =
         icon('bomb') + number(s.stats.capacity, 'Bombs ') + icon('fireball') + number(s.stats.fire, ', fire ')
 
       banner.hidden = s.status === 'playing'
       if (s.status === 'dead') {
-        bannerText.textContent = `Boom. ${s.lives} ${s.lives === 1 ? 'life' : 'lives'} left.`
+        bannerText.textContent = `Ouch! ${s.lives} ${s.lives === 1 ? 'life' : 'lives'} left.`
         bannerButton.textContent = 'Try again'
         bannerIcon.src = spriteUrl('character_green_hit')
       } else if (s.status === 'gameOver') {
         bannerText.textContent = `Game over on level ${s.level}. Seed ${s.seed}.`
         bannerButton.textContent = 'New run'
         bannerIcon.src = spriteUrl('hud_heart_empty')
+      } else if (s.status === 'cleared') {
+        const bonus = s.turn <= s.par ? ` Under par: +${PAR_BONUS} coins!` : ''
+        bannerText.textContent = `Level ${s.level} cleared in ${s.turn} turns.${bonus}`
+        bannerButton.textContent = 'Next level'
+        bannerIcon.src = spriteUrl('flag_green_a')
       }
     },
     /** True if the banner is showing; Enter presses its button. */
diff --git a/test/danger.test.ts b/test/danger.test.ts
index 1d0c4e3..ed6ece8 100644
--- a/test/danger.test.ts
+++ b/test/danger.test.ts
@@ -7,9 +7,9 @@ import { step } from '../src/sim/turn'
 import type { Action, GameState } from '../src/sim/types'
 import { Rng } from '../src/sim/rng'
 
-/** A random but legal action; `continue` when dead. */
+/** A random but legal action; `continue` when dead or the level is cleared. */
 function randomAction(s: GameState, rng: Rng): Action {
-  if (s.status === 'dead') return { id: 'continue' }
+  if (s.status === 'dead' || s.status === 'cleared') return { id: 'continue' }
   const ids = ACTION_ORDER.filter((id) => isAvailable(s, id))
   const id = ids[rng.int(ids.length)]!
   const targets = ACTIONS[id].validTargets(s)
@@ -26,7 +26,7 @@ describe('danger overlay', () => {
   it('"now" is exactly what burns if the player waits, and "next" what burns the turn after', () => {
     const rng = new Rng(7)
     for (let seed = 1; seed <= 20; seed++) {
-      let s = newRun(seed)
+      let s = newRun(seed, 1 + (seed % 20), 1)
       for (let t = 0; t < 150 && s.status !== 'gameOver'; t++) {
         if (s.status === 'playing') {
           const danger = predictDanger(s)
@@ -43,8 +43,9 @@ describe('danger overlay', () => {
 describe('run record', () => {
   it('replays to the identical state', () => {
     const rng = new Rng(99)
-    const record = newRecord(1234)
-    let s = newRun(record.seed)
+    // With charges, so kicks, throws, shields and freezes are in the mix too.
+    const record = newRecord(1234, 1, 2)
+    let s = newRun(record.seed, 1, 2)
     for (let t = 0; t < 300 && s.status !== 'gameOver'; t++) {
       const a = randomAction(s, rng)
       record.actions.push(a)
diff --git a/test/enemies.test.ts b/test/enemies.test.ts
new file mode 100644
index 0000000..5458470
--- /dev/null
+++ b/test/enemies.test.ts
@@ -0,0 +1,355 @@
+import { describe, expect, it } from 'vitest'
+import { ACTIONS, isAvailable } from '../src/sim/actions'
+import { 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'
+import type { GameState, Vec } from '../src/sim/types'
+import { board, v } from './helpers'
+
+const dist = (a: Vec, b: Vec) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y)
+
+describe('levelgen: enemies and exit', () => {
+  for (let seed = 1; seed <= 100; seed++) {
+    const level = 1 + (seed % 50)
+    it(`seed ${seed}: exit under a block, enemies on floor away from the start`, () => {
+      const g = generateLevel(seed, level)
+      const at = (p: Vec) => g.cells[p.y * g.width + p.x]
+      expect(at(g.exit)).toBe('block')
+      expect(dist(g.exit, START)).toBeGreaterThan(SAFE_DISTANCE)
+      expect(g.enemies).toHaveLength(enemyCount(level))
+      expect(new Set(g.enemies.map((e) => `${e.pos.x},${e.pos.y}`)).size).toBe(g.enemies.length)
+      for (const e of g.enemies) {
+        expect(at(e.pos)).toBe('floor')
+        expect(dist(e.pos, START)).toBeGreaterThan(SAFE_DISTANCE)
+        expect(enemyKinds(level)).toContain(e.kind)
+      }
+    })
+  }
+
+  it('brings in a new type every 6 levels', () => {
+    expect(enemyKinds(1)).toEqual(['blob'])
+    expect(enemyKinds(6)).toEqual(['blob'])
+    expect(enemyKinds(7)).toEqual(['blob', 'seeker'])
+    expect(enemyKinds(13)).toEqual(['blob', 'seeker', 'drifter'])
+    expect(enemyKinds(50)).toEqual(['blob', 'seeker', 'drifter'])
+  })
+
+  it('a new level already shows every enemy intent', () => {
+    const s = newRun(5)
+    expect(s.enemies.length).toBeGreaterThan(0)
+    expect(s.enemies.some((e) => e.intent)).toBe(true)
+  })
+})
+
+/** Give every enemy an intent of one step in a direction. */
+function intend(s: GameState, ...targets: Vec[]): GameState {
+  s.enemies.forEach((e, i) => (e.intent = targets[i]!))
+  return s
+}
+
+describe('turn order with enemies', () => {
+  it('enemies move before bombs explode: one walking into a blast dies', () => {
+    const s = intend(board(`
+      #######
+      #P.1.B#
+      #######`), v(4, 1))
+    const { state, events } = step(s, { id: 'wait' })
+    expect(events.map((e) => e.type).slice(0, 5)).toEqual(['waited', 'enemyMoved', 'bombTicked', 'exploded', 'enemyDied'])
+    expect(state.enemies).toEqual([])
+  })
+
+  it('an enemy whose tile got a bomb this turn stays put and does not re-plan', () => {
+    const s = intend(board(`
+      ######
+      #P.B.#
+      ######`), v(2, 1))
+    const { state, events } = step(s, { id: 'bomb', target: v(2, 1) })
+    expect(state.enemies[0]!.pos).toEqual(v(3, 1))
+    expect(events.some((e) => e.type === 'enemyMoved')).toBe(false)
+  })
+
+  it('walking into an enemy kills the player', () => {
+    const s = board(`
+      #####
+      #PB.#
+      #####`)
+    const { state } = step(s, { id: 'move', target: v(2, 1) })
+    expect(state.status).toBe('dead')
+  })
+
+  it('an enemy walking into the player kills them', () => {
+    const s = intend(board(`
+      #####
+      #PB.#
+      #####`), v(1, 1))
+    expect(step(s, { id: 'wait' }).state.status).toBe('dead')
+  })
+
+  it('swapping tiles with an enemy counts as contact', () => {
+    const s = intend(board(`
+      #####
+      #PB.#
+      #####`), v(1, 1))
+    expect(step(s, { id: 'move', target: v(2, 1) }).state.status).toBe('dead')
+  })
+
+  it('a bomb cannot go on an enemy', () => {
+    const s = board(`
+      #####
+      #PB.#
+      #.###
+      #####`)
+    expect(ACTIONS.bomb.validTargets(s)).toEqual([v(1, 2)])
+  })
+})
+
+describe('enemy movement', () => {
+  it('a Seeker can find the shortest way round', () => {
+    const s = board(`
+      #######
+      #P....#
+      ####.##
+      #S....#
+      #######`)
+    expect(stepToward(s, s.enemies[0]!)?.to).toEqual(v(2, 3))
+  })
+
+  it('a Drifter keeps going straight until blocked, then turns', () => {
+    const s = board(`
+      #####
+      #D..#
+      #.#.#
+      #P..#
+      #####`)
+    s.enemies[0]!.kind = 'drifter'
+    planIntents(s)
+    expect(s.enemies[0]!.intent).toEqual(v(2, 1))
+    s.enemies[0]!.pos = v(3, 1)
+    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')
+  })
+
+  it('an enemy with nowhere to go stays put', () => {
+    const s = board(`
+      #####
+      #B#P#
+      #####`)
+    planIntents(s)
+    expect(s.enemies[0]!.intent).toBeNull()
+  })
+
+  it('the same state always plans the same intents', () => {
+    const a = newRun(77, 13)
+    const b = newRun(77, 13)
+    expect(a.enemies).toEqual(b.enemies)
+    expect(a.aiRng).toBe(b.aiRng)
+  })
+})
+
+describe('drops', () => {
+  it('walking onto coins collects them', () => {
+    const s = board(`
+      #####
+      #P$.#
+      #####`)
+    const { state, events } = step(s, { id: 'move', target: v(2, 1) })
+    expect(state.coins).toBe(1)
+    expect(state.drops).toEqual([])
+    expect(events[1]).toEqual({ type: 'pickedUp', drop: { pos: v(2, 1), kind: 'coins', amount: 1 } })
+  })
+
+  it('walking onto a charge adds one to that action', () => {
+    const s = board(`
+      #####
+      #P..#
+      #####`)
+    s.drops.push({ pos: v(2, 1), kind: 'kick' })
+    expect(step(s, { id: 'move', target: v(2, 1) }).state.charges.kick).toBe(1)
+  })
+
+  it('flame burns drops lying in the open, but not the ones its own kills leave', () => {
+    let found = false
+    // Try drop streams until a slain enemy leaves something; the old coin always burns.
+    for (let r = 1; r < 200 && !found; r++) {
+      const s = board(`
+        #######
+        #P.$1B#
+        #######`)
+      s.dropRng = r
+      const { state, events } = step(s, { id: 'wait' })
+      expect(events.some((e) => e.type === 'dropBurned')).toBe(true)
+      expect(state.drops.some((d) => d.kind === 'coins' && d.pos.x === 3)).toBe(false)
+      if (state.drops.length) {
+        found = true
+        expect(state.drops.every((d) => d.pos.x === 5)).toBe(true)
+      }
+    }
+    expect(found).toBe(true)
+  })
+})
+
+describe('the exit', () => {
+  it('a block over the exit burns away and reveals it', () => {
+    const s = board(`
+      ######
+      #P.1e#
+      ######`)
+    const { events } = step(s, { id: 'wait' })
+    expect(events).toContainEqual({ type: 'exitRevealed', pos: v(4, 1) })
+  })
+
+  it('stays shut while any enemy lives', () => {
+    const s = board(`
+      ######
+      #PE.B#
+      ######`)
+    const { state } = step(s, { id: 'move', target: v(2, 1) })
+    expect(state.status).toBe('playing')
+  })
+
+  it('opens when the last enemy dies, and stepping on it clears the level with a par bonus', () => {
+    let s = board(`
+      #######
+      #PE.1B#
+      #######`)
+    const r = step(s, { id: 'wait' })
+    expect(r.events).toContainEqual({ type: 'exitOpened', pos: v(2, 1) })
+    s = r.state
+    const cleared = step(s, { id: 'move', target: v(2, 1) })
+    expect(cleared.state.status).toBe('cleared')
+    expect(cleared.state.coins).toBe(PAR_BONUS)
+    expect(cleared.events.at(-1)).toEqual({ type: 'levelCleared', level: 1, turns: 7, parBonus: PAR_BONUS })
+  })
+
+  it('no par bonus over par', () => {
+    const s = { ...board(`
+      ####
+      #PE#
+      ####`), turn: 100, par: 50 }
+    const { state } = step(s, { id: 'move', target: v(2, 1) })
+    expect(state.status).toBe('cleared')
+    expect(state.coins).toBe(0)
+  })
+
+  it('continue after clearing starts the next level, carrying lives, stats, coins and charges', () => {
+    const s = { ...board(`
+      ####
+      #PE#
+      ####`, { fire: 3 }), coins: 4, lives: 2 }
+    s.charges.kick = 2
+    const cleared = step(s, { id: 'move', target: v(2, 1) }).state
+    const { state, events } = step(cleared, { id: 'continue' })
+    expect(events).toEqual([{ type: 'levelStarted', level: 2 }])
+    expect(state).toMatchObject({ level: 2, turn: 0, lives: 2, coins: 4 + PAR_BONUS, status: 'playing' })
+    expect(state.stats.fire).toBe(3)
+    expect(state.charges.kick).toBe(2)
+    expect(state.enemies.length).toBe(enemyCount(2))
+  })
+
+  it('flaming the uncovered exit lets out enemies that drop nothing', () => {
+    const s = board(`
+      ######
+      #P.1E#
+      ######`)
+    const { state, events } = step(s, { id: 'wait' })
+    expect(events).toContainEqual({ type: 'enemiesSpawned', pos: v(4, 1), count: EXIT_SPAWN })
+    expect(state.enemies).toHaveLength(EXIT_SPAWN)
+    expect(state.enemies.every((e) => e.spawned && e.kind === 'blob')).toBe(true)
+  })
+})
+
+describe('action charges', () => {
+  it('are only offered while held, and using one spends it', () => {
+    const s = board(`
+      ######
+      #P3..#
+      ######`)
+    expect(isAvailable(s, 'kick')).toBe(false)
+    s.charges.kick = 1
+    expect(isAvailable(s, 'kick')).toBe(true)
+    const { state } = step(s, { id: 'kick', target: v(2, 1) })
+    expect(state.charges.kick).toBe(0)
+  })
+
+  it('kick: the bomb slides until the next tile cannot hold it, keeping its timer', () => {
+    const s = board(`
+      #######
+      #P3..B#
+      #######`)
+    s.charges.kick = 1
+    const { state, events } = step(s, { id: 'kick', target: v(2, 1) })
+    expect(events).toContainEqual({ type: 'bombKicked', from: v(2, 1), to: v(4, 1) })
+    expect(state.bombs[0]).toMatchObject({ pos: v(4, 1), timer: 2 })
+  })
+
+  it('kick: a bomb with no room to slide is not a target', () => {
+    const s = board(`
+      #####
+      #P3+#
+      #####`)
+    s.charges.kick = 1
+    expect(ACTIONS.kick.validTargets(s)).toEqual([])
+  })
+
+  it('throw: exactly 2 tiles away, over a wall', () => {
+    const s = board(`
+      ######
+      #P#..#
+      #.####
+      #.####
+      ######`)
+    s.charges.throw = 1
+    expect(ACTIONS.throw.validTargets(s)).toEqual([v(1, 3), v(3, 1)])
+    const { state } = step(s, { id: 'throw', target: v(3, 1) })
+    expect(state.bombs).toEqual([{ pos: v(3, 1), timer: 3, fire: 1, placedTurn: 5 }])
+  })
+
+  it('throw: needs bomb capacity', () => {
+    const s = board(`
+      #######
+      #P...3#
+      #######`)
+    s.charges.throw = 1
+    expect(isAvailable(s, 'throw')).toBe(false)
+  })
+
+  it('shield: absorbs a hit, then is gone', () => {
+    const s = board(`
+      #####
+      #P1.#
+      #####`)
+    s.charges.shield = 1
+    const r = step(s, { id: 'shield' })
+    expect(r.state.status).toBe('playing')
+    expect(r.events).toContainEqual({ type: 'shieldBroke', pos: v(1, 1) })
+    expect(r.state.shield).toBe(false)
+    expect(r.state.lives).toBe(3)
+  })
+
+  it('shield: absorbs every hit in the turn it breaks (enemy, then flame)', () => {
+    const s = intend(board(`
+      #####
+      #PB.#
+      #1###
+      #####`), v(1, 1))
+    s.shield = true
+    const { state } = step(s, { id: 'wait' })
+    expect(state.status).toBe('playing')
+    expect(state.lives).toBe(3)
+    expect(state.enemies).toEqual([]) // the flame got the Blob on the player's tile
+  })
+
+  it('freeze: enemies skip the move they announced', () => {
+    const s = intend(board(`
+      ######
+      #P..B#
+      ######`), v(3, 1))
+    s.charges.freeze = 1
+    const { state } = step(s, { id: 'freeze' })
+    expect(state.enemies[0]!.pos).toEqual(v(4, 1))
+    expect(state.frozen).toBe(false)
+  })
+})
diff --git a/test/helpers.ts b/test/helpers.ts
index ece641f..c59abc7 100644
--- a/test/helpers.ts
+++ b/test/helpers.ts
@@ -1,9 +1,14 @@
-import { START_LIVES, START_STATS } from '../src/sim/state'
-import type { Cell, GameState, Stats, Vec } from '../src/sim/types'
+import { noCharges, START_LIVES, START_STATS } from '../src/sim/state'
+import type { Cell, EnemyKind, GameState, Stats, Vec } from '../src/sim/types'
+
+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).
+ * `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.
+ * Without `E` or `e` the exit is off the board, so it never opens.
  */
 export function board(picture: string, stats: Partial<Stats> = {}): GameState {
   const rows = picture.trim().split('\n').map((r) => r.trim())
@@ -14,13 +19,19 @@ export function board(picture: string, stats: Partial<Stats> = {}): GameState {
   const s: 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,
   }
   rows.forEach((row, y) => {
     if (row.length !== width) throw new Error(`row ${y} is ${row.length} wide, not ${width}`)
     ;[...row].forEach((ch, x) => {
-      cells.push(ch === '#' ? 'wall' : ch === '+' ? 'block' : 'floor')
+      cells.push(ch === '#' ? 'wall' : ch === '+' || ch === 'e' ? 'block' : 'floor')
       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 (ch === 'E' || ch === 'e') s.exit = { x, y }
+      if (ch === '$') s.drops.push({ pos: { x, y }, kind: 'coins', amount: 1 })
     })
   })
   if (!player) throw new Error('no P in the picture')
diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs
index 89635cb..f341cee 100644
--- a/tools/screenshots.mjs
+++ b/tools/screenshots.mjs
@@ -30,15 +30,19 @@ const SHOTS = [
   // Standing next to our own bomb as it goes off.
   { name: 'death', act: press('KeyB', 'KeyD', 'Space', 'Space', 'Space') },
   { 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') },
+  // 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]') } },
 ]
 
 mkdirSync(OUT, { recursive: true })
 const server = await preview({ preview: { port: 4179, strictPort: true } })
 const browser = await chromium.launch()
 try {
-  for (const { name, viewport = PHONE, act } of SHOTS) {
+  for (const { name, viewport = PHONE, query = '', act } of SHOTS) {
     const page = await browser.newPage({ viewport, deviceScaleFactor: 2, hasTouch: viewport === PHONE })
-    await page.goto('http://localhost:4179/?seed=1')
+    await page.goto(`http://localhost:4179/?seed=1&${query}`)
     await page.waitForSelector('canvas')
     await page.waitForTimeout(300)
     await act(page)