Skip to content

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.

  • /sim files (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_cap bool), the same pattern suppress_tutorial/randomize already use — never read main._mode from /sim.
  • Never call MetaStore.save_state() in a test. It writes the real user://meta.json save file — there is no test-sandboxed path. Any test that would otherwise trigger _bank_run()’s save call must set meta = null first (its existing guard if _run_banked or meta == null: return makes this a safe no-op) — see tests/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 (default false); 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 dialogue
var 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 = randomize

Replace 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
Terminal window
git add sim/story_state.gd sim/sim.gd main.gd tests/test_story_director.gd
git 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 (new tutorial_complete dialogue entry)
  • Test: tests/test_story_director.gd

Interfaces:

  • Consumes: StoryState.tutorial_cap (Task 1).

  • Produces: when tutorial_cap is true and the room ember_portal is entered, _say(sim, "tutorial_complete") fires instead of the room’s authored on_enter id — later tasks poll sim.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_dialogue fails

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_complete dialogue entry to data/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
Terminal window
git add sim/story_director.gd data/story.json tests/test_story_director.gd
git 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_intro dialogue 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
Terminal window
git add data/story.json tests/test_story_loader.gd
git 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, no self state) — a decision helper later reused nowhere else in this plan, but written pure so it’s unit-testable without touching MetaStore.

  • 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 = false

Replace 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 + randomize

Replace 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 + randomize

Then, 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
Terminal window
git add main.gd tests/test_main.gd
git 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
Terminal window
git add main.gd tests/test_main.gd
git 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-frame advance() 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) -> void and ControlHint.done() -> bool (see ui/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_tutorial fails

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 = null

Replace 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 = null

Replace 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
Terminal window
git add main.gd tests/test_main.gd
git 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 hardcode tutorial as 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) asserts tutorial is hidden when V01_CRYSTALS_ONLY is 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 := true

Replace 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 := true

Then 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
Terminal window
git add ui/start_menu.gd tests/test_start_menu.gd tests/test_start_menu_modes.gd
git commit -m "feat(menu): restore Tutorial alongside Survival on the v0.1 mode picker"

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):

  1. Fresh launch → should skip the menu, land directly in a Tutorial run.
  2. Walk to Echo (movement + ControlHint both readable).
  3. Confirm ember_intro dialogue now mentions the warp/dash line.
  4. Fight through to the Ember Warden, defeat it, walk into the portal room.
  5. Confirm the tutorial_complete beat plays (not “The Hush is next”), then the game auto-drops into a Survival run with no menu stop.
  6. 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).