fuzzyworld

explodorama / commit

Rubble demo scene and a timeline bar on every scene (0.2.0 work)

b8849d36079a35798453cc03e075ae5af980572e · Pavel Safronov · 2026-10-31 17:37:57 -0700 · parents bb3823d2742d

Claude Code usage: this commit 60.6K out / 20.7M cached · cost unknown (no per-model trailer) · session 7 tally 694 in / 235.5K out / 53.0M cached · usage by commit and session

Rubble demo scene and a timeline bar on every scene (0.2.0 work)

A rubble scene (a pillar already cracked, played until the ceiling falls,
then two turns of aftermath) replaces Acid as the Almanac's Rubble clip.
A bar under the demo caption and each dialog's clip fills over each turn,
with a mark at the scene's moment (left out when it is the last turn).

FW-Tokens-In: 694
FW-Tokens-Out: 235516
FW-Tokens-Cached: 52965044
FW-Session: fd332071-d2da-4a3e-a9ee-59cdeec5b405
FW-Rtk-Saved: 2712545
FW-Rtk-Cmds: 7601

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
 CHANGELOG.md       |  1 +
 README.md          |  4 +--
 decisions.md       | 11 ++++++++
 index.html         |  1 +
 src/changelog.ts   |  8 ++++++
 src/main.ts        | 10 +++++++-
 src/sim/demo.ts    | 73 ++++++++++++++++++++++++++++++++++++++++++++----------
 src/style.css      | 39 +++++++++++++++++++++++++++++
 src/ui/clip.ts     | 16 ++++++++++--
 src/ui/info.ts     |  2 +-
 src/ui/timeline.ts | 50 +++++++++++++++++++++++++++++++++++++
 test/demo.test.ts  | 15 +++++++++--
 12 files changed, 209 insertions(+), 21 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index df15f22..0405ea1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,7 @@ saved before the change is dropped and the next visit starts a new run.
 
 ### Changed
 
+- **A demo scene for rubble, and a timeline on every scene** (user request, 2026-10-31): the new `rubble` scene starts with a pillar already cracked and plays until the ceiling falls, then two turns of waiting to see the rubble (`SceneDef.maxSteps` and `tail`; the Acid scene ended when the pillar cracked, which is why the Almanac's Rubble clip never showed any). A scene now records its `moment`. A bar under the demo's caption and under each dialog's clip (`ui/timeline.ts`) fills a turn at a time, with a mark at the moment.
 - **Two copies of the game** (user request, 2026-10-31): the released one (built from a git tag, `/explodorama/`) and a **next** copy built from `main` (`/explodorama-next/`, deployed with `fw deploy --as explodorama-next`; tab title "EXPLODORAMA! (next)"). Each has its own saves, options, scores and last-played version (`explodorama-next.*` keys), so a rules change on `main` never drops a released run. The released game's menu has a **Try it early** link and its Changes dialog a **Coming next** teaser (read from the next copy's `next.json`); the next copy's Changes dialog lists **Not released yet**. A run finished on the next copy is marked **Early adopter** on its board and counted in one shared key (`explodorama.earlyRuns`) the released copy shows in its menu. Developer mode is separate and works on either.
 - **A bomb two of your actions could handle asks which** (user request, 2026-10-31): with Kick and Bomb Pass, a tap or arrow key on a bomb next to you offers Kick or Walk onto it (the Detonator's Set it off too). Bomb Pass alone now walks onto the bomb on a tap, which was refused before.
 - **A tap on a crate walks to the tile beside it** (2026-10-31); with Run, a crate two tiles away did nothing.
diff --git a/README.md b/README.md
index bab4242..354ccb5 100644
--- a/README.md
+++ b/README.md
@@ -44,7 +44,7 @@ and a seed gives the same dungeon only within one rules version.
 the real rules, each with a caption and an icon. The screen reads top to bottom: the title
 **EXPLODORAMA!**, the TV with the scene, then the caption; both the title and the caption go
 once the game starts. 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
+about happens 3 to 6 turns in (more for a moment that takes longer to come, like rubble, with a turn or two after it), 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
@@ -78,7 +78,7 @@ Coin bag only change numbers, so they have no scene; nor does the shop. Every ot
 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). The scenes play **in a fixed order** and loop —
 the controls first, then the rest, with a way to die after every four — each made new every
-time. The panel shows which one it is (**12 / 47**); **◀ ▶** (or the **← →** keys) go to the
+time. The panel shows which one it is (**12 / 56**), with a bar under it that fills as the scene plays and a yellow mark where its moment comes (the same bar sits under every clip in a dialog); **◀ ▶** (or the **← →** keys) go to the
 one before or after, and **↺** back to the first; **the dice button** plays them in a random order instead (lit while on; kept for next time). The demo carries on where it was the next time
 it plays, after a reload too. It is
 view-only; **click or tap anywhere else to play** (a key does nothing: Ctrl or Shift used to start the game), and that tap does nothing
diff --git a/decisions.md b/decisions.md
index 4209c14..d043af0 100644
--- a/decisions.md
+++ b/decisions.md
@@ -2292,3 +2292,14 @@ early", the Changes dialog "Coming next" (from the next copy's `next.json`), sco
 next copy are marked "Early adopter" and counted. Rejected: giving the Developer rainbow gem on the
 next copy (scores would stop being comparable; Developer stays a separate option for either copy).
 Scores are per copy, as storage is.
+
+### 2026-10-31 — Scripted-or-found demo scenes: found, with a push; timeline bar
+
+User question: should demos be scripted rather than found by random play? Kept: scenes are found by
+random play on a random room (varied, and always by the real rules), but a scene can now **start
+from a set-up state** (`prepare`: rubble starts with a pillar already cracked), **ask for a longer
+run** (`maxSteps`) and **play a tail** after its moment (`tail`: waiting turns, while safe). Scripting
+a whole sequence (Acid, then a second bomb, then waiting) was not needed once the set-up did the
+first half. The Almanac's Rubble clip now plays the `rubble` scene, not `acid`. A timeline bar
+(`ui/timeline.ts`) under the demo caption and every clip shows turns played, with a mark at the
+scene's `moment`; chosen: normal speed, turn-by-turn fill, on both.
diff --git a/index.html b/index.html
index 3f607a5..c570cc0 100644
--- a/index.html
+++ b/index.html
@@ -59,6 +59,7 @@
 <h1 id="demo-title" hidden>EXPLODORAMA!</h1>
 <div id="demo" hidden aria-live="polite">
   <p id="demo-caption"></p>
+  <div id="demo-time"></div>
   <div class="demo-nav">
     <button id="demo-prev" type="button" aria-label="Previous scene"></button>
     <span id="demo-count"></span>
diff --git a/src/changelog.ts b/src/changelog.ts
index b05795a..4f24886 100644
--- a/src/changelog.ts
+++ b/src/changelog.ts
@@ -48,6 +48,14 @@ export const RELEASES: Release[] = [
 
 /** Finished since the latest release, not yet part of one. Moved into RELEASES when one is tagged. */
 export const UNRELEASED: Change[] = [
+  {
+    kind: 'added',
+    icon: 'rock',
+    title: 'A demo of rubble forming; a time bar on every demo',
+    text: ['The Rubble entry in the Almanac now shows a cracked pillar being broken and the ceiling coming down, with a couple of turns after it. Every demo scene and clip has a bar under it showing how much is left, with a mark where the important moment comes.'],
+    clip: 'rubble',
+    date: '2026-10-31',
+  },
   {
     kind: 'added',
     icon: 'bomb_active',
diff --git a/src/main.ts b/src/main.ts
index ef49c1f..88d8a0f 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -42,6 +42,7 @@ import { closeDialog, dialogOpen, openDialog, setDialogInDemo, type DialogButton
 import { actionInfo, chargeInfo, hudInfo, tileInfo, type Info } from './ui/info'
 import { openChargeOffer } from './ui/chargeOffer'
 import { openTapChoice } from './ui/tapChoice'
+import { bindTimeline } from './ui/timeline'
 import { openOffer } from './ui/offer'
 import { showShop, updateShop } from './ui/shop'
 import { shopAfter } from './sim/shop'
@@ -896,6 +897,8 @@ const viewMask = document.getElementById('view-mask')!
 /** The demo's title, on top above the TV (user request, 2026-10-25: title, TV, caption). */
 const demoTitle = document.getElementById('demo-title')!
 const demoCount = document.getElementById('demo-count')!
+/** The bar of the scene's turns, with its moment marked (ui/timeline.ts). */
+const demoTime = bindTimeline(document.getElementById('demo-time')!)
 // The demo's buttons: the scene before, the scene after, and back to the first.
 document.getElementById('demo-prev')!.addEventListener('click', () => demo && skipTo(adjacentScene(demo.index, -1)))
 document.getElementById('demo-next')!.addEventListener('click', () => demo && skipTo(adjacentScene(demo.index, 1)))
@@ -995,7 +998,7 @@ const SCENE_ICONS: Record<SceneKind, string> = {
   dieIntoBlast: 'fireball', dieNotFar: 'fireball', dieBlockedIn: 'bomb', dieChain: 'bomb', dieEnemy: 'slime_normal_rest',
   dieDetonator: POWER_UP_ICONS.detonator, dieRun: POWER_UP_ICONS.run,
   rpg: POWER_UP_ICONS.rpg, proximity: POWER_UP_ICONS.proximity, slide: POWER_UP_ICONS.slide, icbm: CHARGE_ICONS.icbm,
-  acid: CHARGE_ICONS.acid,
+  acid: CHARGE_ICONS.acid, rubble: 'rock',
   bush: 'grass_a', bushFire: 'grass_a', metal: 'block_metal', oil: 'oil', spikes: 'spikes', barrel: 'block_strong_danger',
 }
 
@@ -1044,7 +1047,9 @@ function nextScene(): void {
     // The tour's room holds a power-up and a charge, so the top bar's last row has something to show.
     if (item === 'topBar') state = { ...state, powers: { ...state.powers, kick: true }, charge: { kind: 'pierce', left: 3 } }
     demo.beats = item === 'topBar' ? topBarTour() : whatIsThat()
+    demoTime.set(0, 0)
   } else {
+    demoTime.set(0, scene.actions.length, scene.moment)
     const icon = SCENE_ICONS[item]
     setCaption(spriteUrl(icon), MONO_ICONS.has(icon), SCENES[item].caption)
   }
@@ -1153,6 +1158,8 @@ function demoTick(): void {
     if (!demo.scene || demo.next >= demo.scene.actions.length) return nextScene()
     const i = demo.next++
     demo.beats = beatsFor(demo.scene.gestures[i]!, demo.scene.actions[i]!, demo.next === demo.scene.actions.length)
+    // The bar fills over the turn's beats, so it moves all the time rather than a turn at a time.
+    demoTime.set(demo.next, demo.scene.actions.length, demo.scene.moment, demo.beats.reduce((sum, b) => sum + b.then, 0))
   }
   const beat = demo.beats.shift()!
   beat.run()
@@ -1295,6 +1302,7 @@ function skipTo(index: number): void {
   demo.beats = []
   demo.scene = null
   demo.at = 0
+  demoTime.set(0, 0)
   if (dialogOpen()) closeDialog()
   spotlight(null)
   fingerUp()
diff --git a/src/sim/demo.ts b/src/sim/demo.ts
index 47c86d2..eb2ebf7 100644
--- a/src/sim/demo.ts
+++ b/src/sim/demo.ts
@@ -1,6 +1,6 @@
 import { ACTIONS, isAvailable, isValidTarget } from './actions'
 import { isDeadly, predictDanger } from './danger'
-import { blastTiles } from './explosions'
+import { blastTiles, COLLAPSE_TURNS } from './explosions'
 import { planIntents } from './enemies'
 import { Rng } from './rng'
 import { autoMoveAction, playerPath, safeStep } from './path'
@@ -30,7 +30,7 @@ import {
  * 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:
+ * and a few scenes are about the controls alone. And each scene's moment comes 3 to 6 turns in (`maxSteps` asks for more, `tail` for turns after it):
  * the scene is the last few turns of a longer game, from a calm moment (no bomb, no flame).
  */
 export type SceneKind =
@@ -39,7 +39,7 @@ export type SceneKind =
   | 'bombPlus' | 'firePlus' | 'fusePlus' | 'run' | 'kick' | 'throw' | 'teleport' | 'diagonal' | 'detonate'
   | 'rpg' | 'proximity' | 'slide' | 'icbm'
   | 'wallPass' | 'bombPass' | 'flamePass' | 'star'
-  | 'pierce' | 'power' | 'nuke' | 'acid' | 'shield' | 'freeze' | 'decoy' | 'chargeSwap'
+  | 'pierce' | 'power' | 'nuke' | 'acid' | 'rubble' | 'shield' | 'freeze' | 'decoy' | 'chargeSwap'
   | 'exit' | 'doorBlast'
   | 'lastEnemy' | 'crate' | 'pillar' | 'rainbowGem' | 'immortal'
   | 'bush' | 'bushFire' | 'metal' | 'oil' | 'spikes' | 'barrel'
@@ -72,6 +72,8 @@ export interface Scene {
   actions: Action[]
   /** How each action is played, one per action. */
   gestures: Gesture[]
+  /** The turn (index in `actions`) the scene is about; any after it are the aftermath (`SceneDef.tail`). Last if absent. */
+  moment?: number
 }
 
 /** What a scene's game has done so far, for moments that need an earlier turn (a charge picked up). */
@@ -119,6 +121,10 @@ interface SceneDef {
   steer?: (s: GameState, rng: Rng, walking: Walking) => Play | null
   /** Bombs placed with the Bomb button and a tap, not a long press. */
   bombButton?: boolean
+  /** More turns to a scene than MAX_STEPS, for a moment that takes a while to come (rubble). */
+  maxSteps?: number
+  /** Turns of waiting played after the moment, as far as they are safe, to see its result (user request, 2026-10-31). */
+  tail?: number
   /** A scene about the sell-or-swap choice: turns that ask it are kept (elsewhere they are skipped). */
   choice?: boolean
 }
@@ -381,6 +387,14 @@ export const SCENES: Record<SceneKind, SceneDef> = {
     prepare: (s, rng) => ((s.stats.fire = 2), dropping('acid', 1)(s, rng)),
     shows: (t, seen) => seen.picked.has('acid') && t.events.some((e) => e.type === 'pillarCracked'),
   },
+  rubble: {
+    caption: `Rubble: the next blast breaks a cracked pillar, and ${COLLAPSE_TURNS} turns later the ceiling round it comes down for good.`,
+    enemies: ['blob'],
+    maxSteps: 12,
+    tail: 2,
+    prepare: crackedPillar,
+    shows: (t) => t.events.some((e) => e.type === 'ceilingFell'),
+  },
   shield: {
     caption: 'Shield: while you carry it, it absorbs the next hit.',
     enemies: ['seeker', 'seeker'],
@@ -599,7 +613,7 @@ export function generateScene(kind: SceneKind, seed: number): Scene | null {
   return null
 }
 
-/** Replays a scene: its turns, and whether its last turn is its moment (for tests, and `ending`). */
+/** Replays a scene: its turns, and whether its moment turn shows it (for tests, and `ending`). */
 export function replayScene(scene: Scene): { turns: Turn[]; shows: boolean } {
   let s = scene.start
   const seen = newSeen()
@@ -610,19 +624,19 @@ export function replayScene(scene: Scene): { turns: Turn[]; shows: boolean } {
     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)
+    if (i === (scene.moment ?? scene.actions.length - 1)) last = SCENES[scene.kind].shows(turn, seen)
     s = r.state
   }
   return { turns, shows: last }
 }
 
-/** Whether a scene's last turn is its moment, replaying it from the start (for tests). */
+/** Whether a scene's moment turn shows 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
+ * The scene in a game that shows its moment: the turns up to its moment, 3 to 6 of them (`maxSteps` allows more; as few as do), then `tail` turns of waiting, 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.
@@ -631,16 +645,27 @@ export function showsAtEnd(scene: Scene): boolean {
  */
 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 def = SCENES[kind]
+  for (let n = MIN_STEPS; n <= Math.min(def.maxSteps ?? 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.bombs.length || s.burning.length || s.collapsing.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 turns: Play[] = game.slice(-n)
     // Spikes keep their rhythm across the reset to turn 0.
     const spikes = s.spikes.map((sp) => ({ ...sp, phase: (sp.phase + s.turn) % 2 }))
-    const scene = { kind, start: { ...s, turn: 0, coins: 0, spikes }, actions: turns.map((t) => t.action), gestures: turns.map((t) => t.gesture) }
-    if (showsAtEnd(scene)) return scene
+    const scene: Scene = { kind, start: { ...s, turn: 0, coins: 0, spikes }, actions: turns.map((t) => t.action), gestures: turns.map((t) => t.gesture), moment: n - 1 }
+    if (!showsAtEnd(scene)) continue
+    // The aftermath: a few turns of waiting, while that is safe, to see what the moment did.
+    let after = game[game.length - 1]!.next
+    for (let i = 0; i < (def.tail ?? 0) && after.status === 'playing' && !after.offer; i++) {
+      const play = playOf(def, after, { id: 'wait' })
+      if (!play || isDeadly(after, play.action)) break
+      scene.actions.push(play.action)
+      scene.gestures.push(play.gesture)
+      after = step(after, play.action).state
+    }
+    return scene
   }
   return null
 }
@@ -698,6 +723,28 @@ function placeDoor(s: GameState, rng: Rng): void {
   if (pick.length) s.exit = pick[rng.int(pick.length)]!
 }
 
+/**
+ * Rubble's room: one pillar already cracked (as Acid leaves it), the player a few steps from it with
+ * a blast long enough to reach it from beside it.
+ */
+function crackedPillar(s: GameState, rng: Rng): void {
+  const pillars = s.cells.flatMap((c, i) => {
+    const p = { x: i % s.width, y: Math.floor(i / s.width) }
+    return c === 'wall' && !(p.x === 0 || p.y === 0 || p.x === s.width - 1 || p.y === s.height - 1) ? [i] : []
+  })
+  const pillar = pillars[rng.int(pillars.length)]!
+  s.cracked.push(pillar)
+  const at2 = { x: pillar % s.width, y: Math.floor(pillar / s.width) }
+  const near = s.cells.flatMap((c, i) => {
+    const p = { x: i % s.width, y: Math.floor(i / s.width) }
+    const d = steps(p, at2)
+    return c === 'floor' && d >= 2 && d <= 3 && !s.enemies.some((e) => at(e.pos, p)) ? [p] : []
+  })
+  if (near.length) s.player = { ...near[rng.int(near.length)]! }
+  s.lastSeen = { ...s.player }
+  s.stats.fire = 2
+}
+
 /** Free floor tiles `n` steps from the player, with no enemy. */
 function tilesAt(s: GameState, n: number): Vec[] {
   return s.cells.flatMap((c, i) => {
@@ -841,7 +888,7 @@ function reasonable(kind: SceneKind, t: Turn): boolean {
   const uncovered = (p: Vec) => s.cells[idx(s, p)] === 'floor' && (at(p, s.exit) || s.gems.some((g) => at(g.pos, p)))
   if (punishing && tiles.some(uncovered)) return false
   return tiles.some(
-    (p) => s.cells[idx(s, p)] === 'block' || s.bombs.some((b) => at(b.pos, p)) || s.enemies.some((e) => at(e.pos, p) || e.plan.some((q) => at(q, p))),
+    (p) => s.cells[idx(s, p)] === 'block' || s.cracked.includes(idx(s, p)) || s.bombs.some((b) => at(b.pos, p)) || s.enemies.some((e) => at(e.pos, p) || e.plan.some((q) => at(q, p))),
   )
 }
 
diff --git a/src/style.css b/src/style.css
index aea8589..20d0146 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1638,3 +1638,42 @@ canvas.clip[hidden] {
   font-size: 11px;
   color: var(--muted);
 }
+
+/* The bar of a scene's turns (ui/timeline.ts): filling as it plays, with a mark where its moment
+   is, lit once that has passed. In the demo's panel and under each dialog's clip. */
+.timeline {
+  position: relative;
+  height: 6px;
+  margin: 8px auto 0;
+  max-width: 260px;
+  border-radius: 3px;
+  background: rgb(128 128 128 / 0.3);
+}
+
+.timeline i {
+  display: block;
+  height: 100%;
+  border-radius: 3px;
+  background: var(--accent);
+  transition: width 0.5s linear;
+}
+
+.timeline b {
+  position: absolute;
+  top: -4px;
+  width: 4px;
+  height: 14px;
+  margin-left: -2px;
+  border-radius: 2px;
+  background: #ffc83d;
+  box-shadow: 0 0 0 1px rgb(53 53 65 / 0.7);
+  opacity: 0.8;
+}
+
+.timeline b.passed {
+  opacity: 1;
+}
+
+#demo .timeline {
+  margin-top: 10px;
+}
diff --git a/src/ui/clip.ts b/src/ui/clip.ts
index d5d258d..467b040 100644
--- a/src/ui/clip.ts
+++ b/src/ui/clip.ts
@@ -7,6 +7,7 @@ import { step } from '../sim/turn'
 import type { Action, ActionId, GameState, Vec } from '../sim/types'
 import handIcon from '../../assets/kenney-ui-rpg/cursorHand_beige.svg'
 import { makeScene } from './scenes'
+import { timelineAfter, type Timeline } from './timeline'
 
 /**
  * A dialog's clip (PLAN.md §13c, user request, 2026-10-24): a small looping scene of what the
@@ -30,6 +31,8 @@ interface Clip {
   scene: Scene | null
   upcoming: Scene | null
   state: GameState | null
+  /** Its bar of turns left (ui/timeline.ts); none for a fixed scene (the player's own last turns). */
+  timeline: Timeline | null
   /** The next turn to play, and when (LittleJS `time`). */
   next: number
   at: number
@@ -82,6 +85,8 @@ export function startClip(canvas: HTMLCanvasElement, source: SceneKind | Scene,
     frame.append(canvas)
   }
   for (const old of frame.querySelectorAll('.clip-finger, .demo-ripple, .demo-ring')) old.remove()
+  // A canvas started again (the shop's and the offer's cards) keeps one bar, not one more each time.
+  if (frame.nextElementSibling?.classList.contains('timeline')) frame.nextElementSibling.remove()
   let finger: HTMLElement | null = null
   if (kind && FINGER_KINDS.has(kind)) {
     finger = document.createElement('div')
@@ -91,6 +96,7 @@ export function startClip(canvas: HTMLCanvasElement, source: SceneKind | Scene,
   }
   const c: Clip = {
     canvas, kind, fixed: kind ? null : (source as Scene), focus, seed: Math.floor(Math.random() * 2 ** 31), scene: null, upcoming: null, state: null,
+    timeline: kind ? timelineAfter(frame) : null,
     next: 0, at: 0, anim: newAnimation(), finger, beats: [], selected: null, hover: null, armed: null,
   }
   clips = [...clips.filter((o) => o.canvas !== canvas), c]
@@ -100,6 +106,8 @@ export function startClip(canvas: HTMLCanvasElement, source: SceneKind | Scene,
 
 /** Stops the clip in `canvas`, if one plays there. */
 export function stopClip(canvas: HTMLCanvasElement): void {
+  const bar = canvas.parentElement?.nextElementSibling
+  if (bar?.classList.contains('timeline')) bar.remove()
   clips = clips.filter((o) => o.canvas !== canvas)
 }
 
@@ -125,6 +133,7 @@ function tick(c: Clip): void {
     c.upcoming = null
     c.state = c.scene.start
     c.next = 0
+    c.timeline?.set(0, c.scene.actions.length, c.scene.moment)
     c.at = time + LEAD_SECONDS
     c.anim = newAnimation()
     if (matchMedia('(prefers-reduced-motion: reduce)').matches) return still(c)
@@ -157,16 +166,19 @@ function tick(c: Clip): void {
     play(c, action)
     c.at = time + (last ? HOLD_SECONDS : STEP_SECONDS)
   }
+  // The bar fills over the turn: as long as its beats, or until the next turn.
+  c.timeline?.set(c.next, c.scene.actions.length, c.scene.moment, c.finger ? c.beats.reduce((sum, b) => sum + b.then, 0) : last ? HOLD_SECONDS : STEP_SECONDS)
 }
 
 /**
  * With reduced motion, a clip is a still (user's choice, 2026-10-24: "whatever is clearer"): the
- * scene's moment, just after it — the flame still on the board, the player's pose — held, with
- * no finger and no next loop.
+ * scene's end (its moment, and any aftermath turns after it) — the flame still on the board, the
+ * player's pose — held, with no finger and no next loop. The bar shows it full.
  */
 function still(c: Clip): void {
   for (const action of c.scene!.actions) c.state = step(c.state!, action).state
   c.next = c.scene!.actions.length
+  c.timeline?.set(c.next, c.next, c.scene!.moment, 0)
   c.at = Infinity
   c.finger?.classList.add('away')
 }
diff --git a/src/ui/info.ts b/src/ui/info.ts
index 544c7f9..93d7d5f 100644
--- a/src/ui/info.ts
+++ b/src/ui/info.ts
@@ -310,7 +310,7 @@ export const BOARD_TEXT: Record<BoardThing, { icon: string; name: string; text:
     icon: 'rock',
     name: 'Rubble',
     text: [`A fallen ceiling: Acid cracked a pillar, a later blast broke it, and ${COLLAPSE_TURNS} turns on the ceiling round it came down. Nothing gets through rubble, not even flame, and nothing clears it.`],
-    clip: 'acid',
+    clip: 'rubble',
   },
 }
 
diff --git a/src/ui/timeline.ts b/src/ui/timeline.ts
new file mode 100644
index 0000000..0814e1d
--- /dev/null
+++ b/src/ui/timeline.ts
@@ -0,0 +1,50 @@
+/**
+ * The bar under a scene that shows how much of it is left, with a mark where the moment it is
+ * about comes (user request, 2026-10-31): in the demo's panel and under each dialog's clip. It
+ * fills a turn at a time; the mark lights once the moment has passed, and is left out when the moment is the last turn.
+ */
+export interface Timeline {
+  /**
+   * `done` of `total` turns played; the scene's moment is turn `moment` (0-based; the last if undefined).
+   * Hidden with no turns. The fill moves to its new width over `seconds` (how long the turn about to
+   * play takes), so it keeps moving rather than stepping a turn at a time.
+   */
+  set(done: number, total: number, moment?: number, seconds?: number): void
+}
+
+export function bindTimeline(el: HTMLElement): Timeline {
+  el.classList.add('timeline')
+  const fill = document.createElement('i')
+  const mark = document.createElement('b')
+  el.replaceChildren(fill, mark)
+  return {
+    set(done, total, moment, seconds = 0.5) {
+      el.hidden = total <= 0
+      if (total <= 0) return
+      const width = `${(Math.min(done, total) / total) * 100}%`
+      if (done === 0) {
+        // A new loop: jump back to empty at once, rather than draining from full.
+        fill.style.transition = 'none'
+        fill.style.width = width
+        void fill.offsetWidth
+        fill.style.transition = ''
+      } else {
+        fill.style.transitionDuration = `${seconds}s`
+        fill.style.width = width
+      }
+      // The mark says where the moment is; at the very end it says nothing, so it isn't shown.
+      const at = moment ?? total - 1
+      mark.hidden = at >= total - 1
+      mark.style.left = `${((at + 1) / total) * 100}%`
+      // Lit once the turn after the moment has begun, i.e. the moment has shown.
+      mark.classList.toggle('passed', done > at + 1)
+    },
+  }
+}
+
+/** A timeline in a new element placed after `anchor`. */
+export function timelineAfter(anchor: Element): Timeline {
+  const el = document.createElement('div')
+  anchor.after(el)
+  return bindTimeline(el)
+}
diff --git a/test/demo.test.ts b/test/demo.test.ts
index b0b1e29..307d316 100644
--- a/test/demo.test.ts
+++ b/test/demo.test.ts
@@ -39,8 +39,11 @@ describe('demo scenes', () => {
       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)
+        // The moment is MIN-MAX turns in (a scene may ask for more); a few turns of aftermath may follow it.
+        const moment = scene.moment!
+        expect(moment + 1).toBeGreaterThanOrEqual(MIN_STEPS)
+        expect(moment + 1).toBeLessThanOrEqual(SCENES[kind].maxSteps ?? MAX_STEPS)
+        expect(scene.actions.length - (moment + 1)).toBeLessThanOrEqual(SCENES[kind].tail ?? 0)
         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([])
@@ -73,6 +76,14 @@ describe('demo scenes', () => {
     })
   }
 
+  it('rubble: the ceiling falls at the moment, and the aftermath shows it', () => {
+    const scene = generateScene('rubble', 3)!
+    expect(scene.start.cracked.length).toBe(1)
+    const { turns } = replayScene(scene)
+    expect(turns[scene.moment!]!.events.some((e) => e.type === 'ceilingFell')).toBe(true)
+    expect(turns[turns.length - 1]!.next.cells.includes('rubble')).toBe(true)
+  })
+
   it('the same seed gives the same scene', () => {
     expect(generateScene('kill', 7)).toEqual(generateScene('kill', 7))
   })