fuzzyworld

explodorama / commit

Sprinter, Ghost, Wisp and Hunter; a new enemy type every 3 levels

565a6873e99fb0141e89a25b0035097a3a925306 · Pavel Safronov · 2026-10-24 22:40:37 -0700 · parents 0b63d158a98d

Claude Code usage: this commit ≈0 out / ~22.1M cached · cost unknown (no per-model trailer) · session 4 tally 2.6K in / 1.2M out / 519.6M cached · usage by commit and session

Sprinter, Ghost, Wisp and Hunter; a new enemy type every 3 levels

- Sprinter: rests every other turn, then dashes 2 tiles (two arrows);
  a dash through the player catches them, a bomb on either tile stops it.
- Ghost: every other turn, always chasing, through crates.
- Wisp: wanders through crates. Inside a crate both are drawn
  see-through, and flame that burns the crate kills them.
- Hunter: always chases.
- Rests are planned moves to the same tile, drawn as a dot, eyes closed.
- One new type every 3 levels (Seeker 4, Drifter 7 ... Hunter 19), so a
  run meets all seven. SIM_VERSION 28.
- README: a phone in Mute mode doesn't vibrate.

Usage: 2.6K in / 1.2M out / 519.6M cached (session tally)
rtk:   1.9M saved, 38.9% over 4468 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 CHANGELOG.md                         |   1 +
 PLAN.md                              |  19 +++-
 README.md                            |  33 ++++---
 decisions.md                         |  23 +++++
 docs/screenshots/almanac-enemies.png | Bin 195003 -> 314698 bytes
 docs/screenshots/enemies.png         | Bin 217399 -> 213010 bytes
 docs/screenshots/menu.png            | Bin 144018 -> 145160 bytes
 src/render/board.ts                  |  19 +++-
 src/render/sprites.ts                |  17 ++++
 src/sim/demo.ts                      |   2 +-
 src/sim/enemies.ts                   | 135 ++++++++++++++++++++------
 src/sim/levelgen.ts                  |  10 +-
 src/sim/path.ts                      |   8 +-
 src/sim/record.ts                    |   2 +-
 src/sim/types.ts                     |  10 +-
 src/ui/almanac.ts                    |   4 +-
 src/ui/info.ts                       |  17 +++-
 test/enemies.test.ts                 |  14 ++-
 test/helpers.ts                      |   7 +-
 test/new-enemies.test.ts             | 177 +++++++++++++++++++++++++++++++++++
 tools/screenshots.mjs                |   2 +-
 21 files changed, 425 insertions(+), 75 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7d0e5bd..bfc5c6f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ saved before the change is dropped and the next visit starts a new run.
 
 ### Added
 
+- **Four new enemies:** the **Sprinter** (rests, then dashes 2 tiles), the **Ghost** (every other turn, chasing you through crates), the **Wisp** (wanders through crates) and the **Hunter** (always chases). A new type now comes every 3 levels, so a run meets all seven. Version 28: runs saved before don't resume.
 - **A frame-rate meter** (`?fps`), and `?gl=0`, `?ratio=1` and `?aa` to find what makes a device slow.
 - **Sounds and vibration:** Kenney's sound effects (and a few made with ZzFX) for bombs, blasts, enemies, pickups, the gem, the exit, hits and a cleared level, timed with the animations; buttons click. Phones that can (Android) vibrate for blasts, hits and pickups. Both can be switched off in the menu. The demo and the clips stay silent.
 - **Coins for every cleared level** (2, and 5 more within par), coins from enemies more often (about 1 a kill), and **cheaper shop prices** (Bomb +1 4, most abilities 6, Heart 12, charges 3–6, a restock 1): the first shop now buys two to four things. Version 27: runs saved before don't resume.
diff --git a/PLAN.md b/PLAN.md
index daea85b..f32bb1b 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -366,7 +366,7 @@ are in git history and decisions.md.)
     | Shop after level | Kills | From kills | Clear bonus | Within par | Total |
     |---|---|---|---|---|---|
     | 3 | 6 Blobs | ≈ 5 | 6 | + 15 | ≈ 11 – 26 |
-    | 6 | 8 Blobs | ≈ 7 | 6 | + 15 | ≈ 13 – 28 |
+    | 6 | 8, Blobs and Seekers | ≈ 7 | 6 | + 15 | ≈ 13 – 28 |
     | 9 | 10, Blobs and Seekers | ≈ 10 | 6 | + 15 | ≈ 16 – 31 |
     | 12 – 18 | 12 – 15 | ≈ 12 – 15 | 6 | + 15 | ≈ 18 – 36 |
 
@@ -423,6 +423,21 @@ contact. Intent shown every turn. Adapted from the NES eight:
 | Hunter | Pass | 1 tile/turn | Always BFS shortest path to player | coins + charge |
 | Reaper | Pontan | 2 tiles/turn | Passes soft blocks, always chases. **Later milestone:** spawns only as punishment (turn limit, destroyed exit/pickup) | big coins |
 
+- *Built 2026-10-24 (user request: "add more enemies"):* Sprinter, Ghost, Wisp and Hunter, as in
+  the table, with the sprites of §13. Choices made while building:
+  - **Speed as rests:** a plan still holds one move per turn; a Ghost's or Sprinter's every other
+    move is a rest (its own tile, drawn as a dot, eyes closed). A Sprinter's move is a dash of 2
+    tiles in a line (two arrows), or 1 when the second is blocked. A dash through the player's
+    tile stops there and catches them; a bomb on either tile stops it. (`RESTS`, `moveTiles`.)
+  - **Through crates:** Ghosts and Wisps may enter soft blocks (`PASSES_CRATES`), drawn
+    see-through inside one; bombs and walls still stop them. Flame that burns their crate kills
+    them; a crate in front shelters them, as it stops the flame.
+  - **Hunter** always chases (unless another enemy takes that tile, as a Seeker's chase).
+  - **When they come:** one new type every 3 levels (Blob 1, Seeker 4, Drifter 7, Sprinter 10,
+    Ghost 13, Wisp 16, Hunter 19), so a 20-level run meets them all; it was every 6 levels while
+    there were three. Each enemy's type is picked evenly from those met so far.
+  - **Drops:** Sprinter and Wisp 40% coins / 60% charge, Ghost 90% / 25%, Hunter 90% / 60%.
+  `SIM_VERSION` 28.
 - 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
@@ -677,7 +692,7 @@ shows the scene's moment; the shortest of those is the scene (a new room if none
 Kinds (`SCENES` in `sim/demo.ts`, each with its moment): *since 2026-10-23 (user request)* every
 feature on the board but the shop and the exit — kill, **arrows** (an enemy killed on the tile it
 stepped onto, the bomb waiting where its arrows led: watch them; user request), escape, chain,
-lingering flame, the three enemy types, drops, the gem (uncovered and taken; its choice isn't
+lingering flame, the first three enemy types, drops, the gem (uncovered and taken; its choice isn't
 shown), each power-up that shows on the board (Bomb +1, Fire +1, Fuse +1, Run, Kick, Throw,
 Teleport, Diagonal, the Detonator, Wall / Bomb / Flame Pass, Star), and each charge (Pierce,
 Power, Nuke, Shield, Freeze, Decoy, picked up first). Heart and Coin bag only change numbers,
diff --git a/README.md b/README.md
index 3f4ed37..740ac7e 100644
--- a/README.md
+++ b/README.md
@@ -6,12 +6,12 @@ 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, bombs, lives, the first three enemy types, coins, all
+Status: **early development.** The board, bombs, lives, seven enemy types, coins, all
 seven charges that work when picked up, a hidden gem on every level that offers a choice of power-ups, the shop every 3rd level, the exit, the 20-level run with its ending, sounds and vibration, and saving
-mid-run work. Curses and more enemies are planned —
+mid-run work. Curses and the Reaper enemy are planned —
 see [PLAN.md](PLAN.md).
 
-Version: **27**, shown at the bottom of the menu with the commit the game was built from (a link
+Version: **28**, shown at the bottom of the menu with the commit the game was built from (a link
 to it on GitLab, plus "plus local changes" in a build with uncommitted changes) and a link to the
 [source code](https://gitlab.com/PavelSafronov/explodorama). The version is the rules version
 (`SIM_VERSION` in [src/sim/record.ts](src/sim/record.ts)): it goes up by one whenever a rule
@@ -43,7 +43,7 @@ faint static, a dark bar rolling down the screen and bright lines sweeping acros
 then a burst of interference, as if someone switched a vacuum cleaner on nearby, rounded glass in a dark bezel, at most 4:3 wide with black bars beside it on a wide
 screen — and the finger in front of the glass. The rest show every feature on the
 board: bombs and their countdown, watching the enemies' arrows to bomb where they're going, a
-narrow escape, chain reactions, lingering flame, the three enemy types, drops, the gem, every
+narrow escape, chain reactions, lingering flame, the first three enemy types, drops, the gem, every
 power-up you can see in action (Bomb +1, Fire +1, Fuse +1, Run, Kick — a tap on a bomb next to
 you —, Throw, Teleport, Diagonal, Slide, RPG, the Detonator — a tap on your bomb —, Proximity
 alert, Wall / Bomb / Flame Pass,
@@ -201,13 +201,16 @@ will end up, a faded bomb with its blast, a kicked bomb's stopping place, a chan
 
 ### 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">
+<img src="docs/screenshots/enemies.png" alt="Level 19: three Ghosts (green blocks, one inside a crate, see-through), resting ones with a dot among their arrows, and a Wisp (red flame slime)" width="260" align="right">
 
 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,
+eyes): it has nowhere to go and stays put. A **dot** among the arrows is a rest: Ghosts and
+Sprinters move only every other turn, with their eyes closed while they rest. **Two arrows one
+behind the other** are a Sprinter's dash: 2 tiles in one turn (a dash through your tile catches
+you there). If you put a bomb on a tile it planned to walk to,
 it stops there and plans a new route. Enemies keep clear of each other: each plans its moves
 around where the others will be, so a group (like the 3 let out of a blasted exit) splits up
 instead of moving as one; they only share a tile when there's nowhere else to go. Touching an
@@ -217,8 +220,16 @@ 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 (or another enemy is heading there), then turns. |
+| **Seeker** | fly | level 4 | Half the time takes the shortest way toward you; otherwise wanders like a Blob. |
+| **Drifter** | spinning saw | level 7 | Goes straight until blocked (or another enemy is heading there), then turns. |
+| **Sprinter** | mouse | level 10 | Rests every other turn, then dashes 2 tiles at once, half the time toward you. |
+| **Ghost** | green block slime | level 13 | Moves every other turn, always the shortest way toward you, **through crates**. |
+| **Wisp** | fire slime | level 16 | Wanders like a Blob, **through crates**. |
+| **Hunter** | frog | level 19 | Always takes the shortest way toward you. |
+
+Ghosts and Wisps can be inside a crate (drawn see-through there); flame that burns that crate
+kills them, but flame stopped by a crate in front of them doesn't reach them. Bombs stop every
+enemy. Each level mixes the types met so far.
 
 Level 1 has 2 enemies, with one more every 4 levels (at most 5). Enemies never start within
 4 tiles of you.
@@ -365,7 +376,7 @@ Turns make sounds, timed with what you see: a pop when a bomb is set down, a boo
 picked up, the gem, the exit opening, a hurt, a cleared level; buttons click. Phones that can
 vibrate (Android; iPhones can't, from a web page) also buzz for blasts, hits, pickups and a
 cleared level. Both can be turned off in the menu, and the setting is remembered in this browser.
-The demo and the clips in the dialogs are silent. On an iPhone, the silent switch mutes the game.
+The demo and the clips in the dialogs are silent. On an iPhone, the silent switch mutes the game. A phone in **Mute** mode (on Android, the sound mode in the quick settings) doesn't vibrate either: switch it to Vibrate or Sound.
 
 ### The board
 
@@ -387,8 +398,8 @@ npm run preview      # serve dist/ (also on the network, with a QR code), as it
 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 (level 13
-has all three enemy types); `?charges=2` starts as if 2 of each lasting charge had been picked up
+URL parameters: `?seed=123` plays a fixed board; `?level=4` starts at a later level (level 19
+has all seven enemy types); `?charges=2` starts as if 2 of each lasting charge had been picked up
 (6 Pierce, Power and Nuke bombs, and a Shield), for trying them out; `?dev` and `?immortal` set the modes; `?demo` shows the demo, which test URLs otherwise skip (with `?seed`, the same demo every time). `?fps` shows a frame-rate meter in the bottom left corner: frames a second, the slowest frame, the game's own time per frame, and the canvas size. For tracking down slowness on a device: `?gl=0` draws with Canvas 2D instead of WebGL (no demo TV effect, no clips), `?ratio=1` draws at one pixel per CSS pixel, `?aa` turns WebGL's antialiasing on (it is off: it held Android Chrome to 12 fps). These four keep your saved run. A URL with any of these parameters plays a test board: it doesn't load or overwrite your
 saved run.
 
diff --git a/decisions.md b/decisions.md
index 55d71a2..493901a 100644
--- a/decisions.md
+++ b/decisions.md
@@ -1791,3 +1791,26 @@ frame. (Headless Chrome warned "GPU stall due to ReadPixels" too.)
   manages at 60 fps once antialiasing is off; the cost was one setting, not the hardware's
   limits. Such tuning (e.g. dropping the pixel ratio after a few slow seconds) is to add only if
   a slow device turns up, measured first with `?fps` and the switches.
+
+### 2026-10-24 — Sprinter, Ghost, Wisp and Hunter
+
+User request: "can you add more enemies next?" Built the four of PLAN.md §10 (the Reaper stays for
+later), with the sprites §13 had chosen (mouse, block slime, fire slime, frog). The table's speeds
+had to fit plans of one move per turn, which the arrows, the danger overlay and Proximity alert
+all read:
+
+- **Every other turn is a rest**, a planned move to the tile it is on (a dot among the arrows;
+  eyes closed). Plans keep alternating as they are topped up; with nothing kept, the turn's
+  evenness and the enemy's id decide, so a group doesn't all move on the same turns. A Freeze
+  holds the plan as it is, rests included.
+- **A Sprinter's move is a dash of 2 tiles** in a line (drawn as two arrows), 1 if the second
+  tile is blocked. The middle tile counts: a bomb there stops the dash, and the player there is
+  caught (the Sprinter stops on them). Auto-move's safety check doesn't step onto either tile.
+- **Ghosts and Wisps go through crates**, as the NES's Kondoria and Ovapi went through soft
+  blocks; drawn see-through inside one. Flame stops on a crate and burns it, so it kills one
+  inside the first crate it reaches but not one behind it.
+- **A new type every 3 levels**, not 6, so a 20-level run meets all seven by level 19. This moves
+  the Seeker to level 4 and the Drifter to 7.
+- **Drops** follow the table's words: Sprinter and Wisp mostly a charge, Ghost coins, Hunter both.
+
+`SIM_VERSION` 28. Tests in `test/new-enemies.test.ts`.
diff --git a/docs/screenshots/almanac-enemies.png b/docs/screenshots/almanac-enemies.png
index fbab680..b6f6744 100644
Binary files a/docs/screenshots/almanac-enemies.png and b/docs/screenshots/almanac-enemies.png differ
diff --git a/docs/screenshots/enemies.png b/docs/screenshots/enemies.png
index 6c979a6..b8e6958 100644
Binary files a/docs/screenshots/enemies.png and b/docs/screenshots/enemies.png differ
diff --git a/docs/screenshots/menu.png b/docs/screenshots/menu.png
index b996e3f..b7866bd 100644
Binary files a/docs/screenshots/menu.png and b/docs/screenshots/menu.png differ
diff --git a/src/render/board.ts b/src/render/board.ts
index 61c20d1..9002b59 100644
--- a/src/render/board.ts
+++ b/src/render/board.ts
@@ -398,17 +398,21 @@ function drawEnemy(s: GameState, e: Enemy, stepped: { from: Vec; at: number } |
   const pos = k < 1 ? toWorld(s, stepped!.from).lerp(toWorld(s, e.pos), k).add(vec2(0, 0.08 * Math.sin(k * Math.PI))) : toWorld(s, e.pos)
   const art = ENEMY_SPRITES[e.kind]
   const moving = e.plan.length > 0
+  // Resting this turn (a Ghost or a Sprinter between moves): eyes closed, like one with nowhere to go.
+  const resting = moving && eq(e.plan[0]!, e.pos) && k >= 1
   // Frozen (Freeze): still, blue and iced over, thawing a step each turn.
   const frost = frostLevel(fx, s, time)
   const still = frost > 0.05
-  const frame = !moving ? art.rest : still ? art.a : Math.floor(time * 2.5 + e.id) % 2 ? art.a : art.b
+  const frame = !moving || resting ? art.rest : still ? art.a : Math.floor(time * 2.5 + e.id) % 2 ? art.a : art.b
   const angle = art.spins && moving && !still ? time * 3 : 0
-  const heading = moving ? dirBetween(e.pos, e.plan[0]!) : e.dir
+  const heading = (moving && e.plan.map((to, i) => dirBetween(i ? e.plan[i - 1]! : e.pos, to)).find((d) => d)) || e.dir
   const mirror = art.facesLeft && heading === 'right'
   // Just spawned: pops in.
   const size = 0.8 * appearScale(fx, key.enemy(e.id), time)
   const at = pos.add(vec2(-0.09 + frostCrack(fx, time), 0))
-  drawTile(at, vec2(size), sprite(frame), C.white.lerp(C.ice, frost), angle, mirror, C.frostGlow.scale(frost, 1))
+  // Inside a crate (a Ghost or a Wisp): see-through, so the crate shows it is in there.
+  const inCrate = cellAt(s, e.pos) === 'block'
+  drawTile(at, vec2(size), sprite(frame), C.white.lerp(C.ice, frost).scale(1, inCrate ? 0.6 : 1), angle, mirror, C.frostGlow.scale(frost, 1))
   if (frost > 0) drawIce(at, size, frost, e.id)
   if (!moving) return
 
@@ -416,10 +420,15 @@ function drawEnemy(s: GameState, e: Enemy, stepped: { from: Vec; at: number } |
   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
+    const d = vec2(Math.sign(to.x - from.x), Math.sign(from.y - to.y)) // world y is up
+    const tiles = Math.abs(to.x - from.x) + Math.abs(to.y - from.y)
     // Frozen, its plan waits: the arrows go icy and faint until it thaws.
     const color = C.intent.lerp(C.ice, frost).scale(1, (PLAN_ALPHA[i] ?? 0.3) * (1 - 0.5 * frost))
-    drawArrow(top.add(vec2(0, -gap * i)), d, 0.26, color)
+    const slot = top.add(vec2(0, -gap * i))
+    // A rest is a dot; a Sprinter's 2-tile dash, two arrows one behind the other.
+    if (!tiles) drawCircle(slot, 0.13, color)
+    else if (tiles > 1) for (const a of [-0.07, 0.07]) drawArrow(slot.add(d.scale(a)), d, 0.2, color)
+    else drawArrow(slot, d, 0.26, color)
     from = to
   }
 }
diff --git a/src/render/sprites.ts b/src/render/sprites.ts
index d26cd5b..46b8520 100644
--- a/src/render/sprites.ts
+++ b/src/render/sprites.ts
@@ -77,6 +77,18 @@ const BOARD = [
   'saw_rest',
   'saw_a',
   'saw_b',
+  'mouse_rest',
+  'mouse_walk_a',
+  'mouse_walk_b',
+  'slime_block_rest',
+  'slime_block_walk_a',
+  'slime_block_walk_b',
+  'slime_fire_rest',
+  'slime_fire_walk_a',
+  'slime_fire_walk_b',
+  'frog_rest',
+  'frog_idle',
+  'frog_jump',
   'powerup_bomb_plus',
   'powerup_fire_plus',
   'powerup_fuse_plus',
@@ -178,6 +190,11 @@ export const ENEMY_SPRITES: Record<EnemyKind, { rest: BoardSprite; a: BoardSprit
   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 },
+  // The later ones (2026-10-24), with the sprites PLAN.md §13 chose for them.
+  sprinter: { rest: 'mouse_rest', a: 'mouse_walk_a', b: 'mouse_walk_b', facesLeft: true, spins: false },
+  ghost: { rest: 'slime_block_rest', a: 'slime_block_walk_a', b: 'slime_block_walk_b', facesLeft: true, spins: false },
+  wisp: { rest: 'slime_fire_rest', a: 'slime_fire_walk_a', b: 'slime_fire_walk_b', facesLeft: true, spins: false },
+  hunter: { rest: 'frog_rest', a: 'frog_idle', b: 'frog_jump', facesLeft: true, spins: false },
 }
 
 /** Coin drops look like their value: bronze 1, silver 2, gold 5 or more. */
diff --git a/src/sim/demo.ts b/src/sim/demo.ts
index c55cee7..151dc54 100644
--- a/src/sim/demo.ts
+++ b/src/sim/demo.ts
@@ -226,7 +226,7 @@ export const SCENES: Record<SceneKind, SceneDef> = {
     shows: (t) => kills(t).some((k) => t.prev.burning.includes(idx(t.prev, k.pos))),
   },
   enemies: {
-    caption: 'Three kinds of enemy: Blobs wander, Seekers chase you, Drifters go straight until blocked.',
+    caption: 'Each kind of enemy moves its own way: Blobs wander, Seekers chase you, Drifters go straight. More kinds come later.',
     enemies: ['blob', 'seeker', 'drifter'],
     shows: (t) => kills(t).length > 0,
   },
diff --git a/src/sim/enemies.ts b/src/sim/enemies.ts
index c8fa0c1..8cc2e86 100644
--- a/src/sim/enemies.ts
+++ b/src/sim/enemies.ts
@@ -2,9 +2,22 @@ import { Rng } from './rng'
 import { add, bombAt, cellAt, eq } from './state'
 import { CHARGE_IDS, DIRS, DIR_LIST, type ChargeId, type Dir, type Drop, type Enemy, type EnemyKind, type GameEvent, type GameState, type Vec } from './types'
 
-/** 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)
+/** Ghosts and Wisps go through crates (soft blocks), as the NES's Kondoria and Ovapi did. */
+export const PASSES_CRATES: ReadonlySet<EnemyKind> = new Set(['ghost', 'wisp'])
+
+/**
+ * Ghosts and Sprinters move only every other turn, resting in between (a Sprinter dashes 2 tiles
+ * when it moves). A rest is a planned "move" to the tile it is on.
+ */
+export const RESTS: ReadonlySet<EnemyKind> = new Set(['ghost', 'sprinter'])
+
+/**
+ * Enemies walk on floor, Ghosts and Wisps through crates too, and all are stopped by bombs. They
+ * don't block each other; they can share a tile.
+ */
+export function enemyCanEnter(s: GameState, p: Vec, kind?: EnemyKind): boolean {
+  const cell = cellAt(s, p)
+  return (cell === 'floor' || (cell === 'block' && !!kind && PASSES_CRATES.has(kind))) && !bombAt(s, p)
 }
 
 export function enemyAt(s: GameState, p: Vec): Enemy | undefined {
@@ -18,22 +31,45 @@ interface Step {
 
 /** Where an enemy is, or will be, while its moves are planned ahead. */
 interface Walker {
+  kind: EnemyKind
   pos: Vec
   dir: Dir
   /** Whether another enemy, planned before this one, will be on `p` at the move being planned. */
   crowded(p: Vec): boolean
 }
 
-/** 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))!
+/** The direction of a step in a straight line (one tile, or a Sprinter's two); null for a rest. */
+export function dirBetween(from: Vec, to: Vec): Dir | null {
+  const dx = Math.sign(to.x - from.x)
+  const dy = Math.sign(to.y - from.y)
+  return DIR_LIST.find((d) => DIRS[d].x === dx && DIRS[d].y === dy) ?? null
+}
+
+/**
+ * The tiles a planned move from `from` to `to` passes over and ends on: none for a rest, the one
+ * tile for a step, the middle one and the end for a Sprinter's dash.
+ */
+export function moveTiles(from: Vec, to: Vec): Vec[] {
+  const steps = Math.abs(to.x - from.x) + Math.abs(to.y - from.y)
+  if (steps < 2) return steps ? [to] : []
+  return [{ x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 }, to]
+}
+
+/** Whether a kept planned move is still one this enemy can make on the board as it is now. */
+function stillPossible(s: GameState, kind: EnemyKind, from: Vec, to: Vec): boolean {
+  const dx = Math.abs(to.x - from.x)
+  const dy = Math.abs(to.y - from.y)
+  if (dx + dy === 0) return RESTS.has(kind)
+  if (dx + dy === 2 && (dx === 0 || dy === 0) && kind !== 'sprinter') return false
+  if (dx + dy > 2 || (dx && dy)) return false
+  return moveTiles(from, to).every((p) => enemyCanEnter(s, p, kind))
 }
 
 /** How many moves ahead every enemy plans, and shows (PLAN.md §4). */
 export const PLAN_AHEAD = 3
 
-function openSteps(s: GameState, w: Pick<Walker, 'pos'>): Step[] {
-  return DIR_LIST.map((dir) => ({ dir, to: add(w.pos, DIRS[dir]) })).filter((st) => enemyCanEnter(s, st.to))
+function openSteps(s: GameState, w: Pick<Walker, 'pos' | 'kind'>): Step[] {
+  return DIR_LIST.map((dir) => ({ dir, to: add(w.pos, DIRS[dir]) })).filter((st) => enemyCanEnter(s, st.to, w.kind))
 }
 
 /**
@@ -61,10 +97,11 @@ function drift(s: GameState, w: Walker, rng: Rng): 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.
+ * The first step of a shortest path over tiles this enemy can enter (through crates, for a
+ * Ghost) 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: Pick<Walker, 'pos'>): Step | null {
+export function stepToward(s: GameState, w: Pick<Walker, 'pos' | 'kind'>): 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>()
@@ -78,7 +115,7 @@ export function stepToward(s: GameState, w: Pick<Walker, 'pos'>): 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, w.pos) || !enemyCanEnter(s, q)) continue
+      if (first.has(key(q)) || eq(q, w.pos) || !enemyCanEnter(s, q, w.kind)) continue
       first.set(key(q), first.get(key(p))!)
       if (eq(q, goal)) return first.get(key(q))!
       queue.push(q)
@@ -87,15 +124,35 @@ export function stepToward(s: GameState, w: Pick<Walker, 'pos'>): Step | null {
   return null
 }
 
+/** A step along the shortest way to the player (unless another enemy takes that tile then), else a wander. */
+function chase(s: GameState, w: Walker, rng: Rng): Step | null {
+  const toward = stepToward(s, w)
+  return toward && !w.crowded(toward.to) ? toward : wander(s, w, rng)
+}
+
+/**
+ * Sprinter: half the time a chase, otherwise a wander; then on along the same line for a second
+ * tile if it can (a dash), or just the one.
+ */
+function dash(s: GameState, w: Walker, rng: Rng): Step | null {
+  const first = rng.next() < 0.5 ? chase(s, w, rng) : wander(s, w, rng)
+  if (!first) return null
+  const second = add(first.to, DIRS[first.dir])
+  return enemyCanEnter(s, second, w.kind) && !w.crowded(second) ? { dir: first.dir, to: second } : first
+}
+
 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 (unless another enemy
-  // takes that tile then), otherwise a Blob's wander.
-  seeker: (s, w, rng) => {
-    const toward = rng.next() < 0.5 ? stepToward(s, w) : null
-    return toward && !w.crowded(toward.to) ? toward : wander(s, w, rng)
-  },
+  // Seeker: half the time a chase, otherwise a Blob's wander.
+  seeker: (s, w, rng) => (rng.next() < 0.5 ? chase(s, w, rng) : wander(s, w, rng)),
   drifter: drift,
+  sprinter: dash,
+  // Ghost: always chases, through crates, every other turn.
+  ghost: chase,
+  // Wisp: wanders like a Blob, through crates.
+  wisp: wander,
+  // Hunter: always chases.
+  hunter: chase,
 }
 
 /**
@@ -114,25 +171,33 @@ export function planIntents(s: GameState): void {
   for (const e of s.enemies) {
     let from = e.pos
     const kept: Vec[] = []
+    let dir = e.dir
+    let rested: boolean | null = null
     for (const p of e.plan) {
-      if (!enemyCanEnter(s, p) || Math.abs(p.x - from.x) + Math.abs(p.y - from.y) !== 1) break
+      if (!stillPossible(s, e.kind, from, p)) break
       kept.push(p)
+      rested = eq(p, from)
+      dir = dirBetween(from, p) ?? dir
       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,
-      crowded: (p) => taken.has(at(e.plan.length + 1, p)),
-    }
+    // Carry on from the last kept step, heading the way the last move went.
+    const w: Walker = { kind: e.kind, pos: from, dir, crowded: (p) => taken.has(at(e.plan.length + 1, p)) }
+    // One that rests moves and rests by turns: after its last kept move it rests; with nothing
+    // kept, the turn's evenness decides, so they don't all move on the same turns.
+    let rest = RESTS.has(e.kind) && (rested === null ? (s.turn + e.id) % 2 === 1 : !rested)
     e.plan = kept
     while (e.plan.length < PLAN_AHEAD) {
+      if (rest) {
+        e.plan.push(w.pos)
+        rest = false
+        continue
+      }
       const st = PLANNERS[e.kind](s, w, rng)
       if (!st) break
       e.plan.push(st.to)
       w.pos = st.to
       w.dir = st.dir
+      rest = RESTS.has(e.kind)
     }
     e.dir = w.dir
     for (let t = 1; t <= PLAN_AHEAD; t++) taken.add(at(t, e.plan[t - 1] ?? e.plan.at(-1) ?? e.pos))
@@ -153,13 +218,19 @@ export function moveEnemies(s: GameState, events: GameEvent[]): void {
   for (const e of s.enemies) {
     const next = e.plan[0]
     if (!next) continue
-    if (!enemyCanEnter(s, next)) {
+    const tiles = moveTiles(e.pos, next)
+    if (!tiles.every((p) => enemyCanEnter(s, p, e.kind))) {
       e.plan = []
       continue
     }
-    events.push({ type: 'enemyMoved', id: e.id, from: e.pos, to: next })
-    e.pos = next
     e.plan.shift()
+    // A rest: it stays where it is.
+    if (!tiles.length) continue
+    // A Sprinter dashing through the player's tile stops there: it caught them.
+    const to = tiles.find((p) => eq(p, s.player)) ?? next
+    if (!eq(to, next)) e.plan = []
+    events.push({ type: 'enemyMoved', id: e.id, from: e.pos, to })
+    e.pos = to
   }
 }
 
@@ -171,6 +242,12 @@ const DROP_CHANCE: Record<EnemyKind, { coins: number; charge: number }> = {
   blob: { coins: 0.6, charge: 0.15 },
   seeker: { coins: 0.8, charge: 0.25 },
   drifter: { coins: 0.5, charge: 0.5 },
+  // The later ones (2026-10-24), after PLAN.md §10: a Sprinter and a Wisp mostly a charge, a
+  // Ghost coins, a Hunter both.
+  sprinter: { coins: 0.4, charge: 0.6 },
+  ghost: { coins: 0.9, charge: 0.25 },
+  wisp: { coins: 0.4, charge: 0.6 },
+  hunter: { coins: 0.9, charge: 0.6 },
 }
 
 /**
diff --git a/src/sim/levelgen.ts b/src/sim/levelgen.ts
index 4fe4b9a..13b59a9 100644
--- a/src/sim/levelgen.ts
+++ b/src/sim/levelgen.ts
@@ -17,12 +17,14 @@ export const SAFE_DISTANCE = 4
 export const LAST_LEVEL = 20
 
 /**
- * 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.
+ * Which enemy types a level can have (PLAN.md §10): Blobs only at first, then one new type every
+ * 3 levels, so all seven are met by level 19 of the 20 (every 6 levels while there were three).
  */
+export const ENEMY_ORDER: readonly EnemyKind[] = ['blob', 'seeker', 'drifter', 'sprinter', 'ghost', 'wisp', 'hunter']
+export const NEW_ENEMY_EVERY = 3
+
 export function enemyKinds(level: number): EnemyKind[] {
-  const all: EnemyKind[] = ['blob', 'seeker', 'drifter']
-  return all.slice(0, 1 + Math.floor((level - 1) / 6))
+  return ENEMY_ORDER.slice(0, 1 + Math.floor((level - 1) / NEW_ENEMY_EVERY))
 }
 
 /** 2 enemies on level 1, one more every 4 levels, at most 5. To tune by playtesting. */
diff --git a/src/sim/path.ts b/src/sim/path.ts
index cc6433d..559e12e 100644
--- a/src/sim/path.ts
+++ b/src/sim/path.ts
@@ -1,6 +1,6 @@
 import { ACTIONS, isAvailable, isValidTarget } from './actions'
 import type { Danger } from './danger'
-import { enemyAt } from './enemies'
+import { enemyAt, moveTiles } from './enemies'
 import { add, eq, exitOpen, flameSafe, idx, playerCanPass } from './state'
 import { DIRS, DIR_LIST, type Action, type GameState, type Vec } from './types'
 
@@ -71,11 +71,11 @@ export function autoMoveAction(s: GameState, to: Vec, safe: (p: Vec) => boolean)
 }
 
 /**
- * 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.
+ * Whether auto-move may take this step: not onto an enemy, not onto a tile an enemy moves to or
+ * dashes through next, and not into flame this turn. Those would be deaths the player didn't choose.
  */
 export function safeStep(s: GameState, danger: Danger, p: Vec): boolean {
   // Frozen enemies won't move this turn; flame doesn't matter while it can't hurt.
-  const intended = s.frozen === 0 && s.enemies.some((e) => e.plan[0] && eq(e.plan[0], p))
+  const intended = s.frozen === 0 && s.enemies.some((e) => e.plan[0] && moveTiles(e.pos, e.plan[0]).some((t) => eq(t, p)))
   return !enemyAt(s, p) && !intended && (flameSafe(s) || !danger.now.has(idx(s, p)))
 }
diff --git a/src/sim/record.ts b/src/sim/record.ts
index dd49645..12b6210 100644
--- a/src/sim/record.ts
+++ b/src/sim/record.ts
@@ -6,7 +6,7 @@ import { DEFAULT_OPTIONS, type Action, type GameEvent, type GameState, type RunO
  * 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 = 27
+export const SIM_VERSION = 28
 
 /** Everything needed to reproduce a run exactly: mid-run saves, replays, certificates. */
 export interface RunRecord {
diff --git a/src/sim/types.ts b/src/sim/types.ts
index 5533ebb..8b228ae 100644
--- a/src/sim/types.ts
+++ b/src/sim/types.ts
@@ -133,8 +133,8 @@ export interface Powers {
   slide: boolean
 }
 
-/** The enemy types so far (PLAN.md §10). */
-export type EnemyKind = 'blob' | 'seeker' | 'drifter'
+/** The enemy types so far (PLAN.md §10); the Reaper is for later. */
+export type EnemyKind = 'blob' | 'seeker' | 'drifter' | 'sprinter' | 'ghost' | 'wisp' | 'hunter'
 
 export interface Enemy {
   /** Stable for the enemy's life, so the renderer can follow it between turns. */
@@ -144,8 +144,10 @@ export interface Enemy {
   /** The way its last planned move goes. Blobs and Drifters keep going this way. */
   dir: Dir
   /**
-   * 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.
+   * Its next moves, up to PLAN_AHEAD of them, first one first (announced in phase 5, taken one
+   * per turn in phase 2): where it will be after each. Usually the next tile; the tile it is on
+   * for a rest (Ghost, Sprinter); 2 tiles on for a Sprinter's dash. Empty: it has nowhere to go
+   * and stays put.
    */
   plan: Vec[]
   /** Spawned as a punishment (the exit was flamed): drops nothing. */
diff --git a/src/ui/almanac.ts b/src/ui/almanac.ts
index 51c5276..d97333c 100644
--- a/src/ui/almanac.ts
+++ b/src/ui/almanac.ts
@@ -1,4 +1,4 @@
-import { enemyKinds } from '../sim/levelgen'
+import { ENEMY_ORDER, enemyKinds } from '../sim/levelgen'
 import { OFFER_SIZE } from '../sim/turn'
 import { CHARGE_IDS, type EnemyKind, type PowerUpKind } from '../sim/types'
 import { CHARGE_ICONS, ENEMY_SPRITES, POWER_UP_ICONS, spriteUrl } from '../render/sprites'
@@ -25,7 +25,7 @@ const POWER_UPS: PowerUpKind[] = [
   'wallPass', 'bombPass', 'flamePass', 'star', 'heart', 'coinBag',
 ]
 
-const ENEMIES: EnemyKind[] = ['blob', 'seeker', 'drifter']
+const ENEMIES = ENEMY_ORDER
 
 /** The first level an enemy type can appear on. */
 function firstLevel(kind: EnemyKind): number {
diff --git a/src/ui/info.ts b/src/ui/info.ts
index 28478f1..3aaf144 100644
--- a/src/ui/info.ts
+++ b/src/ui/info.ts
@@ -4,7 +4,7 @@ import { bombAt, cellAt, eq, exitOpen, isBurning, CLEAR_BONUS, PAR_BONUS } from
 import { COIN_BAG, EXIT_SPAWN, OFFER_SIZE, STAR_TURNS } from '../sim/turn'
 import { SHOP_EVERY } from '../sim/shop'
 import type { ActionId, ChargeId, Death, Dir, EnemyKind, GameState, Powers, PowerUpKind, Vec } from '../sim/types'
-import { dirBetween } from '../sim/enemies'
+import { dirBetween, RESTS } from '../sim/enemies'
 import type { SceneKind } from '../sim/demo'
 import { CHARGE_ICONS, coinSprite, ENEMY_SPRITES, GEM_SPRITES, isOuterWall, POWER_UP_ICONS, spriteUrl, wallSprite } from '../render/sprites'
 
@@ -26,6 +26,10 @@ export const ENEMY_TEXT: Record<EnemyKind, { name: string; moves: string }> = {
   blob: { name: 'Blob', moves: 'Wanders, usually carrying on the way it was going.' },
   seeker: { name: 'Seeker', moves: 'Half the time it takes the shortest way toward you (or your decoy); otherwise it wanders like a Blob.' },
   drifter: { name: 'Drifter', moves: 'Goes straight until something blocks it (or another enemy is heading there), then turns.' },
+  sprinter: { name: 'Sprinter', moves: 'Rests every other turn, then dashes 2 tiles at once, half the time toward you (or your decoy). A dash through your tile catches you.' },
+  ghost: { name: 'Ghost', moves: 'Moves every other turn, always the shortest way toward you (or your decoy), straight through crates. Flame that burns its crate kills it.' },
+  wisp: { name: 'Wisp', moves: 'Wanders like a Blob, but straight through crates. Flame that burns its crate kills it.' },
+  hunter: { name: 'Hunter', moves: 'Always takes the shortest way toward you (or your decoy).' },
 }
 
 export const POWER_UP_TEXT: Record<PowerUpKind, { name: string; text: string }> = {
@@ -216,7 +220,14 @@ export function tileInfo(s: GameState, p: Vec): Info | null {
   const enemies = s.enemies.filter((e) => eq(e.pos, p))
   const e = enemies[0]
   if (e) {
-    const moves = e.plan.map((to, i) => ARROWS[dirBetween(i ? e.plan[i - 1]! : e.pos, to)]).join(' ')
+    const moves = e.plan
+      .map((to, i) => {
+        const from = i ? e.plan[i - 1]! : e.pos
+        const dir = dirBetween(from, to)
+        // A rest is a dot; a Sprinter's dash, a doubled arrow.
+        return !dir ? '·' : Math.abs(to.x - from.x) + Math.abs(to.y - from.y) > 1 ? ARROWS[dir].repeat(2) : ARROWS[dir]
+      })
+      .join(' ')
     const more = enemies.length > 1 ? ` (and ${enemies.length - 1} more on this tile)` : ''
     return {
       icon: ENEMY_SPRITES[e.kind].a,
@@ -224,7 +235,7 @@ export function tileInfo(s: GameState, p: Vec): Info | null {
       body: [
         ENEMY_TEXT[e.kind].moves,
         moves
-          ? `Its next moves: ${moves}. The arrows beside it show them, next move on top. It makes exactly those moves unless a bomb gets in its way.`
+          ? `Its next moves: ${moves}. The arrows beside it show them, next move on top${RESTS.has(e.kind) ? ' (a dot is a rest' + (e.kind === 'sprinter' ? ', a doubled arrow a 2-tile dash)' : ')') : ''}. It makes exactly those moves unless a bomb gets in its way.`
           : 'It has nowhere to go right now, so it stays put (eyes closed).',
         'Flame kills it, including flame still burning from last turn. Touching it costs you a life.',
       ],
diff --git a/test/enemies.test.ts b/test/enemies.test.ts
index 919f04d..d7d6b3e 100644
--- a/test/enemies.test.ts
+++ b/test/enemies.test.ts
@@ -27,12 +27,16 @@ describe('levelgen: enemies and exit', () => {
     })
   }
 
-  it('brings in a new type every 6 levels', () => {
+  it('brings in a new type every 3 levels, all seven by level 19', () => {
     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'])
+    expect(enemyKinds(3)).toEqual(['blob'])
+    expect(enemyKinds(4)).toEqual(['blob', 'seeker'])
+    expect(enemyKinds(7)).toEqual(['blob', 'seeker', 'drifter'])
+    expect(enemyKinds(10)).toContain('sprinter')
+    expect(enemyKinds(13)).toContain('ghost')
+    expect(enemyKinds(16)).toContain('wisp')
+    expect(enemyKinds(19)).toEqual(['blob', 'seeker', 'drifter', 'sprinter', 'ghost', 'wisp', 'hunter'])
+    expect(enemyKinds(50)).toEqual(enemyKinds(19))
   })
 
   it('a new level already shows every enemy plan, up to 3 moves', () => {
diff --git a/test/helpers.ts b/test/helpers.ts
index ec6ae89..3147eb0 100644
--- a/test/helpers.ts
+++ b/test/helpers.ts
@@ -1,13 +1,14 @@
 import { noBoosts, noPowers, START_LIVES, START_STATS } from '../src/sim/state'
 import { DEFAULT_OPTIONS, type Cell, type EnemyKind, type GameState, type Stats, type Vec } from '../src/sim/types'
 
-const ENEMIES: Record<string, EnemyKind> = { B: 'blob', S: 'seeker', D: 'drifter' }
+const ENEMIES: Record<string, EnemyKind> = { B: 'blob', S: 'seeker', D: 'drifter', R: 'sprinter', G: 'ghost', W: 'wisp', H: 'hunter' }
 
 /**
  * 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 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.
+ * `B` / `S` / `D` / `R` / `G` / `W` / `H` a Blob / Seeker / Drifter / Sprinter (runner) / Ghost /
+ * Wisp / Hunter facing right with an empty plan (it stays put unless the test gives it one),
+ * `E` the exit uncovered, `e` the exit under a soft block, `$` a 1-coin drop.
  * Without `E` or `e` the exit is off the board, so it never opens.
  */
 export function board(picture: string, stats: Partial<Stats> = {}): GameState {
diff --git a/test/new-enemies.test.ts b/test/new-enemies.test.ts
new file mode 100644
index 0000000..3cbcd59
--- /dev/null
+++ b/test/new-enemies.test.ts
@@ -0,0 +1,177 @@
+import { describe, expect, it } from 'vitest'
+import { predictDanger } from '../src/sim/danger'
+import { enemyCanEnter, moveTiles, planIntents } from '../src/sim/enemies'
+import { safeStep } from '../src/sim/path'
+import { step } from '../src/sim/turn'
+import type { GameState, Vec } from '../src/sim/types'
+import { board, v } from './helpers'
+
+const steps = (a: Vec, b: Vec) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y)
+
+/** The size of each planned move: 0 a rest, 1 a step, 2 a Sprinter's dash. */
+function moveSizes(s: GameState, i = 0): number[] {
+  const e = s.enemies[i]!
+  return e.plan.map((to, k) => steps(k ? e.plan[k - 1]! : e.pos, to))
+}
+
+describe('Ghosts and Wisps go through crates', () => {
+  it('a Ghost or a Wisp can enter a crate; a Blob, Hunter or Sprinter cannot; a bomb stops all', () => {
+    const s = board(`
+      #####
+      #P+1#
+      #####`)
+    for (const kind of ['ghost', 'wisp'] as const) expect(enemyCanEnter(s, v(2, 1), kind)).toBe(true)
+    for (const kind of ['blob', 'hunter', 'sprinter'] as const) expect(enemyCanEnter(s, v(2, 1), kind)).toBe(false)
+    expect(enemyCanEnter(s, v(3, 1), 'ghost')).toBe(false)
+  })
+
+  it('a Ghost chases straight through a wall of crates', () => {
+    const s = board(`
+      #######
+      #P++.G#
+      #######`)
+    let s2 = s
+    planIntents(s2)
+    const been: Vec[] = []
+    for (let turn = 0; turn < 5; turn++) {
+      s2 = step(s2, { id: 'wait' }).state
+      been.push(s2.enemies[0]!.pos)
+    }
+    // Into the first crate, and on to the next, a step every other turn.
+    expect(been).toContainEqual(v(3, 1))
+    expect(been.filter((p) => p.x === 3)).toHaveLength(2)
+  })
+
+  it('flame that burns the crate a Ghost is in kills it', () => {
+    let s = board(`
+      ######
+      #P.1+#
+      ######`)
+    s.enemies.push({ id: 0, kind: 'ghost', pos: v(4, 1), dir: 'left', plan: [v(4, 1)], spawned: true, sheltered: false })
+    s.bombs[0]!.timer = 1
+    s = step(s, { id: 'wait' }).state
+    expect(s.enemies).toHaveLength(0)
+  })
+})
+
+describe('Ghosts and Sprinters rest every other turn', () => {
+  it('a Ghost alternates a rest and a step', () => {
+    const s = board(`
+      #########
+      #P.....G#
+      #########`)
+    planIntents(s)
+    const sizes = moveSizes(s)
+    expect(sizes).toHaveLength(3)
+    expect(sizes.every((n, k) => n === (sizes[0]! === 0 ? [0, 1, 0] : [1, 0, 1])[k])).toBe(true)
+  })
+
+  it('keeps alternating as its plan is topped up turn after turn', () => {
+    let s = board(`
+      ###########
+      #P.......G#
+      ###########`)
+    planIntents(s)
+    const done: number[] = []
+    for (let turn = 0; turn < 6; turn++) {
+      const before = s.enemies[0]!.pos
+      s = step(s, { id: 'wait' }).state
+      done.push(steps(before, s.enemies[0]!.pos))
+    }
+    for (let k = 1; k < done.length; k++) expect(done[k]).not.toBe(done[k - 1])
+  })
+
+  it('a Sprinter dashes 2 tiles along a corridor, resting between dashes', () => {
+    const s = board(`
+      ###########
+      #P.......R#
+      ###########`)
+    planIntents(s)
+    const sizes = moveSizes(s).filter((n) => n > 0)
+    expect(sizes.length).toBeGreaterThan(0)
+    expect(sizes.every((n) => n === 2)).toBe(true)
+  })
+
+  it('a dash goes only 1 tile when the second is blocked', () => {
+    const s = board(`
+      #####
+      #.R.#
+      #P###
+      #####`)
+    s.enemies[0]!.plan = [v(2, 1)]
+    planIntents(s)
+    for (const [k, to] of s.enemies[0]!.plan.entries()) expect(steps(k ? s.enemies[0]!.plan[k - 1]! : v(2, 1), to)).toBeLessThanOrEqual(1)
+  })
+
+  it('a dash through the player catches them on the way', () => {
+    const s = board(`
+      ######
+      #R.P.#
+      ######`)
+    s.enemies[0]!.plan = [v(3, 1)]
+    const r = step(s, { id: 'wait' })
+    // The dash would have ended at (3, 1) anyway here; a longer one stops on the player.
+    expect(r.state.status).toBe('dead')
+  })
+
+  it('a dash whose middle tile is the player stops there', () => {
+    const s = board(`
+      #######
+      #.R.P.#
+      #.#####
+      #######`)
+    s.player = v(3, 1)
+    s.enemies[0]!.plan = [v(4, 1)]
+    const r = step(s, { id: 'wait' })
+    expect(r.events).toContainEqual({ type: 'enemyMoved', id: 0, from: v(2, 1), to: v(3, 1) })
+    expect(r.state.status).toBe('dead')
+  })
+
+  it('a bomb on the middle tile stops a dash', () => {
+    const s = board(`
+      #######
+      #R5..P#
+      #######`)
+    s.enemies[0]!.plan = [v(3, 1)]
+    const r = step(s, { id: 'wait' })
+    expect(r.state.enemies[0]!.pos).toEqual(v(1, 1))
+  })
+
+  it('moveTiles: none for a rest, the tile for a step, the middle and the end for a dash', () => {
+    expect(moveTiles(v(1, 1), v(1, 1))).toEqual([])
+    expect(moveTiles(v(1, 1), v(2, 1))).toEqual([v(2, 1)])
+    expect(moveTiles(v(1, 1), v(1, 3))).toEqual([v(1, 2), v(1, 3)])
+  })
+})
+
+describe('Hunters always chase', () => {
+  it('every planned step of a Hunter gets closer to the player', () => {
+    for (let seed = 1; seed <= 20; seed++) {
+      const s = board(`
+        #########
+        #P......#
+        #.#.#.#.#
+        #......H#
+        #########`)
+      s.aiRng = seed
+      planIntents(s)
+      let from = s.enemies[0]!.pos
+      for (const to of s.enemies[0]!.plan) {
+        expect(steps(to, s.player)).toBe(steps(from, s.player) - 1)
+        from = to
+      }
+    }
+  })
+})
+
+describe('auto-move and a dash', () => {
+  it('will not step onto the middle tile of a Sprinter’s next dash', () => {
+    const s = board(`
+      #######
+      #R..P.#
+      #######`)
+    s.enemies[0]!.plan = [v(3, 1)]
+    expect(safeStep(s, predictDanger(s), v(2, 1))).toBe(false)
+    expect(safeStep(s, predictDanger(s), v(5, 1))).toBe(true)
+  })
+})
diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs
index a9f0257..917eb60 100644
--- a/tools/screenshots.mjs
+++ b/tools/screenshots.mjs
@@ -53,7 +53,7 @@ const SHOTS = [
   { 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') },
+  { name: 'enemies', query: 'level=19', act: press('Space', 'Space') },
   // Tapped a far tile: the way there in blue, 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.