fuzzyworld

explodorama / commit

Demo: the ways to die, and the exit

d5e3ad4d75ff0ca178618b03977f5f949f97418e · Pavel Safronov · 2026-10-23 23:01:28 -0700 · parents 8da5a7c028bf

Claude Code usage: this commit ≈0 out / ~23.0M cached · cost unknown (no per-model trailer) · session 3 tally 2.1K in / 1.1M out / 403.7M cached · usage by commit and session

Demo: the ways to die, and the exit

Seven death scenes, each ending on the death: walking into a blast, not
getting far enough, blocking yourself in, a chain reaction, an enemy, and
two funny ones (the Detonator next to you, a Run into an enemy). The random
play still refuses deadly moves except the scene's own death. Two exit
scenes: the exit opens and the player walks in (the scene ends there), and
flame on the uncovered exit lets three enemies out. Living and dying scenes
take turns, each from a shuffled queue; the demo hides the death and
level-cleared banners.

Usage: 2.1K in / 1.1M out / 403.7M cached (session tally)
rtk:   1.8M saved, 40.4% over 3315 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 CHANGELOG.md                         |   5 +-
 PLAN.md                              |  12 +++-
 README.md                            |   8 ++-
 decisions.md                         |  25 ++++++++
 docs/screenshots/almanac-charges.png | Bin 369698 -> 369609 bytes
 docs/screenshots/almanac.png         | Bin 429320 -> 429479 bytes
 docs/screenshots/auto-move.png       | Bin 244640 -> 244588 bytes
 docs/screenshots/bomb-picked.png     | Bin 243547 -> 243597 bytes
 docs/screenshots/charges.png         | Bin 256659 -> 256654 bytes
 docs/screenshots/desktop.png         | Bin 583119 -> 583108 bytes
 docs/screenshots/dev.png             | Bin 245790 -> 245842 bytes
 docs/screenshots/gem.png             | Bin 244258 -> 244285 bytes
 docs/screenshots/info-mode.png       | Bin 242881 -> 242990 bytes
 docs/screenshots/lingering.png       | Bin 248622 -> 248488 bytes
 docs/screenshots/menu.png            | Bin 170738 -> 170784 bytes
 docs/screenshots/scrolled.png        | Bin 246763 -> 246872 bytes
 docs/screenshots/small.png           | Bin 186730 -> 186731 bytes
 src/main.ts                          |  20 ++++--
 src/sim/demo.ts                      | 117 +++++++++++++++++++++++++++++++++--
 src/style.css                        |   3 +-
 test/demo.test.ts                    |  10 +--
 21 files changed, 177 insertions(+), 23 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index b403a1f..6856adb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -57,8 +57,9 @@ saved before the change is dropped and the next visit starts a new run.
   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, 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.
+  the enemy types, drops, the gem, each power-up and each charge, the exit — alternating with the
+  ways to die (into a blast, not far enough, blocked in, a chain, an enemy, and a couple of funny
+  ones), 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
diff --git a/PLAN.md b/PLAN.md
index 051c259..563bb3b 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -601,8 +601,16 @@ lingering flame, the three enemy types, drops, the gem (uncovered and taken; its
 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
+so they have none. *Since 2026-10-23 (user request)* also the exit — **exit** (it opens, the
+player walks in and the scene ends there; once open, the random play mostly heads for it) and
+**doorBlast** (flame on the uncovered exit lets 3 enemies out) — and **the ways to die**, each a
+scene whose moment is the death: into a blast (from a safe tile onto a burning one), not far
+enough (moved but stayed in reach; Fire 3), blocked in (no step out of danger), chain (caught
+only by a bomb that wasn't due), an enemy, and two funny ones: the Detonator next to you, a Run
+into an enemy. The random play keeps refusing deadly moves, except the one that is the scene's
+death. Living and dying scenes take turns, each queue shuffled. 36 kinds, all found on every
+seed tried, the slowest under 0.5 s; each caption has the thing's icon. The "Ouch!" and
+level-cleared banners are hidden in the demo. 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. 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
diff --git a/README.md b/README.md
index 5dbe1d6..3af9ad0 100644
--- a/README.md
+++ b/README.md
@@ -21,8 +21,12 @@ board: bombs and their countdown, watching the enemies' arrows to bomb where the
 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
+Freeze, Decoy), and the exit (walking out once it opens; blasting it lets enemies out). Heart and
+Coin bag only change numbers, so they have no scene; nor does the shop. Every other scene shows
+**a way to die**: walking into a blast, not getting far enough, blocking yourself in with your
+own bomb, one bomb setting off another, touching an enemy, and a couple of funny ones (the
+Detonator next to you, a Run into an enemy). Living and dying scenes take turns, each 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 acb47a5..846863a 100644
--- a/decisions.md
+++ b/decisions.md
@@ -867,3 +867,28 @@ out, so there is always something to drop. A search over 40 seeds of every demo
 such drops before; after, only 2 Freezes dropped in the turn the old freeze ran out (rolled once
 it had, so not wasted). Tests: over 2,000 rolls each, none of the three drops while one lies
 there or works; Pierce still does. `SIM_VERSION` 16.
+
+### 2026-10-23 — The demo shows how you die, and the exit
+
+User request: "how does the player die?" — scenes for the ways to die (walking into flame,
+blocking yourself in, not getting far enough, a bomb setting off another, an enemy, "etc."),
+alternating living and dying scenes, "some funny ones maybe"; and the exit, with scenes ending
+naturally when the player walks out through the open door.
+
+- **Seven death scenes**, each ending on the death: into a blast (stepped from a safe tile onto
+  one that burns that turn), not far enough (was in reach, moved, still in reach; the room gets
+  Fire 3 so it happens), blocked in (every step is about to burn), chain (caught only by a bomb
+  that wasn't due, so the chain set it off), an enemy, and the funny two: the Detonator set off
+  while standing next to the bomb, and a Run straight into an enemy. Walking into lingering
+  flame isn't possible (the game refuses that step), so "walking into flame" is the blast one.
+  The random play still refuses deadly moves except the one that is the scene's death.
+- **The exit**: one scene where it opens and the player walks out — the scene ends there, with
+  the jump into the doorway (once open, the random play heads for it four times in five, or it
+  is rarely found) — and one where flame on the uncovered exit lets three enemies out. I read
+  "demos end naturally when the player walks into an open door" as that exit scene; other living
+  scenes still end on their own moment.
+- **Living and dying scenes take turns**, each from its own shuffled queue, so the 29 living
+  ones aren't spread thin between deaths. The "Ouch!" and level-cleared banners are hidden in
+  the demo. Tests: every kind found on 6 seeds, the player alive throughout and dead at the end
+  of a death scene, and `showsAtEnd` confirms the moment. In a browser: 12 scenes in 70 s,
+  alternating, no errors.
diff --git a/docs/screenshots/almanac-charges.png b/docs/screenshots/almanac-charges.png
index 7e35bd5..4d03396 100644
Binary files a/docs/screenshots/almanac-charges.png and b/docs/screenshots/almanac-charges.png differ
diff --git a/docs/screenshots/almanac.png b/docs/screenshots/almanac.png
index f91eb05..7b2e903 100644
Binary files a/docs/screenshots/almanac.png and b/docs/screenshots/almanac.png differ
diff --git a/docs/screenshots/auto-move.png b/docs/screenshots/auto-move.png
index 37829e5..bf11d50 100644
Binary files a/docs/screenshots/auto-move.png and b/docs/screenshots/auto-move.png differ
diff --git a/docs/screenshots/bomb-picked.png b/docs/screenshots/bomb-picked.png
index 319006e..8146a75 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 0475bc3..4b4c9d6 100644
Binary files a/docs/screenshots/charges.png and b/docs/screenshots/charges.png differ
diff --git a/docs/screenshots/desktop.png b/docs/screenshots/desktop.png
index a7c0728..83428b3 100644
Binary files a/docs/screenshots/desktop.png and b/docs/screenshots/desktop.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 f428aec..5d3c9e6 100644
Binary files a/docs/screenshots/gem.png and b/docs/screenshots/gem.png differ
diff --git a/docs/screenshots/info-mode.png b/docs/screenshots/info-mode.png
index 3ba678f..c8c2e8a 100644
Binary files a/docs/screenshots/info-mode.png and b/docs/screenshots/info-mode.png differ
diff --git a/docs/screenshots/lingering.png b/docs/screenshots/lingering.png
index c1a0e4b..43d66c1 100644
Binary files a/docs/screenshots/lingering.png and b/docs/screenshots/lingering.png differ
diff --git a/docs/screenshots/menu.png b/docs/screenshots/menu.png
index 99d0710..008af24 100644
Binary files a/docs/screenshots/menu.png and b/docs/screenshots/menu.png differ
diff --git a/docs/screenshots/scrolled.png b/docs/screenshots/scrolled.png
index 19ebf69..177a4b7 100644
Binary files a/docs/screenshots/scrolled.png and b/docs/screenshots/scrolled.png differ
diff --git a/docs/screenshots/small.png b/docs/screenshots/small.png
index 6b4d434..6ae57e2 100644
Binary files a/docs/screenshots/small.png and b/docs/screenshots/small.png differ
diff --git a/src/main.ts b/src/main.ts
index fce8c1d..4561117 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_KINDS, SCENES, type Scene, type SceneKind } from './sim/demo'
+import { DYING_KINDS, generateScene, LIVING_KINDS, 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'
@@ -531,7 +531,10 @@ const DEMO_LEAD_SECONDS = 0.8
 let demo: { scene: Scene; next: number; at: number; real: GameState } | null = null
 // The scenes' seeds: from `?seed` for a repeatable demo, otherwise random.
 let demoSeed = Number(params.get('seed')) || randomSeed()
-let demoKinds: SceneKind[] = []
+// Two shuffled queues, taken in turn: a scene where the player lives, then one where they die
+// (user request, 2026-10-23: alternate them). In their usual order with `?seed`, for a test URL.
+const demoQueues: Record<'living' | 'dying', SceneKind[]> = { living: [], dying: [] }
+let demoDying = false
 const demoPanel = document.getElementById('demo')!
 const demoCaption = document.getElementById('demo-caption')!
 /**
@@ -547,15 +550,20 @@ const SCENE_ICONS: Record<SceneKind, string> = {
   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,
+  exit: 'door_open_top', doorBlast: 'door_closed_top',
+  dieIntoBlast: 'fireball', dieNotFar: 'fireball', dieBlockedIn: 'bomb', dieChain: 'bomb', dieEnemy: 'slime_normal_rest',
+  dieDetonator: POWER_UP_ICONS.detonator, dieRun: POWER_UP_ICONS.run,
 }
 
-/** The next scene, cycling through every kind in a shuffled order. */
+/** The next scene: living and dying ones in turn, each cycling through its kinds in a shuffled order. */
 function nextScene(): void {
   if (!demo) return
   for (let tries = 0; tries < SCENE_KINDS.length; tries++) {
-    // Shuffled; in their usual order with `?seed`, so a test URL shows the same demo every time.
-    if (!demoKinds.length) demoKinds = params.has('seed') ? [...SCENE_KINDS] : [...SCENE_KINDS].sort(() => Math.random() - 0.5)
-    const scene = generateScene(demoKinds.shift()!, demoSeed++)
+    const which = demoDying ? 'dying' : 'living'
+    demoDying = !demoDying
+    const all = which === 'dying' ? DYING_KINDS : LIVING_KINDS
+    if (!demoQueues[which].length) demoQueues[which] = params.has('seed') ? [...all] : [...all].sort(() => Math.random() - 0.5)
+    const scene = generateScene(demoQueues[which].shift()!, demoSeed++)
     if (!scene) continue
     resetView()
     demo.scene = scene
diff --git a/src/sim/demo.ts b/src/sim/demo.ts
index 1ba5d11..896896b 100644
--- a/src/sim/demo.ts
+++ b/src/sim/demo.ts
@@ -1,8 +1,9 @@
-import { ACTIONS, isAvailable } from './actions'
+import { ACTIONS, isAvailable, isValidTarget } from './actions'
 import { predictDanger } from './danger'
 import { planIntents } from './enemies'
 import { Rng } from './rng'
-import { idx, noBoosts, noPowers, START_LIVES, START_STATS } from './state'
+import { playerPath } from './path'
+import { exitOpen, idx, noBoosts, noPowers, START_LIVES, START_STATS } from './state'
 import { step } from './turn'
 import {
   DEFAULT_OPTIONS,
@@ -29,6 +30,8 @@ export type SceneKind =
   | 'bombPlus' | 'firePlus' | 'fusePlus' | 'run' | 'kick' | 'throw' | 'teleport' | 'diagonal' | 'detonate'
   | 'wallPass' | 'bombPass' | 'flamePass' | 'star'
   | 'pierce' | 'power' | 'nuke' | 'shield' | 'freeze' | 'decoy'
+  | 'exit' | 'doorBlast'
+  | 'dieIntoBlast' | 'dieNotFar' | 'dieBlockedIn' | 'dieChain' | 'dieEnemy' | 'dieDetonator' | 'dieRun'
 
 export interface Scene {
   kind: SceneKind
@@ -58,6 +61,10 @@ interface SceneDef {
   prepare?: (s: GameState, rng: Rng) => void
   /** Whether this turn is the moment the scene is about. */
   shows: (t: Turn, seen: Seen) => boolean
+  /** A scene about a way to die: its moment is the turn the player dies (user request, 2026-10-23). */
+  dies?: boolean
+  /** A nudge for the random play: an action to take now, or null for a random one. */
+  steer?: (s: GameState, rng: Rng) => Action | null
 }
 
 const WIDTH = 9
@@ -73,6 +80,12 @@ 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'
+/** The player died this turn, and how: caught by flame (new or lingering), or by an enemy. */
+const died = (t: Turn) => t.events.some((e) => e.type === 'playerDied')
+const burnt = (t: Turn) =>
+  died(t) && (explosions(t).some((e) => e.tiles.some((p) => at(p, t.next.player))) || t.prev.burning.includes(idx(t.prev, t.next.player)))
+const moved = (t: Turn) => !at(t.prev.player, t.next.player)
+const inBlast = (t: Turn, p: Vec) => explosions(t).some((e) => e.tiles.some((q) => at(q, p)))
 /** 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)
 
@@ -238,9 +251,89 @@ export const SCENES: Record<SceneKind, SceneDef> = {
     prepare: dropping('decoy'),
     shows: (t, seen) => seen.picked.has('decoy') && kills(t).length > 0 && !!t.prev.decoy,
   },
+  // The exit (user request, 2026-10-23: "demos end naturally when the player walks into an open door").
+  exit: {
+    caption: 'A crate hides the exit. Once every enemy is gone it opens: walk in to finish the level.',
+    enemies: ['blob'],
+    prepare: (s, rng) => placeExit(s, rng, true),
+    // Once the exit is open, mostly head for it.
+    steer: (s, rng) => {
+      if (!exitOpen(s) || rng.next() < 0.2) return null
+      const next = playerPath(s, s.exit)?.[0]
+      return next && isAvailable(s, 'move') && isValidTarget(s, 'move', next) ? { id: 'move', target: next } : null
+    },
+    shows: (t) => t.events.some((e) => e.type === 'levelCleared'),
+  },
+  doorBlast: {
+    caption: 'Don’t blast the exit once it’s uncovered: three more enemies come out.',
+    enemies: ['blob'],
+    prepare: (s, rng) => placeExit(s, rng, false),
+    shows: (t) => t.events.some((e) => e.type === 'enemiesSpawned'),
+  },
+  // The ways to die.
+  dieIntoBlast: {
+    caption: 'Ouch! Walked straight into a blast. Check the red tiles before you step.',
+    enemies: ['blob'],
+    dies: true,
+    shows: (t) => burnt(t) && moved(t) && !inBlast(t, t.prev.player),
+  },
+  dieNotFar: {
+    caption: 'Ouch! Not far enough: a blast reaches as far as its fire. Get further, or round a corner.',
+    enemies: ['blob'],
+    // A long blast, easy to run along and stay in.
+    prepare: (s) => (s.stats.fire = 3),
+    dies: true,
+    shows: (t) => burnt(t) && moved(t) && inBlast(t, t.prev.player),
+  },
+  dieBlockedIn: {
+    caption: 'Ouch! Never put a bomb in your only way out.',
+    enemies: ['blob'],
+    dies: true,
+    // Nowhere to step that isn't about to burn.
+    shows: (t) => {
+      if (!burnt(t)) return false
+      const danger = predictDanger(t.prev).now
+      return ACTIONS.move.validTargets(t.prev).every((p) => danger.has(idx(t.prev, p)))
+    },
+  },
+  dieChain: {
+    caption: 'Ouch! One bomb set off another, and its blast reached you.',
+    enemies: ['blob'],
+    prepare: (s) => (s.stats.capacity = 2),
+    dies: true,
+    // Caught only by a bomb that wasn't due yet: the chain set it off.
+    shows: (t) => {
+      if (!burnt(t)) return false
+      const hits = explosions(t).filter((e) => e.tiles.some((p) => at(p, t.next.player)))
+      return hits.length > 0 && hits.every((e) => (t.prev.bombs.find((b) => at(b.pos, e.pos))?.timer ?? 0) > 1)
+    },
+  },
+  dieEnemy: {
+    caption: 'Ouch! Touching an enemy costs a life. Watch where its arrows go.',
+    enemies: ['seeker', 'blob'],
+    dies: true,
+    shows: (t) => died(t) && !burnt(t) && t.action.id !== 'run',
+  },
+  dieDetonator: {
+    caption: 'Oops! The Detonator sets a bomb off at once, even with you right next to it.',
+    enemies: ['blob'],
+    prepare: (s) => ((s.powers.detonate = true), (s.stats.fuse = 6)),
+    dies: true,
+    shows: (t) => burnt(t) && t.events.some((e) => e.type === 'detonated'),
+  },
+  dieRun: {
+    caption: 'Oops! Ran two tiles, straight into the enemy in between.',
+    enemies: ['seeker', 'seeker', 'blob'],
+    prepare: (s) => (s.powers.run = true),
+    dies: true,
+    shows: (t) => died(t) && !burnt(t) && t.action.id === 'run',
+  },
 }
 
 export const SCENE_KINDS = Object.keys(SCENES) as SceneKind[]
+/** The scenes about a way to die, and the rest, for the UI to take turns between. */
+export const DYING_KINDS = SCENE_KINDS.filter((k) => SCENES[k].dies)
+export const LIVING_KINDS = SCENE_KINDS.filter((k) => !SCENES[k].dies)
 
 /** A scene of `kind`, from `seed`; null if no room gave one (rare). */
 export function generateScene(kind: SceneKind, seed: number): Scene | null {
@@ -323,6 +416,14 @@ function moreCrates(s: GameState, rng: Rng): void {
   })
 }
 
+/** The exit two steps from the player: under a crate, or uncovered (but locked while enemies live). */
+function placeExit(s: GameState, rng: Rng, covered: boolean): void {
+  const near = tilesAt(s, 2)
+  if (!near.length) return
+  s.exit = near[rng.int(near.length)]!
+  if (covered) s.cells[idx(s, s.exit)] = 'block'
+}
+
 /** Put a charge two steps from the player. */
 function dropNear(s: GameState, rng: Rng, kind: ChargeId): void {
   const near = tilesAt(s, 2)
@@ -349,17 +450,21 @@ function rollout(kind: SceneKind, start: GameState, rng: Rng): Action[] | null {
   const actions: Action[] = []
   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).
+    // A random action (or the scene's nudge) that doesn't get the player killed this turn, a few
+    // goes at it — unless dying is what the scene is about, and this is the way it shows.
+    const def = SCENES[kind]
     let turn: Turn | null = null
     for (let go = 0; go < 4 && !turn; go++) {
-      const action = randomAction(s, rng)
+      const action = def.steer?.(s, rng) ?? randomAction(s, rng)
       const r = step(s, action)
-      if (r.state.status === 'playing' || r.state.status === 'cleared') turn = { prev: s, action, events: r.events, next: r.state }
+      const t = { prev: s, action, events: r.events, next: r.state }
+      const alive = r.state.status === 'playing' || r.state.status === 'cleared' || r.state.status === 'won'
+      if (alive || (def.dies && def.shows(t, seen))) turn = t
     }
     if (!turn) return null
     actions.push(turn.action)
     remember(turn, seen)
-    if (SCENES[kind].shows(turn, seen)) return actions
+    if (def.shows(turn, seen) && !(def.dies && turn.next.status === 'playing')) return actions
     s = turn.next
   }
   return null
diff --git a/src/style.css b/src/style.css
index 25e81dc..77dc3f7 100644
--- a/src/style.css
+++ b/src/style.css
@@ -720,7 +720,8 @@ img {
 
 /* The demo before the game (main.ts): the board alone, a caption panel at the bottom. */
 body.demo #hud,
-body.demo #actions {
+body.demo #actions,
+body.demo #banner {
   display: none;
 }
 
diff --git a/test/demo.test.ts b/test/demo.test.ts
index 6613548..f327d63 100644
--- a/test/demo.test.ts
+++ b/test/demo.test.ts
@@ -1,17 +1,19 @@
 import { describe, expect, it } from 'vitest'
-import { generateScene, SCENE_KINDS, showsAtEnd } from '../src/sim/demo'
+import { generateScene, SCENE_KINDS, SCENES, showsAtEnd } from '../src/sim/demo'
 import { step } from '../src/sim/turn'
 
 describe('demo scenes', () => {
   for (const kind of SCENE_KINDS) {
-    it(`${kind}: found, replayable with the player alive, and ending on what it shows`, () => {
+    it(`${kind}: found, replayable, the player alive (or dying at the end of a death scene), ending on what it shows`, () => {
       for (let seed = 1; seed <= 6; seed++) {
         const scene = generateScene(kind, seed)!
         expect(scene).not.toBeNull()
+        // Alive all along; at the end, dead in a scene about a way to die, alive in the rest.
         let s = scene.start
-        for (const a of scene.actions) {
+        for (const [i, a] of scene.actions.entries()) {
           s = step(s, a).state
-          expect(['playing', 'cleared']).toContain(s.status)
+          const last = i === scene.actions.length - 1
+          expect(last && SCENES[kind].dies ? ['dead', 'gameOver'] : ['playing', 'cleared']).toContain(s.status)
         }
         expect(showsAtEnd(scene)).toBe(true)
       }