Tutorial Onboarding (Ember Reach) Implementation Plan
Tutorial Onboarding (Ember Reach) Implementation Plan
Section titled “Tutorial Onboarding (Ember Reach) 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: Re-enable the built-but-gated Tutorial mode as the mandatory-by-default first-run flow, capped to Ember Reach only, teaching movement/weapons/dash/decoy, then auto-handing off into a Survival run — without hard-locking Survival for anyone else.
Architecture: This is almost entirely wiring + content, not new systems. StoryDirector/
StoryState/data/story.json already implement the full campaign; we add one new StoryState
flag (tutorial_cap) threaded through Sim.enable_story(), one room-entry override in
StoryDirector._enter_room(), one new dialogue entry, two new dash/warp lines in existing
dialogue, and a handful of main.gd orchestration changes (fresh-save gating, capped-tutorial→
Survival handoff, a Tutorial-only ControlHint). All sim-layer changes stay behind the existing
story != null null-object seam, so the Survival determinism baseline is untouched by
construction.
Tech Stack: Godot 4.7.1, typed GDScript, GUT 9.7.1 test framework.
Global Constraints
Section titled “Global Constraints”/simfiles (sim/story_state.gd,sim/sim.gd,sim/story_director.gd) must stay pure — no Node/render/Input/Engine/Time APIs. All mode-awareness flows in as data (tutorial_capbool), the same patternsuppress_tutorial/randomizealready use — never readmain._modefrom/sim.- Never call
MetaStore.save_state()in a test. It writes the realuser://meta.jsonsave file — there is no test-sandboxed path. Any test that would otherwise trigger_bank_run()’s save call must setmeta = nullfirst (its existing guardif _run_banked or meta == null: returnmakes this a safe no-op) — seetests/test_meta_store_debug.gd’s header comment for the same rule applied elsewhere. - Full design context:
docs/superpowers/specs/2026-07-21-tutorial-onboarding-design.md. - After the whole plan lands: run the full GUT suite +
scripts/check-test-count.sh+ determinism baseline (Task 8) before considering this done.
Task 1: StoryState.tutorial_cap + thread it through Sim.enable_story()
Section titled “Task 1: StoryState.tutorial_cap + thread it through Sim.enable_story()”Files:
- Modify:
sim/story_state.gd(add field) - Modify:
sim/sim.gd:959(enable_story()signature + body) - Modify:
main.gd:622(the one existing call site) - Test:
tests/test_story_director.gd
Interfaces:
-
Produces:
StoryState.tutorial_cap: bool(defaultfalse);Sim.enable_story(data: StoryData, suppress_tutorial: bool = false, randomize: bool = false, tutorial_cap: bool = false) -> void— new 4th optional param, fully backward compatible with every existing call site (all currently pass 1-3 positional args). -
Step 1: Add the field to
StoryState
In sim/story_state.gd, find:
var suppress_tutorial: bool = false # on replays: skip tutorial-flagged dialoguevar randomize: bool = false # on replays: random enemy types + shuffled weapon drops (metroidvania feel)Add immediately after:
var tutorial_cap: bool = false # true only for the capped first-act tutorial (Ember Reach only, then hands off to Survival)- Step 2: Thread the param through
Sim.enable_story()
In sim/sim.gd, find:
func enable_story(data: StoryData, suppress_tutorial: bool = false, randomize: bool = false) -> void: story = StoryState.new(data) story.suppress_tutorial = suppress_tutorial story.randomize = randomizeReplace with:
func enable_story(data: StoryData, suppress_tutorial: bool = false, randomize: bool = false, tutorial_cap: bool = false) -> void: story = StoryState.new(data) story.suppress_tutorial = suppress_tutorial story.randomize = randomize story.tutorial_cap = tutorial_cap- Step 3: Write the failing test
In tests/test_story_director.gd, add at the end of the file:
func test_enable_story_defaults_tutorial_cap_false(): var sim := _sim() # existing helper — calls enable_story(_map()), no 4th arg assert_false(sim.story.tutorial_cap, "tutorial_cap defaults false when not passed")
func test_enable_story_threads_tutorial_cap_true(): var sim := Sim.new(1, SimContentFixture.db()) sim.enable_story(_map(), false, false, true) assert_true(sim.story.tutorial_cap, "tutorial_cap is set when passed true")- Step 4: Run the tests to verify they fail, then pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_story_director -gexit
Before Step 1-2 edits: test_enable_story_threads_tutorial_cap_true fails (tutorial_cap not a
member of StoryState / wrong arg count). After: both pass.
- Step 5: Update the one real call site
In main.gd, find (inside _new_run()):
sim.enable_story(sdata, not is_tutorial, not is_tutorial)Replace with:
sim.enable_story(sdata, not is_tutorial, not is_tutorial, is_tutorial)- Step 6: Commit
git add sim/story_state.gd sim/sim.gd main.gd tests/test_story_director.gdgit commit -m "feat(story): add StoryState.tutorial_cap, threaded through enable_story()"Task 2: StoryDirector — override the Ember→Hush portal in capped-tutorial mode
Section titled “Task 2: StoryDirector — override the Ember→Hush portal in capped-tutorial mode”Files:
- Modify:
sim/story_director.gd(_enter_room()) - Modify:
data/story.json(newtutorial_completedialogue entry) - Test:
tests/test_story_director.gd
Interfaces:
-
Consumes:
StoryState.tutorial_cap(Task 1). -
Produces: when
tutorial_capis true and the roomember_portalis entered,_say(sim, "tutorial_complete")fires instead of the room’s authoredon_enterid — later tasks pollsim.story.seen_dialogue.has("tutorial_complete")for this. -
Step 1: Write the failing tests
In tests/test_story_director.gd, add:
func test_tutorial_cap_overrides_portal_dialogue(): var rooms := [ {"id": "entry", "kind": "corridor", "region": "ember", "rect": Rect2(0, 0, 400, 300), "doors": []}, {"id": "ember_portal", "kind": "portal", "region": "ember", "rect": Rect2(500, 0, 400, 300), "doors": [], "on_enter": "ember_outro"}, ] var d := StoryData.from_arrays(rooms, [], "entry") d.dialogue = { "ember_outro": {"id": "ember_outro", "speaker": "echo", "lines": ["Hush is next."]}, "tutorial_complete": {"id": "tutorial_complete", "speaker": "echo", "lines": ["Go survive."]}, } d.speakers = {"echo": {"name": "Echo", "color": "#fff"}} var sim := Sim.new(1, SimContentFixture.db()) sim.enable_story(d, false, false, true) # tutorial_cap=true sim.tick_single(InputState.new(Vector2.ZERO)) # enter start room sim.player.pos = Vector2(700, 150) # walk into ember_portal sim.tick_single(InputState.new(Vector2.ZERO)) assert_true(sim.story.seen_dialogue.has("tutorial_complete"), "tutorial_cap fires the capped-tutorial line") assert_false(sim.story.seen_dialogue.has("ember_outro"), "the authored 'Hush is next' line is overridden, not also fired")
func test_normal_story_mode_still_fires_authored_portal_dialogue(): var rooms := [ {"id": "entry", "kind": "corridor", "region": "ember", "rect": Rect2(0, 0, 400, 300), "doors": []}, {"id": "ember_portal", "kind": "portal", "region": "ember", "rect": Rect2(500, 0, 400, 300), "doors": [], "on_enter": "ember_outro"}, ] var d := StoryData.from_arrays(rooms, [], "entry") d.dialogue = { "ember_outro": {"id": "ember_outro", "speaker": "echo", "lines": ["Hush is next."]}, } d.speakers = {"echo": {"name": "Echo", "color": "#fff"}} var sim := Sim.new(1, SimContentFixture.db()) sim.enable_story(d) # tutorial_cap defaults false (regression coverage for the full Story campaign) sim.tick_single(InputState.new(Vector2.ZERO)) sim.player.pos = Vector2(700, 150) sim.tick_single(InputState.new(Vector2.ZERO)) assert_true(sim.story.seen_dialogue.has("ember_outro"), "outside tutorial_cap, the authored on_enter dialogue is unchanged")- Step 2: Run to verify
test_tutorial_cap_overrides_portal_dialoguefails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_story_director -gexit
Expected: FAIL — tutorial_complete never fires (no such id referenced anywhere yet, and no
override branch exists), seen_dialogue.has("ember_outro") is true instead (the un-overridden
behavior).
- Step 3: Add the override in
StoryDirector._enter_room()
In sim/story_director.gd, find:
if room.has("on_enter") and room["on_enter"] != null: _say(sim, String(room["on_enter"]))Replace with:
if room_id == "ember_portal" and sim.story.tutorial_cap: # Capped tutorial (Ember Reach only — docs/superpowers/specs/ # 2026-07-21-tutorial-onboarding-design.md): this run ends here and hands off to # Survival, not to Hush. Override the room's authored "Hush is next" line. _say(sim, "tutorial_complete") elif room.has("on_enter") and room["on_enter"] != null: _say(sim, String(room["on_enter"]))- Step 4: Add the
tutorial_completedialogue entry todata/story.json
In data/story.json, find the ember_outro dialogue entry:
{ "id": "ember_outro", "speaker": "echo", "lines": [ "A portal, humming open.", "The Hush is next — colder, quieter. I'll meet you there.", "Climb." ] },Add a new entry immediately after it (still inside the "dialogue" array):
{ "id": "tutorial_complete", "speaker": "echo", "lines": [ "The first Warden falls. You've got real instincts now.", "Everything past here is the same fight, forever — the swarm doesn't stop for anyone.", "Go. Survive as long as you can." ] },- Step 5: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_story_director -gexit
Expected: PASS for both new tests, and all pre-existing tests in the file still pass (regression
check — nothing else references ember_portal by id).
- Step 6: Commit
git add sim/story_director.gd data/story.json tests/test_story_director.gdgit commit -m "feat(story): capped tutorial hands off at ember_portal instead of continuing to Hush"Task 3: Ember Reach dash/warp teaching content
Section titled “Task 3: Ember Reach dash/warp teaching content”Files:
- Modify:
data/story.json(ember_introdialogue lines) - Test:
tests/test_story_loader.gd
Interfaces:
-
None (pure content — no new ids, no schema change).
-
Step 1: Write the failing test
In tests/test_story_loader.gd, add at the end of the file (check the file’s existing helper
for loading the real shipped data/story.json — it should already have one; if it loads via
StoryLoader.load_from_path("res://data/story.json") directly in other tests, mirror that
exactly):
func test_ember_intro_teaches_dash_warp(): var data := StoryLoader.load_from_path("res://data/story.json") assert_not_null(data, "the real story.json must load") var dlg: Dictionary = data.dialogue.get("ember_intro", {}) var lines: Array = dlg.get("lines", []) var joined := "\n".join(lines) var mentions_warp := joined.findn("warp") != -1 or joined.findn("dash") != -1 assert_true(mentions_warp, "ember_intro must teach the dash/warp mechanic")- Step 2: Run to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_story_loader -gexit
Expected: FAIL — today’s ember_intro lines mention neither “warp” nor “dash”.
- Step 3: Add the dash/warp lines to
ember_intro
In data/story.json, find:
"id": "ember_intro", "speaker": "echo", "lines": [ "You're awake. Good.", "The Spectrum is going dark, from the top down.", "Up is the only way that was ever open. Take the blade and climb.", "Your weapons fight on their own — just fly, and they find their marks. The right stick throws an aimed bolt.", "And if you're torn up — hold still a moment. Light mends what stops moving.", "The blade sweeps an arc around you — no aiming. Charge in close and it cuts everything it touches." ],Replace with (one new line inserted after the weapons line, before the heal line):
"id": "ember_intro", "speaker": "echo", "lines": [ "You're awake. Good.", "The Spectrum is going dark, from the top down.", "Up is the only way that was ever open. Take the blade and climb.", "Your weapons fight on their own — just fly, and they find their marks. The right stick throws an aimed bolt.", "Tap the warp when it's tight — for a heartbeat you can't be touched. Watch the glow at your engine: dark means it's spent, bright means it's charged and ready.", "And if you're torn up — hold still a moment. Light mends what stops moving.", "The blade sweeps an arc around you — no aiming. Charge in close and it cuts everything it touches." ],- Step 4: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_story_loader -gexit
Expected: PASS. Also run -gselect=test_story_director again — ember_intro‘s content isn’t
touched by those tests’ fixtures (they build their own inline StoryData), so no regression
expected, but confirm.
- Step 5: Commit
git add data/story.json tests/test_story_loader.gdgit commit -m "content(story): teach dash/warp in ember_intro (was the one untaught mechanic)"Task 4: main.gd — capped-tutorial → Survival auto-handoff
Section titled “Task 4: main.gd — capped-tutorial → Survival auto-handoff”Files:
- Modify:
main.gd(new var,_physics_process(),_bank_run(),_new_run()reset) - Test:
tests/test_main.gd
Interfaces:
-
Consumes:
sim.story.tutorial_cap(Task 1),sim.story.seen_dialogue.has("tutorial_complete")(Task 2). -
Produces:
main._tutorial_run_taught_player(s: StoryState) -> bool(pure, instance method, noselfstate) — a decision helper later reused nowhere else in this plan, but written pure so it’s unit-testable without touchingMetaStore. -
Step 1: Add the new instance var
In main.gd, find:
var _story_won: bool = false # latched when the story campaign is completed (victory screen)Add immediately after:
var _tutorial_cap_won: bool = false # latched once the capped tutorial (Ember Reach only) hands off to Survival- Step 2: Reset it in
_new_run()
In main.gd, find:
_run_banked = false _story_won = false _boss_music_active = falseReplace with:
_run_banked = false _story_won = false _tutorial_cap_won = false _boss_music_active = false- Step 3: Add the pure
_tutorial_run_taught_player()helper and use it in_bank_run()
In main.gd, find:
# Tutorial counts as "done" only once the player gets PAST the first domain (reaches the # Hush) or finishes the campaign — dying in room 1 shouldn't disable tutorials forever. if _is_story() and sim.story != null and (sim.story.complete or sim.story.regions_seen.size() > 1): meta.tutorial_done = true # future runs skip tutorials + randomizeReplace with:
# Tutorial counts as "done" once the player gets PAST the first domain (reaches the Hush), # finishes the full campaign, or clears the capped first-act tutorial — dying in room 1 # shouldn't disable tutorials forever. if _is_story() and sim.story != null and _tutorial_run_taught_player(sim.story): meta.tutorial_done = true # future runs skip tutorials + randomizeThen, directly above func _bank_run() -> void:, add:
# Pure decision, no I/O — unit-testable without touching the real user://meta.json save file# (see test_meta_store_debug.gd's header comment for why that matters).func _tutorial_run_taught_player(s: StoryState) -> bool: return s.complete or s.regions_seen.size() > 1 or s.seen_dialogue.has("tutorial_complete")- Step 4: Add the handoff block in
_physics_process()
(Correction, 2026-07-22, from Task 4’s review: the anchor text below is actually inside
_physics_process(), not _process() — this file has a separate, unrelated _process() doing
render-FX decay. The anchor text is unambiguous and correct either way; only this label was
wrong. Placement is outside the dev render-timescale for _s in range(steps): loop, so it fires
at most once per _physics_process() call, matching the adjacent _story_won pattern.)
In main.gd, find:
if sim.story.complete and not _story_won: _story_won = true _bank_run() audio.stop_music() results.show_victory(sim) # ASCENT COMPLETE audio.victory()Insert immediately BEFORE it (still inside the enclosing if sim.story != null: block, after
region_title.consume(sim.story.events)):
if _mode == "tutorial" and sim.story.tutorial_cap and not _tutorial_cap_won \ and sim.story.seen_dialogue.has("tutorial_complete") and not dialogue_box.is_showing(): _tutorial_cap_won = true _bank_run() _mode = "survival" audio.play_music("survival_loop") _new_run() return # `sim` now points at a brand-new Survival run; don't touch it again this frame- Step 5: Write the failing test
In tests/test_main.gd, add at the end of the file:
# ── Capped tutorial → Survival handoff (2026-07-21) ─────────────────────────────────────────func test_tutorial_run_taught_player_true_on_regions_seen(): var s := StoryState.new() s.regions_seen = {"ember": true, "hush": true} var m = preload("res://main.gd").new() assert_true(m._tutorial_run_taught_player(s)) m.free()
func test_tutorial_run_taught_player_true_on_tutorial_complete_seen(): var s := StoryState.new() s.seen_dialogue = {"tutorial_complete": true} var m = preload("res://main.gd").new() assert_true(m._tutorial_run_taught_player(s)) m.free()
func test_tutorial_run_taught_player_false_for_fresh_state(): var s := StoryState.new() var m = preload("res://main.gd").new() assert_false(m._tutorial_run_taught_player(s)) m.free()
func test_capped_tutorial_hands_off_to_survival_at_ember_portal(): var m = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame m._on_mode_chosen("tutorial") assert_not_null(m.sim.story, "precondition: tutorial mode runs the story sim") m.meta = null # prevent _bank_run() from writing the real user://meta.json in this test # Teleport straight to ember_portal — data/story.json's real rect (see docs/superpowers/ # specs/2026-07-21-tutorial-onboarding-design.md's Context section for the room layout). m.sim.player.pos = Vector2(0, -4940) # center of ember_portal's rect [-450,-5200,900,520] await get_tree().physics_frame await get_tree().process_frame assert_true(m.dialogue_box.is_showing(), "the tutorial_complete beat is showing") assert_eq(m._mode, "tutorial", "handoff has not fired yet -- dialogue still up") # Drain the dialogue (bounded loop -- don't hardcode the real content's line count). var guard := 0 while m.dialogue_box.is_showing() and guard < 10: var a := InputEventJoypadButton.new() a.button_index = JOY_BUTTON_A a.pressed = true get_tree().root.push_input(a) await get_tree().physics_frame await get_tree().process_frame guard += 1 assert_false(m.dialogue_box.is_showing(), "dialogue fully drained within the guard bound") await get_tree().physics_frame await get_tree().process_frame assert_eq(m._mode, "survival", "auto-handed-off into Survival") assert_null(m.sim.story, "the new run is a plain Survival sim, not story")- Step 6: Run to verify the new tests fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_main -gexit
Expected: FAIL — _tutorial_run_taught_player doesn’t exist yet; the handoff test never sees
_mode become "survival".
- Step 7: Run to verify they pass
Same command as Step 6. Expected: PASS. Also re-run the full test_main file to confirm no
regressions in pre-existing tests (particularly test_tutorial_read_freeze_resumes_after_confirm_skip,
which exercises the same dialogue-pause machinery).
- Step 8: Commit
git add main.gd tests/test_main.gdgit commit -m "feat(story): capped tutorial auto-launches Survival once ember_portal is reached"Task 5: main.gd — force fresh saves straight into Tutorial (skip the menu)
Section titled “Task 5: main.gd — force fresh saves straight into Tutorial (skip the menu)”Files:
- Modify:
main.gd(_ready()) - Test:
tests/test_main.gd
Interfaces:
-
Produces:
main._should_force_tutorial(m: MetaState) -> bool(pure, instance method). -
Step 1: Write the failing test
In tests/test_main.gd, add:
func test_should_force_tutorial_true_for_fresh_save(): var s := MetaState.new() assert_false(s.tutorial_done, "sanity: a fresh MetaState defaults tutorial_done=false") var m = preload("res://main.gd").new() assert_true(m._should_force_tutorial(s)) m.free()
func test_should_force_tutorial_false_once_done(): var s := MetaState.new() s.tutorial_done = true var m = preload("res://main.gd").new() assert_false(m._should_force_tutorial(s)) m.free()
func test_should_force_tutorial_false_for_null_meta(): var m = preload("res://main.gd").new() assert_false(m._should_force_tutorial(null)) m.free()- Step 2: Run to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_main -gexit
Expected: FAIL — _should_force_tutorial doesn’t exist yet.
- Step 3: Add the helper and wire it into
_ready()
In main.gd, find:
# Headless (CI / boot smoke) has no display to drive the menu — auto-start survival # so the boot path still exercises a real run. A real display gets the mode picker. if DisplayServer.get_name() == "headless": _new_run() else: _show_start_menu()Replace with:
# Headless (CI / boot smoke) has no display to drive the menu — auto-start survival # so the boot path still exercises a real run. A real display: a fresh save (never # finished the tutorial) is forced straight into it, no menu stop; everyone else gets # the normal picker (Tutorial stays selectable there too, for a replay). if DisplayServer.get_name() == "headless": _new_run() elif _should_force_tutorial(meta): _on_mode_chosen("tutorial") else: _show_start_menu()Directly above func _is_story() -> bool:, add:
# Pure decision, no I/O — unit-testable without touching the real user://meta.json save file.func _should_force_tutorial(m: MetaState) -> bool: return m != null and not m.tutorial_done- Step 4: Run to verify it passes
Same command as Step 2. Expected: PASS.
- Step 5: Commit
git add main.gd tests/test_main.gdgit commit -m "feat(story): fresh saves launch straight into the Tutorial, skipping the menu"Task 6: ControlHint — re-add for Tutorial-mode runs only, and fix its dismiss wiring
Section titled “Task 6: ControlHint — re-add for Tutorial-mode runs only, and fix its dismiss wiring”Files:
- Modify:
main.gd(_new_run(), the per-frameadvance()call) - Modify:
tests/test_main.gd(update the 2026-07-05 “never shown” test to be Survival-specific; add a Tutorial-specific test)
Interfaces:
-
None new — reuses existing
ControlHint.advance(dt: float, moved: bool) -> voidandControlHint.done() -> bool(seeui/control_hint.gd). -
Step 1: Update the existing regression test to be Survival-specific
In tests/test_main.gd, find:
# ── Control hint no longer shown at run start (2026-07-05, Chris) ───────────────────────────func test_control_hint_is_not_shown_at_run_start() -> void: var m = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame m._on_mode_chosen("survival") assert_null(m.control_hint, "the controls-reminder overlay must not appear at run start")Replace with:
# ── Control hint stays off for Survival (2026-07-05, Chris) — Tutorial is the new onboarding# entry point (2026-07-21) and gets its own hint below; this removal is unchanged for Survival.func test_control_hint_is_not_shown_in_survival() -> void: var m = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame m._on_mode_chosen("survival") assert_null(m.control_hint, "the controls-reminder overlay must not appear in Survival")
func test_control_hint_is_shown_in_tutorial() -> void: var m = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame m._on_mode_chosen("tutorial") assert_not_null(m.control_hint, "the controls-reminder overlay appears at the start of a Tutorial run")- Step 2: Run to verify
test_control_hint_is_shown_in_tutorialfails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_main -gexit
Expected: FAIL — control_hint is still always null.
- Step 3: Gate the instantiation on Tutorial mode
In main.gd, find:
# First-run controls reminder -- disabled at run start (2026-07-05, Chris). control_hint = nullReplace with:
# Controls reminder -- Tutorial-mode runs only (2026-07-21). Survival's removal (2026-07-05, # Chris, commit c7ef9d9) stays as-is; Tutorial is the new mandatory-by-default first-run # flow, so it gets an explicit movement callout before the player reaches Echo. control_hint = null if _mode == "tutorial": control_hint = ControlHint.new() add_child(control_hint)- Step 4: Fix the dismiss-trigger wiring bug
In main.gd, find:
if is_instance_valid(control_hint): control_hint.advance(delta, not _auto or sim.game_over) if control_hint.done(): control_hint.queue_free() control_hint = nullReplace with:
if is_instance_valid(control_hint): # `moved` = the player has actually moved (sim.player.velocity, set every tick — not # `not _auto`, which was true from frame 1 of any human session and dismissed the hint # almost instantly, defeating its own "show for a few seconds" purpose). control_hint.advance(delta, sim.player.velocity.length() > 1.0 or sim.game_over) if control_hint.done(): control_hint.queue_free() control_hint = null- Step 5: Run to verify both tests pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_main -gexit
Expected: PASS. Re-run the full test_main + test_control_hint files to confirm no
regressions.
- Step 6: Commit
git add main.gd tests/test_main.gdgit commit -m "feat(ui): show ControlHint for Tutorial runs; fix its dismiss-trigger wiring"Task 7: ui/start_menu.gd — narrow V01_CRYSTALS_ONLY to Tutorial + Survival
Section titled “Task 7: ui/start_menu.gd — narrow V01_CRYSTALS_ONLY to Tutorial + Survival”Files:
- Modify:
ui/start_menu.gd - Modify:
tests/test_start_menu.gd(existing test hardcodes the old Survival-only content) - Modify:
tests/test_start_menu_modes.gd(existing tests hardcodetutorialas hidden)
Interfaces:
-
None new.
-
Step 1: Update
tests/test_start_menu.gd’s existing content-assertion test
Two existing tests currently hardcode the Survival-only menu; find and confirm both before editing:
tests/test_start_menu.gd:11-31(test_default_ready_state_shows_survival_and_ship_two_cards) asserts_items[0]is"survival",_items[1]is"ship_config", and an item-count of 2-3.tests/test_start_menu_modes.gd:3-21(test_menu_mode_offering) assertstutorialis hidden whenV01_CRYSTALS_ONLYis true.tests/test_start_menu_modes.gd:23-34(test_first_launch_shows_only_survival) only asserts Survival is present (doesn’t assert Tutorial’s absence), so it stays correct either way, but its name becomes misleading once Tutorial also shows — rename for accuracy.
In tests/test_start_menu.gd, find:
func test_default_ready_state_shows_survival_and_ship_two_cards() -> void: var m := StartMenu.new() add_child_autofree(m) await get_tree().process_frame # _ready() has run: V01_CRYSTALS_ONLY=true -> one mode card, plus Ship is always offered # (has_progressed defaults false, so Shop/Enemies don't join yet) -> Survival + Ship, plus # a REMOTE card too when BuildConfig.dev_tools() is true (always true under the headless # test runner -- it's still the editor binary, just run without a window -- so don't # hardcode an exact item count here; assert the two guaranteed items are present and in # order instead). # Every reveal-tracked node (including carousel cards) starts at alpha 0 and eases in # via _process -- fast-forward past REVEAL_DUR before asserting the settled alpha. m._process(1.0) assert_eq(String(m._items[0][0]), "survival") assert_eq(String(m._items[1][0]), "ship_config") assert_eq(m._items.size(), 3 if BuildConfig.dev_tools() else 2, "Survival + Ship, plus Remote iff dev_tools()") var carousel_cards := m._carousel_root.get_children() var expected_carousel_cards := 2 if m._items.size() == 2 else 3 assert_eq(carousel_cards.size(), expected_carousel_cards, "center + side neighbour(s), depending on item count") assert_almost_eq(carousel_cards[0].scale.x, StartMenu.CENTER_SCALE, 0.01) assert_almost_eq(carousel_cards[0].modulate.a, 1.0, 0.01)Replace with:
func test_default_ready_state_shows_tutorial_survival_and_ship() -> void: var m := StartMenu.new() add_child_autofree(m) await get_tree().process_frame # _ready() has run: V01_CRYSTALS_ONLY=true -> Tutorial + Survival mode cards, plus Ship is # always offered (has_progressed defaults false, so Shop/Enemies don't join yet) -> Tutorial # + Survival + Ship, plus a REMOTE card too when BuildConfig.dev_tools() is true (always true # under the headless test runner -- it's still the editor binary, just run without a window # -- so don't hardcode an exact item count here; assert the guaranteed items are present and # in order instead). # Every reveal-tracked node (including carousel cards) starts at alpha 0 and eases in # via _process -- fast-forward past REVEAL_DUR before asserting the settled alpha. m._process(1.0) assert_eq(String(m._items[0][0]), "tutorial") assert_eq(String(m._items[1][0]), "survival") assert_eq(String(m._items[2][0]), "ship_config") assert_eq(m._items.size(), 4 if BuildConfig.dev_tools() else 3, "Tutorial + Survival + Ship, plus Remote iff dev_tools()") var carousel_cards := m._carousel_root.get_children() assert_eq(carousel_cards.size(), 3, "center + 2 neighbours once 3+ items exist") assert_almost_eq(carousel_cards[0].scale.x, StartMenu.CENTER_SCALE, 0.01) assert_almost_eq(carousel_cards[0].modulate.a, 1.0, 0.01)In tests/test_start_menu_modes.gd, find:
func test_menu_mode_offering() -> void: var menu := StartMenu.new() menu.has_progressed = true # Shop + Enemies are gated on progress add_child_autofree(menu) await get_tree().process_frame var ids: Array = [] for c in menu._cards: ids.append(String(c.get_meta("mode", ""))) # The Survival card (id "survival") is in _cards; footer buttons are no longer in _cards. assert_true(ids.has("survival"), "the Survival mode is offered") # Footer buttons (shop, bestiary) should NOT be in _cards (they're no longer part of carousel nav) assert_false(ids.has("shop") or ids.has("bestiary"), "footer buttons are not in the carousel card list") if StartMenu.V01_CRYSTALS_ONLY: # v0.1: the other modes are gated off the launch menu. assert_false(ids.has("story"), "story hidden for v0.1") assert_false(ids.has("tutorial"), "tutorial hidden for v0.1") else: assert_true(ids.has("tutorial") and ids.has("story") and ids.has("survival"), "all modes offered when the v0.1 gate is off")
func test_first_launch_shows_only_survival() -> void: var menu := StartMenu.new() menu.has_progressed = false # never played add_child_autofree(menu) await get_tree().process_frame var ids: Array = [] for c in menu._cards: ids.append(String(c.get_meta("mode", ""))) assert_true(ids.has("survival"), "first launch still offers the Survival button") assert_false(ids.has("shop"), "no Shop on first launch") assert_false(ids.has("bestiary"), "no Enemies on first launch")Replace with:
func test_menu_mode_offering() -> void: var menu := StartMenu.new() menu.has_progressed = true # Shop + Enemies are gated on progress add_child_autofree(menu) await get_tree().process_frame var ids: Array = [] for c in menu._cards: ids.append(String(c.get_meta("mode", ""))) # The Survival card (id "survival") is in _cards; footer buttons are no longer in _cards. assert_true(ids.has("survival"), "the Survival mode is offered") # Footer buttons (shop, bestiary) should NOT be in _cards (they're no longer part of carousel nav) assert_false(ids.has("shop") or ids.has("bestiary"), "footer buttons are not in the carousel card list") if StartMenu.V01_CRYSTALS_ONLY: # v0.1 (2026-07-21 on): Tutorial + Survival are offered; the full Story campaign stays # gated off the launch menu (a bigger, separate commitment). assert_true(ids.has("tutorial"), "tutorial is offered for v0.1") assert_false(ids.has("story"), "story hidden for v0.1") else: assert_true(ids.has("tutorial") and ids.has("story") and ids.has("survival"), "all modes offered when the v0.1 gate is off")
func test_first_launch_shows_tutorial_and_survival() -> void: var menu := StartMenu.new() menu.has_progressed = false # never played add_child_autofree(menu) await get_tree().process_frame var ids: Array = [] for c in menu._cards: ids.append(String(c.get_meta("mode", ""))) assert_true(ids.has("tutorial"), "first launch offers the Tutorial button") assert_true(ids.has("survival"), "first launch still offers the Survival button") assert_false(ids.has("shop"), "no Shop on first launch") assert_false(ids.has("bestiary"), "no Enemies on first launch")- Step 2: Run to verify both files now fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_start_menu -gexit
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_start_menu_modes -gexit
Expected: FAIL on both — today’s menu is still Survival-only.
- Step 3: Narrow the flag’s effect
In ui/start_menu.gd, find:
# v0.1 launch flag: show ONLY Survival on the menu (other modes gated off, easily# restored by flipping this to false). The other modes still exist + pass their tests.const V01_CRYSTALS_ONLY := trueReplace with:
# v0.1 launch flag: show only Tutorial + Survival on the menu (Story stays gated off — a# bigger, separate commitment deferred past v0.1). Easily restored to all three by flipping# this to false. See docs/superpowers/specs/2026-07-21-tutorial-onboarding-design.md.const V01_CRYSTALS_ONLY := trueThen find:
# v0.1 App Store launch ships ONLY Survival. The other modes are built + tested but # gated off the launch menu; flip V01_CRYSTALS_ONLY to false to restore the full mode picker. if V01_CRYSTALS_ONLY: modes = [["survival", "SURVIVAL", "Endless waves, build your run", NeonTheme.CYAN, "mode"]]Replace with:
# v0.1 App Store launch ships Tutorial + Survival (Story stays gated off — see the # 2026-07-21 design doc). Flip V01_CRYSTALS_ONLY to false to restore the full 3-mode picker. if V01_CRYSTALS_ONLY: modes = [ ["tutorial", "TUTORIAL", "Learn the ropes, guided and paused", Color(0.5, 0.95, 0.62), "mode"], ["survival", "SURVIVAL", "Endless waves, build your run", NeonTheme.CYAN, "mode"], ]- Step 4: Run to verify all three test files pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_start_menu -gexit
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_start_menu_modes -gexit
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_story_ui -gexit
Expected: PASS on all three (test_story_ui.gd isn’t edited but exercises the same StartMenu
carousel windowing — confirm no regression there).
- Step 5: Commit
git add ui/start_menu.gd tests/test_start_menu.gd tests/test_start_menu_modes.gdgit commit -m "feat(menu): restore Tutorial alongside Survival on the v0.1 mode picker"Task 8: Full verification sweep
Section titled “Task 8: Full verification sweep”Files: none (verification only)
- Step 1: Full GUT suite
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: exit 0, 0 failures.
- Step 2: Test-count guard
Run: scripts/check-test-count.sh
Expected: passes (no silent stale-class-cache script drop).
- Step 3: Determinism baseline
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_determinism_survival -gexit
and
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_determinism_coop -gexit
Expected: PASS, unchanged baselines (4217109746/3312602286 for survival,
370166760/2437492589 for co-op — see CLAUDE.md’s Architecture section). Every change in
this plan lives behind story != null or is pure render (ControlHint), so no re-pin is
expected; if either test fails, that is a real regression to investigate, not a re-pin to apply.
- Step 4: Headless boot smoke
Run: godot --headless --path . --quit-after 120
Expected: no SCRIPT ERROR in stderr. This exercises the _ready() headless branch (still
_new_run() → Survival, unaffected by Task 5’s change since that only applies with a real
display).
- Step 5: Manual playtest on Mac (not automatable — do this by hand)
Export or run in-editor (godot --path .), with a fresh user://meta.json (rename/back up the
real one first if testing on Chris’s machine, then restore it — do NOT delete it):
- Fresh launch → should skip the menu, land directly in a Tutorial run.
- Walk to Echo (movement +
ControlHintboth readable). - Confirm
ember_introdialogue now mentions the warp/dash line. - Fight through to the Ember Warden, defeat it, walk into the portal room.
- Confirm the
tutorial_completebeat plays (not “The Hush is next”), then the game auto-drops into a Survival run with no menu stop. - Quit and relaunch — menu should now show Tutorial + Survival (2-entry picker), not force Tutorial again.
This is the step that actually validates the “short victory beat, no hard lock, teaches everything asked for” experience end-to-end — the automated tests above cover the mechanics, not the feel.
- Step 6: Update
docs/RELEASE-0.1.md
Mark the “Tutorial onboarding (Ember Reach)” item [x] with a ✅ DONE (commit range) note,
following the file’s existing convention (see any other [x] entry in ## 2 · RC-blockers for
the exact style).