fuzzyworld

explodorama / commit

Demos get to the point in 3-6 turns and show the controls with a finger

36c5d6a2f71f2e4d63446a2f622eae8231bfc318 · Pavel Safronov · 2026-10-24 08:37:26 -0700 · parents e3f29330e9a4

Claude Code usage: this commit ~80.7K out / ~9.6M cached · cost unknown (no per-model trailer) · session 4 tally 346 in / 184.1K out / 27.4M cached · usage by commit and session

Demos get to the point in 3-6 turns and show the controls with a finger

Each scene is now the last 3-6 turns of a random game, from a calm board
(no bomb, flame or stray drop), replayed to check its moment still comes.
Every turn carries the gesture the real controls read as that action
(tap, long press, button then tile, far tap, Go); a finger acts it out on
the action bar, which sits under the board, apart from the caption. Five
control scenes come first. tapMeaning (picks.ts) and safeStep (path.ts)
moved into the sim so the demo and its tests share the real tap rules.

Usage: 346 in / 184.1K out / 27.4M cached (session tally)
rtk:   1.8M saved, 39.6% over 3715 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 CHANGELOG.md                              |   4 +-
 PLAN.md                                   |  21 ++-
 README.md                                 |  25 ++-
 assets/kenney-ui-rpg/cursorHand_beige.svg |   5 +
 decisions.md                              |  54 ++++++
 docs/screenshots/demo.png                 | Bin 130427 -> 147519 bytes
 index.html                                |   1 +
 src/main.ts                               | 255 ++++++++++++++++++++-----
 src/sim/demo.ts                           | 301 ++++++++++++++++++++++++------
 src/sim/path.ts                           |  14 +-
 src/sim/picks.ts                          |  28 ++-
 src/style.css                             | 122 ++++++++++--
 test/demo.test.ts                         |  40 +++-
 tools/screenshots.mjs                     |  17 +-
 14 files changed, 748 insertions(+), 139 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index a64a141..f8fee62 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -65,7 +65,9 @@ saved before the change is dropped and the next visit starts a new run.
   feature on the board — bombs, watching the enemies' arrows, escapes, chains, lingering flame,
   the enemy types, drops, the gem, each power-up and each charge, the exit — alternating with the
   ways to die (into a blast, not far enough, blocked in, a chain, an enemy, and a couple of funny
-  ones), each with a caption and an icon, until you tap.
+  ones), each with a caption and an icon, until you tap. Each scene gets to its point 3 to 6
+  turns in, and a finger plays every turn on the real action bar — taps with a ripple, a long
+  press for a bomb, a button then a tile, Go — after five opening scenes on the controls alone.
 - **Installable** as an app (Chrome, Edge, Android, iPhone / iPad), and playable offline once
   loaded.
 - **Deployment** to GitLab Pages on every push to the default branch, after the tests and the
diff --git a/PLAN.md b/PLAN.md
index f6ab77b..d856870 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -677,8 +677,25 @@ seed tried, the slowest under 0.5 s; each caption has the thing's icon. The "Ouc
 level-cleared banners are hidden in the demo. The UI (`main.ts`: `startDemo`, `demoTick`) plays a
 turn every 0.6 s through the same animation code as play, hides the top bar and buttons, and
 fits the room above the caption panel. Every caption has an icon beside it: for a power-up or charge, the same icon as on the board, in
-the top bar and the almanac. Ideas: more kinds (the exit, a gem's choice, Kick and
-Throw), and showing it again after the player is idle on the menu.
+the top bar and the almanac.
+*Since 2026-10-24 (user request)* **every scene gets to its point within 3–6 turns** and **the demo
+shows the controls**. A random game still runs until it shows the moment, but the scene is only
+its last 3–6 turns (as few as do), starting from a calm board — no bomb, no flame, no drop the
+room didn't start with, auto-move not under way — and replayed from there to check the moment
+still comes (`ending` in `sim/demo.ts`; Fuse +1 is now fuse 4, the real +1; Pierce's room has a
+row of two crates with Fire 2, and its moment is a crate burnt behind another; Pierce, Power and
+Nuke lie next to the player). Every turn comes with its **gesture** (`Gesture`: a tap, a long
+press, a button and then a tile, a far tap for auto-move, Go), only ones the real controls read
+as that action (`tapMeaning` in `sim/picks.ts`, shared with `tapTile`; `resolvePick`); a turn no
+gesture can play isn't used. The UI acts each one out with a finger (Kenney's pointing hand) on
+the real action bar, shown in the demo as a tray right under the board, apart from the caption
+panel at the bottom (grouped by interactivity: board and buttons, then the words): a ripple where it taps, a ring closing in on a
+long press, then the bomb's preview; a picked button lights up its tiles, a far tap shows
+auto-move's way. Turns take about 1–2 s. **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, all found on 40 seeds each, the slowest under 0.4 s. Ideas: a gem's choice, and
+showing the demo again after the player is idle on the menu.
 
 ## 13b. Run options (user request, 2026-10-21; built)
 
diff --git a/README.md b/README.md
index 21a9c01..0aec994 100644
--- a/README.md
+++ b/README.md
@@ -23,21 +23,31 @@ resumed, and a seed gives the same dungeon only within one version.
 ## How to play
 
 **The demo:** when the game opens, it first plays a demo — short scenes made up on the spot from
-the real rules, each with a caption and an icon. Between them they show every feature on the
+the real rules, each with a caption and an icon. Each scene gets to its point quickly: what it is
+about happens 3 to 6 turns in, starting from a calm board (no bomb or flame yet). **A finger shows
+the controls** as it plays every turn, on the real action bar (it sits right under the board,
+apart from the caption at the bottom: what you play on, then what explains it): a tap on a tile (with a ripple
+where it lands), a long press for a bomb (a ring closes in, the bomb's blast is previewed, the
+bomb goes down when the finger lifts), a button and then a tile (Bomb then a tile; Move then two
+tiles away for a Run), Wait, and Go. It starts with five scenes about the controls alone:
+stepping, a bomb by long press, a bomb with the Bomb button, waiting, and auto-move (tap a far
+tile, then Go). The rest show every feature on the
 board: bombs and their countdown, watching the enemies' arrows to bomb where they're going, a
 narrow escape, chain reactions, lingering flame, the three enemy types, drops, the gem, every
-power-up you can see in action (Bomb +1, Fire +1, Fuse +1, Run, Kick, Throw, Teleport, Diagonal,
-the Detonator, Wall / Bomb / Flame Pass, Star) and every charge (Pierce, Power, Nuke, Shield,
+power-up you can see in action (Bomb +1, Fire +1, Fuse +1, Run, Kick — a tap on a bomb next to
+you —, Throw, Teleport, Diagonal, the Detonator — a tap on your bomb —, Wall / Bomb / Flame Pass,
+Star) and every charge (Pierce, Power, Nuke, Shield,
 Freeze, Decoy), and the exit (walking out once it opens; blasting it lets enemies out). Heart and
 Coin bag only change numbers, so they have no scene; nor does the shop. Every other scene shows
 **a way to die**: walking into a blast, not getting far enough, blocking yourself in with your
 own bomb, one bomb setting off another, touching an enemy, and a couple of funny ones (the
 Detonator next to you, a Run into an enemy). Living and dying scenes take turns, each in a
-shuffled order, and every time they're different. It is
+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.
 
-<img src="docs/screenshots/demo.png" alt="The demo: a bomb about to go off in a small room, with the caption underneath" width="260">
+<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">
 
 You are the green astronaut (animated: it bobs while waiting, hops from tile to tile, crouches
 to place a bomb, and walks into the doorway and jumps when a level is cleared; enemies hop between tiles too, and flip over
@@ -365,7 +375,10 @@ looks the way it does because of it, so it is credited here and in the game (in
 The files used are copied, unchanged and under their original file names, into
 [`assets/kenney/`](assets/kenney/), [`assets/kenney-ui/`](assets/kenney-ui/) (keeping the pack's
 colour folders) and [`assets/kenney-ui-rpg/`](assets/kenney-ui-rpg/), each with its pack's
-licence file. They
+licence file. One exception: the demo's pointing hand, `assets/kenney-ui-rpg/cursorHand_beige.svg`,
+is the RPG Expansion's `cursorHand_beige` cut out of the pack's vector sheet
+(`Vector/uipack_rpg_vector.svg`: just its three shapes, unchanged), since the PNG is only 27 px
+across. They
 are not covered by this project's MIT licence. Sound effects, when the game gets them, will come
 from the same pack.
 
diff --git a/assets/kenney-ui-rpg/cursorHand_beige.svg b/assets/kenney-ui-rpg/cursorHand_beige.svg
new file mode 100644
index 0000000..29316f8
--- /dev/null
+++ b/assets/kenney-ui-rpg/cursorHand_beige.svg
@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="295 277 31 31">
+<path stroke="none" fill="#C6B897" d="M298.3 286.15 Q297.8 284.8 298.2 283.4 298.65 281.7 300.1 280.8 L300.4 280.6 Q301.95 279.7 303.8 280.15 305.65 280.6 306.65 282.3 L307.75 284.25 308.15 284 308.1 284.05 Q311.4 282.15 314.4 282.05 318.3 282 319.55 284.45 L324.25 292.55 324.25 292.6 Q325.35 294.25 324.65 296.35 L323.75 298.2 Q322.2 300.8 318.9 302.7 315.65 304.55 312.75 304.6 L312.65 304.6 Q308.7 304.8 307.45 302.25 L307.3 301.95 302.5 300.6 302.65 300.6 Q300.55 300.15 299.55 298.35 L299.55 298.4 299.1 297.45 Q298.65 296.1 299.05 294.7 L299.15 294.3 Q299.45 293.3 300.15 292.6 300.6 292.1 301.2 291.75 L301.35 291.65 298.65 286.95 298.3 286.15 M300.2 283.75 L300.1 283.95 Q299.8 285 300.35 285.95 L304.5 293.15 Q303.35 292.85 302.3 293.4 301.6 293.85 301.25 294.5 L301.1 294.85 301 295.1 300.95 295.2 Q300.65 296.35 301.25 297.35 301.85 298.4 303.05 298.65 L308.55 300.2 309.2 301.25 Q310.05 302.75 312.6 302.6 315.1 302.55 317.9 300.95 320.7 299.35 322.05 297.15 323.35 295.05 322.5 293.55 L317.8 285.45 Q316.95 284 314.45 284.05 311.9 284.15 309.1 285.75 308.2 286.25 307.5 286.85 L307.1 287.15 304.9 283.3 Q304.35 282.35 303.3 282.1 302.25 281.85 301.35 282.35 L301.3 282.4 Q300.5 282.85 300.2 283.75"/>
+<path stroke="none" fill="#ECE3CE" d="M300.2 283.75 Q300.5 282.85 301.3 282.4 L301.35 282.35 Q302.25 281.85 303.3 282.1 304.35 282.35 304.9 283.3 L307.1 287.15 307.5 286.85 Q308.2 286.25 309.1 285.75 311.9 284.15 314.45 284.05 316.95 284 317.8 285.45 L322.5 293.55 Q323.35 295.05 322.05 297.15 320.7 299.35 317.9 300.95 315.1 302.55 312.6 302.6 310.05 302.75 309.2 301.25 L308.55 300.2 303.05 298.65 Q301.85 298.4 301.25 297.35 300.65 296.35 300.95 295.2 L301 295.1 301.1 294.85 301.25 294.5 Q301.6 293.85 302.3 293.4 303.35 292.85 304.5 293.15 L300.35 285.95 Q299.8 285 300.1 283.95 L300.2 283.75"/>
+<path stroke="none" fill="#000000" fill-opacity="0.10196078431372549" d="M324.65 296.35 Q325.5 298.45 323.75 301.2 322.2 303.8 318.9 305.7 315.65 307.55 312.75 307.6 L312.65 307.6 Q308.7 307.8 307.45 305.25 L307.3 304.95 302.5 303.6 302.65 303.6 Q300.55 303.15 299.55 301.35 L299.55 301.4 Q298.5 299.65 299.05 297.7 L299.1 297.45 299.55 298.4 299.55 298.35 Q300.55 300.15 302.65 300.6 L302.5 300.6 307.3 301.95 307.45 302.25 Q308.7 304.8 312.65 304.6 L312.75 304.6 Q315.65 304.55 318.9 302.7 322.2 300.8 323.75 298.2 L324.65 296.35 M300.15 292.6 L298.65 289.95 Q297.65 288.25 298.2 286.4 L298.3 286.15 298.65 286.95 301.35 291.65 301.2 291.75 Q300.6 292.1 300.15 292.6"/>
+</svg>
diff --git a/decisions.md b/decisions.md
index c928103..9e2d74f 100644
--- a/decisions.md
+++ b/decisions.md
@@ -1131,3 +1131,57 @@ a Freeze chosen this way skips the enemies' next 3 moves (one picked up skips it
 `SIM_VERSION` stays 23: a run recorded before still replays the same (its choices are still on
 offer), and 23 itself is from earlier today.
 
+### 2026-10-24 — Demos get to the point, and show the controls
+
+User request: scenes sometimes ran many moves, the player and enemies dancing around, before
+the thing they're about; it should come within 3–6 steps of the start. And the demo should
+teach the controls: a tap effect, the action buttons, a virtual finger, with scenes for moving,
+a bomb by long press, kicking a bomb, and so on.
+
+- **The moment comes 3–6 turns in.** Rather than limiting the random games to 6 turns (most
+  would fail), a game still runs until it shows the moment, and the scene is its last 3–6
+  turns, from a **calm** board: no bomb (so each bomb is seen placed and counting down), no
+  flame, no drop the room didn't start with (a coin or charge left by an enemy killed before
+  the scene would be a mystery) and auto-move not under way. The shortest such ending wins, and
+  it is replayed from its start to check the moment still comes (a charge must be picked up, a
+  kick made, within the scene). I read "within 3–6 steps" as: at least 3 turns of lead-up, at
+  most 6, so the moment is never the very first turn.
+- **Scene changes for the window:** Fuse +1 now starts the player at fuse 4 (start 3, plus one;
+  it was 5); Pierce, Power and Nuke lie next to the player rather than two tiles away; Pierce's
+  room has Fire 2 and a straight row (Pierce, a free tile, two crates), and its moment is now
+  a crate burnt *behind* another — before, two crates on different sides counted, which any bomb
+  does.
+- **Gestures.** Every turn of a scene comes with the gesture that plays it, chosen so the real
+  controls would read it as exactly that action: a tap (step, diagonal step, kick, detonate),
+  a long press (a bomb), a button and then a tile (Run, Teleport and Throw; a bomb in the Bomb
+  button scene), Wait's button, a far tap (auto-move) and Go. What a tap means moved from
+  `tapTile` into the sim as `tapMeaning` (picks.ts) so the demo and its test use the same rules;
+  `tapTile` behaves exactly as before. Auto-move's safety check moved to path.ts (`safeStep`)
+  for the same reason. A random turn no gesture can play (a step onto a bomb with Bomb Pass,
+  whose tap would kick or do nothing) is never chosen.
+- **The finger** is the UI Pack: RPG Expansion's pointing hand (`cursorHand_beige`), already one
+  of the game's packs. Its PNG is 27 px, too small to scale up, so its three shapes were cut out
+  of the pack's vector sheet into an SVG. It glides to each tile or button, sinks as it presses,
+  and leaves a white ripple (with a thin dark rim, to show on the pale floor); a long press
+  draws a yellow ring closing in over the real 450 ms, then the armed bomb's preview for 0.6 s.
+  The board answers through the real view state: the picked button's tiles and the preview
+  under the finger, the armed bomb, auto-move's way and Move turning into Go.
+- **The action bar shows in the demo**, as a dark rounded tray right under the board (8 px
+  below it, as wide as it), and the caption panel stays apart at the bottom, at least 24 px
+  below: the board and its buttons are one group, the explanation another (user request, same
+  day — at first the bar sat on the caption panel, which read as part of the text). The camera
+  fits board and bar together above the panel; main.ts places the bar every frame from the
+  board's screen rectangle. A press shows as the real one (`.pressed`, like `:active`).
+- **Five control scenes** — step (3 taps), bomb by long press, bomb with the Bomb button, Wait
+  (a wait, then a kill), auto-move (a far tap, then Go until there) — play first, in that order,
+  and again each time the living scenes have all been shown. Kicking has no scene of its own:
+  the Kick scene already shows it, and its caption now says "tap a bomb next to you"; the
+  Run, Throw, Teleport, Diagonal and Detonator captions say how too.
+- **Timing:** a tap turn takes about 1.2 s, a button-and-tile turn about 2 s, so a scene is
+  4–12 s. Tests: every kind on 6 seeds is 3–6 turns long from a calm start, and each gesture,
+  read by the real rules (`tapMeaning`, `resolvePick`, `autoMoveAction` with `safeStep`), gives
+  that turn's action. A probe found every one of the 41 kinds on 40 seeds each, the slowest
+  0.39 s (Nuke). Checked in a browser: two minutes of the demo played 15 scenes (the five
+  control scenes first) with no errors; frame by frame, the finger, ripple, ring, button
+  presses, highlighted tiles and auto-move's way look as described; a tap ended the demo and left
+  no finger or marks behind. The `demo` screenshot is now taken mid long-press.
diff --git a/docs/screenshots/demo.png b/docs/screenshots/demo.png
index 5ae57d2..1adb52a 100644
Binary files a/docs/screenshots/demo.png and b/docs/screenshots/demo.png differ
diff --git a/index.html b/index.html
index 1606858..58c2675 100644
--- a/index.html
+++ b/index.html
@@ -59,6 +59,7 @@
   <p id="demo-caption"></p>
   <p class="demo-hint">Tap anywhere to play</p>
 </div>
+<div id="demo-finger" hidden aria-hidden="true"></div>
 <script type="module" src="/src/main.ts"></script>
 </body>
 </html>
diff --git a/src/main.ts b/src/main.ts
index 298ae7b..e432fff 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -10,18 +10,19 @@ import {
   setTilesPixelated,
   time,
   vec2,
+  worldToScreen,
 } from 'littlejsengine'
-import { BACKGROUND, drawBoard, FLAME_SECONDS, toTile, type Flame } from './render/board'
+import { BACKGROUND, drawBoard, FLAME_SECONDS, toTile, toWorld, type Flame } from './render/board'
 import { clearEffects, newEffects, playerTransition, pruneEffects, startEffects, type PlayerMove, type PlayerPose } from './render/effects'
 import { updateCamera } from './render/camera'
 import { CHARGE_ICONS, MONO_ICONS, POWER_UP_ICONS, TEXTURES } from './render/sprites'
 import { isAvailable, isValidTarget, ACTIONS } from './sim/actions'
-import { canPick, resolvePick } from './sim/picks'
+import { canPick, resolvePick, tapMeaning } from './sim/picks'
 import { enemyAt } from './sim/enemies'
-import { autoMoveAction, playerPath } from './sim/path'
+import { autoMoveAction, playerPath, safeStep as isSafeStep } from './sim/path'
 import { predictDanger, type Danger } from './sim/danger'
 import { newRecord, replay, SIM_VERSION, type RunRecord } from './sim/record'
-import { bombAt, cellAt, eq, flameSafe, idx, inBounds, isBurning, newRun } from './sim/state'
+import { cellAt, eq, inBounds, isBurning, newRun } from './sim/state'
 import { step } from './sim/turn'
 import { DEFAULT_OPTIONS, DIRS, type Action, type ActionId, type ChargeId, type Dir, type GameEvent, type GameState, type RunOptions, type Vec } from './sim/types'
 import { openAlmanac } from './ui/almanac'
@@ -33,10 +34,11 @@ import { openScores, recordScore } from './ui/scores'
 import { countsForScores, scoreEntry } from './sim/scores'
 import { reportError } from './devlog'
 import { hashSeed } from './sim/rng'
-import { DYING_KINDS, generateScene, LIVING_KINDS, SCENE_KINDS, SCENES, type Scene, type SceneKind } from './sim/demo'
+import { CONTROL_KINDS, DYING_KINDS, generateScene, LIVING_KINDS, SCENE_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'
+import handIcon from '../assets/kenney-ui-rpg/cursorHand_beige.svg'
 
 // `?seed=123&level=4` gives a reproducible board (screenshots, bug reports). Otherwise a random
 // seed — chosen here, never inside the sim.
@@ -290,19 +292,19 @@ function tapTile(p: Vec): void {
       selected = null
       afterChange()
     }
-  } else if (eq(p, state.player)) pick('wait')
-  else if (isAvailable(state, 'detonate') && isValidTarget(state, 'detonate', p)) act({ id: 'detonate', target: p })
-  else if (isAvailable(state, 'kick') && isValidTarget(state, 'kick', p)) act({ id: 'kick', target: p })
-  else if (enemyAt(state, p) || bombAt(state, p) || (isBurning(state, p) && !flameSafe(state))) return
-  else if (destination && eq(destination, p)) stepTowardDestination()
-  else if (isValidTarget(state, 'move', p)) act({ id: 'move', target: p })
-  else if (isAvailable(state, 'diagonal') && isValidTarget(state, 'diagonal', p)) act({ id: 'diagonal', target: p })
-  else if (cellAt(state, p) === 'floor' && playerPath(state, p)) setDestination(p)
-  // Out of walking reach (behind a pillar, say) but within Teleport's: one teleport there.
-  else if (isAvailable(state, 'teleport') && isValidTarget(state, 'teleport', p)) act({ id: 'teleport', target: p })
-  else if (destination) {
-    destination = null
-    afterChange()
+  } else {
+    // What a tap means is worked out in the sim (picks.ts `tapMeaning`), shared with the demo.
+    const means = tapMeaning(state, p)
+    if (means === 'wait') pick('wait')
+    else if (means === 'detonate' || means === 'kick') act({ id: means, target: p })
+    else if (means === 'blocked') return
+    else if (destination && eq(destination, p)) stepTowardDestination()
+    else if (means === 'walk') setDestination(p)
+    else if (means) act({ id: means, target: p })
+    else if (destination) {
+      destination = null
+      afterChange()
+    }
   }
 }
 
@@ -421,14 +423,9 @@ function seedFrom(text: string): number {
   return hashSeed('seed', t) % 2 ** 31
 }
 
-/**
- * Whether auto-move may take this step: not onto an enemy, not onto the tile an enemy moves to
- * next, and not into flame this turn. Those would be deaths the player didn't choose.
- */
+/** Whether auto-move may take this step (path.ts `safeStep`). */
 function safeStep(p: Vec): boolean {
-  // Frozen enemies won't move this turn; flame doesn't matter while it can't hurt.
-  const intended = state.frozen === 0 && state.enemies.some((e) => e.plan[0] && eq(e.plan[0], p))
-  return !enemyAt(state, p) && !intended && (flameSafe(state) || !danger.now.has(idx(state, p)))
+  return isSafeStep(state, danger, p)
 }
 
 /**
@@ -445,7 +442,7 @@ function stepTowardDestination(): boolean {
 
 /** Once every enemy is gone, the flag needs no more taps: auto-move steps on its own. */
 function autoStep(): void {
-  if (state.status !== 'playing' || !destination || state.enemies.length > 0) return
+  if (demo || state.status !== 'playing' || !destination || state.enemies.length > 0) return
   if (selected || inspecting || dialogOpen() || time - lastActionAt < AUTO_STEP_SECONDS) return
   stepTowardDestination()
 }
@@ -567,28 +564,66 @@ if (!resumeSaved()) startRun(Number(params.get('seed')) || randomSeed(), testing
 
 /**
  * The demo, before the game starts (user request, 2026-10-23: some players never played the
- * original). Scenes made up on the spot (sim/demo.ts) are replayed view-only, one turn every
- * DEMO_STEP_SECONDS, each with a caption, the top bar and buttons hidden; the run waits behind it.
- * Any tap or key ends it, and does nothing else. Not on a test URL, unless it has `?demo`.
+ * original). Scenes made up on the spot (sim/demo.ts) are replayed view-only, each with a caption,
+ * the top bar hidden; the run waits behind it. Any tap or key ends it, and does nothing else. Not
+ * on a test URL, unless it has `?demo`.
+ * Since 2026-10-24 (user request) it shows the controls too: a finger plays every turn as the
+ * player would — a tap on a tile (a ripple where it lands), a long press for a bomb (a ring
+ * closing in, then the bomb's preview), an action button and then a tile, Go — on the real
+ * action bar, which shows during the demo, and the board answers as in play (the picked button's
+ * tiles, the preview, auto-move's way). The scenes about the controls come first.
  */
-const DEMO_STEP_SECONDS = 0.6
+/** How long the finger takes to get somewhere, a tap lasts, and a button stays picked before its tile. */
+const FINGER_MOVE_SECONDS = 0.45
+const TAP_SECONDS = 0.18
+const PICKED_SECONDS = 0.45
+/** After a turn: time for its animations before the finger moves on. */
+const DEMO_SETTLE_SECONDS = 0.55
 /** How long a scene's last turn stays on screen, and how long before a scene's first move. */
 const DEMO_HOLD_SECONDS = 1.8
 const DEMO_LEAD_SECONDS = 0.8
-let demo: { scene: Scene; next: number; at: number; real: GameState } | null = null
+/** One beat of the demo's script: something done, then a pause before the next. */
+interface Beat {
+  run: () => void
+  then: number
+}
+let demo: { scene: Scene; next: number; at: number; real: GameState; beats: Beat[] } | null = null
 // The scenes' seeds: from `?seed` for a repeatable demo, otherwise random.
 let demoSeed = Number(params.get('seed')) || randomSeed()
-// Two shuffled queues, taken in turn: a scene where the player lives, then one where they die
-// (user request, 2026-10-23: alternate them). In their usual order with `?seed`, for a test URL.
-const demoQueues: Record<'living' | 'dying', SceneKind[]> = { living: [], dying: [] }
+// 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.
+const demoQueues: Record<'controls' | 'living' | 'dying', SceneKind[]> = { controls: [...CONTROL_KINDS], living: [], dying: [] }
 let demoDying = false
 const demoPanel = document.getElementById('demo')!
 const demoCaption = document.getElementById('demo-caption')!
+const actionBar = document.getElementById('actions')!
+const finger = document.getElementById('demo-finger')!
+finger.innerHTML = `<img src="${handIcon}" alt="" draggable="false">`
+/**
+ * In the demo the action bar sits right under the board, as wide as it, and the caption panel
+ * apart at the bottom: what you play on together, what explains it separately (user request,
+ * 2026-10-24). DEMO_GAP: the bar's distance from the board, and the least space above the panel.
+ */
+const DEMO_GAP = 8
+const DEMO_PANEL_GAP = 24
+
+/** Places the action bar under the board, for this frame's camera. */
+function placeDemoBar(): void {
+  const r = mainCanvas.getBoundingClientRect()
+  const topLeft = worldToScreen(vec2(0, state.height))
+  const bottomRight = worldToScreen(vec2(state.width, 0))
+  actionBar.style.left = `${r.left + topLeft.x}px`
+  actionBar.style.width = `${bottomRight.x - topLeft.x}px`
+  actionBar.style.top = `${r.top + bottomRight.y + DEMO_GAP}px`
+}
 /**
  * The icon beside each scene's caption (user request, 2026-10-23), so the picture and the idea go
  * together: for a power-up or charge, the one it has on the board, in the top bar and the almanac.
  */
 const SCENE_ICONS: Record<SceneKind, string> = {
+  tapMove: 'icon_move', holdBomb: 'bomb', bombButton: 'bomb', wait: 'icon_wait', autoMove: 'icon_move',
   kill: 'bomb', arrows: 'slime_normal_walk_a', escape: 'icon_move', chain: 'bomb', lingering: 'fireball', enemies: 'slime_normal_rest',
   coins: 'hud_coin', gem: 'gem_green',
   bombPlus: POWER_UP_ICONS.bombPlus, firePlus: POWER_UP_ICONS.firePlus, fusePlus: POWER_UP_ICONS.fusePlus,
@@ -602,19 +637,30 @@ const SCENE_ICONS: Record<SceneKind, string> = {
   dieDetonator: POWER_UP_ICONS.detonator, dieRun: POWER_UP_ICONS.run,
 }
 
-/** The next scene: living and dying ones in turn, each cycling through its kinds in a shuffled order. */
+/** The next kind of scene: the controls' queue first, then living and dying ones in turn. */
+function nextKind(): SceneKind {
+  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_KINDS]
+  return kind
+}
+
+/** The next scene: each cycling through its kinds in a shuffled order. */
 function nextScene(): void {
   if (!demo) return
   for (let tries = 0; tries < SCENE_KINDS.length; tries++) {
-    const which = demoDying ? 'dying' : 'living'
-    demoDying = !demoDying
-    const all = which === 'dying' ? DYING_KINDS : LIVING_KINDS
-    if (!demoQueues[which].length) demoQueues[which] = params.has('seed') ? [...all] : [...all].sort(() => Math.random() - 0.5)
-    const scene = generateScene(demoQueues[which].shift()!, demoSeed++)
+    const scene = generateScene(nextKind(), 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
     const icon = SCENE_ICONS[scene.kind]
@@ -622,27 +668,127 @@ function nextScene(): void {
       `<img class="demo-icon${MONO_ICONS.has(icon) ? ' mono' : ''}" src="${spriteUrl(icon)}" alt="" draggable="false">` +
       `<span>${SCENES[scene.kind].caption}</span>`
     afterChange()
+    // The finger comes back, from where it was.
+    finger.classList.remove('away')
     return
   }
 }
 
 function startDemo(): void {
-  demo = { scene: null!, next: 0, at: 0, real: state }
+  demo = { scene: null!, next: 0, at: 0, real: state, beats: [] }
   document.body.classList.add('demo')
   demoPanel.hidden = false
+  finger.hidden = false
+  placeFinger(vec2(innerWidth / 2, innerHeight / 2), false)
   nextScene()
   if (!demo.scene) endDemo()
 }
 
-/** Called every frame: the scene's next turn when it is due, or the next scene. */
+/** 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
-  const { scene } = demo
-  if (demo.next >= scene.actions.length) return nextScene()
-  const action = scene.actions[demo.next++]!
-  animateTurn(step(state, action), action)
-  afterChange()
-  demo.at = time + (demo.next < scene.actions.length ? DEMO_STEP_SECONDS : DEMO_HOLD_SECONDS)
+  if (!demo.beats.length) {
+    if (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)
+  }
+  const beat = demo.beats.shift()!
+  beat.run()
+  demo.at = time + beat.then
+}
+
+/**
+ * The script for one turn: the finger plays its gesture, as the real controls take it (main.ts
+ * pointer handlers, `tapTile`, `pick`), and the turn is played when the finger lets go.
+ */
+function beatsFor(g: Gesture, action: Action, last: boolean): Beat[] {
+  const after = last ? DEMO_HOLD_SECONDS : DEMO_SETTLE_SECONDS
+  const play: Beat = {
+    run: () => {
+      fingerUp()
+      hover = null
+      armed = null
+      selected = null
+      animateTurn(step(state, action), action)
+      afterChange()
+      if (last) finger.classList.add('away')
+    },
+    then: after,
+  }
+  /** The finger to a tile and down on it, then (with `press`) whatever pressing it shows. */
+  const onTile = (tile: Vec, press = () => {}, hold = TAP_SECONDS): Beat[] => [
+    { run: () => placeFinger(tilePoint(tile)), then: FINGER_MOVE_SECONDS },
+    { run: () => (fingerDown(), (hover = tile), press()), then: hold },
+  ]
+  if (g.kind === 'tap') return [...onTile(g.tile), play]
+  if (g.kind === 'hold') {
+    // A long press: held still, it arms the bomb (the preview shows), placed on release.
+    return [...onTile(g.tile, ringUnderFinger, LONG_PRESS_MS / 1000), { run: () => (armed = g.tile), then: 0.6 }, play]
+  }
+  if (g.kind === 'walk') {
+    // A far tile: auto-move's way shows, and its first move is made.
+    return [...onTile(g.tile, () => ((destination = g.tile), afterChange())), play]
+  }
+  const button = g.kind === 'go' ? 'move' : g.button
+  const el = actionBar.querySelector<HTMLElement>(`[data-action=${button}]`)!
+  const pressButton: Beat[] = [
+    { run: () => placeFinger(centreOf(el)), then: FINGER_MOVE_SECONDS },
+    { run: () => (fingerDown(), el.classList.add('pressed')), then: TAP_SECONDS },
+  ]
+  const release = () => el.classList.remove('pressed')
+  if (g.kind === 'go' || !g.tile) return [...pressButton, { ...play, run: () => (release(), play.run()) }]
+  // A button, then its tile: picked, the tiles it can use light up; the finger moves on to one.
+  const tile = g.tile
+  return [
+    ...pressButton,
+    { run: () => (release(), fingerUp(), (selected = g.button), afterChange()), then: PICKED_SECONDS },
+    ...onTile(tile),
+    play,
+  ]
+}
+
+/** Where a tile's centre is on screen, in CSS pixels. */
+function tilePoint(p: Vec) {
+  const r = mainCanvas.getBoundingClientRect()
+  return worldToScreen(toWorld(state, p)).add(vec2(r.left, r.top))
+}
+
+function centreOf(el: Element) {
+  const r = el.getBoundingClientRect()
+  return vec2(r.left + r.width / 2, r.top + r.height / 2)
+}
+
+/** The finger's tip to `at`: gliding there, or (`glide` false) at once. */
+function placeFinger(at: { x: number; y: number }, glide = true): void {
+  finger.classList.toggle('glide', glide)
+  finger.style.transform = `translate(${at.x}px, ${at.y}px)`
+}
+
+/** The finger presses: it sinks a little, and a ripple spreads where it touched. */
+function fingerDown(): void {
+  finger.classList.add('down')
+  const ripple = placeMark('demo-ripple')
+  ripple.addEventListener('animationend', () => ripple.remove(), { once: true })
+}
+
+/** The finger lifts, and any long press's ring goes. */
+function fingerUp(): void {
+  finger.classList.remove('down')
+  for (const ring of document.querySelectorAll('.demo-ring')) ring.remove()
+}
+
+/** A long press: a ring closes in on the finger while it holds. */
+function ringUnderFinger(): void {
+  placeMark('demo-ring').style.setProperty('--hold', `${LONG_PRESS_MS}ms`)
+}
+
+/** A mark under the finger: a ripple or a ring. */
+function placeMark(className: string): HTMLElement {
+  const mark = document.createElement('div')
+  mark.className = className
+  mark.style.transform = finger.style.transform
+  document.body.append(mark)
+  return mark
 }
 
 function endDemo(): void {
@@ -651,6 +797,12 @@ function endDemo(): void {
   demo = null
   document.body.classList.remove('demo')
   demoPanel.hidden = true
+  finger.hidden = true
+  for (const mark of document.querySelectorAll('.demo-ripple, .demo-ring')) mark.remove()
+  for (const b of actionBar.querySelectorAll('.pressed')) b.classList.remove('pressed')
+  actionBar.style.left = actionBar.style.width = actionBar.style.top = ''
+  hover = null
+  armed = null
   resetView()
   afterChange()
 }
@@ -757,8 +909,11 @@ await engineInit(
   },
   guarded('update', () => {
     demoTick()
-    // In the demo the board fits between the top of the screen and its caption panel.
-    updateCamera(state, demo ? { top: 0, bottom: demoPanel.getBoundingClientRect().height } : controls.insets(), snapCamera)
+    // In the demo the board and the action bar under it fit above the caption panel, with room
+    // between them and it.
+    const panel = demoPanel.getBoundingClientRect().height
+    updateCamera(state, demo ? { top: 0, bottom: panel + DEMO_PANEL_GAP + DEMO_GAP + controls.insets().bottom } : controls.insets(), snapCamera)
+    if (demo) placeDemoBar()
     snapCamera = false
     flames = flames.filter((f) => time - f.born < FLAME_SECONDS)
     pruneEffects(fx, time)
diff --git a/src/sim/demo.ts b/src/sim/demo.ts
index e8256de..b23f44d 100644
--- a/src/sim/demo.ts
+++ b/src/sim/demo.ts
@@ -2,11 +2,14 @@ import { ACTIONS, isAvailable, isValidTarget } from './actions'
 import { predictDanger } from './danger'
 import { planIntents } from './enemies'
 import { Rng } from './rng'
-import { playerPath } from './path'
-import { exitOpen, idx, noBoosts, noPowers, START_LIVES, START_STATS } from './state'
+import { autoMoveAction, playerPath, safeStep } from './path'
+import { membersOf, resolvePick, tapMeaning } from './picks'
+import { exitOpen, idx, inBounds, noBoosts, noPowers, START_LIVES, START_STATS } from './state'
 import { step } from './turn'
 import {
   DEFAULT_OPTIONS,
+  DIRS,
+  DIR_LIST,
   type Action,
   type ActionId,
   type Cell,
@@ -24,8 +27,13 @@ import {
  * and those actions; the UI replays them, view-only. Pure and seeded like the rest of the sim.
  * The scenes show every feature that happens on the board (user request, 2026-10-23: all but
  * the shop and the exit) — Heart and Coin bag only change numbers, so they have none.
+ * Since 2026-10-24 (user request) they also show the controls: each turn comes with the gesture
+ * that plays it (a tap, a long press, a button then a tile), which the UI acts out with a finger,
+ * and a few scenes are about the controls alone. And each scene's moment comes 3 to 6 turns in:
+ * the scene is the last few turns of a longer game, from a calm moment (no bomb, no flame).
  */
 export type SceneKind =
+  | 'tapMove' | 'holdBomb' | 'bombButton' | 'wait' | 'autoMove'
   | 'kill' | 'arrows' | 'escape' | 'chain' | 'lingering' | 'enemies' | 'coins' | 'gem'
   | 'bombPlus' | 'firePlus' | 'fusePlus' | 'run' | 'kick' | 'throw' | 'teleport' | 'diagonal' | 'detonate'
   | 'wallPass' | 'bombPass' | 'flamePass' | 'star'
@@ -33,22 +41,58 @@ export type SceneKind =
   | 'exit' | 'doorBlast'
   | 'dieIntoBlast' | 'dieNotFar' | 'dieBlockedIn' | 'dieChain' | 'dieEnemy' | 'dieDetonator' | 'dieRun'
 
+/**
+ * How the player plays a turn, for the demo's finger (main.ts): every gesture is one the real
+ * controls read as that turn's action (test/demo.test.ts checks it).
+ * - tap: a tap on a board tile, nothing picked (picks.ts `tapMeaning`);
+ * - hold: a long press on a tile next to the player: a bomb there;
+ * - button: an action button, then (unless it acts at once, as Wait) a tap on a tile;
+ * - walk: a tap on a far tile: auto-move's destination, and its first move;
+ * - go: the Move button while auto-move has a destination (it shows as Go): the next move.
+ */
+export type Gesture =
+  | { kind: 'tap'; tile: Vec }
+  | { kind: 'hold'; tile: Vec }
+  | { kind: 'walk'; tile: Vec }
+  | { kind: 'button'; button: ButtonId; tile?: Vec }
+  | { kind: 'go' }
+
+/** The action bar's buttons (ui/controls.ts). */
+export type ButtonId = 'move' | 'bomb' | 'wait'
+
 export interface Scene {
   kind: SceneKind
   start: GameState
   actions: Action[]
+  /** How each action is played, one per action. */
+  gestures: Gesture[]
 }
 
 /** What a scene's game has done so far, for moments that need an earlier turn (a charge picked up). */
 interface Seen {
   picked: Set<ChargeId>
-  used: Set<ActionId>
+  /** Each kind of action taken, and how many times. */
+  used: Map<ActionId, number>
+  /** The far tile auto-move was last sent to. */
+  walk: Vec | null
+}
+
+/** One turn as played: the action and its gesture. */
+interface Play {
+  action: Action
+  gesture: Gesture
 }
 
-/** One turn of a game: the state before it, the action, its events and the state after. */
+/** A game's auto-move: the far tile it is walking to, or null. */
+interface Walking {
+  to: Vec | null
+}
+
+/** One turn of a game: the state before it, the action and its gesture, its events and the state after. */
 interface Turn {
   prev: GameState
   action: Action
+  gesture: Gesture
   events: GameEvent[]
   next: GameState
 }
@@ -63,16 +107,21 @@ 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
-  /** A nudge for the random play: an action to take now, or null for a random one. */
-  steer?: (s: GameState, rng: Rng) => Action | null
+  /** 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. */
+  bombButton?: boolean
 }
 
 const WIDTH = 9
 const HEIGHT = 7
-/** How many turns a scene may take, how many random games are tried per room, and how many rooms. */
+/** How many turns a random game may take, how many are tried per room, and how many rooms. */
 const MAX_TURNS = 24
 const TRIES = 150
 const ROOMS = 8
+/** A scene's moment comes this many turns in, at the least and at the most (user request, 2026-10-24). */
+export const MIN_STEPS = 3
+export const MAX_STEPS = 6
 
 const kills = (t: Turn) => t.events.filter((e): e is Extract<GameEvent, { type: 'enemyDied' }> => e.type === 'enemyDied')
 const explosions = (t: Turn) => t.events.filter((e): e is Extract<GameEvent, { type: 'exploded' }> => e.type === 'exploded')
@@ -86,10 +135,59 @@ const burnt = (t: Turn) =>
   died(t) && (explosions(t).some((e) => e.tiles.some((p) => at(p, t.next.player))) || t.prev.burning.includes(idx(t.prev, t.next.player)))
 const moved = (t: Turn) => !at(t.prev.player, t.next.player)
 const inBlast = (t: Turn, p: Vec) => explosions(t).some((e) => e.tiles.some((q) => at(q, p)))
-/** A charge lying on a free floor tile two steps from the player, to walk over first. */
-const dropping = (kind: ChargeId) => (s: GameState, rng: Rng) => dropNear(s, rng, kind)
+/** A charge lying on a free floor tile two steps from the player (or `n`), to walk over first. */
+const dropping = (kind: ChargeId, n = 2) => (s: GameState, rng: Rng) => dropNear(s, rng, kind, n)
 
 export const SCENES: Record<SceneKind, SceneDef> = {
+  // The controls (user request, 2026-10-24), shown first.
+  tapMove: {
+    caption: 'Tap a tile next to you to step onto it.',
+    enemies: ['blob'],
+    steer: (s, rng) => playOf(SCENES.tapMove, s, randomTarget(s, rng, 'move')),
+    shows: (t, seen) => t.action.id === 'move' && (seen.used.get('move') ?? 0) >= MIN_STEPS,
+  },
+  holdBomb: {
+    caption: 'Hold your finger on a tile next to you: a bomb goes there when you let go. Then get away!',
+    enemies: ['blob'],
+    shows: (t) => explosions(t).length > 0,
+  },
+  bombButton: {
+    caption: 'Or press Bomb, then tap where the bomb goes.',
+    enemies: ['blob'],
+    bombButton: true,
+    shows: (t) => explosions(t).length > 0,
+  },
+  wait: {
+    caption: 'Press Wait to stay put for a turn, and let an enemy walk into your blast.',
+    enemies: ['blob', 'seeker'],
+    shows: (t, seen) => seen.used.has('wait') && kills(t).length > 0,
+  },
+  autoMove: {
+    caption: 'Tap a tile further away to walk there: the Move button turns into Go, one step per press.',
+    enemies: ['blob'],
+    // A far tile to walk to, then Go until there.
+    steer: (s, rng, walking) => {
+      const danger = predictDanger(s)
+      const safe = (p: Vec) => safeStep(s, danger, p)
+      if (!walking.to) {
+        const far = s.cells.flatMap((_, i) => {
+          const p = { x: i % s.width, y: Math.floor(i / s.width) }
+          const path = tapMeaning(s, p) === 'walk' ? playerPath(s, p) : null
+          return path && path.length >= 3 && path.length <= 4 ? [p] : []
+        })
+        if (!far.length) return null
+        const to = far[rng.int(far.length)]!
+        const action = autoMoveAction(s, to, safe)
+        if (!action) return null
+        walking.to = to
+        return { action, gesture: { kind: 'walk', tile: to } }
+      }
+      // Not safe yet: wait for the way to clear (the destination stays).
+      const action = autoMoveAction(s, walking.to, safe)
+      return action ? { action, gesture: { kind: 'go' } } : { action: { id: 'wait' }, gesture: { kind: 'button', button: 'wait' } }
+    },
+    shows: (t, seen) => !!seen.walk && at(t.next.player, seen.walk),
+  },
   kill: {
     caption: 'Bombs count down with your moves. Their flame kills enemies.',
     enemies: ['blob', 'seeker'],
@@ -149,41 +247,41 @@ export const SCENES: Record<SceneKind, SceneDef> = {
   fusePlus: {
     caption: 'Fuse +1: your bombs start higher, so you have longer to get away.',
     enemies: ['seeker'],
-    prepare: (s) => (s.stats.fuse = 5),
+    prepare: (s) => (s.stats.fuse = START_STATS.fuse + 1),
     shows: (t) => kills(t).length > 0,
   },
   run: {
-    caption: 'Run: two tiles in one turn — out of trouble fast.',
+    caption: 'Run: press Move, then tap two tiles away — out of trouble fast.',
     enemies: ['seeker', 'blob'],
     prepare: (s) => (s.powers.run = true),
     shows: (t) => t.action.id === 'run' && escaped(t),
   },
   kick: {
-    caption: 'Kick: walk into a bomb and it slides away until something stops it.',
+    caption: 'Kick: tap a bomb next to you and it slides away until something stops it.',
     enemies: ['blob', 'drifter'],
     prepare: (s) => (s.powers.kick = true),
     shows: (t, seen) => seen.used.has('kick') && kills(t).length > 0,
   },
   throw: {
-    caption: 'Throw: put a bomb two tiles away, over a crate, a bomb or an enemy.',
+    caption: 'Throw: press Bomb, then tap two tiles away — over a crate, a bomb or an enemy.',
     enemies: ['blob', 'seeker'],
     prepare: (s) => (s.powers.throw = true),
     shows: (t, seen) => seen.used.has('throw') && kills(t).length > 0,
   },
   teleport: {
-    caption: 'Teleport: jump to any empty tile up to 3 steps away, over anything.',
+    caption: 'Teleport: press Move, then tap any empty tile up to 3 steps away, over anything.',
     enemies: ['seeker', 'blob'],
     prepare: (s) => (s.powers.teleport = true),
     shows: (t) => t.action.id === 'teleport' && escaped(t),
   },
   diagonal: {
-    caption: 'Diagonal: step diagonally, even between two blocked tiles.',
+    caption: 'Diagonal: tap a corner tile to step diagonally, even between two blocked tiles.',
     enemies: ['seeker', 'blob'],
     prepare: (s) => (s.powers.diagonal = true),
     shows: (t) => t.action.id === 'diagonal' && escaped(t),
   },
   detonate: {
-    caption: 'Detonator: set a bomb off right when an enemy walks in.',
+    caption: 'Detonator: tap your bomb to set it off right when an enemy walks in.',
     enemies: ['blob', 'seeker'],
     // A long fuse, so the Detonator is what sets the bomb off.
     prepare: (s) => ((s.powers.detonate = true), (s.stats.fuse = 6)),
@@ -216,20 +314,25 @@ export const SCENES: Record<SceneKind, SceneDef> = {
   pierce: {
     caption: 'Pierce: your next 3 bombs burn through crates.',
     enemies: ['blob'],
-    // More crates than usual, for a row of them to burn through.
-    prepare: (s, rng) => (moreCrates(s, rng), dropNear(s, rng, 'pierce')),
-    shows: (t, seen) => seen.picked.has('pierce') && t.events.filter((e) => e.type === 'blockDestroyed').length >= 2,
+    // Fire 2, and a row of two crates to burn through, with the charge on the way there.
+    prepare: (s, rng) => ((s.stats.fire = 2), crateRow(s, rng)),
+    // A crate burnt behind another one: without Pierce the first would have stopped the flame.
+    shows: (t, seen) => {
+      const burnt = t.events.flatMap((e) => (e.type === 'blockDestroyed' ? [e.pos] : []))
+      return seen.picked.has('pierce') && explosions(t).some((e) =>
+        burnt.some((far) => steps(far, e.pos) === 2 && burnt.some((near) => steps(near, e.pos) === 1 && steps(near, far) === 1)))
+    },
   },
   power: {
     caption: 'Power: your next 3 bombs reach all the way to the walls.',
     enemies: ['blob', 'drifter'],
-    prepare: dropping('power'),
+    prepare: dropping('power', 1),
     shows: (t, seen) => seen.picked.has('power') && kills(t).some((k) => explosions(t).some((e) => steps(e.pos, k.pos) >= 3)),
   },
   nuke: {
     caption: 'Nuke: your next 3 bombs burn every tile 2 steps around them, round corners too.',
     enemies: ['blob', 'seeker'],
-    prepare: dropping('nuke'),
+    prepare: dropping('nuke', 1),
     // Killed off the bomb's row and column: only a Nuke reaches there.
     shows: (t, seen) => seen.picked.has('nuke') && kills(t).some((k) => explosions(t).some((e) => e.pos.x !== k.pos.x && e.pos.y !== k.pos.y)),
   },
@@ -260,7 +363,7 @@ export const SCENES: Record<SceneKind, SceneDef> = {
     steer: (s, rng) => {
       if (!exitOpen(s) || rng.next() < 0.2) return null
       const next = playerPath(s, s.exit)?.[0]
-      return next && isAvailable(s, 'move') && isValidTarget(s, 'move', next) ? { id: 'move', target: next } : null
+      return next && isAvailable(s, 'move') && isValidTarget(s, 'move', next) ? playOf(SCENES.exit, s, { id: 'move', target: next }) : null
     },
     shows: (t) => t.events.some((e) => e.type === 'levelCleared'),
   },
@@ -331,40 +434,66 @@ export const SCENES: Record<SceneKind, SceneDef> = {
 }
 
 export const SCENE_KINDS = Object.keys(SCENES) as SceneKind[]
+/** The scenes about the controls, in the order the UI shows them, first. */
+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)
+export const LIVING_KINDS = SCENE_KINDS.filter((k) => !SCENES[k].dies && !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 {
   const rng = new Rng(seed)
   for (let room = 0; room < ROOMS; room++) {
     const start = makeRoom(kind, rng)
-    let best: Action[] | null = null
     for (let t = 0; t < TRIES; t++) {
-      const actions = rollout(kind, start, rng)
-      if (actions && (!best || actions.length < best.length)) best = actions
-      // A short one is good enough; a few more tries look for a shorter one.
-      if (best && best.length <= 10) break
+      const game = rollout(kind, start, rng)
+      const scene = game && ending(kind, game)
+      if (scene) return scene
     }
-    if (best) return { kind, start, actions: best }
   }
   return null
 }
 
-/** Whether a scene's last turn is its moment, replaying it from the start (for tests). */
-export function showsAtEnd(scene: Scene): boolean {
+/** Replays a scene: its turns, and whether its last turn is its moment (for tests, and `ending`). */
+export function replayScene(scene: Scene): { turns: Turn[]; shows: boolean } {
   let s = scene.start
-  const seen: Seen = { picked: new Set(), used: new Set() }
+  const seen = newSeen()
+  const turns: Turn[] = []
   let last = false
-  for (const action of scene.actions) {
+  for (const [i, action] of scene.actions.entries()) {
     const r = step(s, action)
-    const turn = { prev: s, action, events: r.events, next: r.state }
+    const turn = { prev: s, action, gesture: scene.gestures[i]!, events: r.events, next: r.state }
     remember(turn, seen)
+    turns.push(turn)
     last = SCENES[scene.kind].shows(turn, seen)
     s = r.state
   }
-  return last
+  return { turns, shows: last }
+}
+
+/** Whether a scene's last turn is its moment, replaying it from the start (for tests). */
+export function showsAtEnd(scene: Scene): boolean {
+  return replayScene(scene).shows
+}
+
+/**
+ * The scene in a game that shows its moment: its last 3 to 6 turns (as few as do), starting
+ * from a calm moment — no bomb or flame on the board, no drop but those the room started with
+ * (a coin or charge left by an enemy killed before the scene would be a mystery), auto-move not
+ * under way — and still showing the moment when played from there. Null if no such stretch.
+ */
+function ending(kind: SceneKind, game: GameTurn[]): Scene | null {
+  const room = game[0]!.prev
+  for (let n = MIN_STEPS; n <= Math.min(MAX_STEPS, game.length); n++) {
+    const first = game[game.length - n]!
+    const s = first.prev
+    if (s.bombs.length || s.burning.length || first.gesture.kind === 'go' || first.walking) continue
+    if (s.drops.some((d) => !room.drops.some((r) => at(r.pos, d.pos) && r.kind === d.kind))) continue
+    const turns = game.slice(-n)
+    const scene = { kind, start: s, actions: turns.map((t) => t.action), gestures: turns.map((t) => t.gesture) }
+    if (showsAtEnd(scene)) return scene
+  }
+  return null
 }
 
 /** A small room: walls round it, pillars on every other tile, a few crates; the player and enemies apart. */
@@ -408,12 +537,23 @@ function tilesAt(s: GameState, n: number): Vec[] {
   })
 }
 
-/** Crates on about a third more of the free tiles not next to the player. */
-function moreCrates(s: GameState, rng: Rng): void {
-  s.cells.forEach((c, i) => {
-    const p = { x: i % s.width, y: Math.floor(i / s.width) }
-    if (c === 'floor' && steps(p, s.player) > 2 && !s.enemies.some((e) => at(e.pos, p)) && rng.next() < 0.3) s.cells[i] = 'block'
-  })
+/**
+ * Pierce's room: in a straight line from the player, Pierce on the next tile, a free one for the
+ * bomb, then two crates; or, with no room for that, Pierce on any tile next to the player.
+ */
+function crateRow(s: GameState, rng: Rng): void {
+  const dirs = DIR_LIST.map((d) => DIRS[d]).filter((d) =>
+    [1, 2, 3, 4].every((n) => {
+      const p = { x: s.player.x + n * d.x, y: s.player.y + n * d.y }
+      return inBounds(s, p) && s.cells[idx(s, p)] !== 'wall' && !s.enemies.some((e) => at(e.pos, p))
+    }),
+  )
+  if (!dirs.length) return dropNear(s, rng, 'pierce', 1)
+  const d = dirs[rng.int(dirs.length)]!
+  const along = (n: number) => ({ x: s.player.x + n * d.x, y: s.player.y + n * d.y })
+  for (const n of [1, 2]) s.cells[idx(s, along(n))] = 'floor'
+  for (const n of [3, 4]) s.cells[idx(s, along(n))] = 'block'
+  s.drops.push({ pos: along(1), kind: 'pierce' })
 }
 
 /** The exit two steps from the player: under a crate, or uncovered (but locked while enemies live). */
@@ -424,9 +564,9 @@ function placeExit(s: GameState, rng: Rng, covered: boolean): void {
   if (covered) s.cells[idx(s, s.exit)] = 'block'
 }
 
-/** Put a charge two steps from the player. */
-function dropNear(s: GameState, rng: Rng, kind: ChargeId): void {
-  const near = tilesAt(s, 2)
+/** Put a charge `n` steps from the player. */
+function dropNear(s: GameState, rng: Rng, kind: ChargeId, n = 2): void {
+  const near = tilesAt(s, n)
   if (near.length) s.drops.push({ pos: near[rng.int(near.length)]!, kind })
 }
 
@@ -439,37 +579,75 @@ function hideGem(s: GameState, rng: Rng): void {
   s.gems.push({ pos, color: (['blue', 'green', 'red', 'yellow'] as const)[rng.int(4)]! })
 }
 
+function newSeen(): Seen {
+  return { picked: new Set(), used: new Map(), walk: null }
+}
+
 function remember(t: Turn, seen: Seen): void {
-  seen.used.add(t.action.id as ActionId)
+  seen.used.set(t.action.id as ActionId, (seen.used.get(t.action.id as ActionId) ?? 0) + 1)
+  if (t.gesture.kind === 'walk') seen.walk = t.gesture.tile
   for (const e of t.events) if (e.type === 'pickedUp' && e.drop.kind !== 'coins') seen.picked.add(e.drop.kind)
 }
 
-/** One random game from `start`; its actions if it shows the scene, otherwise null. */
-function rollout(kind: SceneKind, start: GameState, rng: Rng): Action[] | null {
+/** A turn of a random game; `walking`: auto-move had a destination before it. */
+type GameTurn = Turn & { walking: boolean }
+
+/** One random game from `start`; its turns if it shows the scene, otherwise null. */
+function rollout(kind: SceneKind, start: GameState, rng: Rng): GameTurn[] | null {
   let s = start
-  const actions: Action[] = []
-  const seen: Seen = { picked: new Set(), used: new Set() }
+  const turns: GameTurn[] = []
+  const seen = newSeen()
+  const walking: Walking = { to: null }
+  const def = SCENES[kind]
   for (let n = 0; n < MAX_TURNS && s.status === 'playing' && !s.offer; n++) {
-    // A random action (or the scene's nudge) that doesn't get the player killed this turn, a few
-    // goes at it — unless dying is what the scene is about, and this is the way it shows.
-    const def = SCENES[kind]
-    let turn: Turn | null = null
+    // 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.
+    let turn: GameTurn | null = null
+    const was = walking.to
     for (let go = 0; go < 4 && !turn; go++) {
-      const action = def.steer?.(s, rng) ?? randomAction(s, rng)
-      const r = step(s, action)
-      const t = { prev: s, action, events: r.events, next: r.state }
+      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 (!turn) return null
-    actions.push(turn.action)
+    turns.push(turn)
     remember(turn, seen)
-    if (def.shows(turn, seen) && !(def.dies && turn.next.status === 'playing')) return actions
+    if (walking.to && at(turn.next.player, walking.to)) walking.to = null
+    if (def.shows(turn, seen) && !(def.dies && turn.next.status === 'playing')) return turns
     s = turn.next
   }
   return null
 }
 
+/**
+ * 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
+ * a tap on the tile; a bomb is a long press, or the Bomb button and a tap in the scene about it;
+ * Wait is its button; Run, Teleport and Throw are their button (Move or Bomb), then the tile.
+ */
+function gestureFor(def: SceneDef, s: GameState, action: Action): Gesture | null {
+  if (action.id === 'wait') return { kind: 'button', button: 'wait' }
+  if (!('target' in action) || !action.target) return null
+  const tile = action.target
+  if (action.id === 'bomb' && !def.bombButton) return { kind: 'hold', tile }
+  if (TAPPED.has(action.id) && tapMeaning(s, tile) === action.id) return { kind: 'tap', tile }
+  const button: ButtonId = membersOf('move').includes(action.id) ? 'move' : 'bomb'
+  return resolvePick(s, button, tile) === action.id ? { kind: 'button', button, tile } : null
+}
+
+const TAPPED: ReadonlySet<ActionId> = new Set(['move', 'diagonal', 'kick', 'detonate'])
+
+/** An action and the gesture that plays it in this scene, or null if no gesture does. */
+function playOf(def: SceneDef, s: GameState, action: Action | null): Play | null {
+  const gesture = action && gestureFor(def, s, action)
+  return gesture ? { action: action!, gesture } : null
+}
+
 /** Weights for a random action: mostly moving, bombing now and then, sometimes waiting; abilities when held. */
 const WEIGHTS: Partial<Record<ActionId, number>> = { move: 5, bomb: 2, wait: 1, detonate: 3, run: 2, kick: 3, throw: 2, teleport: 2, diagonal: 2 }
 
@@ -478,6 +656,11 @@ function randomAction(s: GameState, rng: Rng): Action {
   let r = rng.next() * choices.reduce((sum, id) => sum + WEIGHTS[id]!, 0)
   const id = choices.find((c) => (r -= WEIGHTS[c]!) < 0) ?? 'wait'
   if (!ACTIONS[id].needsTarget) return { id }
+  return randomTarget(s, rng, id)!
+}
+
+/** `id` aimed at one of its tiles, at random; null if it has none. */
+function randomTarget(s: GameState, rng: Rng, id: ActionId): Action | null {
   const targets = ACTIONS[id].validTargets(s)
-  return { id, target: targets[rng.int(targets.length)]! }
+  return targets.length ? { id, target: targets[rng.int(targets.length)]! } : null
 }
diff --git a/src/sim/path.ts b/src/sim/path.ts
index e6f1026..cc6433d 100644
--- a/src/sim/path.ts
+++ b/src/sim/path.ts
@@ -1,5 +1,7 @@
 import { ACTIONS, isAvailable, isValidTarget } from './actions'
-import { add, eq, exitOpen, playerCanPass } from './state'
+import type { Danger } from './danger'
+import { enemyAt } from './enemies'
+import { add, eq, exitOpen, flameSafe, idx, playerCanPass } from './state'
 import { DIRS, DIR_LIST, type Action, type GameState, type Vec } from './types'
 
 /**
@@ -67,3 +69,13 @@ export function autoMoveAction(s: GameState, to: Vec, safe: (p: Vec) => boolean)
   const now = path?.length ?? Infinity
   return best && best.left < now ? best.action : null
 }
+
+/**
+ * Whether auto-move may take this step: not onto an enemy, not onto the tile an enemy moves to
+ * next, and not into flame this turn. Those would be deaths the player didn't choose.
+ */
+export function safeStep(s: GameState, danger: Danger, p: Vec): boolean {
+  // Frozen enemies won't move this turn; flame doesn't matter while it can't hurt.
+  const intended = s.frozen === 0 && s.enemies.some((e) => e.plan[0] && eq(e.plan[0], p))
+  return !enemyAt(s, p) && !intended && (flameSafe(s) || !danger.now.has(idx(s, p)))
+}
diff --git a/src/sim/picks.ts b/src/sim/picks.ts
index 30f3629..0303437 100644
--- a/src/sim/picks.ts
+++ b/src/sim/picks.ts
@@ -1,5 +1,7 @@
 import { ACTIONS, isAvailable, isValidTarget } from './actions'
-import { eq } from './state'
+import { enemyAt } from './enemies'
+import { playerPath } from './path'
+import { bombAt, cellAt, eq, flameSafe, isBurning } from './state'
 import type { ActionId, GameState, Vec } from './types'
 
 /**
@@ -42,3 +44,27 @@ export function pickTargets(s: GameState, picked: ActionId): Vec[] {
 export function canPick(s: GameState, picked: ActionId): boolean {
   return membersOf(picked).some((id) => isAvailable(s, id))
 }
+
+/** What a tap on a tile means with no button picked (`tapMeaning`). */
+export type TapMeaning = 'wait' | 'detonate' | 'kick' | 'blocked' | 'move' | 'diagonal' | 'walk' | 'teleport'
+
+/**
+ * What a tap on `p` means with nothing picked (main.ts `tapTile`; also the demo's taps), first
+ * match wins: the player waits; one of your bombs, with the Detonator, goes off; a bomb next to
+ * you, with Kick, is kicked; an enemy, any other bomb, or flame that can hurt you is 'blocked'
+ * (never a move into it); then a step, a diagonal step, a far tile you can walk to ('walk': it
+ * becomes auto-move's destination), or one you can only teleport to. Null: it means nothing.
+ * Auto-move's own destination is the UI's to check: a tap on it is its next step.
+ */
+export function tapMeaning(s: GameState, p: Vec): TapMeaning | null {
+  if (eq(p, s.player)) return 'wait'
+  if (isAvailable(s, 'detonate') && isValidTarget(s, 'detonate', p)) return 'detonate'
+  if (isAvailable(s, 'kick') && isValidTarget(s, 'kick', p)) return 'kick'
+  if (enemyAt(s, p) || bombAt(s, p) || (isBurning(s, p) && !flameSafe(s))) return 'blocked'
+  if (isValidTarget(s, 'move', p)) return 'move'
+  if (isAvailable(s, 'diagonal') && isValidTarget(s, 'diagonal', p)) return 'diagonal'
+  if (cellAt(s, p) === 'floor' && playerPath(s, p)) return 'walk'
+  // Out of walking reach (behind a pillar, say) but within Teleport's: one teleport there.
+  if (isAvailable(s, 'teleport') && isValidTarget(s, 'teleport', p)) return 'teleport'
+  return null
+}
diff --git a/src/style.css b/src/style.css
index d31b1b1..c8d56ab 100644
--- a/src/style.css
+++ b/src/style.css
@@ -769,7 +769,6 @@ img {
 
 /* The demo before the game (main.ts): the board alone, a caption panel at the bottom. */
 body.demo #hud,
-body.demo #actions,
 body.demo #banner {
   display: none;
 }
@@ -838,22 +837,119 @@ body.demo #banner {
 }
 
 @media (prefers-reduced-motion: reduce) {
-  /* A scene about one charge or power-up shows its icon before the words. */
-#demo .demo-icon {
-  flex: none;
-  width: 34px;
-  height: 34px;
-  /* A light rim, so a dark sprite (a bomb) stands out on the dark panel, as in the top bar. */
-  filter: drop-shadow(0 0 1.5px rgb(255 255 255 / 0.7));
+  #demo .demo-hint {
+    animation: none;
+  }
 }
 
-/* One-colour ink icons (the padlock) turn white on the dark panel. */
-#demo .demo-icon.mono {
-  filter: brightness(0) invert(1);
+/* The demo's action bar (main.ts): the real one, so the finger can press its buttons, but as a
+   tray right under the board (main.ts places and sizes it), apart from the caption panel. */
+body.demo #actions {
+  right: auto;
+  bottom: auto;
+  box-sizing: border-box;
+  padding: 8px 8px 4px;
+  border-radius: 14px;
 }
 
-#demo .demo-hint {
-    animation: none;
+/* A button the demo's finger is pressing: as the real press (:active above). */
+#actions > button.pressed:not(:disabled) {
+  transform: translateY(4px);
+  box-shadow: none;
+}
+
+/* The demo's finger (Kenney's pointing hand). Its box sits on the point it touches; the image
+   is shifted so the fingertip, not the corner, is there. */
+#demo-finger {
+  position: fixed;
+  left: 0;
+  top: 0;
+  z-index: 4;
+  pointer-events: none;
+  transition: opacity 0.3s;
+}
+
+#demo-finger[hidden] {
+  display: none;
+}
+
+#demo-finger.glide {
+  transition: transform 0.4s ease-in-out, opacity 0.3s;
+}
+
+#demo-finger.away {
+  opacity: 0;
+}
+
+#demo-finger img {
+  display: block;
+  width: 64px;
+  height: 64px;
+  margin: -14px 0 0 -11px;
+  transform-origin: 11px 14px;
+  transition: transform 0.12s;
+  filter: drop-shadow(0 2px 3px rgb(0 0 0 / 0.35));
+}
+
+/* Pressing: the hand sinks toward the screen. */
+#demo-finger.down img {
+  transform: scale(0.86);
+}
+
+/* Where the finger taps, a ripple spreads; while it holds, a ring closes in (main.ts sets its
+   time, --hold, to the long press's). Each sits on the point, drawn by its ::before. */
+.demo-ripple,
+.demo-ring {
+  position: fixed;
+  left: 0;
+  top: 0;
+  z-index: 3;
+  pointer-events: none;
+}
+
+.demo-ripple::before,
+.demo-ring::before {
+  content: '';
+  position: absolute;
+  width: 48px;
+  height: 48px;
+  margin: -24px 0 0 -24px;
+  border-radius: 50%;
+  box-sizing: border-box;
+}
+
+.demo-ripple::before {
+  /* White with a thin dark rim, to show on the pale floor and the dark bar alike. */
+  border: 4px solid #fff;
+  box-shadow: 0 0 0 2px rgb(53 53 65 / 0.55), inset 0 0 0 2px rgb(53 53 65 / 0.55);
+  animation: demo-ripple 0.6s ease-out forwards;
+}
+
+.demo-ring::before {
+  border: 5px solid #ffc83d;
+  box-shadow: 0 0 0 2px rgb(53 53 65 / 0.55);
+  animation: demo-ring var(--hold, 0.45s) linear forwards;
+}
+
+@keyframes demo-ripple {
+  from {
+    transform: scale(0.3);
+    opacity: 1;
+  }
+  to {
+    transform: scale(1.5);
+    opacity: 0;
+  }
+}
+
+@keyframes demo-ring {
+  from {
+    transform: scale(1.8);
+    opacity: 0.2;
+  }
+  to {
+    transform: scale(0.8);
+    opacity: 1;
   }
 }
 
diff --git a/test/demo.test.ts b/test/demo.test.ts
index f327d63..7b6cde3 100644
--- a/test/demo.test.ts
+++ b/test/demo.test.ts
@@ -1,17 +1,53 @@
 import { describe, expect, it } from 'vitest'
-import { generateScene, SCENE_KINDS, SCENES, showsAtEnd } from '../src/sim/demo'
+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 { autoMoveAction, safeStep } from '../src/sim/path'
+import { resolvePick, tapMeaning } from '../src/sim/picks'
 import { step } from '../src/sim/turn'
+import type { Action, GameState, Vec } from '../src/sim/types'
+
+/**
+ * The action the real controls take for a gesture (main.ts: `tapTile`, the long press, `pick`),
+ * given auto-move's destination; null if they would take none.
+ */
+function controlsTake(s: GameState, g: Gesture, destination: Vec | null): Action | null {
+  const safe = (p: Vec) => safeStep(s, predictDanger(s), p)
+  if (g.kind === 'tap') {
+    const means = tapMeaning(s, g.tile)
+    return means === 'move' || means === 'diagonal' || means === 'kick' || means === 'detonate' || means === 'teleport'
+      ? { id: means, target: g.tile }
+      : null
+  }
+  if (g.kind === 'hold') return isValidTarget(s, 'bomb', g.tile) ? { id: 'bomb', target: g.tile } : null
+  if (g.kind === 'walk') return tapMeaning(s, g.tile) === 'walk' ? autoMoveAction(s, g.tile, safe) : null
+  if (g.kind === 'go') return destination && autoMoveAction(s, destination, safe)
+  if (g.button === 'wait') return { id: 'wait' }
+  const id = g.tile && resolvePick(s, g.button, g.tile)
+  return id ? { id, target: g.tile } : null
+}
 
 describe('demo scenes', () => {
   for (const kind of SCENE_KINDS) {
-    it(`${kind}: found, replayable, the player alive (or dying at the end of a death scene), ending on what it shows`, () => {
+    it(`${kind}: found, its moment ${MIN_STEPS}-${MAX_STEPS} turns in from a calm start, each turn played by its gesture, the player alive (or dying at the end of a death scene)`, () => {
       for (let seed = 1; seed <= 6; seed++) {
         const scene = generateScene(kind, seed)!
         expect(scene).not.toBeNull()
+        expect(scene.actions.length).toBeGreaterThanOrEqual(MIN_STEPS)
+        expect(scene.actions.length).toBeLessThanOrEqual(MAX_STEPS)
+        expect(scene.gestures.length).toBe(scene.actions.length)
+        // Calm: the scene's own bombs are placed in it, so the countdown is seen from the start.
+        expect(scene.start.bombs).toEqual([])
+        expect(scene.start.burning).toEqual([])
         // Alive all along; at the end, dead in a scene about a way to die, alive in the rest.
         let s = scene.start
+        let destination: Vec | null = null
         for (const [i, a] of scene.actions.entries()) {
+          const g = scene.gestures[i]!
+          if (g.kind === 'walk') destination = g.tile
+          expect(controlsTake(s, g, destination)).toEqual(a)
           s = step(s, a).state
+          if (destination && s.player.x === destination.x && s.player.y === destination.y) destination = null
           const last = i === scene.actions.length - 1
           expect(last && SCENES[kind].dies ? ['dead', 'gameOver'] : ['playing', 'cleared']).toContain(s.status)
         }
diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs
index 05ec940..ce4f64e 100644
--- a/tools/screenshots.mjs
+++ b/tools/screenshots.mjs
@@ -100,8 +100,17 @@ const SHOTS = [
   })),
   // What picked-up charges still give, in the top bar's own row (?charges=2: 6 bombs of each, a Shield).
   { name: 'charges', query: 'charges=2', act: async () => {} },
-  // The demo before the game (?demo): a scene made up on the spot, part-way through, with its caption.
-  { name: 'demo', query: 'demo', act: async (page) => { await page.waitForTimeout(3500) } },
+  // The demo before the game (?demo): its finger holding on a tile for a bomb (the ring closed in,
+  // the bomb's blast previewed), over the action bar and the caption. Taken mid-press, so no settling.
+  {
+    name: 'demo',
+    query: 'demo',
+    settle: 0,
+    act: async (page) => {
+      await page.waitForSelector('.demo-ring', { state: 'attached', timeout: 20000 })
+      await page.waitForTimeout(600)
+    },
+  },
   // Move picked: the tiles it can step to.
   { name: 'move-picked', act: async (page) => { await page.click('[data-action=move]') } },
 ]
@@ -112,14 +121,14 @@ const server = await preview({ preview: { port: 4179, strictPort: true } })
 // shell's screenshots of the WebGL canvas show a black strip that isn't in the drawing.
 const browser = await chromium.launch({ channel: 'chromium' })
 try {
-  for (const { name, viewport = PHONE, query = '', act } of SHOTS) {
+  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.waitForTimeout(300)
     await act(page)
     // Long enough for the turn's animations to finish and the banner to come in (style.css).
-    await page.waitForTimeout(800)
+    await page.waitForTimeout(settle)
     await page.screenshot({ path: `${OUT}/${name}.png` })
     await page.close()
     console.log(`  ${OUT}/${name}.png`)