fuzzyworld

explodorama / commit

Clips in every info dialog that has one, the offer, the almanac, and the Ouch replay

32dd53cd7a7dbb4f515d856fcc17d0813b670673 · Pavel Safronov · 2026-10-24 17:11:35 -0700 · parents 33a0256b4168

Claude Code usage: this commit ~115.6K out / ~61.1M cached · cost unknown (no per-model trailer) · session 4 tally 1.3K in / 640.8K out / 229.8M cached · usage by commit and session

Clips in every info dialog that has one, the offer, the almanac, and the Ouch replay

- Each Info names its clip: action buttons, top-bar items, board tiles
  and the first-pickup dialogs. The finger shows how in the clips about
  the actions and hand-worked power-ups, starting beside the player.
- New clip-only scenes: the last enemy (the exit opens), a crate
  breaking, flame stopped by a pillar; lives play a death by an enemy.
- The gem's offer plays the power-up looked at above its cards (each
  card's picture is its own button); almanac entries with a clip open
  it, with Back; the Ouch and game-over banners replay the player's own
  last 4 turns round where they died.
- Clips can play several at once, made each loop or fixed.
- The screenshot tool taps the right tiles again (info-enemy shows a
  Blob); all screenshots retaken.

Usage: 1.3K in / 640.8K out / 229.8M cached (session tally)
rtk:   1.9M saved, 38.9% over 4145 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 CHANGELOG.md                         |   2 +
 PLAN.md                              |  13 ++-
 README.md                            |  16 ++-
 decisions.md                         |  71 ++++++++++++
 docs/screenshots/almanac-charges.png | Bin 268407 -> 280855 bytes
 docs/screenshots/almanac-enemies.png | Bin 196087 -> 196295 bytes
 docs/screenshots/almanac.png         | Bin 380266 -> 400082 bytes
 docs/screenshots/auto-move.png       | Bin 216468 -> 217145 bytes
 docs/screenshots/bomb-picked.png     | Bin 216338 -> 216434 bytes
 docs/screenshots/charges.png         | Bin 227866 -> 228162 bytes
 docs/screenshots/countdown.png       | Bin 221002 -> 221116 bytes
 docs/screenshots/death.png           | Bin 187808 -> 235149 bytes
 docs/screenshots/demo.png            | Bin 971948 -> 973013 bytes
 docs/screenshots/desktop.png         | Bin 313907 -> 314015 bytes
 docs/screenshots/dev-offer.png       | Bin 209714 -> 217879 bytes
 docs/screenshots/dev.png             | Bin 219651 -> 219773 bytes
 docs/screenshots/enemies.png         | Bin 219900 -> 220100 bytes
 docs/screenshots/gem-offer.png       | Bin 214049 -> 214176 bytes
 docs/screenshots/gem.png             | Bin 213818 -> 214183 bytes
 docs/screenshots/info-enemy.png      | Bin 134376 -> 143135 bytes
 docs/screenshots/info-hud.png        | Bin 135321 -> 183703 bytes
 docs/screenshots/info-mode.png       | Bin 215488 -> 215678 bytes
 docs/screenshots/lingering.png       | Bin 219430 -> 219582 bytes
 docs/screenshots/long-press.png      | Bin 215678 -> 219891 bytes
 docs/screenshots/menu.png            | Bin 142515 -> 142192 bytes
 docs/screenshots/move-picked.png     | Bin 216468 -> 216597 bytes
 docs/screenshots/new-run.png         | Bin 188794 -> 188971 bytes
 docs/screenshots/nuke.png            | Bin 239964 -> 240183 bytes
 docs/screenshots/start.png           | Bin 215700 -> 215894 bytes
 index.html                           |   1 +
 src/main.ts                          |  38 ++++--
 src/sim/demo.ts                      |  30 ++++-
 src/style.css                        | 146 ++++++++++++++++++++++-
 src/ui/almanac.ts                    |  56 ++++++---
 src/ui/clip.ts                       | 216 ++++++++++++++++++++++++++++++-----
 src/ui/info.ts                       |  48 ++++++--
 src/ui/offer.ts                      |  55 +++++++--
 tools/screenshots.mjs                |  15 ++-
 38 files changed, 615 insertions(+), 92 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0ce749d..8af9049 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,8 @@ saved before the change is dropped and the next visit starts a new run.
 
 ### Added
 
+- **The "Ouch!" banner replays your own last turns** before a death. The gem's offer plays a clip of the power-up you look at, and almanac entries open their clip.
+- **Clips in the info dialogs:** most of them play a short scene of what they describe, new every loop, with a finger showing how for the actions and the power-ups you work yourself.
 - **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
diff --git a/PLAN.md b/PLAN.md
index e815b85..a6723e8 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -774,8 +774,17 @@ too.
 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.
+clips that reuse demo scenes (*done 2026-10-24*: each `Info` names its scene, `clip`; the finger in
+the clips about the actions and the hand-worked power-ups, `FINGER_KINDS` in ui/clip.ts — Wait
+there is a tap on the player, as no action bar is in a clip; charges are still picked up first); 4. the
+new scenes (*done 2026-10-24*, clip-only, `clipOnly` in sim/demo.ts: `lastEnemy` for enemies left,
+`crate`, `pillar` for walls; lives play the `dieEnemy` scene; turn and par, which only a number
+shows, and the modes have none); 5. the offer's cards,
+the almanac and the "Ouch!" replay (*done 2026-10-24*, the user's choices: one clip above the
+offer's cards, playing the card whose picture — now its own button — was tapped or pointed at;
+almanac entries with a clip are buttons that open it in their own dialog, Back returning; the
+banner replays the player's own last 4 turns of the attempt, a demo room's worth of the level
+round where they died). Clips can now be several at once, fixed scenes or made each loop.
 
 ## 13b. Run options (user request, 2026-10-21; built)
 
diff --git a/README.md b/README.md
index 09ca4f9..bfff82b 100644
--- a/README.md
+++ b/README.md
@@ -124,11 +124,16 @@ burning flame, a wall or crate, the exit, the gem, a drop. The **?** turns green
 on; it works for one tap, and tapping it again or **Esc** turns it off. For things you can walk
 to, the dialog has a **Walk there** button that starts auto-move there. The items in the **top
 bar** (lives, level, enemies left, coins, turn / par, bombs, blast, fuse, passes, Star) explain
-themselves with a plain tap.
+themselves with a plain tap. Most of these dialogs **play a short clip** of what they describe,
+made on the spot like the demo's scenes and new every loop: a bomb counting down and going off,
+lingering flame, a crate breaking, flame stopping at a pillar, the last enemy dying and the exit
+opening, losing a life, the exit, the gem, coins, each power-up and charge; for the actions (Move, Bomb,
+Wait) and the power-ups you work yourself (Run, Kick, Throw, Teleport, Diagonal, the Detonator),
+a finger shows how. Enemies have only their picture.
 
 **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 — **High scores**, **Watch the demo**, **Report a bug**
+picture and what it does, in three tabs; an entry with a green ▶ opens its clip — **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
@@ -245,7 +250,9 @@ uncovered:** flame on it destroys it and lets out 3 enemies, just like the exit
 those enemies can't be hurt by the flame that let them out).
 
 **Taking the gem offers 5 different power-ups, and you choose one.** The dialog shows each with
-what it does; it can't be closed without choosing, and choosing takes no turn. The 5 are drawn
+what it does; it can't be closed without choosing, and choosing takes no turn. A clip above the
+cards plays the first one; tap a card's picture (or point at it with a mouse) to play that one,
+and tap the card itself to choose it. The 5 are drawn
 at random, but not all are equally likely: common ones (Bomb +1, Fire +1) come up far more often
 than rare ones (Teleport, Heart). An ability or pass you already hold is never offered. The
 offer is fixed by the seed and level, so a retry after a death offers the same. If you die on the turn you take the gem, there
@@ -281,7 +288,8 @@ You have **3 lives**. Caught in a blast or touched by an enemy, you lose one and
 again with the same layout and enemies. **You keep everything else**: coins, stats, every
 power-up (abilities and passes included) and what your charges still give. (The original took
 the passes and the Detonator away; since 2026-10-23 a death costs only the life.) The banner says how and where you died — "Level 3, turn 12: you walked into a Blob." — and so
-does the game-over banner. With none left, the run is over and the seed is shown, so the same board can be
+does the game-over banner. Both replay **your own last turns** before the death, over and over,
+on the part of the level where it happened, so you can see what went wrong. With none left, the run is over and the seed is shown, so the same board can be
 replayed.
 
 ### New run: modes
diff --git a/decisions.md b/decisions.md
index 2bc0b23..7bc400d 100644
--- a/decisions.md
+++ b/decisions.md
@@ -1415,3 +1415,74 @@ LittleJS never sees them; the board is behind the dialog anyway. The dialog's bo
 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.
+
+### 2026-10-24 — Clips, step 3: the dialogs that reuse demo scenes
+
+Each info text (`Info` in ui/info.ts) now names the scene its dialog plays (`clip`), and
+`showInfo` plays it: the action buttons (Move: plain steps, or the move picked — Run, Teleport,
+Kick, Diagonal; Bomb: the long press, or Throw, the Detonator; Wait), the top bar's level (the
+exit), coins, bombs, blast, fuse, Star, each pass and each charge held, and on the board a bomb
+(its countdown and blast, or the Pierce / Power / Nuke shape), the decoy, flame still burning,
+the gem, the exit locked (blasting it) and open (walking out), a drop, and the first-pickup
+dialog of each charge. Lives, enemies left, turn and par, crates and walls wait for step 4's new
+scenes; the modes have none yet.
+
+**The finger** shows how in the clips about the actions and the power-ups worked by hand (Run,
+Kick, Throw, Teleport, Diagonal, the Detonator), as the user chose ("when helpful"): it glides to
+the tile, taps (a ripple) or holds (the ring, then the armed bomb's blast), and the turn plays as
+it lets go; a button's tiles light up first where the scene picks one, as in play. The action
+bar isn't in a clip, so Wait is shown as a tap on the player, which waits in play too. The
+finger is an image over the dialog's canvas, placed by tile. Checked in a browser: the Bomb,
+Coins and Blast dialogs play their clips, the Bomb one with the finger's long press; no errors.
+(The development build's LittleJS debug text showed behind the dialog after Esc: that is
+LittleJS's own debug key in the dev build, not in the built game.)
+
+### 2026-10-24 — Clips, step 4: the new scenes
+
+Three scenes made for the dialogs only (`clipOnly`: not in the demo, whose 43 stay as they were):
+**lastEnemy** for "Enemies left" (the last one dies and the uncovered exit opens), **crate** (a
+blast breaks the crate over the gem) and **pillar** for walls (flame, Fire 2, stopped by a pillar).
+"Lives" plays the touched-by-an-enemy death (`dieEnemy`). The top bar isn't in a clip, so what only
+a number shows — turn and par — gets no clip, and nor do the modes. On 40 seeds each, all three
+new scenes were found, the slowest in 0.09 s. Checked in a browser: the Crate, Pillar and Enemies
+left dialogs play their clips; no errors.
+
+### 2026-10-24 — Clips, step 5: the offer, the almanac, the "Ouch!" replay
+
+The user chose, of the options offered: one clip above the offer's cards, almanac entries that
+open their clip, and a replay of the player's own last turns on the banner.
+
+- **The gem's offer:** a clip above the cards, kept in view while they scroll (sticky). It plays
+  the first power-up with a clip, then the one whose picture is tapped (or pointed at with a
+  mouse). The picture is now its own square button beside the card, ringed green while it plays;
+  choosing is still one tap on the card. Heart and Coin bag have no clip: the clip goes blank.
+- **The almanac:** entries with a clip (every power-up but Heart and Coin bag, every charge) are
+  buttons with a green ▶; one opens that thing's own dialog with its clip and **Back**, which
+  returns to the almanac on the same tab. Enemies, without clips, stay plain.
+- **The "Ouch!" replay:** main.ts keeps the last 4 turns of the current attempt (cleared when a
+  level starts or restarts); on a death, and on game over, the banner plays them as a fixed scene,
+  over and over: a 9 × 7 window of the level round where the player died (the clip draws the
+  whole level and copies only that part). Not after a resumed save, which has no turns kept.
+- **Under it:** `startClip` takes a kind (a new setup each loop) or a fixed scene, can switch a
+  canvas already playing, and several clips can play at once (the banner's and a dialog's): each
+  is drawn and copied in turn. A clip plays while its canvas is on the page and shown — not in a
+  closed dialog or a hidden banner. Every `canvas.clip` is sized by its frame (the banner's showed
+  only a corner at first, at its natural size). The screenshot tool now waits for the game's own
+  canvas, not the first on the page.
+- Checked in a browser: the rainbow gem's offer plays Bomb +1, then Kick when its picture is
+  tapped, and choosing Kick works; the almanac's Throw entry opens with its clip, Back returns;
+  bombing yourself shows the replay on the banner, and Try again hides it; no errors. All
+  screenshots retaken: most changed by a few thousand pixels, the player now standing a little
+  higher; the death, almanac, offer and info shots show the new clips.
+
+### 2026-10-24 — A clip's finger starts beside the player; the screenshot tool's taps
+
+- **In a clip, the finger now appears beside the player** at the start of each loop (its tip
+  half a tile below and right of the player, faded in there), then glides to its first tile, as
+  in the demo (user report: in the Move dialog it came in from the top left corner, where it sat
+  before its first move). Checked in a browser: the Move clip's finger starts by the player.
+- **The screenshot tool tapped the wrong tiles** (user report: `info-enemy.png` showed a crate). Its
+  `tileCentre` still assumed the old 13-row board scrolled to the left edge; it now works out the
+  9 × 11 board fitted and centred between the bars. The enemy shot taps a Blob (1, 7) now; the
+  auto-move and long-press shots, which tap too, show what their comments say: the way to (3, 3)
+  in blue with Go, and a bomb armed right of the player.
diff --git a/docs/screenshots/almanac-charges.png b/docs/screenshots/almanac-charges.png
index 7100d87..eddefd4 100644
Binary files a/docs/screenshots/almanac-charges.png and b/docs/screenshots/almanac-charges.png differ
diff --git a/docs/screenshots/almanac-enemies.png b/docs/screenshots/almanac-enemies.png
index 2641663..ed0017a 100644
Binary files a/docs/screenshots/almanac-enemies.png and b/docs/screenshots/almanac-enemies.png differ
diff --git a/docs/screenshots/almanac.png b/docs/screenshots/almanac.png
index 3bc7dfe..de2e477 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 45d9cde..c6f0331 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 507d23a..a9fefb5 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 a0ce9e1..1d89a7f 100644
Binary files a/docs/screenshots/charges.png and b/docs/screenshots/charges.png differ
diff --git a/docs/screenshots/countdown.png b/docs/screenshots/countdown.png
index 9c841a1..cc3c063 100644
Binary files a/docs/screenshots/countdown.png and b/docs/screenshots/countdown.png differ
diff --git a/docs/screenshots/death.png b/docs/screenshots/death.png
index 8a80595..07505eb 100644
Binary files a/docs/screenshots/death.png and b/docs/screenshots/death.png differ
diff --git a/docs/screenshots/demo.png b/docs/screenshots/demo.png
index 6db8f0a..edfdb53 100644
Binary files a/docs/screenshots/demo.png and b/docs/screenshots/demo.png differ
diff --git a/docs/screenshots/desktop.png b/docs/screenshots/desktop.png
index bee5ea1..b919247 100644
Binary files a/docs/screenshots/desktop.png and b/docs/screenshots/desktop.png differ
diff --git a/docs/screenshots/dev-offer.png b/docs/screenshots/dev-offer.png
index 2df73ef..d40573e 100644
Binary files a/docs/screenshots/dev-offer.png and b/docs/screenshots/dev-offer.png differ
diff --git a/docs/screenshots/dev.png b/docs/screenshots/dev.png
index acc1a76..c35b4e0 100644
Binary files a/docs/screenshots/dev.png and b/docs/screenshots/dev.png differ
diff --git a/docs/screenshots/enemies.png b/docs/screenshots/enemies.png
index 09026d0..6e4d19b 100644
Binary files a/docs/screenshots/enemies.png and b/docs/screenshots/enemies.png differ
diff --git a/docs/screenshots/gem-offer.png b/docs/screenshots/gem-offer.png
index 1dc2be7..130af7f 100644
Binary files a/docs/screenshots/gem-offer.png and b/docs/screenshots/gem-offer.png differ
diff --git a/docs/screenshots/gem.png b/docs/screenshots/gem.png
index 0a7b88c..6b318ff 100644
Binary files a/docs/screenshots/gem.png and b/docs/screenshots/gem.png differ
diff --git a/docs/screenshots/info-enemy.png b/docs/screenshots/info-enemy.png
index 9160775..04b4b40 100644
Binary files a/docs/screenshots/info-enemy.png and b/docs/screenshots/info-enemy.png differ
diff --git a/docs/screenshots/info-hud.png b/docs/screenshots/info-hud.png
index a1f1c40..bc3fe13 100644
Binary files a/docs/screenshots/info-hud.png and b/docs/screenshots/info-hud.png differ
diff --git a/docs/screenshots/info-mode.png b/docs/screenshots/info-mode.png
index 24f1907..9c1cf21 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 f06c35f..77624f6 100644
Binary files a/docs/screenshots/lingering.png and b/docs/screenshots/lingering.png differ
diff --git a/docs/screenshots/long-press.png b/docs/screenshots/long-press.png
index b9527f6..c927e1f 100644
Binary files a/docs/screenshots/long-press.png and b/docs/screenshots/long-press.png differ
diff --git a/docs/screenshots/menu.png b/docs/screenshots/menu.png
index a224f39..024c09c 100644
Binary files a/docs/screenshots/menu.png and b/docs/screenshots/menu.png differ
diff --git a/docs/screenshots/move-picked.png b/docs/screenshots/move-picked.png
index 45d9cde..867f7b1 100644
Binary files a/docs/screenshots/move-picked.png and b/docs/screenshots/move-picked.png differ
diff --git a/docs/screenshots/new-run.png b/docs/screenshots/new-run.png
index 17bfcc1..6612a98 100644
Binary files a/docs/screenshots/new-run.png and b/docs/screenshots/new-run.png differ
diff --git a/docs/screenshots/nuke.png b/docs/screenshots/nuke.png
index fc10290..5ed23e1 100644
Binary files a/docs/screenshots/nuke.png and b/docs/screenshots/nuke.png differ
diff --git a/docs/screenshots/start.png b/docs/screenshots/start.png
index 83cf85f..c35fc7f 100644
Binary files a/docs/screenshots/start.png and b/docs/screenshots/start.png differ
diff --git a/index.html b/index.html
index 9e9efe6..b76c485 100644
--- a/index.html
+++ b/index.html
@@ -40,6 +40,7 @@
 <nav id="actions" aria-label="Actions"></nav>
 <div id="banner" hidden>
   <img id="banner-icon" alt="" />
+  <canvas id="banner-clip" class="clip" aria-hidden="true" hidden></canvas>
   <p id="banner-text"></p>
   <button id="banner-button" type="button"></button>
   <button id="banner-button-2" type="button" class="secondary" hidden></button>
diff --git a/src/main.ts b/src/main.ts
index c6574c1..1a648bf 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -36,7 +36,7 @@ import { createControls } from './ui/controls'
 import { closeDialog, dialogOpen, openDialog, setDialogInDemo, type DialogButton } from './ui/dialog'
 import { actionInfo, chargeInfo, hudInfo, tileInfo, type Info } from './ui/info'
 import { openOffer } from './ui/offer'
-import { startClip, drawClip } from './ui/clip'
+import { startClip, drawClips } from './ui/clip'
 import { makeScene } from './ui/scenes'
 import { openScores, recordScore } from './ui/scores'
 import { countsForScores, scoreEntry } from './sim/scores'
@@ -101,6 +101,7 @@ function resetView(): void {
 
 function startRun(seed: number, options: RunOptions): void {
   chargesSeen.clear()
+  lastTurns = []
   resetView()
   record = newRecord(seed, startLevel, startCharges, options)
   state = newRun(seed, startLevel, startCharges, options)
@@ -194,8 +195,10 @@ function act(action: Action, auto = false): void {
     return
   }
   const wasGoing = state.status !== 'gameOver' && state.status !== 'won'
+  const before = state
   animateTurn(result, action)
   record.actions.push(action)
+  rememberTurn(before, action, result.events)
   // A run that has just ended goes on the high-score board (not from a test URL); its banner
   // says if it made it.
   if (wasGoing && !testing && countsForScores(state)) {
@@ -216,6 +219,26 @@ function act(action: Action, auto = false): void {
   afterChange()
 }
 
+/**
+ * The last few turns of this attempt at the level, to replay on the "Ouch!" banner when the player
+ * dies (PLAN.md §13c; user's choice, 2026-10-24: their own last turns, so they see what went wrong).
+ */
+const REPLAY_TURNS = 4
+let lastTurns: { prev: GameState; action: Action }[] = []
+const bannerClip = document.getElementById('banner-clip') as HTMLCanvasElement
+
+function rememberTurn(prev: GameState, action: Action, events: GameEvent[]): void {
+  if (events.some((e) => e.type === 'levelStarted' || e.type === 'levelRestarted')) lastTurns = []
+  else lastTurns = [...lastTurns, { prev, action }].slice(-REPLAY_TURNS)
+  const died = state.status === 'dead' || (state.status === 'gameOver' && events.some((e) => e.type === 'playerDied'))
+  bannerClip.hidden = !died || !lastTurns.length
+  if (bannerClip.hidden) return
+  const actions = lastTurns.map((t) => t.action)
+  // Only the part of the level round where it happened.
+  const at = state.player
+  startClip(bannerClip, { kind: 'dieEnemy', start: lastTurns[0]!.prev, actions, gestures: actions.map(() => ({ kind: 'go' as const })) }, at)
+}
+
 /** 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 newLevel = animateBoard(anim, state, result, action, time)
@@ -311,13 +334,12 @@ 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)
-  // 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)
+  showInfo(info, reachable ? [{ label: 'Walk there', kind: 'primary', onClick: () => setDestination(p) }] : [])
 }
 
-/** 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 {
+/** An info dialog; with a clip, that kind of scene looping above the words (ui/clip.ts). */
+function showInfo(info: Info, extra: DialogButton[] = []): void {
+  const clip = info.clip
   openDialog({
     icon: spriteUrl(info.icon),
     title: info.title,
@@ -763,7 +785,7 @@ 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',
+  exit: 'door_open_top', doorBlast: 'door_closed_top', lastEnemy: 'door_open_top', crate: 'block_planks', pillar: 'block_blue',
   dieIntoBlast: 'fireball', dieNotFar: 'fireball', dieBlockedIn: 'bomb', dieChain: 'bomb', dieEnemy: 'slime_normal_rest',
   dieDetonator: POWER_UP_ICONS.detonator, dieRun: POWER_UP_ICONS.run,
 }
@@ -1209,7 +1231,7 @@ await engineInit(
   () => {},
   guarded('draw', () => {
     // A dialog's clip first: it uses the canvas for a moment, then clears it (ui/clip.ts).
-    drawClip()
+    drawClips()
     drawBoard({
       state, danger, selected, hover, destination, path, armed, ...anim,
       pathBlocked: !!path?.[0] && !safeStep(path[0]),
diff --git a/src/sim/demo.ts b/src/sim/demo.ts
index e0f8b7b..f1ab70b 100644
--- a/src/sim/demo.ts
+++ b/src/sim/demo.ts
@@ -40,6 +40,7 @@ export type SceneKind =
   | 'wallPass' | 'bombPass' | 'flamePass' | 'star'
   | 'pierce' | 'power' | 'nuke' | 'shield' | 'freeze' | 'decoy'
   | 'exit' | 'doorBlast'
+  | 'lastEnemy' | 'crate' | 'pillar'
   | 'dieIntoBlast' | 'dieNotFar' | 'dieBlockedIn' | 'dieChain' | 'dieEnemy' | 'dieDetonator' | 'dieRun'
 
 /**
@@ -108,6 +109,8 @@ interface SceneDef {
   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
+  /** Only for a dialog's clip (ui/clip.ts), not in the demo. */
+  clipOnly?: boolean
   /** A nudge for the random play: a turn to play now, or null for a random one. */
   steer?: (s: GameState, rng: Rng, walking: Walking) => Play | null
   /** Bombs placed with the Bomb button and a tap, not a long press. */
@@ -136,6 +139,8 @@ const escaped = (t: Turn) => predictDanger(t.prev).now.has(idx(t.prev, t.prev.pl
 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)))
+/** A tile of the room's outer wall (the rest of the walls are pillars). */
+const isEdge = (s: GameState, p: Vec) => p.x === 0 || p.y === 0 || p.x === s.width - 1 || p.y === s.height - 1
 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 (or `n`), to walk over first. */
@@ -376,6 +381,29 @@ export const SCENES: Record<SceneKind, SceneDef> = {
     prepare: (s, rng) => placeExit(s, rng, false),
     shows: (t) => t.events.some((e) => e.type === 'enemiesSpawned'),
   },
+  // Only for the dialogs' clips (PLAN.md §13c, step 4).
+  lastEnemy: {
+    caption: 'The last enemy gone: the exit opens.',
+    enemies: ['blob'],
+    prepare: (s, rng) => placeExit(s, rng, false),
+    clipOnly: true,
+    shows: (t) => t.prev.enemies.length > 0 && t.next.enemies.length === 0 && exitOpen(t.next),
+  },
+  crate: {
+    caption: 'A blast breaks a crate, and what it hid is uncovered.',
+    enemies: ['blob'],
+    prepare: (s, rng) => hideGem(s, rng),
+    clipOnly: true,
+    shows: (t) => t.events.some((e) => e.type === 'blockDestroyed' && t.next.gems.some((g) => at(g.pos, e.pos))),
+  },
+  pillar: {
+    caption: 'Flame stops at a pillar.',
+    enemies: ['blob'],
+    // Fire 2, so the flame would reach past a pillar if it could.
+    prepare: (s) => (s.stats.fire = 2),
+    clipOnly: true,
+    shows: (t) => explosions(t).some((e) => DIR_LIST.some((d) => t.prev.cells[idx(t.prev, { x: e.pos.x + DIRS[d].x, y: e.pos.y + DIRS[d].y })] === 'wall' && !isEdge(t.prev, { x: e.pos.x + DIRS[d].x, y: e.pos.y + DIRS[d].y }))),
+  },
   // The ways to die.
   dieIntoBlast: {
     caption: 'Ouch! Walked straight into a blast. Check the red tiles before you step.',
@@ -441,7 +469,7 @@ export const SCENE_KINDS = Object.keys(SCENES) as SceneKind[]
 export const CONTROL_KINDS: readonly SceneKind[] = ['tapMove', 'holdBomb', 'bombButton', 'wait', 'autoMove']
 /** 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 && !CONTROL_KINDS.includes(k))
+export const LIVING_KINDS = SCENE_KINDS.filter((k) => !SCENES[k].dies && !SCENES[k].clipOnly && !CONTROL_KINDS.includes(k))
 
 /** A scene of `kind`, from `seed`; null if no room gave one (rare). */
 export function generateScene(kind: SceneKind, seed: number): Scene | null {
diff --git a/src/style.css b/src/style.css
index fb086a4..d405422 100644
--- a/src/style.css
+++ b/src/style.css
@@ -634,10 +634,83 @@ img {
   font-size: 14px;
 }
 
-/* The gem's choice (ui/offer.ts): each power-up is a card to tap, laid out like an almanac entry. */
-.offer-choice {
+/* An almanac entry with a clip (ui/almanac.ts): a button that opens it, a small ▶ on the right. */
+button.almanac-entry.has-clip {
+  position: relative;
   width: 100%;
+  padding-right: 34px;
+  border: 0;
+  border-top: 1px solid rgb(53 53 65 / 0.15);
+  background: none;
+  color: inherit;
+  font: inherit;
+  cursor: pointer;
+}
+
+button.almanac-entry.has-clip::after {
+  content: '▶';
+  position: absolute;
+  right: 4px;
+  top: 50%;
+  display: grid;
+  place-items: center;
+  width: 24px;
+  height: 24px;
+  margin-top: -12px;
+  border-radius: 50%;
+  background: var(--accent);
+  color: #fff;
+  font-size: 11px;
+}
+
+button.almanac-entry.has-clip:active {
+  background: rgb(53 53 65 / 0.06);
+}
+
+/* The gem's choice (ui/offer.ts): the clip of the power-up looked at, kept in view while the cards
+   scroll; each card's picture beside it, a button that plays it there; the card itself chooses. */
+.offer-clip {
+  position: sticky;
+  top: 0;
+  z-index: 1;
+  padding-bottom: 8px;
+  background: #ece3ce;
+}
+
+.offer-row {
+  display: grid;
+  grid-template-columns: 56px 1fr;
+  gap: 8px;
+  align-items: stretch;
   margin: 0 0 8px;
+}
+
+.offer-preview {
+  display: grid;
+  place-items: center;
+  padding: 0;
+  border: 2px solid var(--ink);
+  border-radius: 12px;
+  background: #fff8e6;
+  box-shadow: 0 3px 0 rgb(53 53 65 / 0.35);
+  cursor: pointer;
+}
+
+.offer-preview img {
+  width: 40px;
+  height: 40px;
+}
+
+/* The one playing above. */
+.offer-row.previewed .offer-preview {
+  border-color: var(--accent-dark);
+  background: #d9f6e9;
+}
+
+.offer-choice {
+  width: 100%;
+  margin: 0;
+  text-align: left;
   padding: 8px 10px;
   border: 2px solid var(--ink);
   border-radius: 12px;
@@ -648,6 +721,15 @@ img {
   cursor: pointer;
 }
 
+#dialog-body .offer-choice strong {
+  display: block;
+}
+
+#dialog-body .offer-choice p {
+  margin: 2px 0 0;
+  font-size: 14px;
+}
+
 .offer-choice:active {
   transform: translateY(2px);
   box-shadow: none;
@@ -1255,14 +1337,66 @@ body.demo #actions {
   }
 }
 
-/* A dialog's clip (ui/clip.ts): the scene, drawn by the game, above the words. */
-#dialog-body canvas.clip {
+/* A clip (ui/clip.ts): the scene, drawn by the game, above a dialog's words or on the banner. */
+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;
+  /* A room's shape (9 × 7) while its first scene is being made; then the scene's own. */
+  aspect-ratio: auto 9 / 7;
   margin: 0 auto;
   border-radius: 8px;
 }
+
+/* A clip with the finger (ui/clip.ts): the canvas in a frame, the finger and its marks over it. */
+.clip-frame {
+  position: relative;
+  max-width: 300px;
+  margin: 0 auto;
+  overflow: hidden;
+  border-radius: 8px;
+}
+
+.clip-frame canvas.clip {
+  max-width: none;
+}
+
+.clip-finger {
+  position: absolute;
+  left: 0;
+  top: 0;
+  z-index: 2;
+  pointer-events: none;
+  transition: transform 0.3s ease-in-out, opacity 0.3s;
+}
+
+.clip-finger.away {
+  opacity: 0;
+}
+
+.clip-finger img {
+  display: block;
+  width: 44px;
+  height: 44px;
+  margin: -10px 0 0 -8px;
+  transform-origin: 8px 10px;
+  transition: transform 0.12s;
+  filter: drop-shadow(0 2px 3px rgb(0 0 0 / 0.35));
+}
+
+.clip-finger.down img {
+  transform: scale(0.86);
+}
+
+/* The demo's ripple and ring, inside the clip's frame instead of on the page. */
+.clip-frame .demo-ripple,
+.clip-frame .demo-ring {
+  position: absolute;
+  z-index: 1;
+}
+
+/* The "Ouch!" banner's replay of the player's last turns (main.ts `rememberTurn`). */
+#banner .clip-frame {
+  width: min(300px, 80vw);
+}
diff --git a/src/ui/almanac.ts b/src/ui/almanac.ts
index 7db9b39..3f50f6b 100644
--- a/src/ui/almanac.ts
+++ b/src/ui/almanac.ts
@@ -2,8 +2,10 @@ import { enemyKinds } from '../sim/levelgen'
 import { OFFER_SIZE } from '../sim/turn'
 import { CHARGE_IDS, type EnemyKind, type PowerUpKind } from '../sim/types'
 import { CHARGE_ICONS, ENEMY_SPRITES, POWER_UP_ICONS, spriteUrl } from '../render/sprites'
+import type { SceneKind } from '../sim/demo'
 import { openDialog } from './dialog'
-import { CHARGE_TEXT, ENEMY_TEXT, POWER_UP_TEXT } from './info'
+import { startClip } from './clip'
+import { CHARGE_TEXT, ENEMY_TEXT, POWER_UP_CLIP, POWER_UP_TEXT } from './info'
 
 /**
  * The almanac (user request, 2026-10-22): every power-up, action charge and enemy type, with its
@@ -32,11 +34,15 @@ function firstLevel(kind: EnemyKind): number {
   return level
 }
 
-function entry(sprite: string, name: string, lines: string[]): string {
-  return (
-    `<div class="almanac-entry"><img src="${spriteUrl(sprite)}" alt="" draggable="false">` +
-    `<div><strong>${name}</strong>${lines.map((l) => `<p>${l}</p>`).join('')}</div></div>`
-  )
+/**
+ * An entry; one with a clip (PLAN.md §13c) is a button that opens it in its own dialog (user's
+ * choice, 2026-10-24), with a small ▶ to say so. Enemies, Heart and Coin bag have none.
+ */
+function entry(sprite: string, name: string, lines: string[], clip?: SceneKind): string {
+  const inner = `<img src="${spriteUrl(sprite)}" alt="" draggable="false"><div><strong>${name}</strong>${lines.map((l) => `<p>${l}</p>`).join('')}</div>`
+  return clip
+    ? `<button type="button" class="almanac-entry has-clip" data-clip="${clip}" data-sprite="${sprite}" data-name="${name}">${inner}</button>`
+    : `<div class="almanac-entry">${inner}</div>`
 }
 
 function body(tab: AlmanacTab): string {
@@ -45,11 +51,11 @@ function body(tab: AlmanacTab): string {
   if (tab === 'powerUps') {
     list =
       `<p class="almanac-intro">Every level hides a gem under a crate. Burn the crate and walk onto the gem to choose one of ${OFFER_SIZE} of these.</p>` +
-      POWER_UPS.map((k) => entry(POWER_UP_ICONS[k], POWER_UP_TEXT[k].name, [POWER_UP_TEXT[k].text])).join('')
+      POWER_UPS.map((k) => entry(POWER_UP_ICONS[k], POWER_UP_TEXT[k].name, [POWER_UP_TEXT[k].text], POWER_UP_CLIP[k])).join('')
   } else if (tab === 'charges') {
     list =
       '<p class="almanac-intro">Enemies sometimes drop these. Each works as soon as you pick it up; the top bar shows what it still has to give.</p>' +
-      CHARGE_IDS.map((id) => entry(CHARGE_ICONS[id], CHARGE_TEXT[id].name, [CHARGE_TEXT[id].text])).join('')
+      CHARGE_IDS.map((id) => entry(CHARGE_ICONS[id], CHARGE_TEXT[id].name, [CHARGE_TEXT[id].text], id)).join('')
   } else {
     list =
       '<p class="almanac-intro">Every enemy shows its next 3 moves as arrows. Flame kills them; touching one costs you a life.</p>' +
@@ -58,15 +64,37 @@ function body(tab: AlmanacTab): string {
   return `<div class="almanac-tabs" role="group" aria-label="Almanac sections">${tabs}</div>${list}`
 }
 
-export function openAlmanac(tab: AlmanacTab = 'powerUps'): void {
+let shownTab: AlmanacTab = 'powerUps'
+
+export function openAlmanac(tab: AlmanacTab = shownTab): void {
+  shownTab = tab
   openDialog({ title: 'Almanac', body: body(tab), buttons: [{ label: 'Close', kind: 'plain' }] })
 }
 
-// A tab swaps the list without closing the dialog.
+/** An entry's own dialog: its clip above what it does, and Back to the almanac where it was. */
+function openEntry(button: HTMLElement): void {
+  const { clip, sprite, name } = button.dataset
+  const text = [...button.querySelectorAll('p')].map((p) => p.outerHTML).join('')
+  openDialog({
+    icon: spriteUrl(sprite!),
+    title: name!,
+    body: '<canvas class="clip" aria-hidden="true"></canvas>' + text,
+    buttons: [{ label: 'Back', kind: 'plain', onClick: () => openAlmanac() }],
+  })
+  startClip(document.querySelector<HTMLCanvasElement>('#dialog-body canvas.clip')!, clip as SceneKind)
+}
+
+// A tab swaps the list without closing the dialog; an entry with a clip opens it.
 const dialogBody = document.getElementById('dialog-body')!
 dialogBody.addEventListener('click', (e) => {
-  const tab = (e.target as Element).closest<HTMLElement>('[data-tab]')
-  if (!tab) return
-  dialogBody.innerHTML = body(tab.dataset.tab as AlmanacTab)
-  dialogBody.scrollTop = 0
+  const target = e.target as Element
+  const tab = target.closest<HTMLElement>('[data-tab]')
+  if (tab) {
+    shownTab = tab.dataset.tab as AlmanacTab
+    dialogBody.innerHTML = body(shownTab)
+    dialogBody.scrollTop = 0
+    return
+  }
+  const entryButton = target.closest<HTMLElement>('.almanac-entry[data-clip]')
+  if (entryButton) openEntry(entryButton)
 })
diff --git a/src/ui/clip.ts b/src/ui/clip.ts
index 3966846..1b747ab 100644
--- a/src/ui/clip.ts
+++ b/src/ui/clip.ts
@@ -2,9 +2,10 @@ import { cameraPos, getCanvasPixelRatio, glCanvas, glClearCanvas, glFlush, time,
 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 type { Gesture, Scene, SceneKind } from '../sim/demo'
 import { step } from '../sim/turn'
-import type { GameState } from '../sim/types'
+import type { Action, ActionId, GameState, Vec } from '../sim/types'
+import handIcon from '../../assets/kenney-ui-rpg/cursorHand_beige.svg'
 import { makeScene } from './scenes'
 
 /**
@@ -19,7 +20,11 @@ import { makeScene } from './scenes'
  */
 interface Clip {
   canvas: HTMLCanvasElement
-  kind: SceneKind
+  /** The kind of scene made each loop; null for a fixed scene, replayed as it is (`fixed`). */
+  kind: SceneKind | null
+  fixed: Scene | null
+  /** The part of the room shown, in tiles (row 0 on top): all of it, or a window round `focus`. */
+  focus: Vec | null
   seed: number
   /** The loop playing (null until the first is made), and the next one, once made. */
   scene: Scene | null
@@ -29,29 +34,84 @@ interface Clip {
   next: number
   at: number
   anim: Animation
+  /** The finger over the clip, where it shows how a turn is played (FINGER_KINDS); its script. */
+  finger: HTMLElement | null
+  beats: { run: () => void; then: number }[]
+  /** What the finger's press shows on the board, as in play: a picked button's tiles, a long press's bomb. */
+  selected: ActionId | null
+  hover: Vec | null
+  armed: Vec | null
 }
 
+/**
+ * The clips about something the player does with their finger, where the finger shows how (user's
+ * choice, 2026-10-24: "when helpful"): the actions, and the power-ups worked by hand.
+ */
+const FINGER_KINDS: ReadonlySet<SceneKind> = new Set([
+  'tapMove', 'holdBomb', 'bombButton', 'wait', 'run', 'kick', 'throw', 'teleport', 'diagonal', 'detonate',
+])
+/** The finger's glide, a tap, and a long press (as in play). */
+const GLIDE_SECONDS = 0.35
+const TAP_SECONDS = 0.18
+const HOLD_SECONDS_PRESS = 0.45
+/** After a turn, before the finger moves on. */
+const SETTLE_SECONDS = 0.45
+
 /** 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
+/** The clips playing: one per canvas (a dialog's, the "Ouch!" banner's). */
+let clips: Clip[] = []
 
-/** 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 {
+/**
+ * Plays a clip in `canvas` while it is shown: of `source`, a kind of scene made new each loop, or a
+ * fixed scene replayed as it is (the player's own last turns). A canvas already playing switches.
+ * With `focus`, a room bigger than the demo's (a real level) shows only a demo room's worth of it,
+ * round that tile.
+ */
+export function startClip(canvas: HTMLCanvasElement, source: SceneKind | Scene, focus: Vec | null = null): void {
+  const kind = typeof source === 'string' ? source : null
+  // The canvas in a frame, for the finger over it (FINGER_KINDS) and its marks.
+  let frame = canvas.parentElement
+  if (!frame?.classList.contains('clip-frame')) {
+    frame = document.createElement('div')
+    frame.className = 'clip-frame'
+    canvas.replaceWith(frame)
+    frame.append(canvas)
+  }
+  for (const old of frame.querySelectorAll('.clip-finger, .demo-ripple, .demo-ring')) old.remove()
+  let finger: HTMLElement | null = null
+  if (kind && FINGER_KINDS.has(kind)) {
+    finger = document.createElement('div')
+    finger.className = 'clip-finger away'
+    finger.innerHTML = `<img src="${handIcon}" alt="" draggable="false">`
+    frame.append(finger)
+  }
   const c: Clip = {
-    canvas, kind, seed: Math.floor(Math.random() * 2 ** 31), scene: null, upcoming: null, state: null, next: 0, at: 0, anim: newAnimation(),
+    canvas, kind, fixed: kind ? null : (source as Scene), focus, seed: Math.floor(Math.random() * 2 ** 31), scene: null, upcoming: null, state: null,
+    next: 0, at: 0, anim: newAnimation(), finger, beats: [], selected: null, hover: null, armed: null,
   }
-  clip = c
-  prepare(c)
+  clips = [...clips.filter((o) => o.canvas !== canvas), c]
+  if (c.fixed) c.upcoming = c.fixed
+  else prepare(c)
+}
+
+/** Stops the clip in `canvas`, if one plays there. */
+export function stopClip(canvas: HTMLCanvasElement): void {
+  clips = clips.filter((o) => o.canvas !== canvas)
 }
 
 /** Makes the next loop's scene in the background (a new setup each loop). */
 function prepare(c: Clip): void {
+  if (!c.kind) {
+    c.upcoming = c.fixed
+    return
+  }
   void makeScene(c.kind, c.seed++).then((scene) => {
     // None found (rare): try the next seed.
-    if (!scene) return clip === c && prepare(c)
+    if (!scene) return clips.includes(c) && prepare(c)
     c.upcoming = scene
   })
 }
@@ -59,7 +119,7 @@ function prepare(c: Clip): void {
 /** 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)
+  const done = !c.scene || (c.next >= c.scene.actions.length && !c.beats.length && time >= c.at)
   if (done && c.upcoming) {
     c.scene = c.upcoming
     c.upcoming = null
@@ -67,29 +127,124 @@ function tick(c: Clip): void {
     c.next = 0
     c.at = time + LEAD_SECONDS
     c.anim = newAnimation()
+    if (c.finger) {
+      // The finger appears beside the player, where the eye should start (as in the demo; user
+      // report, 2026-10-24: it came in from the top left corner), and glides from there.
+      c.finger.style.transition = 'none'
+      placeClipFinger(c, c.state.player, 0.55)
+      void c.finger.offsetWidth
+      c.finger.style.transition = ''
+      c.finger.classList.remove('away')
+    }
     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)
+  if (!c.scene || !c.state || time < c.at) return
+  // The finger's script for this turn, beat by beat.
+  if (c.beats.length) {
+    const beat = c.beats.shift()!
+    beat.run()
+    c.at = time + beat.then
+    return
+  }
+  if (c.next >= c.scene.actions.length) return
+  const i = c.next++
+  const last = c.next === c.scene.actions.length
+  const action = c.scene.actions[i]!
+  if (c.finger) c.beats = gestureBeats(c, c.scene.gestures[i]!, action, last)
+  else {
+    play(c, action)
+    c.at = time + (last ? HOLD_SECONDS : STEP_SECONDS)
+  }
+}
+
+function play(c: Clip, action: Action): void {
+  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).
+ * A turn as the finger plays it (as the demo's, main.ts `beatsFor`, on the clip's own board): to
+ * the tile, a tap or a long press — after a button, its tiles lit — and the turn on letting go.
+ * Wait is a tap on the player (which waits, as in play); the action bar isn't in the clip.
  */
-export function drawClip(): void {
-  if (!clip) return
-  if (!clip.canvas.isConnected || !clip.canvas.closest('dialog')?.open) {
-    clip = null
-    return
-  }
-  const c = clip
+function gestureBeats(c: Clip, g: Gesture, action: Action, last: boolean): Clip['beats'] {
+  const s = c.state!
+  const tile = g.kind === 'go' ? null : g.kind === 'button' ? (g.tile ?? s.player) : g.tile
+  if (!tile) return [{ run: () => play(c, action), then: last ? HOLD_SECONDS : STEP_SECONDS }]
+  const finger = c.finger!
+  const hold = g.kind === 'hold'
+  return [
+    {
+      run: () => {
+        // A button picked first: its tiles light up, as in play.
+        if (g.kind === 'button' && g.button !== 'wait') c.selected = g.button
+        placeClipFinger(c, tile)
+      },
+      then: GLIDE_SECONDS,
+    },
+    {
+      run: () => {
+        finger.classList.add('down')
+        c.hover = tile
+        mark(finger, hold ? 'demo-ring' : 'demo-ripple')
+      },
+      then: hold ? HOLD_SECONDS_PRESS : TAP_SECONDS,
+    },
+    ...(hold ? [{ run: () => (c.armed = tile), then: 0.4 }] : []),
+    {
+      run: () => {
+        finger.classList.remove('down')
+        for (const ring of finger.parentElement!.querySelectorAll('.demo-ring')) ring.remove()
+        c.selected = c.hover = c.armed = null
+        play(c, action)
+        if (last) finger.classList.add('away')
+      },
+      then: last ? HOLD_SECONDS : SETTLE_SECONDS,
+    },
+  ]
+}
+
+/** A demo room's size: a window that size is shown of a bigger room, round the clip's focus. */
+const WINDOW = { w: 9, h: 7 }
+
+/** The tiles shown of the clip's room (row 0 on top). */
+function shown(c: Clip, s: GameState): { x: number; y: number; w: number; h: number } {
+  if (!c.focus || (s.width <= WINDOW.w && s.height <= WINDOW.h)) return { x: 0, y: 0, w: s.width, h: s.height }
+  const w = Math.min(WINDOW.w, s.width)
+  const h = Math.min(WINDOW.h, s.height)
+  const clamp = (v: number, hi: number) => Math.max(0, Math.min(hi, v))
+  return { x: clamp(c.focus.x - Math.floor(w / 2), s.width - w), y: clamp(c.focus.y - Math.floor(h / 2), s.height - h), w, h }
+}
+
+/** The finger's tip to a tile of the clip's room (or `off` tiles below and right of its centre) (the canvas shows the room whole, row 0 on top). */
+function placeClipFinger(c: Clip, p: Vec, off = 0): void {
+  const s = c.state!
+  const w = c.canvas.clientWidth / s.width
+  c.finger!.style.transform = `translate(${(p.x + 0.5 + off) * w}px, ${(p.y + 0.5 + off) * w}px)`
+}
+
+/** A ripple or a long press's ring where the finger is, in the clip's frame. */
+function mark(finger: HTMLElement, className: string): void {
+  const m = document.createElement('div')
+  m.className = className
+  m.style.transform = finger.style.transform
+  if (className === 'demo-ripple') m.addEventListener('animationend', () => m.remove(), { once: true })
+  finger.before(m)
+}
+
+/**
+ * Called in the draw callback before the game's own drawing: each clip's room, drawn where the
+ * camera looks, copied into its canvas; then the canvas cleared for the next, and the game.
+ */
+export function drawClips(): void {
+  // A clip plays while its canvas is on the page and shown: not in a closed dialog or a hidden banner.
+  clips = clips.filter((c) => c.canvas.isConnected && !c.canvas.closest('[hidden]') && (c.canvas.closest('dialog')?.open ?? true))
+  for (const c of clips) drawClip(c)
+}
+
+function drawClip(c: Clip): void {
   tick(c)
   const s = c.state
   if (!s) return
@@ -98,14 +253,15 @@ export function drawClip(): void {
   setBoardOrigin(corner)
   try {
     drawBoard({
-      state: s, danger: predictDanger(s), selected: null, hover: null, destination: null, path: null, pathBlocked: false, armed: null,
+      state: s, danger: predictDanger(s), selected: c.selected, hover: c.hover, destination: null, path: null, pathBlocked: false, armed: c.armed,
       ...c.anim,
     })
     glFlush()
-    // The room's patch of the canvas, in its backing pixels, top-left first.
+    // The room's patch of the canvas (or the window round the focus), in 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 win = shown(c, s)
+    const topLeft = worldToScreen(corner.add(vec2(win.x, s.height - win.y)))
+    const bottomRight = worldToScreen(corner.add(vec2(win.x + win.w, s.height - win.y - win.h)))
     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 })
diff --git a/src/ui/info.ts b/src/ui/info.ts
index dd37d23..8c460bc 100644
--- a/src/ui/info.ts
+++ b/src/ui/info.ts
@@ -4,6 +4,7 @@ import { bombAt, cellAt, eq, exitOpen, isBurning, PAR_BONUS } from '../sim/state
 import { COIN_BAG, EXIT_SPAWN, OFFER_SIZE, STAR_TURNS } from '../sim/turn'
 import type { ActionId, ChargeId, Death, Dir, EnemyKind, GameState, Powers, PowerUpKind, Vec } from '../sim/types'
 import { dirBetween } from '../sim/enemies'
+import type { SceneKind } from '../sim/demo'
 import { CHARGE_ICONS, coinSprite, ENEMY_SPRITES, GEM_SPRITES, isOuterWall, POWER_UP_ICONS, spriteUrl, wallSprite } from '../render/sprites'
 
 /**
@@ -16,6 +17,8 @@ export interface Info {
   title: string
   /** Paragraphs of plain text. */
   body: string[]
+  /** The demo scene its dialog plays as a looping clip, if any (ui/clip.ts, PLAN.md §13c). */
+  clip?: SceneKind
 }
 
 export const ENEMY_TEXT: Record<EnemyKind, { name: string; moves: string }> = {
@@ -42,6 +45,12 @@ export const POWER_UP_TEXT: Record<PowerUpKind, { name: string; text: string }>
   diagonal: { name: 'Diagonal', text: 'Move can also step diagonally, even squeezing between two blocked tiles.' },
 }
 
+/** The scene that shows each power-up, for its clip (PLAN.md §13c); Heart and Coin bag have none. */
+export const POWER_UP_CLIP: Partial<Record<PowerUpKind, SceneKind>> = {
+  bombPlus: 'bombPlus', firePlus: 'firePlus', fusePlus: 'fusePlus', run: 'run', detonator: 'detonate', wallPass: 'wallPass',
+  bombPass: 'bombPass', flamePass: 'flamePass', star: 'star', kick: 'kick', throw: 'throw', teleport: 'teleport', diagonal: 'diagonal',
+}
+
 /** What each charge does once picked up (user request, 2026-10-23: they work at once, as a temporary change). */
 export const CHARGE_TEXT: Record<ChargeId, { name: string; text: string }> = {
   pierce: { name: 'Pierce', text: `Your next ${BOMB_BOOST} bombs’ flame goes through crates, burning every one in reach.` },
@@ -123,6 +132,7 @@ export function hudInfo(item: HudItem, s: GameState): Info {
           'Caught by flame or touched by an enemy, you lose a life and the level starts again, same layout and enemies.',
           s.options.immortal ? 'Immortal: nothing can kill you, so you never lose one.' : 'With no lives left, the run is over.',
         ],
+        clip: 'dieEnemy',
       }
     case 'modes':
       return { icon: 'lever', title: 'Run settings', body: modeLines(s) }
@@ -131,18 +141,21 @@ export function hudInfo(item: HudItem, s: GameState): Info {
         icon: 'flag_green_a',
         title: `Level ${s.level} of ${LAST_LEVEL}`,
         body: [`Clear all ${LAST_LEVEL} levels to win the run. New enemy types join every few levels.`],
+        clip: 'exit',
       }
     case 'enemies':
       return {
         icon: 'slime_normal_rest',
         title: `Enemies left: ${s.enemies.length}`,
         body: ['The exit only opens when this reaches 0.', 'Tap an enemy to see what it does.'],
+        clip: 'lastEnemy',
       }
     case 'coins':
       return {
         icon: 'hud_coin',
         title: `Coins: ${s.coins}`,
         body: ['From enemies, coin bags, and finishing a level under par. They will buy upgrades in the shop (coming soon).'],
+        clip: 'coins',
       }
     case 'turn':
       return {
@@ -155,31 +168,34 @@ export function hudInfo(item: HudItem, s: GameState): Info {
         icon: 'bomb',
         title: `Bombs: ${s.stats.capacity} at a time`,
         body: ['How many of your bombs can be on the board at once. The Bomb +1 power-up adds one.'],
+        clip: 'bombPlus',
       }
     case 'fire':
       return {
         icon: 'fireball',
         title: `Blast: ${s.stats.fire} ${s.stats.fire === 1 ? 'tile' : 'tiles'}`,
         body: ['How far your blasts reach in each of the four directions. The Fire +1 power-up adds one.'],
+        clip: 'firePlus',
       }
     case 'fuse':
       return {
         icon: 'icon_fuse',
         title: `Fuse: ${s.stats.fuse}`,
         body: [`The number your new bombs start at: they explode after ${s.stats.fuse} more of your actions. The Fuse +1 power-up adds one.`],
+        clip: 'fusePlus',
       }
     case 'star':
-      return { icon: 'star', title: `Star: ${s.star} turns left`, body: ['Nothing can hurt you until it runs out.'] }
+      return { icon: 'star', title: `Star: ${s.star} turns left`, body: ['Nothing can hurt you until it runs out.'], clip: 'star' }
     case 'pierce':
     case 'power':
     case 'nuke':
     case 'shield':
     case 'freeze':
     case 'decoy':
-      return { icon: CHARGE_ICONS[item], title: chargeTitle(s, item), body: [CHARGE_TEXT[item].text] }
+      return { icon: CHARGE_ICONS[item], title: chargeTitle(s, item), body: [CHARGE_TEXT[item].text], clip: item }
     default: {
       const kind = PASS_KIND[item]
-      return { icon: POWER_UP_ICONS[kind], title: POWER_UP_TEXT[kind].name, body: [POWER_UP_TEXT[kind].text] }
+      return { icon: POWER_UP_ICONS[kind], title: POWER_UP_TEXT[kind].name, body: [POWER_UP_TEXT[kind].text], clip: item }
     }
   }
 }
@@ -215,16 +231,19 @@ export function tileInfo(s: GameState, p: Vec): Info | null {
       `Its blast reaches ${reach} in each direction. Flame stops at walls${bomb.pierce ? ' (it goes through crates)' : ' and on the first crate'}, and sets off any bomb it reaches.`,
     ]
     if (bomb.nuke) body.push(`Nuke: it also burns every tile within ${NUKE_RANGE} steps, through crates and round corners.`)
-    return { icon: 'bomb', title: `${kinds.length ? `${kinds.join(' + ')} bomb` : 'Bomb'}: ${bomb.timer}`, body }
+    // Its clip: the charge that shapes its blast, or a bomb's countdown and blast.
+    const clip = bomb.nuke ? 'nuke' : bomb.power ? 'power' : bomb.pierce ? 'pierce' : 'kill'
+    return { icon: 'bomb', title: `${kinds.length ? `${kinds.join(' + ')} bomb` : 'Bomb'}: ${bomb.timer}`, body, clip }
   }
   if (s.decoy && eq(s.decoy.pos, p)) {
-    return { icon: 'character_green_front', title: 'Decoy', body: [`Seekers chase it instead of you for ${s.decoy.turns} more ${s.decoy.turns === 1 ? 'turn' : 'turns'}. Flame destroys it.`] }
+    return { icon: 'character_green_front', title: 'Decoy', body: [`Seekers chase it instead of you for ${s.decoy.turns} more ${s.decoy.turns === 1 ? 'turn' : 'turns'}. Flame destroys it.`], clip: 'decoy' }
   }
   if (isBurning(s, p)) {
     return {
       icon: 'fireball',
       title: 'Still burning',
       body: ['Flame from last turn’s blast. It goes out at the end of this turn. Anything in it then dies, and you can’t step in.'],
+      clip: 'lingering',
     }
   }
   const cell = cellAt(s, p)
@@ -235,6 +254,7 @@ export function tileInfo(s: GameState, p: Vec): Info | null {
       icon,
       title: pillar ? 'Pillar' : 'Outer wall',
       body: [`Indestructible: nothing gets through, not even flame.${pillar ? ' Pillars change colour from level to level.' : ''}`],
+      clip: 'pillar',
     }
   }
   if (cell === 'block') {
@@ -242,6 +262,7 @@ export function tileInfo(s: GameState, p: Vec): Info | null {
       icon: 'block_planks',
       title: 'Crate',
       body: ['Flame destroys it (and stops there). On every level one crate hides the exit and another hides a gem.'],
+      clip: 'crate',
     }
   }
   const gem = s.gems.find((g) => eq(g.pos, p))
@@ -255,22 +276,24 @@ export function tileInfo(s: GameState, p: Vec): Info | null {
           : `Walk onto it to choose one of ${OFFER_SIZE} power-ups. The colour doesn’t matter.`,
         PUNISH,
       ],
+      clip: 'gem',
     }
   }
   if (eq(s.exit, p)) {
     return exitOpen(s)
-      ? { icon: 'door_open_top', title: 'Exit: open', body: ['Step onto it to finish the level.', PUNISH] }
+      ? { icon: 'door_open_top', title: 'Exit: open', body: ['Step onto it to finish the level.', PUNISH], clip: 'exit' }
       : {
           icon: 'door_closed_top',
           title: 'Exit: locked',
           body: [`It opens when every enemy is gone (${s.enemies.length} left). Then step onto it to finish the level.`, PUNISH],
+          clip: 'doorBlast',
         }
   }
   const drop = s.drops.find((d) => eq(d.pos, p))
   if (drop) {
-    if (drop.kind === 'coins') return { icon: coinSprite(drop.amount), title: `${drop.amount} ${drop.amount === 1 ? 'coin' : 'coins'}`, body: ['Walk onto it to take it. A blast destroys it.'] }
+    if (drop.kind === 'coins') return { icon: coinSprite(drop.amount), title: `${drop.amount} ${drop.amount === 1 ? 'coin' : 'coins'}`, body: ['Walk onto it to take it. A blast destroys it.'], clip: 'coins' }
     const t = CHARGE_TEXT[drop.kind]
-    return { icon: CHARGE_ICONS[drop.kind], title: `${t.name} charge`, body: [t.text, 'Walk onto it: it works as soon as you pick it up. A blast destroys it.'] }
+    return { icon: CHARGE_ICONS[drop.kind], title: `${t.name} charge`, body: [t.text, 'Walk onto it: it works as soon as you pick it up. A blast destroys it.'], clip: drop.kind }
   }
   return null
 }
@@ -278,7 +301,7 @@ export function tileInfo(s: GameState, p: Vec): Info | null {
 /** Shown when a kind of charge is picked up for the first time in a run (user request, 2026-10-22). */
 export function chargeInfo(kind: ChargeId): Info {
   const t = CHARGE_TEXT[kind]
-  return { icon: CHARGE_ICONS[kind], title: `${t.name} charge`, body: ['You picked it up! It works right away.', t.text, 'The top bar shows what it still has to give.'] }
+  return { icon: CHARGE_ICONS[kind], title: `${t.name} charge`, body: ['You picked it up! It works right away.', t.text, 'The top bar shows what it still has to give.'], clip: kind }
 }
 
 /** A charge in the top bar, with what it has left. */
@@ -313,7 +336,8 @@ export function actionInfo(s: GameState, picked: ActionId): Info {
       if (s.powers.teleport) body.push(`Teleport: any empty tile up to ${TELEPORT_RANGE} steps away, over anything.`)
       if (s.powers.kick) body.push('Kick: walk into a bomb next to you and it slides away until something stops it.')
       if (s.powers.diagonal) body.push('Diagonal: a diagonal step, even between two blocked tiles.')
-      return { icon: 'icon_move', title: 'Move', body }
+      // Its clip: the move that was picked (a Run, a Teleport...), or plain steps.
+      return { icon: 'icon_move', title: 'Move', body, clip: picked === 'move' ? 'tapMove' : picked }
     }
     case 'bomb':
     case 'throw':
@@ -326,10 +350,10 @@ export function actionInfo(s: GameState, picked: ActionId): Info {
       for (const k of ['pierce', 'power', 'nuke'] as const) {
         if (s.boosts[k] > 0) body.push(`${CHARGE_TEXT[k].name}: your next ${s.boosts[k]} ${s.boosts[k] === 1 ? 'bomb' : 'bombs'}.`)
       }
-      return { icon: 'bomb', title: 'Bomb', body }
+      return { icon: 'bomb', title: 'Bomb', body, clip: picked === 'bomb' ? 'holdBomb' : picked }
     }
     case 'wait':
-      return { icon: 'icon_wait', title: 'Wait', body: ['Do nothing; the world takes its turn. Tapping yourself does the same.'] }
+      return { icon: 'icon_wait', title: 'Wait', body: ['Do nothing; the world takes its turn. Tapping yourself does the same.'], clip: 'wait' }
   }
 }
 
diff --git a/src/ui/offer.ts b/src/ui/offer.ts
index 51cb456..321fc11 100644
--- a/src/ui/offer.ts
+++ b/src/ui/offer.ts
@@ -1,12 +1,18 @@
 import { isCharge, type OfferKind } from '../sim/types'
 import { CHARGE_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites'
+import type { SceneKind } from '../sim/demo'
+import { startClip, stopClip } from './clip'
 import { closeDialog, openDialog } from './dialog'
-import { CHARGE_TEXT, POWER_UP_TEXT } from './info'
+import { CHARGE_TEXT, POWER_UP_CLIP, POWER_UP_TEXT } from './info'
 
 /**
  * The gem's choice (user request, 2026-10-23): its offer of power-ups, each with what it does,
  * laid out like the almanac. One must be picked; the dialog can't be closed any other way.
  * Developer mode's rainbow gem offers the charges too (2026-10-24).
+ *
+ * One clip above the cards (PLAN.md §13c; user's choice, 2026-10-24) plays the power-up looked at:
+ * the first one's at first, then the one whose picture was last tapped (or hovered with a mouse).
+ * The picture is its own button, beside the card; choosing is still one tap on the card.
  */
 let chosen: ((kind: OfferKind) => void) | null = null
 
@@ -14,25 +20,54 @@ export function openOffer(offer: OfferKind[], choose: (kind: OfferKind) => void)
   chosen = choose
   openDialog({
     title: 'Choose a power-up',
-    body: offer
-      .map(
-        (k) =>
-          `<button type="button" class="almanac-entry offer-choice" data-offer="${k}">` +
-          `<img src="${spriteUrl(isCharge(k) ? CHARGE_ICONS[k] : POWER_UP_ICONS[k])}" alt="" draggable="false">` +
-          `<div><strong>${(isCharge(k) ? CHARGE_TEXT[k] : POWER_UP_TEXT[k]).name}</strong>` +
-          `<p>${(isCharge(k) ? CHARGE_TEXT[k] : POWER_UP_TEXT[k]).text}</p></div></button>`,
-      )
-      .join(''),
+    body:
+      '<div class="offer-clip"><canvas class="clip" aria-hidden="true"></canvas></div>' +
+      offer
+        .map((k) => {
+          const text = isCharge(k) ? CHARGE_TEXT[k] : POWER_UP_TEXT[k]
+          return (
+            `<div class="offer-row" data-row="${k}">` +
+            `<button type="button" class="offer-preview" data-preview="${k}" aria-label="Show ${text.name}">` +
+            `<img src="${spriteUrl(isCharge(k) ? CHARGE_ICONS[k] : POWER_UP_ICONS[k])}" alt="" draggable="false"></button>` +
+            `<button type="button" class="offer-choice" data-offer="${k}"><strong>${text.name}</strong><p>${text.text}</p></button></div>`
+          )
+        })
+        .join(''),
     buttons: [],
     required: true,
   })
+  preview(offer.find((k) => clipOf(k)) ?? offer[0]!)
+}
+
+function clipOf(k: OfferKind): SceneKind | undefined {
+  return isCharge(k) ? k : POWER_UP_CLIP[k]
+}
+
+/** The clip above the cards plays `k` (one without a clip, Heart or Coin bag, leaves it blank). */
+function preview(k: OfferKind): void {
+  const canvas = document.querySelector<HTMLCanvasElement>('#dialog-body .offer-clip canvas.clip')
+  if (!canvas) return
+  for (const row of document.querySelectorAll('.offer-row')) row.classList.toggle('previewed', (row as HTMLElement).dataset.row === k)
+  const clip = clipOf(k)
+  if (clip) return startClip(canvas, clip)
+  stopClip(canvas)
+  canvas.getContext('2d')?.clearRect(0, 0, canvas.width, canvas.height)
 }
 
 const dialogBody = document.getElementById('dialog-body')!
 dialogBody.addEventListener('click', (e) => {
+  const look = (e.target as Element).closest<HTMLElement>('[data-preview]')
+  if (look && chosen) return preview(look.dataset.preview as OfferKind)
   const button = (e.target as Element).closest<HTMLElement>('[data-offer]')
   if (!button || !chosen) return
   const choose = chosen
   chosen = null
   closeDialog(() => choose(button.dataset.offer as OfferKind))
 })
+
+// With a mouse, pointing at a card plays it too.
+dialogBody.addEventListener('pointerover', (e) => {
+  if (e.pointerType !== 'mouse' || !chosen) return
+  const row = (e.target as Element).closest<HTMLElement>('[data-row]')
+  if (row && !row.classList.contains('previewed')) preview(row.dataset.row as OfferKind)
+})
diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs
index ce4f64e..2787087 100644
--- a/tools/screenshots.mjs
+++ b/tools/screenshots.mjs
@@ -21,13 +21,18 @@ const press = (...keys) => async (page) => {
   }
 }
 
-/** The screen position of a tile's centre, while the camera is at the left edge of the level. */
+/**
+ * The screen position of a tile's centre: the 9 × 11 board fits between the top bar and the action
+ * bar, centred both ways (render/camera.ts). It assumed a 13-row board scrolled to the left until
+ * 2026-10-24, so taps landed on the wrong tiles (the "enemy" shot showed a crate).
+ */
 async function tileCentre(page, x, y) {
   return page.evaluate(([x, y]) => {
     const top = document.getElementById('hud').getBoundingClientRect().height
     const bottom = document.getElementById('actions').getBoundingClientRect().height
-    const size = (innerHeight - top - bottom) / 13
-    return { x: (x + 0.5) * size, y: top + (y + 0.5) * size }
+    const available = innerHeight - top - bottom
+    const size = Math.min(available / 11, innerWidth / 9)
+    return { x: (innerWidth - 9 * size) / 2 + (x + 0.5) * size, y: top + (available - 11 * size) / 2 + (y + 0.5) * size }
   }, [x, y])
 }
 
@@ -76,7 +81,7 @@ const SHOTS = [
   // "What is that?" (the ? button beside the menu) on, then tapping an enemy explains it; the top bar's items
   // explain themselves with a plain tap.
   { name: 'info-mode', act: async (page) => { await page.click('#info-button') } },
-  { name: 'info-enemy', act: async (page) => { await page.click('#info-button'); await tap(4, 5)(page) } },
+  { name: 'info-enemy', act: async (page) => { await page.click('#info-button'); await tap(1, 7)(page) } },
   { name: 'info-hud', act: async (page) => { await page.click('[data-info=fuse]') } },
   // The menu (top right), and the confirmation before abandoning a run.
   { name: 'menu', act: async (page) => { await page.click('#menu-button') } },
@@ -124,7 +129,7 @@ try {
   for (const { name, viewport = PHONE, query = '', act, settle = 800 } of SHOTS) {
     const page = await browser.newPage({ viewport, deviceScaleFactor: 2, hasTouch: viewport === PHONE })
     await page.goto(`http://localhost:4179/?${query.includes('seed=') ? '' : 'seed=1&'}${query}`)
-    await page.waitForSelector('canvas')
+    await page.waitForSelector('body > canvas') // the game's, not a clip's
     await page.waitForTimeout(300)
     await act(page)
     // Long enough for the turn's animations to finish and the banner to come in (style.css).