fuzzyworld

explodorama / commit

Dialog clips (steps 1-2), the demo in order with ◀ ▶ ↺, Report a bug

33a0256b4168e644feded95710ef9f27c084e164 · Pavel Safronov · 2026-10-24 16:41:28 -0700 · parents e3384c6bff2d

Claude Code usage: this commit ~165.7K out / ~76.6M cached · cost unknown (no per-model trailer) · session 4 tally 1.1K in / 525.2K out / 168.7M cached · usage by commit and session

Dialog clips (steps 1-2), the demo in order with ◀ ▶ ↺, Report a bug

- Clips in info dialogs: the board is drawn twice a frame while one
  shows (the clip's room at an offset, copied into the dialog's canvas,
  then the game); "Still burning" has the first clip. Enemies get none.
- The board's animation state is one object (render/animate.ts), and
  scenes are made in a Web Worker, the next one while one plays.
- The demo plays its 43 scenes in one order, numbered, with ◀ ▶ (and
  ← →) and ↺; it carries on where it was after a reload. The random
  play is sensible outside the death scenes (no pointless bombs, no
  flame on an uncovered exit or gem). The CRT's sideways tearing halved.
- Menu: Watch the demo, Report a bug (an email to GitLab Service Desk
  with the version, device and run record), modes named in one line.
- Long dialogs scroll with a finger again.

Usage: 1.1K in / 525.2K out / 168.7M cached (session tally)
rtk:   1.9M saved, 39.0% over 4104 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 CHANGELOG.md              |   4 +
 PLAN.md                   |  83 +++++++++++-
 README.md                 |  22 ++--
 decisions.md              | 123 +++++++++++++++++-
 docs/screenshots/demo.png | Bin 1035346 -> 971948 bytes
 docs/screenshots/menu.png | Bin 136917 -> 142515 bytes
 index.html                |   6 +
 src/devlog.ts             |  12 +-
 src/main.ts               | 317 +++++++++++++++++++++++++++++++---------------
 src/render/animate.ts     |  70 ++++++++++
 src/render/board.ts       |  14 +-
 src/sim/demo.ts           |  34 ++++-
 src/sim/scene-worker.ts   |  11 ++
 src/style.css             |  54 ++++++++
 src/ui/clip.ts            | 117 +++++++++++++++++
 src/ui/dialog.ts          |   6 +
 src/ui/scenes.ts          |  36 ++++++
 test/demo.test.ts         |  10 +-
 18 files changed, 793 insertions(+), 126 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index afdeb28..0ce749d 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
 
+- **Report a bug** in the menu: an email to the project's GitLab Service Desk (no account needed), filled in with the game's version, the device and the run, so it can be replayed.
 - **A version number**, at the bottom of the menu and near the top of the README. It is the
   rules version (`SIM_VERSION`), so it changes exactly when old saved runs stop resuming. The
   menu also shows the commit the game was built from and links to the source code.
@@ -78,6 +79,8 @@ saved before the change is dropped and the next visit starts a new run.
 
 ### Changed
 
+- The demo plays its scenes in one order, numbered, with ◀ ▶ (and ← →) to go through them and ↺ back to the first; it carries on where it was, after a reload too.
+- The menu can play the demo again (**Watch the demo**), and names the run's modes in one short line.
 - The game is now called **EXPLODORAMA!**
 - One board, 9 × 11 with 20 levels (it was a choice with the original's 31 × 13 and 50 levels),
   and crates always crumble once the enemies are gone (it was an option). The New run menu has
@@ -121,6 +124,7 @@ saved before the change is dropped and the next visit starts a new run.
 
 ### Fixed
 
+- Long dialogs (the almanac, the rainbow gem's offer) scroll with a finger again.
 - The player is drawn a little higher, so their feet never touch the line between tiles.
 - A top-bar number bumping at the edge of its row (Turn) is no longer cut off.
 - Moving off the auto-move path now clears the flag.
diff --git a/PLAN.md b/PLAN.md
index 82420da..e815b85 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -676,7 +676,8 @@ scene whose moment is the death: into a blast (from a safe tile onto a burning o
 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
+death. Living and dying scenes take turns, each queue shuffled (*until 2026-10-24: now one fixed
+order, see below*). 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
@@ -710,10 +711,71 @@ lines, a rolling dark bar and sweeping bright lines, vignette, rounded glass and
 too), at most 4:3 with black bars; the finger is in front of the glass. **Five control scenes** come first (step, long-press
 bomb, Bomb button, Wait, auto-move with Go), and again each time the living scenes have all been
 shown. Captions of Run, Kick, Throw, Teleport, Diagonal and the Detonator say how to do them.
-41 kinds; on 40 seeds each, all but one Nuke scene found (a missing scene is skipped), the
-slowest under 0.45 s. *Since 2026-10-24 (user request)* every room has its exit: under a crate,
-or in one room in six already uncovered (locked while enemies live). Ideas: a gem's choice, and
-showing the demo again after the player is idle on the menu.
+41 kinds; on 40 seeds each, all found, the slowest 0.5 s (made in a worker). *Since 2026-10-24
+(user request)* the random play is **sensible** outside the scenes about dying: no flame on an
+uncovered exit or gem (except in the scene about that), and a bomb only where its blast reaches a
+crate, an enemy, a tile an enemy is going to, or another bomb. *Since 2026-10-24 (user request)* every room has its exit: under a crate,
+or in one room in six already uncovered (locked while enemies live). *Since 2026-10-24 (user
+request)* the menu's **Watch the demo** plays it again; a tap returns to the run as it was. *Since 2026-10-24 (user request)* the scenes play **in one fixed
+order**, looped (`DEMO_ORDER` in main.ts: the controls' seven, then the table's living scenes with
+a way to die after every four); the panel numbers them (n / 43), ◀ ▶ and ← → step through them,
+↺ goes back to the first, and the place is kept in `localStorage` so the demo carries on there
+next time, after a reload too (not on a test URL, which starts at the first). Scenes are made in
+a Web Worker (`ui/scenes.ts`), the next while one plays. Ideas: a gem's choice, and showing the
+demo again after the player is idle on the menu.
+
+## 13c. Animated clips in the info dialogs (user request, 2026-10-24; planned)
+
+Every info dialog gets a small looping clip of what it describes, made on the spot like the demo's
+scenes (`sim/demo.ts`): a small room, random play by the real rules until the moment shows, the
+last few turns replayed. Each loop holds its moment for **1 s**, then starts over **with a new
+setup** (a new seed), for variety. **No CRT** (that is the demo's joke). **The finger** only where
+it helps: the action buttons and the power-ups you work yourself (Run, Kick, Throw, Teleport,
+Diagonal, the Detonator). **Reduced motion:** a still of the moment wherever one frame says it
+(a bomb at 1 with its blast previewed, a Nuke's shape, the exit with its three enemies coming);
+for what is about moving (the enemies' rules), a still with the arrows.
+
+**What gets a clip** (an existing demo scene where there is one; *new* otherwise):
+
+- *Action buttons* ("What is that?" on them): Move (`tapMove`, or with Run / Teleport / Kick /
+  Diagonal held, theirs), Bomb (`holdBomb`, `bombButton`; with Throw or the Detonator, theirs),
+  Wait (`wait`).
+- *Top-bar items:* lives (*new*: hit, a heart empties, the level starts over the same), level
+  (`exit`), enemies left (*new*: the last one dies, the count reaches 0, the exit opens), coins
+  (`coins`), turn and par (*new*: the count ticking), bombs (`bombPlus`), fire (`firePlus`), fuse
+  (`fusePlus` / `kill`), Star (`star`), each pass held (its scene), each charge held (its scene,
+  the charge already held), the modes (Developer: the rainbow gem's offer; Immortal: unharmed by
+  a blast).
+- *Tiles* ("What is that?" on the board): a bomb (its countdown and blast; a Pierce / Power / Nuke bomb its shape),
+  the decoy (`decoy`), still burning (`lingering`), a crate (*new*: burst, uncovering a drop, the
+  gem or the exit), a pillar or the outer wall (*new*: a blast stopped by it), the gem and the
+  rainbow gem (`gem`), the exit locked (`doorBlast`) and open (`exit`), coins or a charge on the
+  floor (picked up; a charge also doing its thing). *The enemies get no clip, only their picture*
+  (user's call, 2026-10-24: the first try, the `enemies` scene, showed a busy room with all three
+  kinds; the player learns how they move soon enough) — on the board and in the almanac alike.
+- *Other dialogs:* the first-pickup dialog of each charge (its top-bar clip), the gem's offer (a
+  clip per card, perhaps only the card in focus), the almanac's entries (perhaps only one opened),
+  and the "Ouch!" banner (the matching death scene, or a replay of the player's own last turns).
+Clips should keep their rooms simple: few crates and as few enemies as the moment needs.
+
+**How they are drawn** (decided 2026-10-24): one LittleJS canvas, drawn twice a frame while a
+dialog with a clip is open — first the clip's room, drawn at an offset in world space where the
+camera already looks (LittleJS builds the camera's transform only at the start of a frame), copied into a small
+`<canvas>` in the dialog, then cleared and the game as usual — so clips use exactly the board's
+drawing and animation code. Fallback, if a copy in the middle of a frame doesn't work: LittleJS's
+2D drawing straight into the dialog's canvas. It needs the board's view state (effects, flames,
+the player's move and pose, enemy steps, …), now module variables in `main.ts`, to become an
+object, one for the game and one per clip; and scene generation (up to ~0.45 s) to move to a
+**Web Worker** (the sim is pure), so the next loop's setup is ready in time — the demo can use it
+too.
+
+**Order:** 1. a spike proving the two-pass drawing and copy (*done 2026-10-24*: `ui/clip.ts`;
+"Still burning" plays the `lingering` scene as a first clip); 2. *done 2026-10-24*: the board's
+animation state is one object (`render/animate.ts`: `Animation`, `animateTurn`), one for the game
+and one per clip, and scenes are made in a Web Worker (`sim/scene-worker.ts`, `ui/scenes.ts`; on
+the page where a worker can't be made), the next loop's while one plays — the demo's too; 3. the
+clips that reuse demo scenes; 4. the new scenes (no longer the enemies'); 5. the offer's cards,
+the almanac and the "Ouch!" replay, which need layout decisions.
 
 ## 13b. Run options (user request, 2026-10-21; built)
 
@@ -901,3 +963,14 @@ tools/                 compose-icons.sh (composed sprites and the app icons), sc
 9. **M8 – Permanence:** Workshop, save export/import, victory certificate + QR, replay file and
    verify page.
 10. **Later:** Reapers + turn limit, daily seed, final art.
+
+### Backlog
+
+- ~~**Bug reports from players**~~ *Built 2026-10-24* (user request): GitLab Service Desk is on
+  (the user turned it on; an email to its address becomes a confidential issue, no GitLab account
+  needed, replies go back by email), and the menu's **Report a bug** opens an email to it, filled
+  in with the version and build commit, the device, the seed, level, turn, lives and modes, the
+  last 5 errors the game caught (`recentErrors` in devlog.ts), and the run record. A record too
+  long for an email link (over ~1,900 characters) stays out of the email and goes on the
+  clipboard instead, and the email asks to paste it.
+
diff --git a/README.md b/README.md
index 195ba2c..09ca4f9 100644
--- a/README.md
+++ b/README.md
@@ -51,11 +51,14 @@ Freeze, Decoy), and the exit (walking out once it opens; blasting it lets enemie
 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; once all the living ones have been shown,
-the controls come round again. 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.
+Detonator next to you, a Run into an enemy). The scenes play **in a fixed order** and loop —
+the controls first, then the rest, with a way to die after every four — each made new every
+time. The panel shows which one it is (**12 / 43**); **◀ ▶** (or the **← →** keys) go to the
+one before or after, and **↺** back to the first. The demo carries on where it was the next time
+it plays, after a reload too. It is
+view-only; **tap anywhere else (or press any other key) to play**, and that tap does nothing
+else. The menu's **Watch the demo** plays it again; a tap brings you back to your run as it was.
+A test URL (`?seed` and the like) skips it; `?demo` shows it anyway, from the first scene.
 
 <img src="docs/screenshots/demo.png" alt="The demo: the finger holding on a tile next to the player, a ring closed round it and the bomb's blast previewed; the action bar right under the board, the caption apart at the bottom" width="260">
 
@@ -125,8 +128,11 @@ themselves with a plain tap.
 
 **Menu:** the round button at the top right (or **Esc** with nothing picked). It shows the level
 and seed, and has the **Almanac** — every power-up, charge and enemy type, with its
-picture and what it does, in three tabs — and **New run**, which asks first, since the current
-run can't be got back.
+picture and what it does, in three tabs — **High scores**, **Watch the demo**, **Report a bug**
+(an email to the game's GitLab Service Desk, which needs no GitLab account, filled in with the
+game's version, your device and the run, so it can be replayed; a long run's record goes on
+your clipboard to paste in), and **New run**, which asks first, since the current run can't be
+got back.
 
 **Press and hold** a tile next to you (about half a second, with a short buzz on phones that
 support it) to arm a bomb there: its blast shows, and letting go places it. Sliding off the
@@ -303,7 +309,7 @@ Immortal runs don't. A global board and a daily dungeon are planned (PLAN.md §1
 and **New dungeon** (a random seed, same modes).
 
 The choice is remembered for next time, is fixed for the run, and shows as small yellow tags at the start of
-the top bar's last row (tap them for details) and in the menu. Starting a new run while one is
+the top bar's last row (tap them for details) and, by name only, in the menu. Starting a new run while one is
 going warns that it will be lost.
 
 ### The run
diff --git a/decisions.md b/decisions.md
index c9010a2..2bc0b23 100644
--- a/decisions.md
+++ b/decisions.md
@@ -1270,7 +1270,9 @@ its width with black bars beside it. And the app is now called **EXPLODORAMA!**.
   tears — its lines jump sideways, some far, in a custom post-process piece before the colour
   fringes, driven by a shader value, `interference`, set each frame and fluttering like a motor —
   the static gets ten times louder (`staticNoise`), and an SVG-noise snow jumps over the whole
-  screen, top bar and buttons included; then it stops at once. None with reduced motion. The finger is in
+  screen, top bar and buttons included; then it stops at once. None with reduced motion. Then
+  the sideways jumps were halved (user request, same day: the left-to-right distortion was too
+  much); the static, snow and darkening stayed as they were. The finger is in
   front of the glass (above it), the ripples on the screen (below it); the caption panel is not
   on the TV.
 - **No glow, no bulge.** LittleJS's TV effect glows from black up, and even a glow only past 0.8
@@ -1294,3 +1296,122 @@ exit is part of the rules (flame on an uncovered one lets enemies out; once open
 clears the level), the random games play with it; a probe over 40 seeds of every kind found all
 but one Nuke scene (a missing scene is skipped, and the tests' seeds all pass), the slowest
 0.44 s. At a scene's start, 79% of the doors are still covered.
+
+### 2026-10-24 — Clips in the info dialogs: plan, and the drawing spike
+
+User request: now that the demo works, give the info dialogs animations made on the spot the same
+way. The plan (every dialog's clip, which demo scene each reuses, the new ones, the order) is in
+PLAN.md §13c. The user's answers: the finger where it helps, no CRT, a still where one frame says
+it best under reduced motion, a 1 s hold and a new setup every loop, and every item on the list.
+How to draw them was left to me:
+
+- **One canvas, drawn twice a frame while a clip shows**: the clip's room first, copied into a
+  `<canvas>` in the dialog, then the canvas cleared and the game drawn. The clips use exactly the
+  board's drawing and animation code, with nothing duplicated. Rejected: drawing the clip behind
+  a hole in the dialog (the modal's backdrop covers it too), a second renderer (a second copy of
+  the board's drawing), frames rendered ahead (heavy, and no new setup each loop).
+- **The spike** (`ui/clip.ts`): LittleJS builds the camera's transform only at the start of a
+  frame (`glPreRender`, not exported), so the clip doesn't move the camera; the board gets an
+  origin instead (`setBoardOrigin` in board.ts, used by `toWorld` and `toTile`), and the clip's room
+  is drawn centred where the camera already looks, at the game's scale. After `glFlush`, that patch
+  of the WebGL canvas is copied with `drawImage` (the drawing buffer is still there within the
+  frame), then `glClearCanvas`, the origin back at 0, and the game drawn as before. Its own view
+  (effects, flames, moves) for now repeats main.ts's `animateTurn`; step 2 makes that view an
+  object both use. A clip stops when its dialog closes.
+- **First clip, to prove it:** "What is that?" on an enemy played the demo's `enemies` scene in
+  the dialog, a turn every 0.6 s, its moment held 1 s, then a new setup. Checked in a browser: the
+  clip plays crisply above the words, loops with new rooms, the dimmed game behind is undisturbed,
+  and the board is normal once the dialog closes; no errors.
+- **Enemies get no clip after all** (user's call, on seeing it): the scene was a busy room with all
+  three kinds, and the player learns soon enough how a Blob moves; the others aren't different
+  enough to need one either. Their dialogs keep only their picture, on the board and in the
+  almanac. The first clip is now "Still burning" (flame from last turn), with the `lingering`
+  scene. Clips should keep their rooms simple, with as few enemies as the moment needs.
+
+### 2026-10-24 — The demo from the menu; a shorter menu; bug reports on the backlog
+
+- **Watch the demo** (user request: "just seems fun"): a menu button that starts the demo again,
+  from its first scene (the controls'), as when the game opens; the run waits behind it, and the
+  tap that ends it brings the run back as it was (an auto-move destination is dropped).
+- **The menu names the run's modes and nothing more** ("Modes: Developer, Immortal."), instead of
+  a paragraph on each (user request: the menu looked busy). The tags in the top bar still open
+  the full explanation.
+- **Bug reports** through GitLab Service Desk (user request) are on PLAN.md's new Backlog: the
+  user turns it on in the project's settings, then a menu button opens an email to its address,
+  filled in with the version, device, seed and run record.
+
+### 2026-10-24 — Clips, step 2: one animation object, scenes made in a worker
+
+- **The board's animation state is one object** (`Animation` in `render/animate.ts`: effects,
+  flames, the player's move, pose and facing, enemy steps), with one `animateTurn` that starts a
+  turn's animations. main.ts keeps one for the game and the demo; each clip has its own. Nothing
+  about play changed; main.ts lost six module variables and its copy of the turn's animation.
+- **Scenes are made in a Web Worker** (`sim/scene-worker.ts`; `makeScene` in `ui/scenes.ts`): one
+  can take ~0.45 s, which on the page stalls every animation. The demo makes its next scene, and a
+  clip its next loop, while the current one plays. Where a worker can't be made (or fails), they
+  are made on the page as before. Vite builds the worker as its own file (32 kB).
+- Checked in a browser: three minutes of the demo with no errors, the "Still burning" clip, and the
+  menu's Watch the demo and back.
+
+### 2026-10-24 — The demo in order, numbered, with ◀ ▶ and ↺, carrying on where it was
+
+User request: "loop through all the possible demos in order, indicate which number demo this is,
+and allow left/right controls to switch demo scenes. When a page is reloaded, the demos continue
+from the same spot. Add a round arrow or something to restart from the beginning, but make it a
+choice."
+
+- **One fixed order**, looped (`DEMO_ORDER`): the seven control scenes (the five on the controls,
+  the top-bar tour, "What is that?"), then the living scenes in the table's order with a way to
+  die after every four — the dying ones used to take turns with the living, shuffled; I kept them
+  spread out rather than all at the end. 43 in all. Each is still made new every time it plays.
+- **The panel shows "n / 43"**, between **◀** and **▶** (Game Icons' arrow, the left one mirrored),
+  with **↺** (the UI Pack's repeat icon, as on New run) set apart after them: back to the first, only
+  when pressed. **← and →** do the same as ◀ ▶. These buttons and keys don't end the demo; any
+  other tap or key still does. Skipping stops whatever was under way (the finger's script, an open
+  dialog, the tour's ring) and plays the chosen scene as soon as it is made.
+- **It carries on where it was**: the scene playing is kept in `localStorage`
+  (`explodorama.demoAt`), so the demo starts there next time — on a reload, and from the menu's
+  Watch the demo. Not on a test URL, which starts at the first scene and saves nothing (the
+  screenshots stay the same).
+- Checked in a browser: ▶ and → step forward, ← and ◀ back (wrapping from 1 to 43), a reload
+  resumes at the same scene, ↺ goes to 1, and a tap elsewhere ends the demo; no errors.
+
+### 2026-10-24 — Report a bug, through GitLab Service Desk
+
+The user turned Service Desk on and gave its address; it lives in the source (`BUG_EMAIL` in
+main.ts), not a CI variable: it is public by design — the game's email link carries it either way —
+and if it draws spam, GitLab can give the project a new alias. The menu's **Report a bug** opens
+an email to it (`mailto:`), subject "Bug report: version 23, level N", body "What happened:" with
+room to write, then the details: version and build commit, the device and its user agent, seed,
+level, turn, lives, status and modes, the last 5 errors the game caught and carried on from (kept
+by `reportError`, their first lines), and the run record, which replays the run exactly. Some mail
+apps cut off long links, so over ~1,900 characters the record stays out of the email; the full
+details go on the clipboard (only then, so the clipboard isn't overwritten for nothing), and the
+email asks to paste them. Checked in a browser: a short run's email carries the record (919
+characters); after 90 turns it doesn't, and the clipboard holds the full details.
+
+### 2026-10-24 — Sensible play in the demo
+
+User report: an early scene placed its bomb on the exit; demos should show reasonable actions,
+"unless the entire point is to paint ourselves into a corner". The random play now only takes a
+turn a sensible player might (`reasonable` in sim/demo.ts), except in the scenes about dying:
+
+- nothing that lets enemies out of an uncovered exit or gem — neither a turn whose flame does,
+  nor a bomb whose blast would reach one (the scene about blasting the exit is exempt);
+- a bomb, placed or thrown, only where its blast reaches a crate, an enemy, a tile an enemy is
+  going to, or another bomb (to set it off) — never one that does nothing.
+
+A random game gets 6 goes at each turn instead of 4. On 40 seeds of every kind, every scene was
+found (the one Nuke miss is gone too), the slowest in 0.5 s, in the worker. Tests: in the living
+scenes no enemies are let out (but in the exit-blast scene) and no bomb goes on the exit.
+
+### 2026-10-24 — Long dialogs scroll by touch again
+
+User report: the almanac wouldn't scroll. By mouse wheel it did (fixed 2026-10-22); by finger it
+didn't: LittleJS listens for touches on the whole document and cancels every one that isn't on a
+control (no page scrolling in a game), which also cancelled a finger dragging the almanac's list,
+and the gem's long offer. Touches inside the dialog now stop there (as the wheel already did), so
+LittleJS never sees them; the board is behind the dialog anyway. The dialog's body also allows
+vertical panning (`touch-action: pan-y`) against the body's `none`. Checked with simulated touch
+drags in a mobile browser: the almanac's list scrolls (it didn't on the last three commits), and
+taps on its tabs and buttons still work.
diff --git a/docs/screenshots/demo.png b/docs/screenshots/demo.png
index 01cfb6e..6db8f0a 100644
Binary files a/docs/screenshots/demo.png and b/docs/screenshots/demo.png differ
diff --git a/docs/screenshots/menu.png b/docs/screenshots/menu.png
index b525d58..a224f39 100644
Binary files a/docs/screenshots/menu.png and b/docs/screenshots/menu.png differ
diff --git a/index.html b/index.html
index e9cab75..9e9efe6 100644
--- a/index.html
+++ b/index.html
@@ -57,6 +57,12 @@
 <div id="demo" hidden aria-live="polite">
   <h1>EXPLODORAMA!</h1>
   <p id="demo-caption"></p>
+  <div class="demo-nav">
+    <button id="demo-prev" type="button" aria-label="Previous scene"></button>
+    <span id="demo-count"></span>
+    <button id="demo-next" type="button" aria-label="Next scene"></button>
+    <button id="demo-restart" type="button" aria-label="Back to the first scene" title="Back to the first scene"></button>
+  </div>
   <p class="demo-hint">Tap anywhere to play</p>
 </div>
 <div id="demo-finger" hidden aria-hidden="true"></div>
diff --git a/src/devlog.ts b/src/devlog.ts
index e7c4490..59f453f 100644
--- a/src/devlog.ts
+++ b/src/devlog.ts
@@ -6,13 +6,23 @@
  */
 
 /** Which device it came from, in a few words: "Android 390×844", "iPhone 390×844", "desktop 1280×720". */
-function device(): string {
+export function device(): string {
   const ua = navigator.userAgent
   const kind = /iPad/.test(ua) ? 'iPad' : /iPhone/.test(ua) ? 'iPhone' : /Android/.test(ua) ? 'Android' : 'desktop'
   return `${kind} ${innerWidth}×${innerHeight}`
 }
 
+/** The last few errors caught, oldest first, for a bug report (main.ts `reportBug`). */
+const recent: string[] = []
+const RECENT_KEPT = 5
+
 export function reportError(where: string, error: unknown): void {
   const detail = error instanceof Error ? (error.stack ?? `${error.name}: ${error.message}`) : String(error)
   console.error(`[${device()}] ${where}: ${detail}`)
+  recent.push(`${where}: ${detail.split('\n')[0]}`)
+  if (recent.length > RECENT_KEPT) recent.shift()
+}
+
+export function recentErrors(): readonly string[] {
+  return recent
 }
diff --git a/src/main.ts b/src/main.ts
index 3798d3a..c6574c1 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -17,8 +17,8 @@ import {
   vec2,
   worldToScreen,
 } from 'littlejsengine'
-import { BACKGROUND, drawBoard, FLAME_SECONDS, playerPos, toTile, toWorld, type Flame } from './render/board'
-import { clearEffects, newEffects, playerTransition, pruneEffects, startEffects, type PlayerMove, type PlayerPose } from './render/effects'
+import { BACKGROUND, drawBoard, playerPos, toTile, toWorld } from './render/board'
+import { animateTurn as animateBoard, clearAnimation, newAnimation, pruneAnimation } from './render/animate'
 import { updateCamera } from './render/camera'
 import { CHARGE_ICONS, MONO_ICONS, POWER_UP_ICONS, TEXTURES } from './render/sprites'
 import { isAvailable, isValidTarget, ACTIONS } from './sim/actions'
@@ -34,13 +34,15 @@ import { DEFAULT_OPTIONS, DIRS, type Action, type ActionId, type ChargeId, type
 import { openAlmanac } from './ui/almanac'
 import { createControls } from './ui/controls'
 import { closeDialog, dialogOpen, openDialog, setDialogInDemo, type DialogButton } from './ui/dialog'
-import { actionInfo, chargeInfo, hudInfo, modeLines, tileInfo, type Info } from './ui/info'
+import { actionInfo, chargeInfo, hudInfo, tileInfo, type Info } from './ui/info'
 import { openOffer } from './ui/offer'
+import { startClip, drawClip } from './ui/clip'
+import { makeScene } from './ui/scenes'
 import { openScores, recordScore } from './ui/scores'
 import { countsForScores, scoreEntry } from './sim/scores'
-import { reportError } from './devlog'
+import { device, recentErrors, reportError } from './devlog'
 import { hashSeed } from './sim/rng'
-import { CONTROL_KINDS, DYING_KINDS, generateScene, LIVING_KINDS, SCENE_KINDS, SCENES, type Gesture, type Scene, type SceneKind } from './sim/demo'
+import { CONTROL_KINDS, DYING_KINDS, LIVING_KINDS, SCENES, type Gesture, 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'
@@ -61,12 +63,8 @@ let selected: ActionId | null = null
 /** "What is that?" is on: the next tap on the board explains the tile instead of acting. */
 let inspecting = false
 let hover: Vec | null = null
-let flames: Flame[] = []
-let facingLeft = false
-let playerMove: PlayerMove | null = null
-let playerPose: PlayerPose = { kind: 'none', at: 0 }
-const enemySteps = new Map<number, { from: Vec; at: number }>()
-const fx = newEffects()
+/** What animates the board between turns (render/animate.ts). */
+const anim = newAnimation()
 let snapCamera = true
 /** Auto-move: a far tile the player tapped. Each press of Move takes one step along `path` toward it. */
 let destination: Vec | null = null
@@ -94,14 +92,10 @@ function firstCharge(e: GameEvent): ChargeId | null {
 
 /** A fresh view of the board: no animations under way, nothing picked, the camera snapped. */
 function resetView(): void {
-  playerMove = null
-  playerPose = { kind: 'none', at: 0 }
-  enemySteps.clear()
-  clearEffects(fx)
+  clearAnimation(anim)
   selected = null
   inspecting = false
   destination = null
-  flames = []
   snapCamera = true
 }
 
@@ -224,31 +218,10 @@ function act(action: Action, auto = false): void {
 
 /** A turn played: the new state, and everything that animates it (also for the demo). */
 function animateTurn(result: ReturnType<typeof step>, action: Action): void {
-  const prev = state
-  startEffects(fx, prev, result.state, result.events, time)
+  const newLevel = animateBoard(anim, state, result, action, time)
   state = result.state
-  // The player's move comes from their change of tile, the pose from what happened; the pose
-  // waits for the move, so nothing can skip it (effects.ts `playerTransition`).
-  const player = playerTransition(prev, state, result.events, action.id === 'teleport', time)
-  if (player.move) playerMove = player.move
-  if (player.move || player.pose.kind !== 'none') playerPose = player.pose
-  if (player.move && player.move.from.x !== state.player.x) facingLeft = state.player.x < player.move.from.x
-  const newLevel = result.events.some((e) => e.type === 'levelStarted' || e.type === 'levelRestarted')
-  if (newLevel) {
-    snapCamera = true
-    playerMove = null
-    playerPose = player.pose
-    enemySteps.clear()
-  }
+  if (newLevel) snapCamera = true
   if (newLevel || result.events.some((e) => e.type === 'levelCleared')) destination = null
-  for (const e of result.events) if (e.type === 'exploded') flames.push({ tiles: e.tiles, born: time })
-  // Every enemy that changed tile steps from where it was: from the state, like the player.
-  if (!newLevel) {
-    for (const enemy of state.enemies) {
-      const was = prev.enemies.find((o) => o.id === enemy.id)
-      if (was && (was.pos.x !== enemy.pos.x || was.pos.y !== enemy.pos.y)) enemySteps.set(enemy.id, { from: was.pos, at: time })
-    }
-  }
 }
 
 /** How long after taking a gem (or a new charge) its dialog opens: the walk there and the rise away first. */
@@ -338,19 +311,64 @@ function showTileInfo(p: Vec): void {
   const info = tileInfo(state, p)
   if (!info) return
   const reachable = cellAt(state, p) === 'floor' && !isBurning(state, p) && !enemyAt(state, p) && playerPath(state, p)
-  showInfo(info, reachable ? [{ label: 'Walk there', kind: 'primary', onClick: () => setDestination(p) }] : [])
+  // Clips so far (PLAN.md §13c, step 1): flame still burning. Enemies get none, only their picture.
+  const clip = !enemyAt(state, p) && isBurning(state, p) ? 'lingering' : undefined
+  showInfo(info, reachable ? [{ label: 'Walk there', kind: 'primary', onClick: () => setDestination(p) }] : [], clip)
 }
 
-function showInfo(info: Info, extra: DialogButton[] = []): void {
+/** An info dialog; with `clip`, a looping clip of that kind of scene above the words (ui/clip.ts). */
+function showInfo(info: Info, extra: DialogButton[] = [], clip?: SceneKind): void {
   openDialog({
     icon: spriteUrl(info.icon),
     title: info.title,
-    body: info.body.map((t) => `<p>${t}</p>`).join(''),
+    body: (clip ? '<canvas class="clip" aria-hidden="true"></canvas>' : '') + info.body.map((t) => `<p>${t}</p>`).join(''),
     buttons: [...extra, { label: 'Got it', kind: 'plain' }],
   })
+  if (clip) startClip(document.querySelector<HTMLCanvasElement>('#dialog-body canvas.clip')!, clip)
 }
 
 const REPO_URL = 'https://gitlab.com/PavelSafronov/explodorama'
+/**
+ * The project's GitLab Service Desk address (user, 2026-10-24): an email to it becomes a
+ * confidential issue, so players need no GitLab account. Public by design: it is in the game's
+ * email link either way. If it draws spam, GitLab can give it a new alias.
+ */
+const BUG_EMAIL = 'contact-project+pavelsafronov-fuzzyworld-82950314-issue-@incoming.gitlab.com'
+/** Longer email links than this may be cut off by some mail apps: the run record then stays out. */
+const MAILTO_MAX = 1900
+
+/** What a bug report says about the game and this run, to reproduce it (the run record replays it). */
+function bugDetails(withRecord: boolean): string {
+  const commit = import.meta.env.VITE_COMMIT ? `, commit ${import.meta.env.VITE_COMMIT}${import.meta.env.VITE_COMMIT_DIRTY ? ' plus local changes' : ''}` : ''
+  const modes = [state.options.dev && 'Developer', state.options.immortal && 'Immortal'].filter(Boolean).join(', ') || 'none'
+  const errors = recentErrors()
+  return [
+    `Version ${SIM_VERSION}${commit}`,
+    `Device: ${device()}; ${navigator.userAgent}`,
+    `Seed ${state.seed}, level ${state.level}, turn ${state.turn}, ${state.lives} lives, ${state.status}; modes: ${modes}`,
+    `Errors caught: ${errors.length ? '\n' + errors.map((e) => `- ${e}`).join('\n') : 'none'}`,
+    ...(withRecord ? [`Run record: ${JSON.stringify(record)}`] : []),
+  ].join('\n')
+}
+
+/**
+ * Report a bug (user request, 2026-10-24): an email to the Service Desk, filled in with the
+ * details and the run record. A long run's record makes the link too long for some mail apps:
+ * then it stays out, the full details go on the clipboard, and the email asks to paste them.
+ */
+function reportBug(): void {
+  const full = bugDetails(true)
+  const mailto = (details: string) =>
+    `mailto:${BUG_EMAIL}?subject=${encodeURIComponent(`Bug report: version ${SIM_VERSION}, level ${state.level}`)}` +
+    `&body=${encodeURIComponent(`What happened:\n\n\n\n---\n${details}`)}`
+  const withRecord = mailto(full)
+  if (withRecord.length <= MAILTO_MAX) {
+    location.href = withRecord
+    return
+  }
+  void navigator.clipboard?.writeText(full).catch(() => {})
+  location.href = mailto(bugDetails(false) + '\nRun record: too long for an email link. The full details are on your clipboard: please paste them here.')
+}
 
 /** ", commit 0580e85" linked to it on GitLab, with a note if built with uncommitted changes. */
 function commitLine(): string {
@@ -360,14 +378,20 @@ function commitLine(): string {
   return `, commit ${link}${import.meta.env.VITE_COMMIT_DIRTY ? ' plus local changes' : ''}`
 }
 
-/** The menu: resume, or abandon this run for a new one (confirmed first, since it can't be undone). */
+/**
+ * The menu: resume, the almanac, high scores, the demo again (user request, 2026-10-24: "just
+ * seems fun"), a bug report, or abandon this run for a new one (confirmed first, since it can't be undone). The
+ * run's modes are only named here, to keep it short (user request, same day); their tags in the
+ * top bar say what they do.
+ */
 function showMenu(): void {
   const where = state.status === 'playing' ? `Level ${state.level}, turn ${state.turn}. ` : ''
+  const modes = [state.options.dev && 'Developer', state.options.immortal && 'Immortal'].filter(Boolean)
   openDialog({
     title: 'Menu',
     body:
       `<p>${where}Seed ${state.seed}. The game saves after every move, so you can close it and come back.</p>` +
-      modeLines(state).map((l) => `<p class="mode">${l}</p>`).join('') +
+      (modes.length ? `<p class="mode">${modes.length > 1 ? 'Modes' : 'Mode'}: ${modes.join(', ')}.</p>` : '') +
       `<p class="credit">Art by <a href="https://www.kenney.nl" target="_blank" rel="noopener">Kenney</a>: ` +
       `New Platformer Pack, UI Pack, UI Pack: RPG Expansion, Game Icons and Board Game Icons (CC0). Thank you!</p>` +
       // The version is the rules version: it changes exactly when old saves stop resuming.
@@ -377,6 +401,8 @@ function showMenu(): void {
       { label: 'Resume', kind: 'primary', icon: playIcon },
       { label: 'Almanac', kind: 'plain', onClick: () => openAlmanac() },
       { label: 'High scores', kind: 'plain', onClick: openScores },
+      { label: 'Watch the demo', kind: 'plain', onClick: startDemo },
+      { label: 'Report a bug', kind: 'plain', onClick: reportBug },
       { label: 'New run', kind: 'danger', icon: newRunIcon, onClick: newRunDialog },
     ],
   })
@@ -595,22 +621,65 @@ interface Beat {
   then: number
 }
 /** `rest`: the finger is still to be put beside the player, once the camera shows the new scene. */
-let demo: { scene: Scene; next: number; at: number; real: GameState; beats: Beat[]; rest: boolean } | null = null
+/**
+ * `index`: the scene playing, in DEMO_ORDER. `upcoming`: the one to play next, being made in the
+ * background (`scene` undefined until it is).
+ */
+let demo: {
+  scene: Scene | null
+  index: number
+  upcoming: { index: number; scene: Scene | null | undefined } | null
+  next: number
+  at: number
+  real: GameState
+  beats: Beat[]
+  rest: boolean
+} | null = null
 // The scenes' seeds: from `?seed` for a repeatable demo, otherwise random.
 let demoSeed = Number(params.get('seed')) || randomSeed()
-// The scenes about the controls first, in their order; then 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. Once the living ones have all been
-// shown, the controls come again.
 /**
  * Besides the scenes made in the sim, two acted out on a still room (user request, 2026-10-24):
  * a tour of the top bar, and "What is that?" on an enemy. They come after the controls' scenes.
  */
 type DemoItem = SceneKind | 'topBar' | 'whatIsThat'
-const CONTROL_ITEMS: readonly DemoItem[] = [...CONTROL_KINDS, 'topBar', 'whatIsThat']
-const demoQueues: { controls: DemoItem[]; living: SceneKind[]; dying: SceneKind[] } = { controls: [...CONTROL_ITEMS], living: [], dying: [] }
-let demoDying = false
+/**
+ * Every scene, in one fixed order, looped (user request, 2026-10-24; they were shuffled): the
+ * controls first, then the rest in the table's order (sim/demo.ts), a way to die after every
+ * four (they took turns until then: user request, 2026-10-23).
+ */
+const DEMO_ORDER: readonly DemoItem[] = [
+  ...CONTROL_KINDS,
+  'topBar',
+  'whatIsThat',
+  ...LIVING_KINDS.flatMap((k, i) => (i % 4 === 3 && DYING_KINDS[(i - 3) / 4] ? [k, DYING_KINDS[(i - 3) / 4]!] : [k])),
+  ...DYING_KINDS.slice(Math.floor(LIVING_KINDS.length / 4)),
+]
+/** Where the demo is, kept so it carries on from there next time (not for a test URL: from 1). */
+const DEMO_KEY = 'explodorama.demoAt'
+
+function savedDemoIndex(): number {
+  try {
+    const n = Number(localStorage.getItem(DEMO_KEY))
+    return !testing && Number.isInteger(n) && n >= 0 && n < DEMO_ORDER.length ? n : 0
+  } catch {
+    return 0
+  }
+}
+
+function saveDemoIndex(i: number): void {
+  if (testing) return
+  try {
+    localStorage.setItem(DEMO_KEY, String(i))
+  } catch {
+    // No storage: the demo starts from the beginning next time.
+  }
+}
 const demoPanel = document.getElementById('demo')!
+const demoCount = document.getElementById('demo-count')!
+// The demo's buttons: the scene before, the scene after, and back to the first.
+document.getElementById('demo-prev')!.addEventListener('click', () => demo && skipTo(demo.index - 1))
+document.getElementById('demo-next')!.addEventListener('click', () => demo && skipTo(demo.index + 1))
+document.getElementById('demo-restart')!.addEventListener('click', () => skipTo(0))
 const demoCaption = document.getElementById('demo-caption')!
 const actionBar = document.getElementById('actions')!
 const finger = document.getElementById('demo-finger')!
@@ -627,12 +696,13 @@ const crtScreen = document.getElementById('demo-crt')!
 const TV_ASPECT = 4 / 3
 // Colour fringes and faint static. No glow: the board is pale and bright all over, and any glow
 // (postProcessTV's, or one only past 0.8) washed it out. First, interference (`vacuum` below):
-// with `interference` above 0 the lines of the picture jump sideways, some far, and it darkens a
-// little; the static is `staticNoise`, louder meanwhile.
+// with `interference` above 0 the lines of the picture jump sideways, some far (half as far as at
+// first: user request, 2026-10-24, too disruptive), and it darkens a little; the static is
+// `staticNoise`, louder meanwhile.
 const CRT_SHADER = postProcessEffects(
   `        // interference
         float _torn = step(.75, fract(sin(floor(p.y / 4.) * 12.9898 + floor(iTime * 24.)) * 43758.5453));
-        uv.x += interference * (_torn * .03 + .006 * sin(uv.y * 90. + iTime * 70.));
+        uv.x += interference * (_torn * .015 + .003 * sin(uv.y * 90. + iTime * 70.));
         c = texture(iChannel0, uv);
         c.rgb *= 1. - .12 * interference;`,
   postProcessChromatic(0.0025),
@@ -698,48 +768,58 @@ const SCENE_ICONS: Record<SceneKind, string> = {
   dieDetonator: POWER_UP_ICONS.detonator, dieRun: POWER_UP_ICONS.run,
 }
 
-/** The next kind of scene: the controls' queue first, then living and dying ones in turn. */
-function nextKind(): DemoItem {
-  if (demoQueues.controls.length) return demoQueues.controls.shift()!
-  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 kind = demoQueues[which].shift()!
-  if (which === 'living' && !demoQueues.living.length) demoQueues.controls = [...CONTROL_ITEMS]
-  return kind
+
+/**
+ * Starts making scene `index` of DEMO_ORDER in the background (ui/scenes.ts), to play once the
+ * current one ends: the next one, made while this one plays, or one picked with ◀ ▶.
+ */
+function prepareScene(index: number): void {
+  if (!demo) return
+  const i = (index + DEMO_ORDER.length) % DEMO_ORDER.length
+  const upcoming: NonNullable<typeof demo.upcoming> = { index: i, scene: undefined }
+  demo.upcoming = upcoming
+  const item = DEMO_ORDER[i]!
+  // The still scenes play on the room of a sim scene, without its turns.
+  void makeScene(item === 'topBar' ? 'tapMove' : item === 'whatIsThat' ? 'enemies' : item, demoSeed++).then((scene) => {
+    upcoming.scene = scene
+  })
 }
 
-/** The next scene: each cycling through its kinds in a shuffled order. */
+/**
+ * The upcoming scene, once made (called each frame until it is). One that couldn't be made (none
+ * found, rare) is passed over for the one after it.
+ */
 function nextScene(): void {
   if (!demo) return
-  for (let tries = 0; tries < SCENE_KINDS.length; tries++) {
-    const item = nextKind()
-    // The still scenes play on the room of a sim scene, without its turns.
-    const scene = generateScene(item === 'topBar' ? 'tapMove' : item === 'whatIsThat' ? 'enemies' : item, demoSeed++)
-    if (!scene) continue
-    resetView()
-    hover = null
-    armed = null
-    demo.scene = scene
-    demo.next = 0
-    demo.beats = []
-    demo.at = time + DEMO_LEAD_SECONDS
-    state = scene.start
-    if (item === 'topBar' || item === 'whatIsThat') {
-      demo.scene = { ...scene, actions: [], gestures: [] }
-      // The tour's room holds a power-up and a charge, so the top bar's last row has something to show.
-      if (item === 'topBar') state = { ...state, powers: { ...state.powers, kick: true }, boosts: { ...state.boosts, pierce: 3 } }
-      demo.beats = item === 'topBar' ? topBarTour() : whatIsThat()
-    } else {
-      const icon = SCENE_ICONS[item]
-      setCaption(spriteUrl(icon), MONO_ICONS.has(icon), SCENES[item].caption)
-    }
-    afterChange()
-    // The finger comes back beside the player, where the eye should be (user request, 2026-10-24).
-    demo.rest = true
-    return
+  if (!demo.upcoming) prepareScene(demo.index + 1)
+  const { index, scene } = demo.upcoming!
+  if (scene === undefined) return
+  if (scene === null) return prepareScene(index + 1)
+  const item = DEMO_ORDER[index]!
+  demo.index = index
+  saveDemoIndex(index)
+  demoCount.textContent = `${index + 1} / ${DEMO_ORDER.length}`
+  prepareScene(index + 1)
+  resetView()
+  hover = null
+  armed = null
+  demo.scene = scene
+  demo.next = 0
+  demo.beats = []
+  demo.at = time + DEMO_LEAD_SECONDS
+  state = scene.start
+  if (item === 'topBar' || item === 'whatIsThat') {
+    demo.scene = { ...scene, actions: [], gestures: [] }
+    // The tour's room holds a power-up and a charge, so the top bar's last row has something to show.
+    if (item === 'topBar') state = { ...state, powers: { ...state.powers, kick: true }, boosts: { ...state.boosts, pierce: 3 } }
+    demo.beats = item === 'topBar' ? topBarTour() : whatIsThat()
+  } else {
+    const icon = SCENE_ICONS[item]
+    setCaption(spriteUrl(icon), MONO_ICONS.has(icon), SCENES[item].caption)
   }
+  afterChange()
+  // The finger comes back beside the player, where the eye should be (user request, 2026-10-24).
+  demo.rest = true
 }
 
 /** The demo's caption: an icon (an image URL; `mono` for a one-colour ink icon) and the words. */
@@ -813,8 +893,14 @@ function whatIsThat(): Beat[] {
   ]
 }
 
+/**
+ * The demo: when the game opens, or from the menu; from where it was last time (user request,
+ * 2026-10-24), its ↺ button back to the start.
+ */
 function startDemo(): void {
-  demo = { scene: null!, next: 0, at: 0, real: state, beats: [], rest: false }
+  const at = savedDemoIndex()
+  demo = { scene: null, index: at, upcoming: null, next: 0, at: 0, real: state, beats: [], rest: false }
+  prepareScene(at)
   document.body.classList.add('demo')
   demoPanel.hidden = false
   finger.hidden = false
@@ -824,14 +910,13 @@ function startDemo(): void {
   setDialogInDemo(true)
   postProcess?.setShaderCode(CRT_SHADER)
   nextScene()
-  if (!demo.scene) endDemo()
 }
 
 /** Called every frame: the script's next beat when it is due, the next turn's script, or the next scene. */
 function demoTick(): void {
   if (!demo || time < demo.at) return
   if (!demo.beats.length) {
-    if (demo.next >= demo.scene.actions.length) return nextScene()
+    if (!demo.scene || demo.next >= demo.scene.actions.length) return nextScene()
     const i = demo.next++
     demo.beats = beatsFor(demo.scene.gestures[i]!, demo.scene.actions[i]!, demo.next === demo.scene.actions.length)
   }
@@ -909,7 +994,7 @@ function centreOf(el: Element) {
 function placeDemoMarks(): void {
   if (!demo) return
   // Hugging the top of the player's head, always over them (user request, 2026-10-24).
-  const tip = worldToScreen(playerPos(state, playerMove).add(vec2(0, 0.4)))
+  const tip = worldToScreen(playerPos(state, anim.playerMove).add(vec2(0, 0.4)))
   const r = mainCanvas.getBoundingClientRect()
   youTag.style.transform = `translate(${r.left + tip.x}px, ${r.top + tip.y}px)`
   if (!demo.rest) return
@@ -951,6 +1036,23 @@ function placeMark(className: string): HTMLElement {
   return mark
 }
 
+/**
+ * ◀ ▶ (or ← →), and ↺ (0): leaves the scene playing for scene `index`, as soon as it is made.
+ * Anything under way stops: the finger's script, a dialog, the tour's ring.
+ */
+function skipTo(index: number): void {
+  if (!demo) return
+  demo.beats = []
+  demo.scene = null
+  demo.at = 0
+  if (dialogOpen()) closeDialog()
+  spotlight(null)
+  fingerUp()
+  for (const b of actionBar.querySelectorAll('.pressed')) b.classList.remove('pressed')
+  finger.classList.add('away')
+  prepareScene(index)
+}
+
 function endDemo(): void {
   if (!demo) return
   state = demo.real
@@ -981,7 +1083,8 @@ let swallowClick = 0
 addEventListener(
   'pointerdown',
   (e) => {
-    if (!demo) return
+    // The demo's own buttons (◀ ▶ ↺) work, and keep it going.
+    if (!demo || (e.target as Element).closest?.('.demo-nav')) return
     endDemo()
     swallowClick = performance.now() + 600
     e.stopPropagation()
@@ -1004,6 +1107,13 @@ addEventListener(
   'keydown',
   (e) => {
     if (!demo) return
+    e.stopPropagation()
+    e.preventDefault()
+    // ← and → go through the scenes; any other key ends the demo.
+    if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
+      if (!e.repeat) skipTo(demo.index + (e.key === 'ArrowLeft' ? -1 : 1))
+      return
+    }
     endDemo()
     e.stopPropagation()
     e.preventDefault()
@@ -1093,17 +1203,18 @@ await engineInit(
     placeDemoMarks()
     if (demo) vacuum()
     snapCamera = false
-    flames = flames.filter((f) => time - f.born < FLAME_SECONDS)
-    pruneEffects(fx, time)
+    pruneAnimation(anim, time)
     autoStep()
   }),
   () => {},
-  guarded('draw', () =>
+  guarded('draw', () => {
+    // A dialog's clip first: it uses the canvas for a moment, then clears it (ui/clip.ts).
+    drawClip()
     drawBoard({
-      state, danger, selected, hover, flames, facingLeft, destination, path, armed, playerMove, playerPose, enemySteps, fx,
+      state, danger, selected, hover, destination, path, armed, ...anim,
       pathBlocked: !!path?.[0] && !safeStep(path[0]),
-    }),
-  ),
+    })
+  }),
   () => {},
   TEXTURES,
 )
diff --git a/src/render/animate.ts b/src/render/animate.ts
new file mode 100644
index 0000000..874368f
--- /dev/null
+++ b/src/render/animate.ts
@@ -0,0 +1,70 @@
+import type { step } from '../sim/turn'
+import type { Action, GameState, Vec } from '../sim/types'
+import { FLAME_SECONDS, type Flame } from './board'
+import { clearEffects, newEffects, playerTransition, pruneEffects, startEffects, type Effects, type PlayerMove, type PlayerPose } from './effects'
+
+/**
+ * Everything that animates a board between turns: what the last turns changed (effects.ts), the
+ * flame of a blast, the player's move and pose and which way they face, and each enemy's latest
+ * step. One for the game (and the demo, which plays on its board), one for each dialog's clip
+ * (ui/clip.ts), so a clip can play beside the game (PLAN.md §13c).
+ */
+export interface Animation {
+  fx: Effects
+  flames: Flame[]
+  playerMove: PlayerMove | null
+  playerPose: PlayerPose
+  /** The way the player last stepped. The character sprite faces right; this mirrors it. */
+  facingLeft: boolean
+  /** Each enemy's latest step, by id: where from, and when (LittleJS `time`). */
+  enemySteps: Map<number, { from: Vec; at: number }>
+}
+
+export function newAnimation(): Animation {
+  return { fx: newEffects(), flames: [], playerMove: null, playerPose: { kind: 'none', at: 0 }, facingLeft: false, enemySteps: new Map() }
+}
+
+/** A fresh board: nothing under way. */
+export function clearAnimation(a: Animation): void {
+  clearEffects(a.fx)
+  a.flames = []
+  a.playerMove = null
+  a.playerPose = { kind: 'none', at: 0 }
+  a.enemySteps.clear()
+}
+
+/** Each frame: what has finished playing, dropped. */
+export function pruneAnimation(a: Animation, now: number): void {
+  a.flames = a.flames.filter((f) => now - f.born < FLAME_SECONDS)
+  pruneEffects(a.fx, now)
+}
+
+/**
+ * A turn played, from `prev` to `result.state`: everything that animates it starts at `now`.
+ * True if a level started or restarted (the board is new: nothing walks over from the old one).
+ */
+export function animateTurn(a: Animation, prev: GameState, result: ReturnType<typeof step>, action: Action, now: number): boolean {
+  const next = result.state
+  startEffects(a.fx, prev, next, result.events, now)
+  // The player's move comes from their change of tile, the pose from what happened; the pose
+  // waits for the move, so nothing can skip it (effects.ts `playerTransition`).
+  const player = playerTransition(prev, next, result.events, action.id === 'teleport', now)
+  if (player.move) a.playerMove = player.move
+  if (player.move || player.pose.kind !== 'none') a.playerPose = player.pose
+  if (player.move && player.move.from.x !== next.player.x) a.facingLeft = next.player.x < player.move.from.x
+  const newLevel = result.events.some((e) => e.type === 'levelStarted' || e.type === 'levelRestarted')
+  if (newLevel) {
+    a.playerMove = null
+    a.playerPose = player.pose
+    a.enemySteps.clear()
+  }
+  for (const e of result.events) if (e.type === 'exploded') a.flames.push({ tiles: e.tiles, born: now })
+  // Every enemy that changed tile steps from where it was: from the state, like the player.
+  if (!newLevel) {
+    for (const enemy of next.enemies) {
+      const was = prev.enemies.find((o) => o.id === enemy.id)
+      if (was && (was.pos.x !== enemy.pos.x || was.pos.y !== enemy.pos.y)) a.enemySteps.set(enemy.id, { from: was.pos, at: now })
+    }
+  }
+  return newLevel
+}
diff --git a/src/render/board.ts b/src/render/board.ts
index 966d80b..22a74e9 100644
--- a/src/render/board.ts
+++ b/src/render/board.ts
@@ -12,16 +12,26 @@ import {
 } from './effects'
 import { CHARGE_ICONS, digitSprite, ENEMY_SPRITES, GEM_SPRITES, sprite, wallSprite, type BoardSprite } from './sprites'
 
+/**
+ * Where the board's bottom-left corner is in world space: the origin, except while a dialog's
+ * clip is drawn (ui/clip.ts), which draws its own room somewhere else for a moment.
+ */
+let origin = vec2(0)
+
+export function setBoardOrigin(o: Vector2): void {
+  origin = o
+}
+
 /**
  * The one place where sim tiles meet LittleJS world space. The sim's y grows downward
  * (row 0 at the top); LittleJS's y grows upward. A tile's centre is at (x + 0.5, y + 0.5).
  */
 export function toWorld(s: GameState, p: Vec): Vector2 {
-  return vec2(p.x + 0.5, s.height - 1 - p.y + 0.5)
+  return vec2(origin.x + p.x + 0.5, origin.y + s.height - 1 - p.y + 0.5)
 }
 
 export function toTile(s: GameState, w: Vector2): Vec {
-  return { x: Math.floor(w.x), y: s.height - 1 - Math.floor(w.y) }
+  return { x: Math.floor(w.x - origin.x), y: s.height - 1 - Math.floor(w.y - origin.y) }
 }
 
 /** Shown for a moment after a bomb goes off. Flame has no lasting effect on the board. */
diff --git a/src/sim/demo.ts b/src/sim/demo.ts
index 0b8917a..e0f8b7b 100644
--- a/src/sim/demo.ts
+++ b/src/sim/demo.ts
@@ -1,5 +1,6 @@
 import { ACTIONS, isAvailable, isValidTarget } from './actions'
 import { predictDanger } from './danger'
+import { blastTiles } from './explosions'
 import { planIntents } from './enemies'
 import { Rng } from './rng'
 import { autoMoveAction, playerPath, safeStep } from './path'
@@ -624,19 +625,19 @@ function rollout(kind: SceneKind, start: GameState, rng: Rng): GameTurn[] | null
   const walking: Walking = { to: null }
   const def = SCENES[kind]
   for (let n = 0; n < MAX_TURNS && s.status === 'playing' && !s.offer; n++) {
-    // A random turn (or the scene's nudge) that doesn't get the player killed, a few goes at it —
-    // unless dying is what the scene is about, and this is the way it shows. Only turns a gesture
-    // can play.
+    // A random turn (or the scene's nudge) that doesn't get the player killed and that a sensible
+    // player might take, a few goes at it — unless dying is what the scene is about, and this is
+    // the way it shows. Only turns a gesture can play.
     let turn: GameTurn | null = null
     const was = walking.to
-    for (let go = 0; go < 4 && !turn; go++) {
+    for (let go = 0; go < 6 && !turn; go++) {
       walking.to = was
       const play = def.steer?.(s, rng, walking) ?? playOf(def, s, randomAction(s, rng))
       if (!play) continue
       const r = step(s, play.action)
       const t = { prev: s, ...play, events: r.events, next: r.state, walking: was !== null }
       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 ((alive && (def.dies || reasonable(kind, t))) || (def.dies && def.shows(t, seen))) turn = t
     }
     if (!turn) return null
     turns.push(turn)
@@ -648,6 +649,29 @@ function rollout(kind: SceneKind, start: GameState, rng: Rng): GameTurn[] | null
   return null
 }
 
+/**
+ * Whether a turn is one a sensible player might take (user request, 2026-10-24: a scene put a
+ * bomb on the exit). Not asked in the scenes about dying, whose point is a mistake.
+ * - No flame on an uncovered exit or gem, which lets enemies out — unless that is the scene.
+ * - A bomb (placed or thrown) only where its blast reaches a crate, an enemy, a tile an enemy is
+ *   going to, or another bomb (to set it off): never one that does nothing.
+ */
+function reasonable(kind: SceneKind, t: Turn): boolean {
+  const punishing = kind !== 'doorBlast'
+  if (punishing && t.events.some((e) => e.type === 'enemiesSpawned')) return false
+  if ((t.action.id !== 'bomb' && t.action.id !== 'throw') || !('target' in t.action) || !t.action.target) return true
+  const target = t.action.target
+  const bomb = t.next.bombs.find((b) => at(b.pos, target))
+  if (!bomb) return true
+  const tiles = blastTiles(t.next, bomb)
+  const s = t.prev
+  const uncovered = (p: Vec) => s.cells[idx(s, p)] === 'floor' && (at(p, s.exit) || s.gems.some((g) => at(g.pos, p)))
+  if (punishing && tiles.some(uncovered)) return false
+  return tiles.some(
+    (p) => s.cells[idx(s, p)] === 'block' || s.bombs.some((b) => at(b.pos, p)) || s.enemies.some((e) => at(e.pos, p) || e.plan.some((q) => at(q, p))),
+  )
+}
+
 /**
  * The gesture that plays `action` in a scene, or null if none does (a step onto a bomb with Bomb
  * Pass: a tap there kicks it, or means nothing). A step, a diagonal step, a kick or a detonation is
diff --git a/src/sim/scene-worker.ts b/src/sim/scene-worker.ts
new file mode 100644
index 0000000..236267a
--- /dev/null
+++ b/src/sim/scene-worker.ts
@@ -0,0 +1,11 @@
+import { generateScene, type SceneKind } from './demo'
+
+/**
+ * Makes the demo's and the dialogs' scenes off the main thread (ui/scenes.ts): one can take up to
+ * ~0.45 s, which played on the page would stall the animation. A request is an id, a kind and a
+ * seed; the answer is the id and the scene, or null if none was found.
+ */
+self.onmessage = (e: MessageEvent<{ id: number; kind: SceneKind; seed: number }>) => {
+  const { id, kind, seed } = e.data
+  self.postMessage({ id, scene: generateScene(kind, seed) })
+}
diff --git a/src/style.css b/src/style.css
index d3c872f..fb086a4 100644
--- a/src/style.css
+++ b/src/style.css
@@ -553,6 +553,8 @@ img {
   overflow-y: auto;
   /* Scrolls by touch, wheel or trackpad, without a scroll bar across the parchment. */
   scrollbar-width: none;
+  /* The body has touch-action: none (a game surface); a finger may still scroll this. */
+  touch-action: pan-y;
   font-size: 15px;
   line-height: 1.4;
 }
@@ -841,6 +843,46 @@ body.demo #hud {
   filter: brightness(0) invert(1);
 }
 
+/* Which scene this is, and the buttons to go through them (main.ts): ◀ n / N ▶, and ↺ back to
+   the first, set apart. They don't end the demo. */
+#demo .demo-nav {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 10px;
+  margin-top: 8px;
+}
+
+#demo-count {
+  min-width: 4.5em;
+  font-variant-numeric: tabular-nums;
+  font-weight: 700;
+}
+
+#demo .demo-nav button {
+  width: 36px;
+  height: 36px;
+  border: 0;
+  border-radius: 50%;
+  background: rgb(255 255 255 / 0.14) center / 18px no-repeat;
+  cursor: pointer;
+}
+
+#demo #demo-prev,
+#demo #demo-next {
+  background-image: url('../assets/kenney-icons/White/arrowRight.png');
+}
+
+#demo-prev {
+  transform: scaleX(-1);
+}
+
+#demo #demo-restart {
+  margin-left: 14px;
+  background-image: url('../assets/kenney-ui/Extra/icon_repeat_light.png');
+  background-size: 22px;
+}
+
 #demo .demo-hint {
   margin: 10px 0 0;
   color: #ffc83d;
@@ -1212,3 +1254,15 @@ body.demo #actions {
     display: none;
   }
 }
+
+/* A dialog's clip (ui/clip.ts): the scene, drawn by the game, above the words. */
+#dialog-body canvas.clip {
+  display: block;
+  width: 100%;
+  max-width: 300px;
+  height: auto;
+  /* A room's shape (9 × 7), held while its first scene is being made. */
+  aspect-ratio: 9 / 7;
+  margin: 0 auto;
+  border-radius: 8px;
+}
diff --git a/src/ui/clip.ts b/src/ui/clip.ts
new file mode 100644
index 0000000..3966846
--- /dev/null
+++ b/src/ui/clip.ts
@@ -0,0 +1,117 @@
+import { cameraPos, getCanvasPixelRatio, glCanvas, glClearCanvas, glFlush, time, vec2, worldToScreen } from 'littlejsengine'
+import { animateTurn, newAnimation, pruneAnimation, type Animation } from '../render/animate'
+import { drawBoard, setBoardOrigin } from '../render/board'
+import { predictDanger } from '../sim/danger'
+import type { Scene, SceneKind } from '../sim/demo'
+import { step } from '../sim/turn'
+import type { GameState } from '../sim/types'
+import { makeScene } from './scenes'
+
+/**
+ * A dialog's clip (PLAN.md §13c, user request, 2026-10-24): a small looping scene of what the
+ * dialog describes, made on the spot like the demo's (sim/demo.ts), each loop a new setup.
+ *
+ * Drawn with the board's own code: there is one LittleJS canvas, so while a clip shows, each
+ * frame first draws the clip's room (at an offset in world space, where the camera looks), copies
+ * that patch of the canvas into the dialog's <canvas>, clears, and then the game is drawn as
+ * usual (`drawClip`, called before the game's drawing). Scenes are made in the background
+ * (ui/scenes.ts), the next loop's while this one plays.
+ */
+interface Clip {
+  canvas: HTMLCanvasElement
+  kind: SceneKind
+  seed: number
+  /** The loop playing (null until the first is made), and the next one, once made. */
+  scene: Scene | null
+  upcoming: Scene | null
+  state: GameState | null
+  /** The next turn to play, and when (LittleJS `time`). */
+  next: number
+  at: number
+  anim: Animation
+}
+
+/** A turn every this many seconds; the moment held this long; a new setup's first turn after this. */
+const STEP_SECONDS = 0.6
+const HOLD_SECONDS = 1
+const LEAD_SECONDS = 0.5
+
+let clip: Clip | null = null
+
+/** Plays a clip of `kind` in `canvas` (in an open dialog) until the dialog closes or another starts. */
+export function startClip(canvas: HTMLCanvasElement, kind: SceneKind): void {
+  const c: Clip = {
+    canvas, kind, seed: Math.floor(Math.random() * 2 ** 31), scene: null, upcoming: null, state: null, next: 0, at: 0, anim: newAnimation(),
+  }
+  clip = c
+  prepare(c)
+}
+
+/** Makes the next loop's scene in the background (a new setup each loop). */
+function prepare(c: Clip): void {
+  void makeScene(c.kind, c.seed++).then((scene) => {
+    // None found (rare): try the next seed.
+    if (!scene) return clip === c && prepare(c)
+    c.upcoming = scene
+  })
+}
+
+/** Each frame: the clip's next turn when due, or the next loop once made; its animations moving on. */
+function tick(c: Clip): void {
+  pruneAnimation(c.anim, time)
+  const done = !c.scene || (c.next >= c.scene.actions.length && time >= c.at)
+  if (done && c.upcoming) {
+    c.scene = c.upcoming
+    c.upcoming = null
+    c.state = c.scene.start
+    c.next = 0
+    c.at = time + LEAD_SECONDS
+    c.anim = newAnimation()
+    prepare(c)
+    return
+  }
+  if (!c.scene || !c.state || time < c.at || c.next >= c.scene.actions.length) return
+  const action = c.scene.actions[c.next++]!
+  const result = step(c.state, action)
+  animateTurn(c.anim, c.state, result, action, time)
+  c.state = result.state
+  c.at = time + (c.next < c.scene.actions.length ? STEP_SECONDS : HOLD_SECONDS)
+}
+
+/**
+ * Called in the draw callback before the game's own drawing: the clip's room, drawn where the
+ * camera looks, copied into its canvas; then the canvas cleared for the game. Nothing, and the
+ * clip dropped, once its dialog has closed (or its canvas has gone).
+ */
+export function drawClip(): void {
+  if (!clip) return
+  if (!clip.canvas.isConnected || !clip.canvas.closest('dialog')?.open) {
+    clip = null
+    return
+  }
+  const c = clip
+  tick(c)
+  const s = c.state
+  if (!s) return
+  // The room centred where the camera looks, so it lies inside the canvas.
+  const corner = cameraPos.subtract(vec2(s.width / 2, s.height / 2))
+  setBoardOrigin(corner)
+  try {
+    drawBoard({
+      state: s, danger: predictDanger(s), selected: null, hover: null, destination: null, path: null, pathBlocked: false, armed: null,
+      ...c.anim,
+    })
+    glFlush()
+    // The room's patch of the canvas, in its backing pixels, top-left first.
+    const dpr = getCanvasPixelRatio()
+    const topLeft = worldToScreen(corner.add(vec2(0, s.height)))
+    const bottomRight = worldToScreen(corner.add(vec2(s.width, 0)))
+    const w = Math.round((bottomRight.x - topLeft.x) * dpr)
+    const h = Math.round((bottomRight.y - topLeft.y) * dpr)
+    if (c.canvas.width !== w || c.canvas.height !== h) Object.assign(c.canvas, { width: w, height: h })
+    c.canvas.getContext('2d')!.drawImage(glCanvas, topLeft.x * dpr, topLeft.y * dpr, w, h, 0, 0, w, h)
+  } finally {
+    setBoardOrigin(vec2(0))
+    glClearCanvas()
+  }
+}
diff --git a/src/ui/dialog.ts b/src/ui/dialog.ts
index 724c2ba..ce3b004 100644
--- a/src/ui/dialog.ts
+++ b/src/ui/dialog.ts
@@ -78,6 +78,12 @@ dialog.addEventListener('click', (e) => {
 // LittleJS cancels every wheel event on the page (so the game never scrolls it), which also
 // stopped a long dialog, like the almanac, scrolling with a mouse wheel or trackpad. Kept here.
 dialog.addEventListener('wheel', (e) => e.stopPropagation(), { passive: true })
+// The same for touch: LittleJS cancels every touch on the page that isn't on a control, so a
+// finger couldn't scroll a long dialog, like the almanac, on a phone (user report, 2026-10-24).
+// Touches in the dialog don't reach it; the board is behind the dialog anyway.
+for (const type of ['touchstart', 'touchmove', 'touchend', 'touchcancel'] as const) {
+  dialog.addEventListener(type, (e) => e.stopPropagation(), { passive: true })
+}
 // Esc closes it too, animated like the rest.
 dialog.addEventListener('cancel', (e) => {
   e.preventDefault()
diff --git a/src/ui/scenes.ts b/src/ui/scenes.ts
new file mode 100644
index 0000000..f6d8ec5
--- /dev/null
+++ b/src/ui/scenes.ts
@@ -0,0 +1,36 @@
+import { generateScene, type Scene, type SceneKind } from '../sim/demo'
+
+/**
+ * Scenes for the demo and the dialogs' clips, made in a Web Worker (sim/scene-worker.ts) so the
+ * page never waits on one (PLAN.md §13c). Where a worker can't be made, on the page instead.
+ */
+let worker: Worker | null = null
+try {
+  worker = new Worker(new URL('../sim/scene-worker.ts', import.meta.url), { type: 'module' })
+} catch {
+  worker = null
+}
+
+let nextId = 0
+const waiting = new Map<number, { kind: SceneKind; seed: number; done: (scene: Scene | null) => void }>()
+worker?.addEventListener('message', (e: MessageEvent<{ id: number; scene: Scene | null }>) => {
+  waiting.get(e.data.id)?.done(e.data.scene)
+  waiting.delete(e.data.id)
+})
+// A worker that fails (a script that wouldn't load) answers nothing: what waits on it, and all
+// after, is made here.
+worker?.addEventListener('error', () => {
+  worker = null
+  for (const { kind, seed, done } of waiting.values()) done(generateScene(kind, seed))
+  waiting.clear()
+})
+
+/** A scene of `kind` from `seed`, when it is ready; null if none was found. */
+export function makeScene(kind: SceneKind, seed: number): Promise<Scene | null> {
+  if (!worker) return Promise.resolve(generateScene(kind, seed))
+  const id = nextId++
+  return new Promise((resolve) => {
+    waiting.set(id, { kind, seed, done: resolve })
+    worker!.postMessage({ id, kind, seed })
+  })
+}
diff --git a/test/demo.test.ts b/test/demo.test.ts
index 8cb309e..c11301e 100644
--- a/test/demo.test.ts
+++ b/test/demo.test.ts
@@ -1,7 +1,7 @@
 import { describe, expect, it } from 'vitest'
 import { isValidTarget } from '../src/sim/actions'
 import { predictDanger } from '../src/sim/danger'
-import { generateScene, MAX_STEPS, MIN_STEPS, SCENE_KINDS, SCENES, showsAtEnd, type Gesture } from '../src/sim/demo'
+import { generateScene, MAX_STEPS, MIN_STEPS, replayScene, SCENE_KINDS, SCENES, showsAtEnd, type Gesture } from '../src/sim/demo'
 import { autoMoveAction, safeStep } from '../src/sim/path'
 import { resolvePick, tapMeaning } from '../src/sim/picks'
 import { step } from '../src/sim/turn'
@@ -55,6 +55,14 @@ describe('demo scenes', () => {
           expect(last && SCENES[kind].dies ? ['dead', 'gameOver'] : ['playing', 'cleared']).toContain(s.status)
         }
         expect(showsAtEnd(scene)).toBe(true)
+        // Sensible play, unless the scene is about a mistake: nothing lets enemies out of the exit
+        // or the gem (but in the scene about that), and no bomb goes on the exit.
+        if (!SCENES[kind].dies) {
+          for (const t of replayScene(scene).turns) {
+            if (kind !== 'doorBlast') expect(t.events.some((e) => e.type === 'enemiesSpawned')).toBe(false)
+            if (kind !== 'doorBlast' && t.action.id === 'bomb' && 'target' in t.action) expect(t.action.target).not.toEqual(t.prev.exit)
+          }
+        }
       }
     })
   }