Story Board Tool Implementation Plan
Story Board Tool Implementation Plan
Section titled “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).
Global Constraints
Section titled “Global Constraints”/simpurity: never add Node/render/Input/Engine/Time APIs to anysim/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.gdmust stay green. The only sim-file change here (Task 1, a render-ignoredidfield on asayevent) must not move them — verify, expect no re-pin. - Deterministic replay schema (verbatim):
input/replay_recorder.gdwrites{"seed": int, "ticks": [[m.x,m.y,a.x,a.y,decoy,dash, (×2 for P2)]]};input/replay_loader.gdreads it back viaload_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 asmarketing/capture/shoot.gd. - Python is stdlib-only, no
cgi: Python 3.14 removedcgi(PEP 594). The web server parses request bodies by hand, matchingtools/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).-gselectnarrows;-gtestdoes not (.gutconfig.jsonhardcodes dirs).
File Structure
Section titled “File Structure”sim/story_director.gd— MODIFY_say: stamp the dialogueidinto the emittedsayevent (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, buildscontrolsrefs + flags. No Node/render APIs → unit-testable (Task 3).tools/story_board/capture.gd+capture.tscn— CREATE: windowed harness. Drivesmainin playback, screenshots the scene at each beat, callsBeatLog.build, writesout/storyboard.json+out/beat-NNN.png(Task 4).tools/story_board/server.py— CREATE: stdlib web server; serves the repo + writesannotations.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_sayfunction, ~line 240) - Test:
tests/test_story_say_id.gd(Create)
Interfaces:
-
Produces: a
sayevent now carries"id": <dialogue_id>in addition tokind/speaker/name/color/lines.ui/dialogue_box.gdignores unknown keys (verified:consume()reads onlykind/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
git add sim/story_director.gd tests/test_story_say_id.gdgit 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_recdeclaration (~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— whenmain._capturing == true, main appends{ "tick": int, "run_time": float, "event": Dictionary }for EVERYsim.story.eventsentry 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 inputvar _replay_tick: int = 0var _replay_done: bool = falsevar _capturing: bool = falsevar _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 nullAnd 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
git add main.gd tests/test_replay_playback.gdgit 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_eventslog from Task 2 (Arrayof{tick,run_time,event}), plus the parseddata/story.jsondict (for nothing beyond ids today, but passed socontrols.textcan be validated/extended). -
Produces:
static func build(capture_events: Array, dt: float) -> Array— returns beat dicts (schema in File Structure, minusscreenshot, which Task 4 stamps). One beat persayevent, in order.static func nearest_trigger(capture_events: Array, say_row_index: int) -> Dictionary— the most recent non-sayevent 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 BeatLogextends 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
git add tools/story_board/beat_log.gd tools/story_board/tests/test_beat_log.gdgit 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 shotconst VP_SIZE := Vector2i(1920, 1080)
var vp: SubViewportvar mainvar _replay_name := "tutorial.json"var _seen_says := 0 # how many say-events we've already screenshottedvar _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:
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
echo "tools/story_board/out/" >> .gitignoregit add tools/story_board/capture.gd tools/story_board/capture.tscn .gitignoregit commit -m "feat(story-board): windowed capture harness -> storyboard.json + screenshots"Task 5: Web server (server.py)
Section titled “Task 5: Web server (server.py)”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}]}) onPOST /story_board/save. -
Step 1: Write the failing test
Create tools/story_board/tests/server.test.py:
import json, os, tempfile, threading, unittest, urllib.requestimport syssys.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.pyThen open http://localhost:8081/tools/story_board/index.htmlThe frontend fetches /tools/story_board/out/storyboard.json + out/beat-*.png as staticpaths, and POSTs pins to /story_board/save which writes tools/story_board/annotations.json."""import json, osfrom http.server import HTTPServer, SimpleHTTPRequestHandlerfrom 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
git add tools/story_board/server.py tools/story_board/tests/server.test.pygit 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)andcollectPins(state)exported forapp.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
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.mdgit commit -m "feat(story-board): storyboard UI (timeline + cards + pin/save) and docs"Self-Review
Section titled “Self-Review”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.