Skip to content

Story Board Tool Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build tools/story_board/ — a BugSmash-style loop that freezes the deterministic storyline into a pinnable storyboard (screenshots + timing + triggers + controlling-code refs), so Chris annotates a whole pass and Claude applies it as a batch.

Architecture: A 6-stage deterministic pipeline: (1) record a tutorial run once via RECORD_REPLAY; (2) a windowed capture harness re-ticks that replay through main, logging every sim.story.events beat + screenshotting the scene at each beat’s trigger tick; (3) a pure resolver pairs beats with triggers and controlling-code refs → storyboard.json; (4) a stdlib web tool renders it and Chris pins comments → annotations.json; (5) Claude applies the batch. Reuses RECORD_REPLAY, marketing/capture’s SubViewport harness, and the tools/sound_map/ web stack.

Tech Stack: Godot 4.7.1 / typed GDScript (harness + resolver), Python 3 stdlib (web server), vanilla JS/HTML/CSS (UI), GUT (GDScript tests), unittest (Python tests).

  • /sim purity: never add Node/render/Input/Engine/Time APIs to any sim/ file. All new sim-facing reads happen in render-side files (main.gd, tools/story_board/*.gd). Verbatim from CLAUDE.md.
  • Determinism keystone: the pinned baselines in tests/test_determinism_survival.gd / tests/test_determinism_coop.gd must stay green. The only sim-file change here (Task 1, a render-ignored id field on a say event) must not move them — verify, expect no re-pin.
  • Deterministic replay schema (verbatim): input/replay_recorder.gd writes {"seed": int, "ticks": [[m.x,m.y,a.x,a.y,decoy,dash, (×2 for P2)]]}; input/replay_loader.gd reads it back via load_file(path) / input_at(data, tick, pilot_idx).
  • Windowed capture only: MultiMesh per-instance colours are unreadable headless (memory bullet-heaven-ui-look). The capture harness runs WINDOWED with the Metal/forward path, same as marketing/capture/shoot.gd.
  • Python is stdlib-only, no cgi: Python 3.14 removed cgi (PEP 594). The web server parses request bodies by hand, matching tools/sound_map/server.py.
  • Tests run headless: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=<script_without_.gd> -gexit (exit 0 = pass). -gselect narrows; -gtest does not (.gutconfig.json hardcodes dirs).

  • sim/story_director.gd — MODIFY _say: stamp the dialogue id into the emitted say event (Task 1).
  • main.gd — MODIFY: add replay-playback + capture hooks (seed override, input override, per-tick event log, done flag) (Task 2).
  • tools/story_board/beat_log.gd — CREATE: pure static resolver, class_name BeatLog extends RefCounted. Pairs say-events with triggers, computes gaps, builds controls refs + flags. No Node/render APIs → unit-testable (Task 3).
  • tools/story_board/capture.gd + capture.tscn — CREATE: windowed harness. Drives main in playback, screenshots the scene at each beat, calls BeatLog.build, writes out/storyboard.json + out/beat-NNN.png (Task 4).
  • tools/story_board/server.py — CREATE: stdlib web server; serves the repo + writes annotations.json (Task 5).
  • tools/story_board/index.html, css/app.css, src/app.js — CREATE: timeline strip + beat cards + pin/save UI (Task 6).
  • tools/story_board/tests/ — CREATE: test_beat_log.gd (Task 3), server.test.py (Task 5), app.test.js (Task 6).
  • docs/architecture/dev-tools.md, NEXT_TASKS.md — MODIFY: document the tool (Task 6).

Data contracts:

out/storyboard.json:

{ "schemaVersion": 1, "replay": "tutorial.json",
"beats": [ { "index": 0, "id": "ember_intro", "tick": 42, "t": 0.70,
"speaker": "echo", "name": "Echo", "lines": ["…","…"], "line_count": 7,
"trigger": {"kind":"room","event":"enter","room":"ember_entry"},
"gap_to_next_s": 0.0,
"controls": { "text": "data/story.json#dialogue.ember_intro",
"pacing": "ui/dialogue_box.gd:LINE_TIME/MIN_HOLD/CHARS_PER_SEC",
"trigger": "sim/story_director.gd (room on_enter / rule)" },
"screenshot": "beat-000.png", "flags": ["many_lines","no_gap_to_next"] } ] }

annotations.json (written by the web tool, read by Claude):

{ "schemaVersion": 1, "replay": "tutorial.json",
"pins": [ {"beat_index":0,"beat_id":"ember_intro","comment":"split into 3 boxes, one lesson each","tag":"split"} ] }

Task 1: Stamp the dialogue id into the say event

Section titled “Task 1: Stamp the dialogue id into the say event”

Files:

  • Modify: sim/story_director.gd (the _say function, ~line 240)
  • Test: tests/test_story_say_id.gd (Create)

Interfaces:

  • Produces: a say event now carries "id": <dialogue_id> in addition to kind/speaker/name/color/lines. ui/dialogue_box.gd ignores unknown keys (verified: consume() reads only kind/name/color/lines), so this is render-safe and sim-deterministic (no rng, no new state).

  • Step 1: Write the failing test

Create tests/test_story_say_id.gd:

extends GutTest
# The Story Board capture tool maps each dialogue beat back to its data/story.json entry
# by id. _say must stamp that id into the emitted `say` event (dialogue_box ignores it).
func _content() -> ContentDB:
return ContentLoader.load_from_path("res://data/bible.json")
func test_say_event_carries_its_dialogue_id() -> void:
var sim := Sim.new(1, _content())
var sdata := StoryLoader.load_from_path("res://data/story.json")
sim.enable_story(sdata, false, false, false)
sim.story.events.clear()
# ember_intro is a real dialogue id in story.json.
sim.story_director._say(sim, "ember_intro")
var says := sim.story.events.filter(func(e): return e.get("kind","") == "say")
assert_eq(says.size(), 1, "one say event emitted")
assert_eq(String(says[0].get("id","")), "ember_intro", "the say event carries its dialogue id")
  • Step 2: Run test to verify it fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_story_say_id -gexit Expected: FAIL — "" != "ember_intro" (id not present yet). If it errors on enable_story arg count, open sim/sim.gd’s enable_story signature and match the real parameter order/count, then re-run — the assertion, not the setup, is what must fail.

  • Step 3: Add the id field

In sim/story_director.gd, in _say, add "id": dialogue_id, to the appended dictionary:

sim.story.events.append({
"kind": "say",
"id": dialogue_id,
"speaker": spk_id,
"name": spk.get("name", spk_id),
"color": spk.get("color", "#ffffff"),
"lines": dlg.get("lines", []),
})
  • Step 4: Run test to verify it passes

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_story_say_id -gexit Expected: PASS.

  • Step 5: Verify determinism baselines unchanged

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_determinism_survival -gexit then -gselect=test_determinism_coop. Expected: both PASS with no assertion change (the id field is not in snapshot_string/state_checksum; say events are per-tick and render-consumed). If either fails, STOP — a say event must not be reaching the checksum; investigate rather than re-pinning.

  • Step 6: Commit
Terminal window
git add sim/story_director.gd tests/test_story_say_id.gd
git commit -m "feat(story): stamp dialogue id into say events for Story Board capture"

Task 2: Replay-playback + capture hooks in main.gd

Section titled “Task 2: Replay-playback + capture hooks in main.gd”

Files:

  • Modify: main.gd — new fields near the _replay_rec declaration (~line 167); seed override in _new_run (~line 615); input override + tick advance + done flag in _physics_process (~lines 1085–1137); per-tick event log at the story-consume point (~line 1165).
  • Test: tests/test_replay_playback.gd (Create)

Interfaces:

  • Consumes: ReplayLoader.load_file, ReplayLoader.input_at (Global Constraints schema).

  • Produces (public seams the harness in Task 4 uses):

    • main._replay_play: Dictionary — set to a loaded replay dict to drive playback; {} (default) = off.
    • main._replay_done: bool — set true when playback consumes its last tick.
    • main._capture_events: Array — when main._capturing == true, main appends { "tick": int, "run_time": float, "event": Dictionary } for EVERY sim.story.events entry each tick, in order (dumb log; pairing is Task 3’s job).
    • main._capturing: bool — gate for the above (default false).
  • Step 1: Write the failing test

Create tests/test_replay_playback.gd:

extends GutTest
# main can drive a run from a recorded replay (seed forced from the file), and while capturing
# logs every per-tick story event with its tick. This test drives main headlessly through a few
# ticks of a hand-built 1-pilot replay and asserts the capture log fills and playback ends.
func test_playback_forces_seed_advances_ticks_and_logs_events() -> void:
var main = preload("res://main.tscn").instantiate()
add_child_autofree(main)
await get_tree().process_frame
# Minimal replay: 3 ticks of "move right", seed 777.
var row := [1.0, 0.0, 0.0, 0.0, 0, 0, 0, 0, 0, 0, 0, 0]
main._replay_play = {"seed": 777, "ticks": [row, row, row]}
main._capturing = true
main._capture_events = []
main._on_mode_chosen("tutorial")
assert_eq(main.run_seed, 777, "playback forced the run seed from the replay")
for _f in range(10):
await get_tree().physics_frame
assert_true(main._replay_done, "playback finished after its 3 ticks were consumed")
# The tutorial's very first room-enter fires ember dialogue, so at least one event logged.
assert_gt(main._capture_events.size(), 0, "capture logged per-tick story events")
for e in main._capture_events:
assert_true(e.has("tick") and e.has("event"), "each log row has tick + event")
  • Step 2: Run test to verify it fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_replay_playback -gexit Expected: FAIL — _replay_play/_capturing/_capture_events/_replay_done are undefined properties (or run_seed != 777).

  • Step 3: Add the fields

In main.gd, next to var _replay_rec: ReplayRecorder = null (~line 167):

# Story Board capture (tools/story_board): drive a run from a recorded replay and log every
# per-tick story event so the storyboard can be rebuilt deterministically. Render-side only.
var _replay_play: Dictionary = {} # a loaded replay dict {seed, ticks}; {} = live input
var _replay_tick: int = 0
var _replay_done: bool = false
var _capturing: bool = false
var _capture_events: Array = [] # {tick:int, run_time:float, event:Dictionary}, in order
  • Step 4: Force the seed in _new_run

In main.gd’s run-setup (near _replay_rec = ReplayRecorder.new(run_seed) ..., ~line 615), BEFORE run_seed is used to seed the sim, add:

if not _replay_play.is_empty():
run_seed = int(_replay_play.get("seed", run_seed))
_replay_tick = 0
_replay_done = false
_auto = false

(If run_seed is assigned above line 615, place this immediately after that assignment and before the Sim is constructed/seeded. Grep run_seed in main.gd to confirm the exact line the sim is seeded, and insert just before it.)

  • Step 5: Override input + advance the replay in _physics_process

In main.gd’s _physics_process, the tutorial freeze at ~line 1085 already returns while a box shows — leave it (playback pauses at boxes exactly like the recording did). AFTER var input := human and the if _auto: block, gate the auto branch and inject replay input. Change the auto guard to skip during playback, then before the for _s in range(steps): tick loop add the override:

if not _replay_play.is_empty():
var ticks: Array = _replay_play.get("ticks", [])
if _replay_tick >= ticks.size():
_replay_done = true
return
input = ReplayLoader.input_at(_replay_play, _replay_tick, 0)
input2 = ReplayLoader.input_at(_replay_play, _replay_tick, 1) if sim.pilots.size() > 1 else null

And guard the existing autopilot branch so it can’t overwrite replay input:

if _auto and _replay_play.is_empty():
# …existing auto_move/auto_aim block unchanged…

Then, immediately AFTER sim.tick_single(input, input2) inside the steps loop, advance the replay pointer:

if not _replay_play.is_empty():
_replay_tick += 1
  • Step 6: Log per-tick story events at the consume point

In main.gd, at the story-consume block (~line 1165, guarded by if sim.story != null around dialogue_box.consume(sim.story.events)), add BEFORE dialogue_box.consume(...):

if _capturing:
for _ev in sim.story.events:
_capture_events.append({"tick": _replay_tick, "run_time": sim.run_time, "event": _ev})

(sim.story.events is cleared at each tick start in sim.tick(), so this logs only this tick’s events.)

  • Step 7: Run test to verify it passes

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_replay_playback -gexit Expected: PASS. If _replay_done never sets, confirm the override’s return on exhaustion is reached (the freeze at 1085 must not permanently short-circuit — a run with no box showing advances normally).

  • Step 8: Verify determinism + boot smoke

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_determinism_survival -gexit, then -gselect=test_determinism_coop, then godot --headless --path . --quit-after 120 (grep stderr for SCRIPT ERROR). Expected: baselines PASS unchanged (playback fields are {}/false in normal runs — dead code paths), no SCRIPT ERROR.

  • Step 9: Commit
Terminal window
git add main.gd tests/test_replay_playback.gd
git commit -m "feat(main): replay-playback + capture hooks for Story Board"

Task 3: BeatLog — pure resolver (say↔trigger pairing, gaps, controls refs)

Section titled “Task 3: BeatLog — pure resolver (say↔trigger pairing, gaps, controls refs)”

Files:

  • Create: tools/story_board/beat_log.gd
  • Test: tools/story_board/tests/test_beat_log.gd

Interfaces:

  • Consumes: the raw _capture_events log from Task 2 (Array of {tick,run_time,event}), plus the parsed data/story.json dict (for nothing beyond ids today, but passed so controls.text can be validated/extended).

  • Produces:

    • static func build(capture_events: Array, dt: float) -> Array — returns beat dicts (schema in File Structure, minus screenshot, which Task 4 stamps). One beat per say event, in order.
    • static func nearest_trigger(capture_events: Array, say_row_index: int) -> Dictionary — the most recent non-say event at/before that row ({} if none).
  • Step 1: Write the failing tests

Create tools/story_board/tests/test_beat_log.gd:

extends GutTest
const BeatLog = preload("res://tools/story_board/beat_log.gd")
func _log() -> Array:
# room-enter (trigger) then two say events; a later room-enter then a third say.
return [
{"tick": 10, "run_time": 0.16, "event": {"kind":"room","event":"enter","room":"ember_entry"}},
{"tick": 10, "run_time": 0.16, "event": {"kind":"say","id":"ember_intro","speaker":"echo","name":"Echo","lines":["a","b","c","d","e","f","g"]}},
{"tick": 12, "run_time": 0.20, "event": {"kind":"say","id":"ember_hint","speaker":"echo","name":"Echo","lines":["one"]}},
{"tick": 90, "run_time": 1.50, "event": {"kind":"room","event":"enter","room":"ember_hall"}},
{"tick": 90, "run_time": 1.50, "event": {"kind":"say","id":"ember_hall_intro","speaker":"echo","name":"Echo","lines":["hall"]}},
]
func test_build_makes_one_beat_per_say_in_order() -> void:
var beats := BeatLog.build(_log(), 1.0/60.0)
assert_eq(beats.size(), 3, "one beat per say event")
assert_eq(String(beats[0]["id"]), "ember_intro")
assert_eq(String(beats[2]["id"]), "ember_hall_intro")
assert_eq(int(beats[0]["index"]), 0)
func test_beat_pairs_with_nearest_prior_trigger() -> void:
var beats := BeatLog.build(_log(), 1.0/60.0)
assert_eq(String(beats[0]["trigger"].get("room","")), "ember_entry", "first beat's trigger is its room-enter")
assert_eq(String(beats[1]["trigger"].get("room","")), "ember_entry", "second beat inherits the same trigger (no new one between)")
assert_eq(String(beats[2]["trigger"].get("room","")), "ember_hall", "third beat pairs with the later room-enter")
func test_gap_to_next_and_flags() -> void:
var beats := BeatLog.build(_log(), 1.0/60.0)
# beat0 fires tick10, beat1 tick12 -> gap 2 ticks = ~0.033s -> flagged as too-close.
assert_almost_eq(float(beats[0]["gap_to_next_s"]), 2.0/60.0, 0.001)
assert_true(beats[0]["flags"].has("no_gap_to_next"), "≤0.5s gap flags no_gap_to_next")
assert_true(beats[0]["flags"].has("many_lines"), "7 lines flags many_lines")
assert_eq(int(beats[0]["line_count"]), 7)
# last beat has no next -> gap_to_next_s = -1 (sentinel, no flag)
assert_eq(float(beats[2]["gap_to_next_s"]), -1.0)
func test_controls_refs_point_at_the_right_files() -> void:
var beats := BeatLog.build(_log(), 1.0/60.0)
assert_eq(String(beats[0]["controls"]["text"]), "data/story.json#dialogue.ember_intro")
assert_true(String(beats[0]["controls"]["pacing"]).begins_with("ui/dialogue_box.gd"))
assert_true(String(beats[0]["controls"]["trigger"]).begins_with("sim/story_director.gd"))
  • Step 2: Run tests to verify they fail

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_beat_log -gexit Expected: FAIL — beat_log.gd does not exist (load error / method missing).

  • Step 3: Implement beat_log.gd

Create tools/story_board/beat_log.gd:

class_name BeatLog
extends RefCounted
# Pure resolver for the Story Board capture. Turns the raw per-tick story-event log
# (main._capture_events) into ordered beat cards: one per `say` event, each paired with the
# gameplay event that triggered it, the gap to the next beat, and refs to the code/data that
# controls it. No Node/render/Engine APIs — unit-testable in isolation.
# Design: docs/superpowers/specs/2026-07-23-story-board-tool-design.md
const CLOSE_GAP_S := 0.5 # gap-to-next at/under this flags "boxes too close"
const MANY_LINES := 4 # line count at/over this flags a crammed box
static func nearest_trigger(capture_events: Array, say_row_index: int) -> Dictionary:
var i := say_row_index - 1
while i >= 0:
var ev: Dictionary = capture_events[i]["event"]
if String(ev.get("kind", "")) != "say":
return ev
i -= 1
return {}
static func build(capture_events: Array, dt: float) -> Array:
# Collect say rows (row index + tick) first so gap-to-next is a simple pairwise scan.
var say_rows: Array = []
for row_i in range(capture_events.size()):
if String(capture_events[row_i]["event"].get("kind", "")) == "say":
say_rows.append(row_i)
var beats: Array = []
for n in range(say_rows.size()):
var row_i: int = say_rows[n]
var row: Dictionary = capture_events[row_i]
var ev: Dictionary = row["event"]
var lines: Array = ev.get("lines", [])
var tick := int(row["tick"])
var gap_s := -1.0
if n + 1 < say_rows.size():
var next_tick := int(capture_events[say_rows[n + 1]]["tick"])
gap_s = float(next_tick - tick) * dt
var flags: Array = []
if lines.size() >= MANY_LINES:
flags.append("many_lines")
if gap_s >= 0.0 and gap_s <= CLOSE_GAP_S:
flags.append("no_gap_to_next")
var did := String(ev.get("id", ""))
beats.append({
"index": n,
"id": did,
"tick": tick,
"t": float(tick) * dt,
"speaker": String(ev.get("speaker", "")),
"name": String(ev.get("name", ev.get("speaker", ""))),
"lines": lines,
"line_count": lines.size(),
"trigger": nearest_trigger(capture_events, row_i),
"gap_to_next_s": gap_s,
"controls": {
"text": "data/story.json#dialogue.%s" % did,
"pacing": "ui/dialogue_box.gd:LINE_TIME/MIN_HOLD/CHARS_PER_SEC",
"trigger": "sim/story_director.gd (room on_enter / rule)",
},
"flags": flags,
})
return beats
  • Step 4: Run tests to verify they pass

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_beat_log -gexit Expected: PASS (all 4).

  • Step 5: Commit
Terminal window
git add tools/story_board/beat_log.gd tools/story_board/tests/test_beat_log.gd
git commit -m "feat(story-board): pure BeatLog resolver (pairing, gaps, controls refs)"

Task 4: Capture harness (capture.gd + capture.tscn)

Section titled “Task 4: Capture harness (capture.gd + capture.tscn)”

Files:

  • Create: tools/story_board/capture.gd, tools/story_board/capture.tscn
  • Manual verification (windowed; no headless GUT test — MultiMesh colours + screenshots need a real GPU)

Interfaces:

  • Consumes: main._replay_play, main._capturing, main._capture_events, main._replay_done (Task 2); BeatLog.build (Task 3); ReplayLoader.load_file (Global Constraints).

  • Produces: tools/story_board/out/storyboard.json + out/beat-NNN.png.

  • Step 1: Create the scene

Create tools/story_board/capture.tscn — a single root Node2D whose script is capture.gd:

[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tools/story_board/capture.gd" id="1"]
[node name="Capture" type="Node2D"]
script = ExtResource("1")
  • Step 2: Implement the harness

Create tools/story_board/capture.gd (adapts marketing/capture/shoot.gd’s SubViewport boot):

extends Node2D
# Story Board capture: re-tick a recorded tutorial replay through the real game in a windowed
# Metal SubViewport, screenshot the SCENE at each beat's trigger tick, and write a storyboard.
# Run WINDOWED (MultiMesh colours are unreadable headless):
# godot --path . res://tools/story_board/capture.tscn --rendering-method forward_plus
# Replay path via env STORY_REPLAY (default user://replays/tutorial.json).
# Design: docs/superpowers/specs/2026-07-23-story-board-tool-design.md
const BeatLog = preload("res://tools/story_board/beat_log.gd")
const OUT_DIR := "res://tools/story_board/out"
const SETTLE := 4 # frames to let the scene settle before a shot
const VP_SIZE := Vector2i(1920, 1080)
var vp: SubViewport
var main
var _replay_name := "tutorial.json"
var _seen_says := 0 # how many say-events we've already screenshotted
var _pending: Array = [] # {beat_say_index:int, shoot_at_frame:int}
var _frame := 0
func _ready() -> void:
var path := OS.get_environment("STORY_REPLAY")
if path == "":
path = "user://replays/tutorial.json"
_replay_name = path.get_file()
var data := ReplayLoader.load_file(path)
if data.is_empty():
push_error("[story_board] no replay at %s" % path)
get_tree().quit(1); return
DirAccess.make_dir_recursive_absolute(OUT_DIR)
vp = SubViewport.new()
vp.size = VP_SIZE
vp.render_target_update_mode = SubViewport.UPDATE_ALWAYS
vp.handle_input_locally = false
add_child(vp)
main = preload("res://main.tscn").instantiate()
main._replay_play = data
main._capturing = true
main._capture_events = []
vp.add_child(main)
await get_tree().process_frame
await get_tree().process_frame
main._on_mode_chosen("tutorial")
func _process(_dt: float) -> void:
if main == null:
return
_frame += 1
# A new say-event appeared -> schedule a scene shot a few settle frames later.
var say_count := 0
for row in main._capture_events:
if String(row["event"].get("kind","")) == "say":
say_count += 1
while _seen_says < say_count:
_pending.append({"beat_say_index": _seen_says, "shoot_at_frame": _frame + SETTLE})
_seen_says += 1
# Fire any due screenshots.
for p in _pending.duplicate():
if _frame >= int(p["shoot_at_frame"]):
_shoot(int(p["beat_say_index"]))
_pending.erase(p)
if main._replay_done and _pending.is_empty():
_finish()
func _shoot(beat_say_index: int) -> void:
var img := vp.get_texture().get_image()
img.save_png("%s/beat-%03d.png" % [OUT_DIR, beat_say_index])
func _finish() -> void:
var beats := BeatLog.build(main._capture_events, Sim_Const.DT)
for b in beats:
b["screenshot"] = "beat-%03d.png" % int(b["index"])
var doc := {"schemaVersion": 1, "replay": _replay_name, "beats": beats}
var f := FileAccess.open("%s/storyboard.json" % OUT_DIR, FileAccess.WRITE)
f.store_string(JSON.stringify(doc, "\t", false, true))
f.close()
print("[story_board] wrote %d beats -> %s/storyboard.json" % [beats.size(), OUT_DIR])
get_tree().quit()
  • Step 3: Record a tutorial replay to capture from

Set RECORD_REPLAY := true in main.gd, run godot --path ., play the tutorial to the Ember Warden handoff, quit. Copy the newest user://replays/replay-*.json to user://replays/tutorial.json:

Terminal window
GODOT_USER=$(godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gexit 2>/dev/null; echo "$HOME/Library/Application Support/Godot/app_userdata/Bullet Heaven")
cp "$GODOT_USER/replays/$(ls -t "$GODOT_USER/replays" | head -1)" "$GODOT_USER/replays/tutorial.json"

Then set RECORD_REPLAY := false again and git checkout -- main.gd if the flag was the only change (do NOT commit the flag flipped on).

  • Step 4: Run the capture (windowed) and verify output

Run: godot --path . res://tools/story_board/capture.tscn --rendering-method forward_plus Expected: it plays the tutorial, quits, and tools/story_board/out/ contains storyboard.json (with a beats array whose first entry is ember_intro, line_count 7, flags incl. many_lines) plus beat-000.png … one PNG per beat. Eyeball one PNG — it should show the real game scene at that trigger moment.

  • Step 5: Add out/ to gitignore and commit the harness
Terminal window
echo "tools/story_board/out/" >> .gitignore
git add tools/story_board/capture.gd tools/story_board/capture.tscn .gitignore
git commit -m "feat(story-board): windowed capture harness -> storyboard.json + screenshots"

Files:

  • Create: tools/story_board/server.py
  • Test: tools/story_board/tests/server.test.py

Interfaces:

  • Consumes: tools/story_board/out/storyboard.json + PNGs (served as static files); a POST body of annotation pins.

  • Produces: writes tools/story_board/annotations.json ({schemaVersion, replay, pins:[{beat_index,beat_id,comment,tag}]}) on POST /story_board/save.

  • Step 1: Write the failing test

Create tools/story_board/tests/server.test.py:

import json, os, tempfile, threading, unittest, urllib.request
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import server
class SaveTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
os.makedirs(os.path.join(self.tmp, 'tools', 'story_board', 'out'))
server.REPO_ROOT = self.tmp
self.httpd = server.make_server(port=0)
self.port = self.httpd.server_address[1]
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
def tearDown(self):
self.httpd.shutdown()
def test_save_writes_annotations_json(self):
body = json.dumps({"schemaVersion": 1, "replay": "tutorial.json",
"pins": [{"beat_index": 0, "beat_id": "ember_intro",
"comment": "split this", "tag": "split"}]}).encode()
req = urllib.request.Request(f"http://localhost:{self.port}/story_board/save",
data=body, method="POST",
headers={"Content-Type": "application/json"})
resp = urllib.request.urlopen(req)
self.assertEqual(resp.status, 200)
with open(os.path.join(self.tmp, 'tools', 'story_board', 'annotations.json')) as f:
saved = json.load(f)
self.assertEqual(saved["pins"][0]["tag"], "split")
if __name__ == "__main__":
unittest.main()
  • Step 2: Run test to verify it fails

Run: python3 tools/story_board/tests/server.test.py Expected: FAIL — server module import error (file doesn’t exist yet).

  • Step 3: Implement server.py

Create tools/story_board/server.py (mirrors tools/sound_map/server.py; stdlib only, hand-parses the JSON body — no cgi):

#!/usr/bin/env python3
"""Story Board local dev server: serves the repo statically + writes annotations.json.
Dependency-free (stdlib only). Run from repo root:
python3 tools/story_board/server.py
Then open http://localhost:8081/tools/story_board/index.html
The frontend fetches /tools/story_board/out/storyboard.json + out/beat-*.png as static
paths, and POSTs pins to /story_board/save which writes tools/story_board/annotations.json.
"""
import json, os
from http.server import HTTPServer, SimpleHTTPRequestHandler
from socketserver import ThreadingMixIn
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
def annotations_path():
return os.path.join(REPO_ROOT, 'tools', 'story_board', 'annotations.json')
class Handler(SimpleHTTPRequestHandler):
def translate_path(self, path):
# Serve everything relative to REPO_ROOT (test overrides REPO_ROOT).
rel = path.split('?', 1)[0].lstrip('/')
return os.path.join(REPO_ROOT, rel)
def do_POST(self):
if self.path != '/story_board/save':
self.send_response(404); self.end_headers(); return
length = int(self.headers.get('Content-Length', 0))
try:
payload = json.loads(self.rfile.read(length).decode('utf-8'))
except (ValueError, UnicodeDecodeError):
self.send_response(400); self.end_headers()
self.wfile.write(b'{"error":"bad json"}'); return
with open(annotations_path(), 'w', encoding='utf-8') as f:
json.dump(payload, f, indent=2, ensure_ascii=False)
self.send_response(200)
self.send_header('Content-Type', 'application/json'); self.end_headers()
self.wfile.write(json.dumps({'ok': True}).encode())
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
def make_server(port=8081):
return ThreadingHTTPServer(('localhost', port), Handler)
if __name__ == '__main__':
httpd = make_server(8081)
print('Story Board: http://localhost:8081/tools/story_board/index.html')
httpd.serve_forever()
  • Step 4: Run test to verify it passes

Run: python3 tools/story_board/tests/server.test.py Expected: PASS (OK, 1 test).

  • Step 5: Commit
Terminal window
git add tools/story_board/server.py tools/story_board/tests/server.test.py
git commit -m "feat(story-board): stdlib web server + annotations write-back"

Task 6: Web UI (index.html + css + js) and docs

Section titled “Task 6: Web UI (index.html + css + js) and docs”

Files:

  • Create: tools/story_board/index.html, tools/story_board/css/app.css, tools/story_board/src/app.js
  • Test: tools/story_board/tests/app.test.js
  • Modify: docs/architecture/dev-tools.md, NEXT_TASKS.md

Interfaces:

  • Consumes: out/storyboard.json, out/beat-*.png (static); POST /story_board/save (Task 5).

  • Produces: a testable pure render function renderBeatCard(beat) and collectPins(state) exported for app.test.js.

  • Step 1: Write the failing JS test

Create tools/story_board/tests/app.test.js (Node’s built-in node:test, matching sound_map’s JS tests):

const { test } = require('node:test');
const assert = require('node:assert');
const { renderBeatCard, collectPins } = require('../src/app.js');
test('renderBeatCard shows id, line count and a warning for crammed boxes', () => {
const html = renderBeatCard({ index: 0, id: 'ember_intro', t: 0.7, name: 'Echo',
lines: ['a','b','c','d','e','f','g'], line_count: 7, gap_to_next_s: 0.0,
trigger: { kind:'room', event:'enter', room:'ember_entry' },
controls: { text:'data/story.json#dialogue.ember_intro' },
screenshot: 'beat-000.png', flags: ['many_lines','no_gap_to_next'] });
assert.match(html, /ember_intro/);
assert.match(html, /7 lines/);
assert.match(html, /beat-000\.png/);
assert.match(html, /data\/story\.json#dialogue\.ember_intro/);
});
test('collectPins drops empty comments and keeps filled ones', () => {
const pins = collectPins({
0: { comment: 'split this', tag: 'split', beat_id: 'ember_intro' },
1: { comment: '', tag: 'other', beat_id: 'ember_hint' },
});
assert.equal(pins.length, 1);
assert.equal(pins[0].beat_index, 0);
assert.equal(pins[0].tag, 'split');
});
  • Step 2: Run test to verify it fails

Run: node --test tools/story_board/tests/app.test.js Expected: FAIL — cannot find ../src/app.js.

  • Step 3: Implement src/app.js

Create tools/story_board/src/app.js — pure functions + a browser bootstrap guarded so require works in Node:

'use strict';
function renderBeatCard(b) {
const warn = f => b.flags && b.flags.includes(f) ? ' ⚠' : '';
const gap = b.gap_to_next_s < 0 ? '—' : b.gap_to_next_s.toFixed(2) + 's';
const trig = b.trigger && b.trigger.room ? `${b.trigger.kind}:${b.trigger.room}`
: (b.trigger && b.trigger.kind) || '—';
return `<div class="beat" data-index="${b.index}">
<div class="beat-head">BEAT ${b.index} · t=${b.t.toFixed(2)}s · ${b.name} · #${b.id}</div>
<img class="beat-shot" src="out/${b.screenshot}" alt="beat ${b.index}">
<div class="beat-lines">${b.line_count} lines${warn('many_lines')}: ${b.lines.map(l=>`“${l}”`).join(' / ')}</div>
<div class="beat-meta">trigger: ${trig} · gap→next: ${gap}${warn('no_gap_to_next')}</div>
<div class="beat-controls">controls: ${b.controls.text}</div>
<textarea class="beat-comment" data-index="${b.index}" placeholder="change needed…"></textarea>
<select class="beat-tag" data-index="${b.index}">
<option value="">(tag)</option><option>split</option><option>add-pause</option>
<option>reword</option><option>re-trigger</option><option>reorder</option><option>other</option>
</select>
</div>`;
}
function collectPins(state) {
const pins = [];
for (const k of Object.keys(state)) {
const s = state[k];
if (s && s.comment && s.comment.trim()) {
pins.push({ beat_index: Number(k), beat_id: s.beat_id, comment: s.comment.trim(), tag: s.tag || 'other' });
}
}
return pins;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { renderBeatCard, collectPins };
}
if (typeof window !== 'undefined') {
window.addEventListener('DOMContentLoaded', async () => {
const doc = await (await fetch('out/storyboard.json')).json();
const state = {};
const strip = doc.beats.map(b =>
`<span class="tick ${b.flags.includes('no_gap_to_next') ? 'close':''}" title="#${b.id}"></span>`).join('');
document.getElementById('timeline').innerHTML = strip;
document.getElementById('beats').innerHTML = doc.beats.map(renderBeatCard).join('');
doc.beats.forEach(b => state[b.index] = { beat_id: b.id, comment: '', tag: '' });
document.getElementById('beats').addEventListener('input', e => {
const i = e.target.dataset.index;
if (i === undefined) return;
if (e.target.classList.contains('beat-comment')) state[i].comment = e.target.value;
if (e.target.classList.contains('beat-tag')) state[i].tag = e.target.value;
});
document.getElementById('save').addEventListener('click', async () => {
const body = { schemaVersion: 1, replay: doc.replay, pins: collectPins(state) };
await fetch('/story_board/save', { method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
document.getElementById('save').textContent = `Saved ${body.pins.length} pins ✓`;
});
});
}
  • Step 4: Implement index.html + css/app.css

Create tools/story_board/index.html:

<!doctype html>
<html><head><meta charset="utf-8"><title>Story Board</title>
<link rel="stylesheet" href="css/app.css"></head>
<body>
<header><h1>Story Board</h1><button id="save">Save pins</button></header>
<div id="timeline"></div>
<main id="beats"></main>
<script src="src/app.js"></script>
</body></html>

Create tools/story_board/css/app.css:

body { background:#0b0e14; color:#d7e0ea; font:14px/1.5 ui-monospace,monospace; margin:0; }
header { display:flex; justify-content:space-between; align-items:center; padding:12px 20px; position:sticky; top:0; background:#0b0e14; border-bottom:1px solid #1e2a3a; }
#save { background:#12324a; color:#7fd0ff; border:1px solid #2a5578; padding:8px 14px; cursor:pointer; }
#timeline { display:flex; gap:2px; padding:10px 20px; flex-wrap:wrap; }
.tick { width:10px; height:18px; background:#2a5578; display:inline-block; }
.tick.close { background:#c0603c; }
.beat { border:1px solid #1e2a3a; border-radius:8px; margin:14px 20px; padding:14px; background:#0f141d; }
.beat-head { color:#7fd0ff; font-weight:bold; }
.beat-shot { max-width:520px; width:100%; border-radius:6px; margin:8px 0; }
.beat-lines { color:#e8c07a; } .beat-meta,.beat-controls { color:#8fa3ba; font-size:13px; }
.beat-comment { width:100%; min-height:44px; margin-top:8px; background:#0b0e14; color:#d7e0ea; border:1px solid #2a3a4e; }
.beat-tag { margin-top:6px; background:#0b0e14; color:#d7e0ea; border:1px solid #2a3a4e; }
  • Step 5: Run the JS test to verify it passes

Run: node --test tools/story_board/tests/app.test.js Expected: PASS (2 tests).

  • Step 6: Manual end-to-end check

Run python3 tools/story_board/server.py, open http://localhost:8081/tools/story_board/index.html. Expected: the timeline strip + beat cards render with screenshots; type a comment, pick a tag, click Save; confirm tools/story_board/annotations.json is written with your pin.

  • Step 7: Document the tool

In docs/architecture/dev-tools.md, add a “Story Board” section: purpose, the 6-stage pipeline, the run commands (record → capture.tscn windowed → server.py), and that Claude actions annotations.json as a batch then Chris re-captures on the same replay. In NEXT_TASKS.md, add one line noting the tool ships tutorial-only first and any rough edge (e.g. screenshot-at-trigger, not box-render).

  • Step 8: Commit
Terminal window
git add tools/story_board/index.html tools/story_board/css tools/story_board/src \
tools/story_board/tests/app.test.js docs/architecture/dev-tools.md NEXT_TASKS.md
git commit -m "feat(story-board): storyboard UI (timeline + cards + pin/save) and docs"

Spec coverage: Record (Task 4 Step 3) · Capture harness (Task 4) · Resolve/controls refs (Task 3) · Review UI (Task 6) · Write annotations (Task 5) · Apply loop (documented, Task 6 Step 7 — Claude-side, no code) · _say id stamp (Task 1) · determinism-neutral (Tasks 1 & 2 verify) · sound_map stack reuse (Tasks 5–6) · tutorial-first scope (Task 4 Step 3, Task 6 Step 7) · B-seam kept source-agnostic (BeatLog/server/UI all take generic beat/pin shapes — no A-only assumptions baked in). All spec sections map to a task.

Placeholder scan: No TBD/TODO; every code step shows complete code; commands have expected output. The one manual/GPU-bound step (Task 4) is explicitly manual because headless can’t render MultiMesh colours — stated, not hidden.

Type consistency: _replay_play/_replay_tick/_replay_done/_capturing/_capture_events used identically in Task 2 and consumed by Task 4. BeatLog.build(capture_events, dt) / nearest_trigger(capture_events, say_row_index) signatures match between Task 3’s tests and impl and Task 4’s call. storyboard.json/annotations.json shapes match across Tasks 3/4 (produce), 5 (write pins), 6 (render/collect). renderBeatCard/collectPins names match Task 6 test ↔ impl.