fuzzyworld

explodorama / commit

Demo shows every feature; enemies walk into the flame before dying

fa3e81a7853f19037f39343ae6ba327de8d6d43e · Pavel Safronov · 2026-10-23 21:56:35 -0700 · parents 52d68b59af37

Claude Code usage: this commit ~78.0K out / ~34.9M cached · cost unknown (no per-model trailer) · session 3 tally 2.0K in / 1.0M out / 366.1M cached · usage by commit and session

Demo shows every feature; enemies walk into the flame before dying

The demo's scenes are now a table covering every feature on the board but
the shop and the exit: kill, the enemies' move arrows (bomb where an enemy
is going), escape, chain, lingering flame, the enemy types, drops, the gem,
every power-up that shows on the board and every charge. Each has its own
moment, checked by replaying it (showsAtEnd); each caption has an icon.
Heart and Coin bag only change numbers, so they have no scene.

An enemy that steps into flame finishes its step before its death
animation, instead of dying on the new tile without moving.

Usage: 2.0K in / 1.0M out / 366.1M cached (session tally)
rtk:   1.8M saved, 40.4% over 3296 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 CHANGELOG.md                         |   9 +-
 PLAN.md                              |  17 +-
 README.md                            |  10 +-
 decisions.md                         |  28 +++
 docs/screenshots/almanac-enemies.png | Bin 309080 -> 309032 bytes
 docs/screenshots/bomb-picked.png     | Bin 243597 -> 243580 bytes
 docs/screenshots/charges.png         | Bin 256646 -> 256654 bytes
 docs/screenshots/countdown.png       | Bin 248348 -> 248390 bytes
 docs/screenshots/death.png           | Bin 192554 -> 192485 bytes
 docs/screenshots/demo.png            | Bin 104147 -> 108596 bytes
 docs/screenshots/dev.png             | Bin 245790 -> 245842 bytes
 docs/screenshots/gem.png             | Bin 244285 -> 244258 bytes
 docs/screenshots/info-enemy.png      | Bin 181667 -> 181623 bytes
 docs/screenshots/long-press.png      | Bin 247145 -> 247163 bytes
 docs/screenshots/scrolled.png        | Bin 246848 -> 246763 bytes
 docs/screenshots/small-desktop.png   | Bin 271684 -> 271648 bytes
 docs/screenshots/small.png           | Bin 186731 -> 186730 bytes
 docs/screenshots/start.png           | Bin 243146 -> 243029 bytes
 src/main.ts                          |  22 ++-
 src/render/board.ts                  |  14 +-
 src/render/effects.ts                |  11 +-
 src/sim/demo.ts                      | 346 ++++++++++++++++++++++++++++-------
 test/demo.test.ts                    |  35 +---
 23 files changed, 375 insertions(+), 117 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 858bd41..661f1e9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -55,9 +55,10 @@ saved before the change is dropped and the next visit starts a new run.
   and dialogs.
 - **Art** from Kenney's New Platformer Pack, UI Pack, UI Pack: RPG Expansion, Game Icons and
   Board Game Icons (all CC0). Power-ups that improve something get a green modifier badge.
-- **A demo before the game:** scenes made up on the spot from the real rules (an enemy killed, a
-  narrow escape, a chain reaction, Freeze, the Detonator), each with a caption (and the icon of
-  the charge or power-up it's about), until you tap.
+- **A demo before the game:** scenes made up on the spot from the real rules, showing every
+  feature on the board — bombs, watching the enemies' arrows, escapes, chains, lingering flame,
+  the enemy types, drops, the gem, each power-up and each charge — each with a caption and an
+  icon, until you tap.
 - **Installable** as an app (Chrome, Edge, Android, iPhone / iPad), and playable offline once
   loaded.
 - **Deployment** to GitLab Pages on every push to the default branch, after the tests and the
@@ -79,6 +80,8 @@ saved before the change is dropped and the next visit starts a new run.
 - The top bar has a fixed number of rows, so the board no longer moves as things are picked up.
 - The Shield's ring is smaller and moves with the player.
 - Stepping onto the exit walks into the doorway before the jump (it used to happen instantly).
+- An enemy walking into flame finishes its step before it dies (it used to die on the new tile
+  without moving).
 - Saved runs from before the gems can't be resumed (`SIM_VERSION` 15).
 - Developer mode now only puts every level's gem uncovered next to the player; it no longer
   gives every ability, extra stats or unlimited charges.
diff --git a/PLAN.md b/PLAN.md
index 84a1a05..9b15be2 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -592,13 +592,18 @@ does nothing else; then the saved run, or a new one). Scenes are made **at play
 about one tile in six, the player and 1–3 enemies apart), then up to 150 random games on it by
 the real rules — mostly moves, sometimes bombs, never a move that kills the player — until one
 shows the scene's moment; the shortest of those is the scene (a new room if none does).
-Kinds: **kill** (every enemy dead), **escape** (the player steps out of a blast on the turn it
-goes off), **chain** (two bombs go off together and kill), **freeze** (a Freeze picked up, then
-a frozen enemy killed), **detonate** (the Detonator sets a bomb off and it kills). All five are
-found on every seed tried, in under 0.2 s. The UI (`main.ts`: `startDemo`, `demoTick`) plays a
+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
+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,
+so they have none. All 27 are found on every seed tried, the slowest (Pierce, given extra
+crates) in under 0.4 s; each caption has the thing's icon. The UI (`main.ts`: `startDemo`, `demoTick`) plays a
 turn every 0.6 s through the same animation code as play, hides the top bar and buttons, and
-fits the room above the caption panel. A scene about a charge or power-up (Freeze, the Detonator)
-shows its icon beside the caption, the same icon as on the board, in the top bar and the almanac. Ideas: more kinds (the exit, a gem's choice, Kick and
+fits the room above the caption panel. Every caption has an icon beside it: for a power-up or charge, the same icon as on the board, in
+the top bar and the almanac. Ideas: more kinds (the exit, a gem's choice, Kick and
 Throw), and showing it again after the player is idle on the menu.
 
 ## 13b. Run options (user request, 2026-10-21; built)
diff --git a/README.md b/README.md
index ee44415..8e59836 100644
--- a/README.md
+++ b/README.md
@@ -16,9 +16,13 @@ see [PLAN.md](PLAN.md).
 ## How to play
 
 **The demo:** when the game opens, it first plays a demo — short scenes made up on the spot from
-the real rules, each with a caption: bombs counting down and killing enemies, a narrow escape
-from a blast, a chain reaction, Freeze, and the Detonator; a scene about a charge or power-up
-shows its icon beside the caption. Every time it's different. It is
+the real rules, each with a caption and an icon. Between them they 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
+power-up you can see in action (Bomb +1, Fire +1, Fuse +1, Run, Kick, Throw, Teleport, Diagonal,
+the Detonator, Wall / Bomb / Flame Pass, Star) and every charge (Pierce, Power, Nuke, Shield,
+Freeze, Decoy). Heart and Coin bag only change numbers, so they have no scene; neither do the
+exit or the shop. The scenes come in a shuffled order, and every time they're different. It is
 view-only; **tap anywhere (or press any key) to play**, and that tap does nothing else. A test
 URL (`?seed` and the like) skips it; `?demo` shows it anyway.
 
diff --git a/decisions.md b/decisions.md
index efc27a3..9aa9ced 100644
--- a/decisions.md
+++ b/decisions.md
@@ -827,3 +827,31 @@ turn 20, and the 16 seeds without an error.
   badged bomb for the Detonator — the same icon as on the board, in the top bar and the almanac.
   The three scenes about bombs in general have none. The icon sits beside the words, not in the
   line, so a two-line caption stays evenly spaced.
+
+### 2026-10-23 — An enemy walks into the flame before it dies
+
+User report (spotted in the demo): an enemy walking into flame started its death on the new
+tile at once, without ever leaving the old one. Its step and its death come in the same turn,
+and the death replaced the step. Now an enemy that moved this turn and dies in it finishes the
+step first (the same 0.16 s hop as any enemy step, from the tile it left), then flips over and
+falls away there — as the player walks into the doorway before the level-cleared jump.
+`WALK_SECONDS` moved to effects.ts, which schedules the death after the step. Checked in a
+browser: 70 ms in, the Blob is half-way into the flame; at 350 ms it is dying on the flame tile.
+
+### 2026-10-23 — The demo shows every feature
+
+User request: the demo's scenes should show every feature of the game except the shop and the
+exit, and one more scene on the enemies' move arrows ("a good idea to watch those"). The scenes
+are now a table (`SCENES` in `sim/demo.ts`), each with its enemies, what the player starts with
+(a power-up held, or a charge lying two steps away to pick up first) and its moment — e.g. Kick:
+a kick and then a kill; Wall Pass: a step onto a crate; Nuke: a kill off the bomb's row and
+column; arrows: an enemy killed on the tile it stepped onto, the bomb waiting there. 27 scenes:
+kill, arrows, escape, chain, lingering flame, the enemy types, drops, the gem (uncovered and
+taken; its choice dialog isn't shown), Bomb +1, Fire +1, Fuse +1, Run, Kick, Throw, Teleport,
+Diagonal, the Detonator, Wall Pass, Bomb Pass, Flame Pass, Star, Pierce, Power, Nuke, Shield,
+Freeze and Decoy. Heart and Coin bag are left out: they only change numbers in the top bar,
+which the demo hides. The random play now also uses Run, Kick, Throw, Teleport and Diagonal
+when held. Pierce rarely found two crates in a row, so its room gets extra crates. Every
+caption now has an icon. Tests: each kind is found on 6 seeds, replays with the player alive,
+and `showsAtEnd` confirms its last turn is its moment; probes found every kind on 15–30 seeds,
+the slowest under 0.4 s. A minute of the demo in a browser played 8 scenes with no errors.
diff --git a/docs/screenshots/almanac-enemies.png b/docs/screenshots/almanac-enemies.png
index da9732b..cc02bf0 100644
Binary files a/docs/screenshots/almanac-enemies.png and b/docs/screenshots/almanac-enemies.png differ
diff --git a/docs/screenshots/bomb-picked.png b/docs/screenshots/bomb-picked.png
index 8146a75..527feb8 100644
Binary files a/docs/screenshots/bomb-picked.png and b/docs/screenshots/bomb-picked.png differ
diff --git a/docs/screenshots/charges.png b/docs/screenshots/charges.png
index 3d2252e..4b4c9d6 100644
Binary files a/docs/screenshots/charges.png and b/docs/screenshots/charges.png differ
diff --git a/docs/screenshots/countdown.png b/docs/screenshots/countdown.png
index 0aabfe6..a815c54 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 0d4e780..913089d 100644
Binary files a/docs/screenshots/death.png and b/docs/screenshots/death.png differ
diff --git a/docs/screenshots/demo.png b/docs/screenshots/demo.png
index 0c4ee20..21197d3 100644
Binary files a/docs/screenshots/demo.png and b/docs/screenshots/demo.png differ
diff --git a/docs/screenshots/dev.png b/docs/screenshots/dev.png
index abff8e7..4d41bde 100644
Binary files a/docs/screenshots/dev.png and b/docs/screenshots/dev.png differ
diff --git a/docs/screenshots/gem.png b/docs/screenshots/gem.png
index 5d3c9e6..f428aec 100644
Binary files a/docs/screenshots/gem.png and b/docs/screenshots/gem.png differ
diff --git a/docs/screenshots/info-enemy.png b/docs/screenshots/info-enemy.png
index 0a7fb44..bafa33b 100644
Binary files a/docs/screenshots/info-enemy.png and b/docs/screenshots/info-enemy.png differ
diff --git a/docs/screenshots/long-press.png b/docs/screenshots/long-press.png
index 174ec4e..ff227e7 100644
Binary files a/docs/screenshots/long-press.png and b/docs/screenshots/long-press.png differ
diff --git a/docs/screenshots/scrolled.png b/docs/screenshots/scrolled.png
index 6b1ede5..19ebf69 100644
Binary files a/docs/screenshots/scrolled.png and b/docs/screenshots/scrolled.png differ
diff --git a/docs/screenshots/small-desktop.png b/docs/screenshots/small-desktop.png
index 35e6590..a899fe6 100644
Binary files a/docs/screenshots/small-desktop.png and b/docs/screenshots/small-desktop.png differ
diff --git a/docs/screenshots/small.png b/docs/screenshots/small.png
index 6ae57e2..6b4d434 100644
Binary files a/docs/screenshots/small.png and b/docs/screenshots/small.png differ
diff --git a/docs/screenshots/start.png b/docs/screenshots/start.png
index 03d1e8f..7e9ebe5 100644
Binary files a/docs/screenshots/start.png and b/docs/screenshots/start.png differ
diff --git a/src/main.ts b/src/main.ts
index b5eeb8b..fce8c1d 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -30,7 +30,7 @@ import { dialogOpen, openDialog, type DialogButton } from './ui/dialog'
 import { actionInfo, chargeInfo, hudInfo, modeLines, tileInfo, type Info } from './ui/info'
 import { openOffer } from './ui/offer'
 import { reportError } from './devlog'
-import { generateScene, SCENE_CAPTIONS, SCENE_KINDS, type Scene, type SceneKind } from './sim/demo'
+import { generateScene, SCENE_KINDS, SCENES, type Scene, type SceneKind } from './sim/demo'
 import { spriteUrl } from './render/sprites'
 import playIcon from '../assets/kenney-ui/Extra/icon_play_light.png'
 import newRunIcon from '../assets/kenney-ui/Extra/icon_repeat_light.png'
@@ -535,11 +535,19 @@ let demoKinds: SceneKind[] = []
 const demoPanel = document.getElementById('demo')!
 const demoCaption = document.getElementById('demo-caption')!
 /**
- * The icon beside the caption of a scene about one charge or power-up, the one it has on the board,
- * in the top bar and in the almanac (user request, 2026-10-23), so the picture and the idea go
- * together.
+ * The icon beside each scene's caption (user request, 2026-10-23), so the picture and the idea go
+ * together: for a power-up or charge, the one it has on the board, in the top bar and the almanac.
  */
-const SCENE_ICONS: Partial<Record<SceneKind, string>> = { freeze: CHARGE_ICONS.freeze, detonate: POWER_UP_ICONS.detonator }
+const SCENE_ICONS: Record<SceneKind, string> = {
+  kill: 'bomb', arrows: 'slime_normal_walk_a', escape: 'icon_move', chain: 'bomb', lingering: 'fireball', enemies: 'slime_normal_rest',
+  coins: 'hud_coin', gem: 'gem_green',
+  bombPlus: POWER_UP_ICONS.bombPlus, firePlus: POWER_UP_ICONS.firePlus, fusePlus: POWER_UP_ICONS.fusePlus,
+  run: POWER_UP_ICONS.run, kick: POWER_UP_ICONS.kick, throw: POWER_UP_ICONS.throw, teleport: POWER_UP_ICONS.teleport,
+  diagonal: POWER_UP_ICONS.diagonal, detonate: POWER_UP_ICONS.detonator, wallPass: POWER_UP_ICONS.wallPass,
+  bombPass: POWER_UP_ICONS.bombPass, flamePass: POWER_UP_ICONS.flamePass, star: POWER_UP_ICONS.star,
+  pierce: CHARGE_ICONS.pierce, power: CHARGE_ICONS.power, nuke: CHARGE_ICONS.nuke, shield: CHARGE_ICONS.shield,
+  freeze: CHARGE_ICONS.freeze, decoy: CHARGE_ICONS.decoy,
+}
 
 /** The next scene, cycling through every kind in a shuffled order. */
 function nextScene(): void {
@@ -556,8 +564,8 @@ function nextScene(): void {
     state = scene.start
     const icon = SCENE_ICONS[scene.kind]
     demoCaption.innerHTML =
-      (icon ? `<img class="demo-icon${MONO_ICONS.has(icon) ? ' mono' : ''}" src="${spriteUrl(icon)}" alt="" draggable="false">` : '') +
-      `<span>${SCENE_CAPTIONS[scene.kind]}</span>`
+      `<img class="demo-icon${MONO_ICONS.has(icon) ? ' mono' : ''}" src="${spriteUrl(icon)}" alt="" draggable="false">` +
+      `<span>${SCENES[scene.kind].caption}</span>`
     afterChange()
     return
   }
diff --git a/src/render/board.ts b/src/render/board.ts
index 039d6a8..a33240c 100644
--- a/src/render/board.ts
+++ b/src/render/board.ts
@@ -6,7 +6,7 @@ import { blastTiles } from '../sim/explosions'
 import { dirBetween } from '../sim/enemies'
 import { cellAt, cloneState, eq, exitOpen, idx } from '../sim/state'
 import type { ActionId, Bomb, Enemy, GameState, Vec } from '../sim/types'
-import { appearScale, dropSprite, frostCrack, frostLevel, GONE_SECONDS, key, progress, pulseScale, type Effects, type Gone } from './effects'
+import { appearScale, dropSprite, frostCrack, frostLevel, GONE_SECONDS, key, progress, pulseScale, WALK_SECONDS, type Effects, type Gone } from './effects'
 import { CHARGE_ICONS, digitSprite, ENEMY_SPRITES, GEM_SPRITES, sprite, wallSprite, type BoardSprite } from './sprites'
 
 /**
@@ -63,8 +63,7 @@ export interface PlayerAnim {
   at: number
 }
 
-/** Seconds a step's slide takes, and a crouch. */
-export const WALK_SECONDS = 0.16
+/** Seconds a crouch takes (a step's slide: WALK_SECONDS, effects.ts). */
 const DUCK_SECONDS = 0.22
 /** Each half of a teleport: vanishing, then appearing. */
 const TELEPORT_SECONDS = 0.15
@@ -299,8 +298,15 @@ function drawGone(s: GameState, g: Gone): void {
   const pos = toWorld(s, 'pos' in g ? g.pos : g.enemy.pos)
   switch (g.kind) {
     case 'enemy': {
-      // Flips over with its eyes shut, hops up, and falls away fading.
       const art = ENEMY_SPRITES[g.enemy.kind]
+      // Walked into the flame: first the step there, hopping like any enemy step.
+      if (g.from && time < g.at) {
+        const w = 1 - (g.at - time) / WALK_SECONDS
+        const at = toWorld(s, g.from).lerp(pos, w).add(vec2(-0.09, 0.08 * Math.sin(w * Math.PI)))
+        drawTile(at, vec2(0.8), sprite(Math.floor(time * 2.5 + g.enemy.id) % 2 ? art.a : art.b), C.white, art.spins ? time * 3 : 0, art.facesLeft && g.from.x < g.enemy.pos.x)
+        break
+      }
+      // Flips over with its eyes shut, hops up, and falls away fading.
       const lift = 0.3 * Math.sin(k * Math.PI) - 0.25 * k
       drawTile(pos.add(vec2(-0.09, lift)), vec2(0.8 * (1 - 0.3 * k)), sprite(art.rest), fade, Math.PI * Math.min(1, k * 3))
       break
diff --git a/src/render/effects.ts b/src/render/effects.ts
index e089d9b..53abf4a 100644
--- a/src/render/effects.ts
+++ b/src/render/effects.ts
@@ -28,6 +28,8 @@ const THROW_SECONDS = 0.25
 const LINE_STAGGER = 0.05
 /** A thing picked up waits this long, for the player to arrive, before it rises away. */
 const TAKE_DELAY = 0.12
+/** Seconds a step's slide from tile to tile takes, the player's and the enemies'. */
+export const WALK_SECONDS = 0.16
 /** How long the ice takes to change from one Freeze turn to the next (freezing over, thawing a step). */
 export const FROST_SECONDS = 0.5
 
@@ -37,7 +39,8 @@ export const FROST_SECONDS = 0.5
  * decoy gone, a shield broken.
  */
 export type Gone = (
-  | { kind: 'enemy'; enemy: Enemy }
+  /** `from`: the tile it stepped off this turn, into the flame; it walks from there first. */
+  | { kind: 'enemy'; enemy: Enemy; from?: Vec }
   | { kind: 'crate'; pos: Vec }
   | { kind: 'flame'; pos: Vec }
   | { kind: 'taken'; sprite: BoardSprite; pos: Vec; size: number }
@@ -123,7 +126,11 @@ export function startEffects(fx: Effects, prev: GameState, next: GameState, even
         break
       case 'enemyDied': {
         const enemy = prev.enemies.find((o) => o.id === e.id)
-        if (enemy) fx.gone.push({ kind: 'enemy', enemy: { ...enemy, pos: e.pos }, at: now })
+        // Walked into the flame this turn: it finishes that step first, then dies (user report,
+        // 2026-10-23: it died on the new tile without ever leaving the old one).
+        const moved = events.find((m) => m.type === 'enemyMoved' && m.id === e.id)
+        const from = moved?.type === 'enemyMoved' ? moved.from : undefined
+        if (enemy) fx.gone.push({ kind: 'enemy', enemy: { ...enemy, pos: e.pos }, from, at: from ? now + WALK_SECONDS : now })
         break
       }
       case 'dropSpawned':
diff --git a/src/sim/demo.ts b/src/sim/demo.ts
index efdb080..1ba5d11 100644
--- a/src/sim/demo.ts
+++ b/src/sim/demo.ts
@@ -1,28 +1,34 @@
 import { ACTIONS, isAvailable } from './actions'
+import { predictDanger } from './danger'
 import { planIntents } from './enemies'
 import { Rng } from './rng'
-import { noBoosts, noPowers, START_LIVES, START_STATS } from './state'
+import { idx, noBoosts, noPowers, START_LIVES, START_STATS } from './state'
 import { step } from './turn'
-import { DEFAULT_OPTIONS, type Action, type ActionId, type Cell, type EnemyKind, type GameEvent, type GameState, type Vec } from './types'
+import {
+  DEFAULT_OPTIONS,
+  type Action,
+  type ActionId,
+  type Cell,
+  type ChargeId,
+  type EnemyKind,
+  type GameEvent,
+  type GameState,
+  type Vec,
+} from './types'
 
 /**
  * The demo played before the game starts, for players who never played the original (user
  * request, 2026-10-23). Each scene is made up on the spot: a small random room, then random play
  * on it, by the real rules, until a game shows what the scene is about. The scene is that room
  * and those actions; the UI replays them, view-only. Pure and seeded like the rest of the sim.
+ * The scenes show every feature that happens on the board (user request, 2026-10-23: all but
+ * the shop and the exit) — Heart and Coin bag only change numbers, so they have none.
  */
-export type SceneKind = 'kill' | 'escape' | 'chain' | 'freeze' | 'detonate'
-
-export const SCENE_KINDS: readonly SceneKind[] = ['kill', 'escape', 'chain', 'freeze', 'detonate']
-
-/** What each scene shows, in one line under it. */
-export const SCENE_CAPTIONS: Record<SceneKind, string> = {
-  kill: 'Bombs count down with your moves. Their flame kills enemies.',
-  escape: 'Step out of the blast just in time.',
-  chain: 'A blast sets off every bomb it reaches.',
-  freeze: 'Freeze stops every enemy in its tracks. Then bomb them.',
-  detonate: 'With the Detonator, set a bomb off right when an enemy walks in.',
-}
+export type SceneKind =
+  | 'kill' | 'arrows' | 'escape' | 'chain' | 'lingering' | 'enemies' | 'coins' | 'gem'
+  | 'bombPlus' | 'firePlus' | 'fusePlus' | 'run' | 'kick' | 'throw' | 'teleport' | 'diagonal' | 'detonate'
+  | 'wallPass' | 'bombPass' | 'flamePass' | 'star'
+  | 'pierce' | 'power' | 'nuke' | 'shield' | 'freeze' | 'decoy'
 
 export interface Scene {
   kind: SceneKind
@@ -30,24 +36,212 @@ export interface Scene {
   actions: Action[]
 }
 
+/** What a scene's game has done so far, for moments that need an earlier turn (a charge picked up). */
+interface Seen {
+  picked: Set<ChargeId>
+  used: Set<ActionId>
+}
+
+/** One turn of a game: the state before it, the action, its events and the state after. */
+interface Turn {
+  prev: GameState
+  action: Action
+  events: GameEvent[]
+  next: GameState
+}
+
+interface SceneDef {
+  /** One line under the scene. */
+  caption: string
+  enemies: EnemyKind[]
+  /** What the player starts with, or what lies near them. */
+  prepare?: (s: GameState, rng: Rng) => void
+  /** Whether this turn is the moment the scene is about. */
+  shows: (t: Turn, seen: Seen) => boolean
+}
+
 const WIDTH = 9
 const HEIGHT = 7
 /** How many turns a scene may take, how many random games are tried per room, and how many rooms. */
 const MAX_TURNS = 24
 const TRIES = 150
-const ROOMS = 6
-
-/** What each scene starts with: enemies, and what the player has. */
-const SETUP: Record<SceneKind, { enemies: EnemyKind[]; prepare?: (s: GameState, rng: Rng) => void }> = {
-  kill: { enemies: ['blob', 'seeker'] },
-  escape: { enemies: ['seeker'] },
-  chain: { enemies: ['blob', 'blob'], prepare: (s) => (s.stats.capacity = 2) },
-  // A Freeze charge two steps from the player, to walk over.
-  freeze: { enemies: ['seeker', 'blob', 'blob'], prepare: (s, rng) => dropNear(s, rng, 'freeze') },
-  // A long fuse, so the Detonator is what sets the bomb off.
-  detonate: { enemies: ['blob', 'seeker'], prepare: (s) => ((s.powers.detonate = true), (s.stats.fuse = 6)) },
+const ROOMS = 8
+
+const kills = (t: Turn) => t.events.filter((e): e is Extract<GameEvent, { type: 'enemyDied' }> => e.type === 'enemyDied')
+const explosions = (t: Turn) => t.events.filter((e): e is Extract<GameEvent, { type: 'exploded' }> => e.type === 'exploded')
+const at = (p: Vec, q: Vec) => p.x === q.x && p.y === q.y
+const steps = (p: Vec, q: Vec) => Math.abs(p.x - q.x) + Math.abs(p.y - q.y)
+/** The player was in a blast's way (danger this turn) and the action took them out of it. */
+const escaped = (t: Turn) => predictDanger(t.prev).now.has(idx(t.prev, t.prev.player)) && t.next.status === 'playing'
+/** A charge lying on a free floor tile two steps from the player, to walk over first. */
+const dropping = (kind: ChargeId) => (s: GameState, rng: Rng) => dropNear(s, rng, kind)
+
+export const SCENES: Record<SceneKind, SceneDef> = {
+  kill: {
+    caption: 'Bombs count down with your moves. Their flame kills enemies.',
+    enemies: ['blob', 'seeker'],
+    shows: (t) => t.next.enemies.length === 0,
+  },
+  arrows: {
+    caption: 'The red arrows are each enemy’s next 3 moves. Watch them, and bomb where an enemy is going.',
+    enemies: ['blob', 'drifter'],
+    // An enemy killed on the tile it stepped onto this turn: the bomb was waiting where its arrows led.
+    shows: (t) => kills(t).some((k) => t.events.some((e) => e.type === 'enemyMoved' && e.id === k.id && at(e.to, k.pos)) && explosions(t).length > 0),
+  },
+  escape: {
+    caption: 'Step out of the blast just in time.',
+    enemies: ['seeker'],
+    // Stood in a blast's reach, stepped out of it on the very turn it went off.
+    shows: (t) => explosions(t).some((e) => e.tiles.some((p) => at(p, t.prev.player))),
+  },
+  chain: {
+    caption: 'A blast sets off every bomb it reaches.',
+    enemies: ['blob', 'blob'],
+    prepare: (s) => (s.stats.capacity = 2),
+    shows: (t) => explosions(t).length >= 2 && kills(t).length > 0,
+  },
+  lingering: {
+    caption: 'Flame keeps burning for one more turn: enemies that walk in die.',
+    enemies: ['blob', 'drifter'],
+    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.',
+    enemies: ['blob', 'seeker', 'drifter'],
+    shows: (t) => kills(t).length > 0,
+  },
+  coins: {
+    caption: 'Enemies sometimes leave coins or a charge. Walk over them to take them.',
+    enemies: ['seeker', 'seeker'],
+    shows: (t) => t.events.some((e) => e.type === 'pickedUp'),
+  },
+  gem: {
+    caption: 'Every level hides a gem under a crate. Take it to choose a power-up.',
+    enemies: ['blob'],
+    prepare: (s, rng) => hideGem(s, rng),
+    shows: (t) => t.events.some((e) => e.type === 'powerUpTaken'),
+  },
+  bombPlus: {
+    caption: 'Bomb +1: one more bomb on the board at a time.',
+    enemies: ['blob', 'seeker'],
+    prepare: (s) => (s.stats.capacity = 3),
+    shows: (t) => t.prev.bombs.length >= 2 && kills(t).length > 0,
+  },
+  firePlus: {
+    caption: 'Fire +1: your blasts reach further.',
+    enemies: ['blob', 'drifter'],
+    prepare: (s) => (s.stats.fire = 3),
+    shows: (t) => kills(t).some((k) => explosions(t).some((e) => steps(e.pos, k.pos) >= 2)),
+  },
+  fusePlus: {
+    caption: 'Fuse +1: your bombs start higher, so you have longer to get away.',
+    enemies: ['seeker'],
+    prepare: (s) => (s.stats.fuse = 5),
+    shows: (t) => kills(t).length > 0,
+  },
+  run: {
+    caption: 'Run: two tiles in one turn — out of trouble fast.',
+    enemies: ['seeker', 'blob'],
+    prepare: (s) => (s.powers.run = true),
+    shows: (t) => t.action.id === 'run' && escaped(t),
+  },
+  kick: {
+    caption: 'Kick: walk into a bomb and it slides away until something stops it.',
+    enemies: ['blob', 'drifter'],
+    prepare: (s) => (s.powers.kick = true),
+    shows: (t, seen) => seen.used.has('kick') && kills(t).length > 0,
+  },
+  throw: {
+    caption: 'Throw: put a bomb two tiles away, over anything in between.',
+    enemies: ['blob', 'seeker'],
+    prepare: (s) => (s.powers.throw = true),
+    shows: (t, seen) => seen.used.has('throw') && kills(t).length > 0,
+  },
+  teleport: {
+    caption: 'Teleport: jump to any empty tile up to 3 steps away, over anything.',
+    enemies: ['seeker', 'blob'],
+    prepare: (s) => (s.powers.teleport = true),
+    shows: (t) => t.action.id === 'teleport' && escaped(t),
+  },
+  diagonal: {
+    caption: 'Diagonal: step diagonally, even between two blocked tiles.',
+    enemies: ['seeker', 'blob'],
+    prepare: (s) => (s.powers.diagonal = true),
+    shows: (t) => t.action.id === 'diagonal' && escaped(t),
+  },
+  detonate: {
+    caption: 'Detonator: set a bomb off right when an enemy walks in.',
+    enemies: ['blob', 'seeker'],
+    // A long fuse, so the Detonator is what sets the bomb off.
+    prepare: (s) => ((s.powers.detonate = true), (s.stats.fuse = 6)),
+    shows: (t) => t.events.some((e) => e.type === 'detonated') && kills(t).length > 0,
+  },
+  wallPass: {
+    caption: 'Wall Pass: walk through crates.',
+    enemies: ['blob'],
+    prepare: (s) => (s.powers.wallPass = true),
+    shows: (t) => t.events.some((e) => e.type === 'moved' && t.prev.cells[idx(t.prev, e.to)] === 'block'),
+  },
+  bombPass: {
+    caption: 'Bomb Pass: walk over bombs.',
+    enemies: ['blob'],
+    prepare: (s) => (s.powers.bombPass = true),
+    shows: (t) => t.events.some((e) => e.type === 'moved' && t.prev.bombs.some((b) => at(b.pos, e.to))),
+  },
+  flamePass: {
+    caption: 'Flame Pass: flame can’t hurt you (enemies still can).',
+    enemies: ['blob'],
+    prepare: (s) => (s.powers.flamePass = true),
+    shows: (t) => explosions(t).some((e) => e.tiles.some((p) => at(p, t.next.player))),
+  },
+  star: {
+    caption: 'Star: nothing can hurt you for 8 turns.',
+    enemies: ['seeker', 'blob'],
+    prepare: (s) => (s.star = 8),
+    shows: (t) => explosions(t).some((e) => e.tiles.some((p) => at(p, t.next.player))) || t.next.enemies.some((e) => at(e.pos, t.next.player)),
+  },
+  pierce: {
+    caption: 'Pierce: your next 3 bombs burn through crates.',
+    enemies: ['blob'],
+    // More crates than usual, for a row of them to burn through.
+    prepare: (s, rng) => (moreCrates(s, rng), dropNear(s, rng, 'pierce')),
+    shows: (t, seen) => seen.picked.has('pierce') && t.events.filter((e) => e.type === 'blockDestroyed').length >= 2,
+  },
+  power: {
+    caption: 'Power: your next 3 bombs reach all the way to the walls.',
+    enemies: ['blob', 'drifter'],
+    prepare: dropping('power'),
+    shows: (t, seen) => seen.picked.has('power') && kills(t).some((k) => explosions(t).some((e) => steps(e.pos, k.pos) >= 3)),
+  },
+  nuke: {
+    caption: 'Nuke: your next 3 bombs burn every tile 2 steps around them, round corners too.',
+    enemies: ['blob', 'seeker'],
+    prepare: dropping('nuke'),
+    // Killed off the bomb's row and column: only a Nuke reaches there.
+    shows: (t, seen) => seen.picked.has('nuke') && kills(t).some((k) => explosions(t).some((e) => e.pos.x !== k.pos.x && e.pos.y !== k.pos.y)),
+  },
+  shield: {
+    caption: 'Shield: the next hit is absorbed, then the shield is gone.',
+    enemies: ['seeker', 'seeker'],
+    prepare: dropping('shield'),
+    shows: (t) => t.events.some((e) => e.type === 'shieldBroke'),
+  },
+  freeze: {
+    caption: 'Freeze stops every enemy in its tracks. Then bomb them.',
+    enemies: ['seeker', 'blob', 'blob'],
+    prepare: dropping('freeze'),
+    shows: (t, seen) => seen.picked.has('freeze') && kills(t).length > 0 && t.prev.frozen > 0,
+  },
+  decoy: {
+    caption: 'Decoy: a copy of you that Seekers chase instead.',
+    enemies: ['seeker', 'seeker'],
+    prepare: dropping('decoy'),
+    shows: (t, seen) => seen.picked.has('decoy') && kills(t).length > 0 && !!t.prev.decoy,
+  },
 }
 
+export const SCENE_KINDS = Object.keys(SCENES) as SceneKind[]
+
 /** A scene of `kind`, from `seed`; null if no room gave one (rare). */
 export function generateScene(kind: SceneKind, seed: number): Scene | null {
   const rng = new Rng(seed)
@@ -65,6 +259,21 @@ export function generateScene(kind: SceneKind, seed: number): Scene | null {
   return null
 }
 
+/** Whether a scene's last turn is its moment, replaying it from the start (for tests). */
+export function showsAtEnd(scene: Scene): boolean {
+  let s = scene.start
+  const seen: Seen = { picked: new Set(), used: new Set() }
+  let last = false
+  for (const action of scene.actions) {
+    const r = step(s, action)
+    const turn = { prev: s, action, events: r.events, next: r.state }
+    remember(turn, seen)
+    last = SCENES[scene.kind].shows(turn, seen)
+    s = r.state
+  }
+  return last
+}
+
 /** A small room: walls round it, pillars on every other tile, a few crates; the player and enemies apart. */
 function makeRoom(kind: SceneKind, rng: Rng): GameState {
   const cells: Cell[] = []
@@ -77,9 +286,8 @@ function makeRoom(kind: SceneKind, rng: Rng): GameState {
   const floor = () => cells.flatMap((c, i) => (c === 'floor' ? [{ x: i % WIDTH, y: Math.floor(i / WIDTH) }] : []))
   const tiles = floor()
   const player = tiles[rng.int(tiles.length)]!
-  const far = (p: Vec) => Math.abs(p.x - player.x) + Math.abs(p.y - player.y)
   // Crates on about one free tile in six, never next to the player (room to place a bomb and go).
-  for (const p of tiles) if (far(p) > 2 && rng.next() < 0.16) cells[p.y * WIDTH + p.x] = 'block'
+  for (const p of tiles) if (steps(p, player) > 2 && rng.next() < 0.16) cells[p.y * WIDTH + p.x] = 'block'
   const s: GameState = {
     seed: rng.int(2 ** 31), options: { ...DEFAULT_OPTIONS, size: 'small' }, level: 1, turn: 0, width: WIDTH, height: HEIGHT,
     cells, player: { ...player }, lives: START_LIVES, stats: { ...START_STATS }, bombs: [], status: 'playing',
@@ -88,69 +296,77 @@ function makeRoom(kind: SceneKind, rng: Rng): GameState {
     powers: noPowers(), star: 0, powerUp: null, offer: null, runTurns: 0, deaths: 0, crumbling: [],
     aiRng: rng.int(2 ** 31), dropRng: rng.int(2 ** 31),
   }
-  const open = floor().filter((p) => far(p) >= 3)
-  for (const enemy of SETUP[kind].enemies) {
+  const open = floor().filter((p) => steps(p, player) >= 3)
+  for (const enemy of SCENES[kind].enemies) {
     if (!open.length) break
     const pos = open.splice(rng.int(open.length), 1)[0]!
     s.enemies.push({ id: s.nextEnemyId++, kind: enemy, pos, dir: 'left', plan: [], spawned: false, sheltered: false })
   }
-  SETUP[kind].prepare?.(s, rng)
+  SCENES[kind].prepare?.(s, rng)
   planIntents(s)
   return s
 }
 
-/** Put a charge on a free floor tile two steps from the player. */
-function dropNear(s: GameState, rng: Rng, kind: 'freeze'): void {
-  const near = s.cells.flatMap((c, i) => {
+/** Free floor tiles `n` steps from the player, with no enemy. */
+function tilesAt(s: GameState, n: number): Vec[] {
+  return s.cells.flatMap((c, i) => {
     const p = { x: i % s.width, y: Math.floor(i / s.width) }
-    return c === 'floor' && Math.abs(p.x - s.player.x) + Math.abs(p.y - s.player.y) === 2 && !s.enemies.some((e) => e.pos.x === p.x && e.pos.y === p.y) ? [p] : []
+    return c === 'floor' && steps(p, s.player) === n && !s.enemies.some((e) => at(e.pos, p)) ? [p] : []
   })
+}
+
+/** Crates on about a third more of the free tiles not next to the player. */
+function moreCrates(s: GameState, rng: Rng): void {
+  s.cells.forEach((c, i) => {
+    const p = { x: i % s.width, y: Math.floor(i / s.width) }
+    if (c === 'floor' && steps(p, s.player) > 2 && !s.enemies.some((e) => at(e.pos, p)) && rng.next() < 0.3) s.cells[i] = 'block'
+  })
+}
+
+/** Put a charge two steps from the player. */
+function dropNear(s: GameState, rng: Rng, kind: ChargeId): void {
+  const near = tilesAt(s, 2)
   if (near.length) s.drops.push({ pos: near[rng.int(near.length)]!, kind })
 }
 
+/** Hide the level's gem under a crate two steps from the player. */
+function hideGem(s: GameState, rng: Rng): void {
+  const near = tilesAt(s, 2)
+  if (!near.length) return
+  const pos = near[rng.int(near.length)]!
+  s.cells[idx(s, pos)] = 'block'
+  s.powerUp = { pos, color: (['blue', 'green', 'red', 'yellow'] as const)[rng.int(4)]! }
+}
+
+function remember(t: Turn, seen: Seen): void {
+  seen.used.add(t.action.id as ActionId)
+  for (const e of t.events) if (e.type === 'pickedUp' && e.drop.kind !== 'coins') seen.picked.add(e.drop.kind)
+}
+
 /** One random game from `start`; its actions if it shows the scene, otherwise null. */
 function rollout(kind: SceneKind, start: GameState, rng: Rng): Action[] | null {
   let s = start
   const actions: Action[] = []
-  const seen = { froze: -1 }
-  for (let turn = 0; turn < MAX_TURNS && s.status === 'playing'; turn++) {
+  const seen: Seen = { picked: new Set(), used: new Set() }
+  for (let n = 0; n < MAX_TURNS && s.status === 'playing' && !s.offer; n++) {
     // A random action that doesn't get the player killed this turn (a few goes at it).
-    let next: { action: Action; state: GameState; events: GameEvent[] } | null = null
-    for (let go = 0; go < 4 && !next; go++) {
+    let turn: Turn | null = null
+    for (let go = 0; go < 4 && !turn; go++) {
       const action = randomAction(s, rng)
       const r = step(s, action)
-      if (r.state.status === 'playing' || r.state.status === 'cleared') next = { action, state: r.state, events: r.events }
+      if (r.state.status === 'playing' || r.state.status === 'cleared') turn = { prev: s, action, events: r.events, next: r.state }
     }
-    if (!next) return null
-    actions.push(next.action)
-    if (shows(kind, s, next.state, next.events, seen, turn)) return actions
-    s = next.state
+    if (!turn) return null
+    actions.push(turn.action)
+    remember(turn, seen)
+    if (SCENES[kind].shows(turn, seen)) return actions
+    s = turn.next
   }
   return null
 }
 
-/** Whether this turn (from `prev` to `next`, with its events) is the moment the scene is about. */
-function shows(kind: SceneKind, prev: GameState, next: GameState, events: GameEvent[], seen: { froze: number }, turn: number): boolean {
-  const killed = events.some((e) => e.type === 'enemyDied')
-  const exploded = events.filter((e) => e.type === 'exploded')
-  switch (kind) {
-    case 'kill':
-      return next.enemies.length === 0
-    case 'escape':
-      // Stood in a blast's reach, stepped out of it on the very turn it went off.
-      return exploded.some((e) => e.type === 'exploded' && e.tiles.some((t) => t.x === prev.player.x && t.y === prev.player.y))
-    case 'chain':
-      return exploded.length >= 2 && killed
-    case 'freeze':
-      if (events.some((e) => e.type === 'pickedUp' && e.drop.kind === 'freeze')) seen.froze = turn
-      return seen.froze >= 0 && killed && prev.frozen > 0
-    case 'detonate':
-      return events.some((e) => e.type === 'detonated') && killed
-  }
-}
-
-/** Weights for a random action: mostly moving, bombing now and then, sometimes waiting. */
-const WEIGHTS: Partial<Record<ActionId, number>> = { move: 5, bomb: 2, wait: 1, detonate: 3 }
+/** Weights for a random action: mostly moving, bombing now and then, sometimes waiting; abilities when held. */
+const WEIGHTS: Partial<Record<ActionId, number>> = { move: 5, bomb: 2, wait: 1, detonate: 3, run: 2, kick: 3, throw: 2, teleport: 2, diagonal: 2 }
 
 function randomAction(s: GameState, rng: Rng): Action {
   const choices = (Object.keys(WEIGHTS) as ActionId[]).filter((id) => isAvailable(s, id))
diff --git a/test/demo.test.ts b/test/demo.test.ts
index c33190a..6613548 100644
--- a/test/demo.test.ts
+++ b/test/demo.test.ts
@@ -1,38 +1,19 @@
 import { describe, expect, it } from 'vitest'
-import { generateScene, SCENE_KINDS, type Scene } from '../src/sim/demo'
+import { generateScene, SCENE_KINDS, showsAtEnd } from '../src/sim/demo'
 import { step } from '../src/sim/turn'
-import type { GameEvent, GameState } from '../src/sim/types'
-
-/** Every turn of a scene, replayed by the rules: the state before it and its events. */
-function replay(scene: Scene): { before: GameState; events: GameEvent[]; after: GameState }[] {
-  let s = scene.start
-  return scene.actions.map((a) => {
-    const r = step(s, a)
-    const turn = { before: s, events: r.events, after: r.state }
-    s = r.state
-    return turn
-  })
-}
 
 describe('demo scenes', () => {
   for (const kind of SCENE_KINDS) {
-    it(`${kind}: found, replayable, the player alive, and ending on what it shows`, () => {
-      for (let seed = 1; seed <= 10; seed++) {
+    it(`${kind}: found, replayable with the player alive, and ending on what it shows`, () => {
+      for (let seed = 1; seed <= 6; seed++) {
         const scene = generateScene(kind, seed)!
         expect(scene).not.toBeNull()
-        const turns = replay(scene)
-        expect(turns.every((t) => t.after.status === 'playing' || t.after.status === 'cleared')).toBe(true)
-        const last = turns.at(-1)!
-        const types = last.events.map((e) => e.type)
-        if (kind === 'kill') expect(last.after.enemies).toEqual([])
-        if (kind === 'escape') {
-          const p = last.before.player
-          expect(last.events.some((e) => e.type === 'exploded' && e.tiles.some((t) => t.x === p.x && t.y === p.y))).toBe(true)
+        let s = scene.start
+        for (const a of scene.actions) {
+          s = step(s, a).state
+          expect(['playing', 'cleared']).toContain(s.status)
         }
-        if (kind === 'chain') expect(types.filter((t) => t === 'exploded').length).toBeGreaterThanOrEqual(2)
-        if (kind === 'freeze') expect(last.before.frozen).toBeGreaterThan(0)
-        if (kind === 'detonate') expect(types).toContain('detonated')
-        if (kind !== 'escape' && kind !== 'kill') expect(types).toContain('enemyDied')
+        expect(showsAtEnd(scene)).toBe(true)
       }
     })
   }