Skip to content

Tutorial Stage Gating — Design (Revised)

Tutorial Stage Gating — Design (Revised)

Section titled “Tutorial Stage Gating — Design (Revised)”

Status: revised after 4-lens adversarial review of the first draft. Every blocker/important finding is folded into the stage list and architecture below, not appended as a caveat. This version is what a spec/plan/subagent-driven-development cycle should implement.

Decisions confirmed by Chris (2026-08-10) — resolves Open Questions #1 and #3

Section titled “Decisions confirmed by Chris (2026-08-10) — resolves Open Questions #1 and #3”
  1. Stage 2 teaches the Blaster, not the Blade. Both are granted via the same shared echo_intro NPC reward, which is also used by ordinary (currently hidden) Story mode — swapping the reward data outright would silently change Story’s own first-weapon content the moment it’s ever re-exposed, breaking this design’s own “Story completely unaffected” constraint. Fix: remap the reward id at the call site, gated on tutorial_cap, not in the shared reward data. sim/story_director.gd’s NPC-interaction handler (~line 82, currently sim.grant_story_reward(String(npc["reward"]), npos)):

    var reward_id := String(npc["reward"])
    if sim.story.tutorial_cap and reward_id == "give_blade":
    reward_id = "give_blaster"
    sim.grant_story_reward(reward_id, npos)

    Add a new reward entry to data/story.json’s rewards array:

    { "id": "give_blaster", "kind": "weapon", "weapon": "blaster" }

    grant_weapon’s only preconditions are the weapon existing in weapon_by_id (confirmed: "blaster" is a real key, sim/pilot_arsenal.gd:94) and slot capacity — no special-casing needed. Story mode keeps granting Blade exactly as today; only a Tutorial run’s echo_intro resolves to Blaster. Stage 2’s intro text is unchanged (it never named the weapon).

  2. ember_e1 spawns one enemy at a time during Tutorial, so a multi-kill-in-one-tick level-up stack (Stage 7’s open question) becomes structurally impossible, not just capped. Chris’s own framing: “it probably shouldn’t be possible to do that in the tutorial.” The wave system (sim/story_director.gd::_drive_encounter) is purely timer-driven — it advances to the next wave when _wave_timer <= 0.0, regardless of whether the previous wave’s enemies are still alive — so today’s ember_e1 (2 swarmers at 0.6s, 2 more at 2.5s) can have up to 4 enemies concurrent. A timer-tuning fix would only make simultaneous deaths unlikely, not impossible. Fix: a real wait-for-clear wave-advance mode, gated on tutorial_cap so it only changes Tutorial’s pacing, never Story’s:

    # sim/story_director.gd::_drive_encounter(), replacing the unconditional timer countdown
    if _wave_index < waves.size():
    var solo_spawn := sim.story.tutorial_cap and bool(enc.get("solo_spawn", false))
    if solo_spawn and sim.enemies.count > 0:
    pass # hold the next wave until the arena is clear — no RNG, pure sim-state read
    else:
    _wave_timer -= dt
    if _wave_timer <= 0.0:
    _spawn_wave(sim, waves[_wave_index], _wave_index)
    _wave_index += 1
    if _wave_index < waves.size():
    _wave_timer = float(waves[_wave_index].get("delay", 1.0))
    elif is_survive and waves.size() > 0:
    _wave_index = 0
    _wave_timer = float(waves[0].get("delay", 1.0)) + 1.0

    data/story.json: add "solo_spawn": true to ember_e1’s encounter object. Because the check is tutorial_cap AND enc.solo_spawn, this is inert in Story mode even though the same data flag is present in the shared encounter definition — matches the pattern used for the Blaster remap above, and is the reason both decisions read the same way: gate the behavior on tutorial_cap at the code site, never by forking the shared content data itself. Determinism-safe: sim.enemies.count is a pure existing sim-state read, no new RNG draw. Consequence for Stage 7: with only ever one enemy alive, a single kill can still in principle carry enough XP to cross two level thresholds at once (an XP-tuning question, not a spawn-count one) — keep Stage 7’s “cap the first bank at 1” as a cheap defensive backstop, but it’s no longer the primary fix; solo-spawn removes the actual multi-kill-same-tick case entirely.

What changed since the first draft (read this before the stage list)

Section titled “What changed since the first draft (read this before the stage list)”
  1. ui/weapon_panel.gd’s Aim-tile gate (Stages 3/10) now has an explicit sim.story != null guard. The first draft’s snippet would null-dereference every frame in Survival.
  2. Every new per-tick teaching check (Stages 5–8) now calls sim.story.tutorial_locked(id) — never a hand-rolled seen_dialogue.has(id) check — because tutorial_locked() already folds in tutorial_cap. story_director.update() runs for ANY story != null run, Story mode included, so a hand-rolled check that skips tutorial_cap would leak new narration into ordinary (non-tutorial) Story mode.
  3. ui/hud.gd and ui/threat_radar.gd need NO visibility gate. The first draft’s architecture section wrongly listed them as gate sites; the per-stage detail was right (they’re already content-gated and narration-only). Removed from the gate-site list below.
  4. Stage 4 (dash) is now gated inside /sim (sim/sim.gd::_update_dash), not in main.gd. Gating upstream of sim.tick() only protects call sites that route through that one point in main.gd — it’s silently bypassed by anything that calls sim.tick()/tick_single() directly (a tool, a test, a future AI tutorial-player). Moving the check into _update_dash itself matches Stage 3’s pattern and covers every caller by construction.
  5. Stage 3’s trigger point was wrong. ember_a1’s on_enter is NOT empty — it already fires echo_xp (leveling orientation text), confirmed directly against data/story.json:442. Left untouched (see the corrected trigger map below); aim_intro moves to a genuinely empty slot.
  6. The orphaned Pulse-weapon beat (ember_e1 clear → give_pulse + pulse_learn) is now explicitly folded into Stage 4 instead of being left as an unreviewed leftover the first draft never accounted for.
  7. Local co-op join is now explicitly blocked for the duration of Tutorial. The first draft assumed single-pilot without saying so; main.gd’s P2-join trigger has no _mode guard today, so a second controller pressing A mid-Tutorial would drop in a pilot with an empty active_weapon_ids and no path to any of the pickups.
  8. ui/control_hint.gd is now explicitly addressed — removed for Tutorial mode, since the staged dialogue sequence now teaches everything it used to summarize in one blanket toast.
  9. The weapon dock / drone dock’s default-hidden state is now explicitly overridden for Tutorial — both are invisible by default in every mode until the player discovers the Y/V toggle; without an override, Stages 2/3/9/10’s “the tile appears” claims are false.
  10. Story Board’s capture.gd deadlock on the level-up panel is now a required fix, shipped in the same change as Stage 7, not a follow-up. Without it, no unattended Tutorial capture run can ever reach a level-up (Stage 7’s gated panel or the pre-existing boss-fight XP trickle, whichever fires first) and capture.gd hangs forever with zero output.
  11. A new pre-implementation check on ui/ship_config_panel.gd — reachable via the pause menu mid-Tutorial, never audited by any research pass, and possibly rendering the manual weapon’s name/stats before aim_intro has taught it.

Single mechanism, one helper: StoryState.tutorial_locked(id: String) -> bool (already exists, sim/story_state.gd), defined as:

func tutorial_locked(id: String) -> bool:
return tutorial_cap and not seen_dialogue.has(id)

This is the ONLY function any gate site should call — never a hand-rolled seen_dialogue.has(id) check, because tutorial_locked() already folds in the tutorial_cap test that distinguishes Tutorial from ordinary (non-capped) Story mode. story != null is true in both, tutorial_cap is only true in Tutorial — the helper gets this right by construction so every call site doesn’t have to re-derive it.

Two call-site patterns, by where the code lives:

  • Inside /sim (any module that already only runs when story != null — e.g. sim/story_director.gd’s own update(), or a function on Sim itself that already has a story field): the null-check is implicit from context, but write it anyway at the actual gate line for readability and to survive refactors:
    if story != null and story.tutorial_locked("<beat_id>"):
    return # or: continue / no-op, depending on the call site
  • Outside /sim (main.gd, any ui/*.gd — these run in EVERY mode, including Survival, where sim.story is always null):
    if sim.story != null and sim.story.tutorial_locked("<beat_id>"):
    <hide / suppress>
    No shorthand, ever. Every snippet in this document below shows the full form. This is the literal fix for the null-deref blocker found in the first draft’s weapon_panel.gd snippet.

One deliberate exception: Stage 12 (Dimension Choice popup) gates on story != null ALONE, not tutorial_cap, because the intent there is to suppress the popup in Story mode too, not just Tutorial. This is called out explicitly at Stage 12 below — don’t copy that one site’s pattern anywhere else.

Gate sites, final list (narration-only elements — HUD label already content-gated, threat radar already content-gated — are NOT gate sites; see point 3 above):

# File Function Guard form
1 sim/sim.gd _fire_aim(pilot, ...) in-/sim form
2 sim/sim.gd _update_dash(pilot, ...) in-/sim form
3 sim/drone_director.gd update_drones(sim, pilot, ...) in-/sim form
4 sim/sim.gd _roll_dimension_choice() exception — story != null only
5 sim/story_director.gd new generalized per-tick check (Stages 5–8) calls tutorial_locked(), never hand-rolled
6 ui/weapon_panel.gd update_panel() (Aim tile) outside-/sim form
7 ui/hud.gd update_hud() (story label only) outside-/sim form (unchanged from first draft — this one was already correct)
8 main.gd level-up panel open check outside-/sim form
9 main.gd co-op P2-join trigger new — _mode != "tutorial", not tutorial_locked() (see Stage 0 below)

Stage 0 (new) — Local co-op is disabled for the duration of Tutorial

Section titled “Stage 0 (new) — Local co-op is disabled for the duration of Tutorial”

Not one of Chris’s named mechanics, but required infrastructure the first draft omitted.

Problem: main.gd’s P2-join condition (if not V01_LOCK_COOP and sim.pilots.size() < 2 and playable.size() >= 2: ... sim.add_pilot()) has no mode check. A second controller joining mid-Tutorial gets sim.add_pilot() → a fresh PilotArsenal whose active_weapon_ids is empty and never populated (Stage 2’s give_blade is a one-time world pickup P1 has almost certainly already collected), while P2’s manual weapon and dash work immediately because seen_dialogue is a single shared flag on sim.story, not per-pilot.

Fix:

# main.gd, the P2-join condition
if not V01_LOCK_COOP and _mode != "tutorial" and sim.pilots.size() < 2 and playable.size() >= 2:
...

Tutorial stays strictly single-pilot. (2-pilot Tutorial onboarding — separate blade pickup, own gate acknowledgements per pilot — is a real design if ever wanted, but out of scope here; flagged in Open Questions.)


  • Trigger: run start / entering ember_entry, first frame of a fresh Tutorial run.
  • Intro text: “You’re in your ship. Move with the left stick.”
  • Gated before: Movement input (move_dir) itself is never code-gated — blocking it risks a softlock, since room-transition triggers require real movement to fire. Revised framing: movement is functionally uninhibited from tick 1, but the run opens with move_intro’s dialogue box up, which freezes the whole sim (same mechanism every other beat uses) for roughly 1–2 seconds before the player has had any opportunity to move at all. This is not a contradiction of “movement isn’t gated” — it’s an unavoidable side effect of using the same dialogue-freeze mechanism as every other beat for the very first line of the game, and there is no room-transition or other objective the player could need to reach during those first couple of seconds. Accept this; it reads as a normal “welcome” beat, not a softlock.
  • Also gated at this point: no auto weapon (active_weapon_ids empty by construction), no manual weapon fire, no dash, no decoy, story/objective HUD label suppressed.
  • ui/control_hint.gd — REMOVED for Tutorial mode, not addressed by the first draft. main.gd:946-948 currently instantiates ControlHint unconditionally for every Tutorial run, showing a single toast covering move/dash/decoy/pause all at once — this both spoils every mechanic the staged design exists to reveal progressively, and visually collides with the dialogue box (near-identical bottom-of-screen position) while running on its own unfrozen 5s wall-clock timer that desyncs from the dialogue-driven freeze. Since Stages 1–4/9 now teach each of those controls individually via dialogue, ControlHint is fully redundant in Tutorial. Fix: delete the if _mode == "tutorial": control_hint = ControlHint.new() block at main.gd:942-948 (or gate it to never fire when _mode == "tutorial"). Confirm ControlHint has no other live caller before deleting the class outright — if none, remove the file too.
  • Implementation:
    • Author move_intro dialogue node in data/story.json, attached to ember_entry’s currently empty on_enter.
    • ui/hud.gd: add suppress_story_label: bool, driven by main.gd off sim.story.tutorial_locked("move_intro") (mode-gated form, safe without an extra null check since it’s only ever assigned inside if _mode == "tutorial": ..., where sim.story is guaranteed non-null):
      # main.gd, before hud.update_hud(sim, delta)
      hud.suppress_story_label = (_mode == "tutorial" and sim.story.tutorial_locked("move_intro"))
      ui/hud.gd::update_hud() itself already correctly null-checks (if sim.story != null and not suppress_story_label:) — unchanged from the first draft, this one site was already right.
    • Remove ControlHint for Tutorial mode (main.gd:942-948), per above.

Stage 2 — Auto-aiming weapon (Blaster, via Echo’s remapped echo_intro reward)

Section titled “Stage 2 — Auto-aiming weapon (Blaster, via Echo’s remapped echo_intro reward)”
  • Trigger: existing NPC-proximity trigger echo_intro in ember_entry (130px radius).
  • Intro text: “This is your auto-weapon. It fires and aims on its own. It’s weak right now — upgrades will make it much stronger.”
  • Weapon identity — CONFIRMED by Chris (2026-08-10): Blaster, not Blade. Matches Survival’s actual starter and demonstrates “always-tracking ranged shot” more cleanly than a melee weapon. Implemented as a Tutorial-only reward-id remap at the NPC-interaction call site (see “Decisions confirmed by Chris” at the top of this doc) — the shared echo_intro/give_blade reward data itself is untouched, so ordinary Story mode still grants Blade if ever re-exposed.
  • Gated before: already correctly inert by construction — Sim.enable_story() clears active_weapon_ids to empty for Tutorial/Story (sim/sim.gd:966), so no auto weapon exists in code until this beat’s reward runs. No new code gate needed beyond the remap above.
  • Unlocked after: the remapped give_blaster reward appends "blaster" to active_weapon_ids; its weapon-panel slot tile appears — but only if the weapon panel itself is visible, see the new dock-visibility fix below.
  • Later automatic weapon pickups are intentionally NOT separate stages. echo_gift in ember_safe1 (give_nova) and the optional ember_secret1 (give_orbit) both add further auto weapons the same way give_blade does. Once Stage 2 has taught “auto weapons appear in your dock when you pick them up,” these later pickups are “more of the same” and don’t need their own teaching beat — same reasoning a normal Survival card-pick weapon add doesn’t need one either. Documented here explicitly so it isn’t mistaken for a gap.
  • Implementation:
    • Trim the existing combined ember_intro dialogue node in data/story.json down to ONLY the auto-weapon/blade content — remove its manual-weapon, dash/warp, and heal lines (moved to Stages 3 and 4 below; the heal line is NOT re-added anywhere else, see minor punch-list).
    • No sim/UI code change required beyond the dock-visibility fix (next section, shared across Stages 2/3/9/10).

New (shared) fix — force the weapon dock and drone dock visible for the whole Tutorial run

Section titled “New (shared) fix — force the weapon dock and drone dock visible for the whole Tutorial run”

Problem, not addressed by the first draft: ui/weapon_panel.gd and ui/drone_dock.gd are both whole CanvasLayers hidden by default in EVERY mode (main.gd:607-609, weapon_panel.visible = false / drone_dock.visible = false), only shown via _toggle_tactical_hud() (Y/V keys, or a touch long-press). ControlHint’s own text never mentioned this toggle either. Stages 2/3/9/10’s “the tile appears” / “Aim tile appears” claims are false unless the player has separately discovered a control nothing in the design teaches.

Fix: force the tactical HUD visible for the duration of Tutorial mode, at the same point main.gd sets up Tutorial-only state (near the removed ControlHint block):

# main.gd, Tutorial setup (~line 942)
if _mode == "tutorial":
weapon_panel.visible = true
drone_dock.visible = true
# if _toggle_tactical_hud() tracks a separate bool driving these, set that instead
# so a manual Y/V press during Tutorial doesn't fight this override — confirm which.

Check _toggle_tactical_hud()’s implementation before writing this — if it reads a bool field rather than the .visible property directly, set that field instead so the two don’t fight each other if the player presses Y/V mid-Tutorial.

Stage 3 — Manual aiming weapon (WeaponAim / “Rift Bolt”, right stick)

Section titled “Stage 3 — Manual aiming weapon (WeaponAim / “Rift Bolt”, right stick)”
  • Trigger — REVISED. ember_a1’s on_enter is NOT empty (it fires echo_xp, confirmed directly in data/story.json:442 — the first draft’s “confirmed empty” claim was wrong and is not reused here). New trigger: encounter ember_e1’s START dialogue, repurposing the existing placeholder id null_1 (confirmed genuinely a placeholder per the story_flow research table, and NOT claimed by anything else in this revised design — Stage 4 below uses a different slot). echo_xp at ember_a1’s on_enter is left completely untouched; it continues to fire as general leveling-orientation flavor text when the player crosses into arena 1, before the wave starts. It does not conflict with Stage 7’s reactive XP-threshold gate, which fires later, when a real threshold is actually crossed.
  • Intro text: “The right stick fires your manual weapon. It hits harder than your auto-weapon, but you have to aim it yourself — it won’t track for you, so it’s easy to miss.”
  • Gated before: currently fires unconditionally from tick 1 (sim/sim.gd::_fire_aim, lines 2249–2261), gated only by input deadzone, not story state.
  • Same-tick ordering — decided, not left ambiguous. story_director.update() runs before the per-pilot weapon-fire loop within the same Sim.tick() call, so mark_dialogue_seen("aim_intro") can flip seen_dialogue on the exact tick aim_intro fires, before that same tick’s _fire_aim call sees the updated flag — a single tick (1/60s) where a player already holding the right stick at that instant could fire one frame early. Decision: accept this as immaterial (imperceptible, not reliably exploitable, and after this revision applies equally to Stage 4’s dash gate now that it also lives in /sim — both gates share the same tick-order behavior, so there’s nothing inconsistent to fix). Documented here so it’s a recorded decision, not an unnoticed gap.
  • Unlocked after: right-stick input actually fires WeaponAim; its panel tile appears (dock now forced visible per the shared fix above) and starts reading live stats.
  • Implementation:
    • sim/sim.gd::_fire_aim(pilot, pilot_input, dt): add as the first line:
      if story != null and story.tutorial_locked("aim_intro"):
      return
    • ui/weapon_panel.gd: gate _aim_root.visible and its per-frame stat read — with the null-check the first draft’s snippet was missing, since update_panel() runs in every mode including Survival where sim.story is always null:
      if sim.story != null and sim.story.tutorial_locked("aim_intro"):
      _aim_root.visible = false
      else:
      _aim_root.visible = true
      # existing per-frame stat read, unchanged
    • data/story.json: new dialogue node aim_intro, attached to ember_e1’s encounter dialogue field (replacing null_1).
    • Pre-implementation check, required before this stage is coded: read ui/ship_config_panel.gd’s weapon-ring build logic directly. It’s reachable mid-Tutorial via the pause menu (main.gd:434-438, opened with the live sim) and was never audited by any research pass for this design. It’s display-only (cannot let the player equip an ungated weapon), but if it renders the Aim weapon’s name/stats before aim_intro has fired, that still spoils the beat. If it does, add the same sim.story != null and sim.story.tutorial_locked("aim_intro") guard to whatever draws that ring entry.

Stage 4 — Dash / Warp (+ folds in the Pulse-weapon beat)

Section titled “Stage 4 — Dash / Warp (+ folds in the Pulse-weapon beat)”
  • Trigger — REVISED. The first draft’s null_1 slot is now used by Stage 3. New trigger: encounter ember_e1’s CLEAR dialogue, repurposing the existing pulse_learn node (currently paired with reward give_pulse — a second weapon pickup the first draft never accounted for and left as an unreviewed leftover). This beat now does double duty: acknowledge the new Pulse weapon AND teach dash — folded into one beat rather than left orphaned or given its own untested new trigger.
  • Intro text (dash portion, unchanged, already-approved copy): “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.”
  • Required content step before implementation: read pulse_learn’s actual current text in data/story.json verbatim (never retrieved verbatim by any research pass so far). Splice the dash lines in as an addition to whatever it already says about the Pulse weapon, not a replacement — don’t silently discard existing approved copy. If pulse_learn’s existing text already covers something that would clash tonally with the dash lines, flag it rather than merging blind.
  • Gated before: currently fires unconditionally from tick 1, gated only by pilot.dash_cd <= 0.0 (sim/sim.gd:2122-2144). No story-state gate exists today.
  • Gate location — REVISED, moved into /sim (was main.gd in the first draft). Gating upstream of sim.tick() in main.gd only protects the one call path that routes through that exact line — it’s silently bypassed by any future caller of sim.tick()/tick_single() directly (a tool, a headless test, the unmerged networked-MP branch, a future AI tutorial-player), and would also need separate, easy-to-drop handling for input2 in the co-op case. Since Stage 0 now blocks co-op join during Tutorial, and gating inside _update_dash(pilot, ...) naturally covers every pilot generically (used or not), this is both simpler and structurally safer than the main.gd approach:
    # sim/sim.gd, _update_dash(pilot: PlayerState, input: InputState, dt: float)
    func _update_dash(pilot: PlayerState, input: InputState, dt: float) -> void:
    if story != null and story.tutorial_locked("dash_intro"):
    return
    ...
  • Unlocked after: dash input executes in _update_dash(); the existing thruster-glow readiness cosmetic (dash_cd <= 0.0) continues to reflect real state — unaffected, since dash_cd itself is untouched by this gate, only the actual dash execution is blocked.
  • Implementation:
    • sim/sim.gd::_update_dash(): add the guard clause above as the first line.
    • data/story.json: rename/repoint ember_e1’s clear_dialogue node (currently pulse_learn) to carry both the existing Pulse-weapon content and the dash text above, under id dash_intro (or keep the id pulse_learn and just extend its text — either is fine, tutorial_locked() just needs to reference whichever id actually fires here).
  • Trigger: first tick pilot.hp < pilot.max_hp for the tutorial pilot — a new per-tick condition generalizing the existing boss-only _check_teaching pattern to run for the whole Tutorial run, not just while a boss is alive.
  • Intro text: “That’s your health bar — it only shows when you’re hurt. Watch it: the border warns you harder the lower you get.”
  • Gated before: already content-gated in code (_hp_group invisible until real damage, _hp_flash_t starts at a 9999 sentinel) — no visibility gate needed, confirmed correct by the per-stage research and NOT contradicted by the architecture section anymore (see revision point 3 above — ui/hud.gd’s HP bar is off the gate-site list). This stage is narration-only.
  • Unlocked after: no mechanical change.
  • Implementation:
    • sim/story_director.gd: generalize _check_teaching (or add a sibling method) to test, every tick during the whole Tutorial run:
      if pilot.hp < pilot.max_hp and story.tutorial_locked("hp_intro"):
      _say(sim, "hp_intro")
      Uses tutorial_locked(), not a hand-rolled seen_dialogue.has() check — this is the direct fix for the leak the first draft’s literal snippet had (see revision point 2 above). Since this code path only ever runs inside story_director.update(), which itself only runs when story != null, tutorial_locked() alone is sufficient here.
    • data/story.json: new dialogue node hp_intro.
    • Pre-implementation check, required: confirm _check_teaching’s current structural coupling to “boss alive” — does it rely on any boss-specific state beyond just being called from boss-fight update code, or can it be generalized to run for the entire Tutorial run without side effects on the two hooks it already powers (teach_decoy/teach_heal)? Read the actual function before extending it.
    • Frame-ordering check, required: confirm whether hud.update_hud() runs before or after this same-tick dialogue-open check in main.gd’s _process()/_physics_process() — if HUD update runs first, the HP bar could flash visible for one frame before hp_intro’s dialogue box opens. Verify directly; don’t assume.
  • Trigger: first tick ThreatRadar.select_top_k() would return at least one entry.
  • Intro text: “That ring at the edge of your screen is your threat radar — it points at danger you can’t see on screen yet.”
  • Gated before: already content-gated (update_radar() sets visible = false whenever no enemy clears the weight bar) — no visibility gate needed, same correction as Stage 5.
  • Unlocked after: no mechanical change — narration only.
  • Implementation:
    • sim/story_director.gd: same generalized per-tick pattern as Stage 5, calling story.tutorial_locked("radar_intro"), testing the underlying enemy-presence signal ThreatRadar.select_top_k() reads (exact source not confirmed — required check before implementation: locate the real signal, likely sim.enemies/EntityPool occupancy, by reading ui/threat_radar.gd directly).
    • data/story.json: new dialogue node radar_intro.
  • Trigger: first tick the tutorial pilot’s XP crosses the first level-up threshold — the tick _next_leveling_pilot() would first return non-null, during ember_e1’s wave.
  • Intro text: “Defeating enemies earns XP. Enough of it and you’ll get to pick a new upgrade — take a moment to choose, there’s no rush.”
  • Gated before: the level-up panel is content-gated (opens only via show_for() when _next_leveling_pilot() != null), but it’s a MODAL that takes over input — letting it pop before its own teaching beat breaks the “no earlier than its beat” rule, so this needs a real code gate, not just after-the-fact narration.
  • XP accrual is untouched — clarified, was ambiguous in the first draft. pilot.pending_levelups keeps incrementing in /sim exactly as today; ONLY the main.gd panel-OPEN call is deferred. No sim-level change, no new guard needed inside the XP-accrual code path itself.
  • Two call sites for the open-check — both identified, only one needs the guard. _next_leveling_pilot() gates opening LevelUpPanel at two places in main.gd: the main per-frame open check (~main.gd:1231) and inside _on_upgrade_chosen() (~main.gd:2054, which re-checks for a chained next level-up after the current panel closes). Only the first needs the new guard — the second is only reachable once a panel has already legitimately opened once, which by construction can’t happen before levelup_intro has fired.
  • Multi-panel stack on first open — RESOLVED by Chris (2026-08-10), see “Decisions confirmed by Chris” at the top of this doc. The root cause (multiple enemies dying in the same tick) is now structurally prevented — ember_e1 gets a Tutorial-only solo_spawn mode so only one enemy is ever alive at once, meaning at most one kill can land in any given tick. A single kill’s XP crossing two thresholds at once (a wholly separate, much lower-probability case — depends on per-kill XP size vs. threshold size, not spawn count) is still worth a cheap defensive backstop: cap the first bank at 1 regardless, deferring any extra to open normally right after.
  • Implementation:
    • sim/story_director.gd: per-tick check — “pilot’s accrued XP has crossed the first threshold” AND story.tutorial_locked("levelup_intro") — calling _say(sim, "levelup_intro").
    • main.gd:~1231 (the primary open check only, per above): add and not (_mode == "tutorial" and sim.story.tutorial_locked("levelup_intro")).
    • data/story.json: new dialogue node levelup_intro.
    • sim/story_director.gd’s _drive_encounter() / data/story.json’s ember_e1 entry: the solo_spawn wave-advance fix specified in “Decisions confirmed by Chris” above — implement alongside this stage since it’s what makes the defensive cap-at-1 backstop (not primary fix) sufficient.
    • Story Board capture compatibility fix — REQUIRED, ships with this stage, not after. See the dedicated section below — without it, tools/story_board/capture.gd hangs forever the first time an unattended Tutorial capture reaches ANY level-up, gated or not.

Required fix, shipped with Stage 7 — Story Board’s capture.gd deadlocks on the level-up panel

Section titled “Required fix, shipped with Stage 7 — Story Board’s capture.gd deadlocks on the level-up panel”

Confirmed mechanism, not a hypothetical: main.gd:1027 sets _auto = not _is_story(), so _auto == false for the entire Tutorial run. _paused_for_levelup is part of _frozen (main.gd:1092-1098), so _physics_process returns immediately every frame while the panel is open — _replay_tick never advances. The ONLY existing auto-dismiss path is if _auto and _auto_pick_timer > 0.0: ... _on_upgrade_chosen(...) (main.gd:1430-1433, same pattern for the dimension-choice popup at ~1439) — gated on _auto, which is false in Tutorial. ui/level_up_panel.gd has no capture-aware fallback; it only responds to real hardware input events. capture.gd’s only exit condition is main._replay_done and _pending.is_empty(), which can never become true once the panel opens. Result: capture hangs indefinitely, no PNGs for anything after that point, storyboard.json never written.

This is not purely hypothetical to Stage 7 either — it’s already a live risk today, independent of this redesign: StoryDirector._spawn_boss_adds() spawns an extra swarmer every 3.5s for the whole Warden fight with no cap, and a long enough fight can push a Tutorial run over the first XP threshold from boss-fight adds alone — meaning the in-flight Story Board recording (the ATV build 185 handed to Eve, per this repo’s Current status) could already hit this exact hang today.

Fix, ship alongside Stage 7’s gate, not as a follow-up:

# main.gd, ~1430 and ~1439
if (_auto or _capturing) and _auto_pick_timer > 0.0:
... _on_upgrade_chosen(...) # same for the dimension-choice popup auto-dismiss

or equivalently have tools/story_board/capture.gd force main._auto = true before ticking a capture run. Either way, land this before or alongside Stage 7 — do not treat it as separately schedulable follow-up work.

  • Trigger: first crystal grant to the tutorial pilot.
  • Intro text: “Crystals build up as you fight — spend enough on a weapon and it upgrades for good.”
  • Gated before: UNKNOWN — no research pass located the crystal-counter HUD element’s owning file or gating status. Do not implement until this gap is closed.
  • Required pre-implementation step: locate the crystal-counter HUD element (likely ui/hud.gd or a dedicated script, tied to WeaponThresholds/elemental_system crystal drops per this repo’s weapons architecture) and characterize it the same way Stages 5–6 were (content-gated already, or needs a real gate). Once located, this stage should follow the same reactive tutorial_locked() pattern as Stages 5–6/7 in story_director.gd.
  • Trigger: existing ember_a2 room on_enter → echo_decoy dialogue, fires when the player crosses into arena 2 after clearing ember_e1. Unchanged from the first draft.
  • Intro text: use echo_decoy’s existing text verbatim — read data/story.json directly before implementing, don’t rewrite from memory.
  • Gated before: currently fires unconditionally once pilot.drone_charge >= 1.0 AND input.decoy is pressed (sim/drone_director.gd:100-106) — the 25s charge meter fills from tick 1 regardless of room/story progress, so a player lingering near ember_entry/ember_a1 could deploy before reaching ember_a2.
  • Implementation — wrap the WHOLE conditional block, not just the accrual line. The first draft’s snippet wrapped only the pilot.drone_charge = minf(...) accrual line, leaving the following if input.decoy and pilot.drone_charge >= 1.0: deploy check textually unguarded — correct only because the meter can never reach 1.0 while frozen at 0, an unstated fragile invariant. Wrap the whole block instead, for robustness and readability:
    # sim/drone_director.gd, update_drones()
    if sim.story == null or not sim.story.tutorial_locked("echo_decoy"):
    pilot.drone_charge = minf(pilot.drone_charge + DECOY_RECHARGE_RATE * sim.drone_recharge_mult * dt, 1.0)
    if input.decoy and pilot.drone_charge >= 1.0:
    deploy_drones(sim, pilot, current_loadout(sim))
    pilot.drone_charge = 0.0
    Use the existing dialogue id echo_decoy directly as the gate key (already fires at the right trigger) rather than inventing a separate decoy_intro id.

Stage 10 — Weapon dock / panel (Aim tile)

Section titled “Stage 10 — Weapon dock / panel (Aim tile)”
  • Same trigger as Stage 3 — no separate beat, listed individually only because Chris’s mechanic list named it explicitly.
  • Gated before: see Stage 3 — ui/weapon_panel.gd’s _aim_root visibility, gated on the same "aim_intro" flag (now with the corrected null-checked guard). Auto-weapon slot tiles are already correctly content-gated and need no change. The dock’s own overall visibility is fixed by the new shared “force tactical HUD visible in Tutorial” change under Stage 2.
  • Implementation: none beyond Stage 3’s ui/weapon_panel.gd change and the shared dock-visibility fix.
  • Trigger: ember_warden room on_enter + encounter ember_ew start dialogue (warden_intro) — already fires correctly today.
  • Gated before: already structurally gated — the boss can’t exist until the player physically reaches ember_warden, which by room-chain order can only happen after every earlier stage (movement, both weapons, dash, decoy) has already fired. No new gate needed.
  • Implementation: no code change. Verify teach_decoy’s reminder text doesn’t contradict or duplicate Stage 9’s fuller intro now that decoy is taught properly beforehand.

Stage 12 — Post-Warden Elemental Dimension choice popup — the one deliberate exception

Section titled “Stage 12 — Post-Warden Elemental Dimension choice popup — the one deliberate exception”
  • Trigger: N/A within Tutorial by design — Tutorial hands off directly into a fresh Survival run the instant the Warden falls and tutorial_complete is read.
  • Gated before: currently NOT reliably suppressed. _roll_dimension_choice() is gated only on areas_enabled, and avoids colliding with the Tutorial closing beat only “usually,” due to execution-order timing rather than a real gate — a genuine bug lets the panel pop over the tutorial’s own closing dialogue on some ticks.
  • Gate condition — deliberately story != null alone, NOT story != null and tutorial_cap. This is the one intentional exception to the architecture section’s rule, called out explicitly so it isn’t mistaken for another missed tutorial_cap leak: the intent here is to suppress the popup in ordinary Story mode too, not just Tutorial, since Story mode “hands its own boss outcome” per its own design. This is an open product question (see Open Questions) — if Chris wants Story mode to keep offering the popup, change this to the mode-scoped form at the _open_dimension_choice() call site in main.gd instead of touching sim.gd.
  • Implementation:
    # sim/sim.gd, _roll_dimension_choice()
    func _roll_dimension_choice() -> void:
    if not areas_enabled or story != null:
    return
    ...

Stage 13 — Tutorial-complete hand-off (closes every gate)

Section titled “Stage 13 — Tutorial-complete hand-off (closes every gate)”
  • Trigger: existing tutorial_complete dialogue, read once at the ember_portal room.
  • Gated before: N/A — this is the closing beat.
  • Unlocked after: player is handed into a brand-new Survival Sim where sim.story is null. Every gate in Stages 1–10 checks sim.story != null (or, inside /sim, relies on the fact that tutorial_locked() internally requires story to be a non-null StoryState with tutorial_cap == true) first, so all evaluate false immediately in the new Sim — no explicit “unlock everything” step exists to write, run, or forget.
  • Implementation: no new code.

Determinism & telemetry notes (carried forward, unchanged in substance)

Section titled “Determinism & telemetry notes (carried forward, unchanged in substance)”
  • All gate checks are pure boolean reads of sim.story.seen_dialogue/tutorial_cap — no wall-clock, Engine, OS, or Time API calls, consistent with /sim’s purity constraint.
  • New per-tick condition checks (Stages 5–8) live inside sim/story_director.gd, ticked deterministically off real sim state, never off main.gd/render-side timing.
  • None of the proposed gates draw from any of Sim’s four seeded RNG streams — no re-pin of tests/test_determinism_survival.gd’s baseline is expected, but run a full soak + GUT suite after implementation anyway, since several changes touch shared sim/ files (sim.gd, drone_director.gd, story_director.gd) even though the new branches are inert outside Tutorial.
  • New dialogue ids don’t touch the determinism baseline — story.events/dialogue content is explicitly excluded from snapshot_string()/state_checksum().
  • No new InputState columns needed — every gate reads existing story/pilot state — so ReplayRecorder’s fixed-width row format and existing on-ATV replay fixtures stay compatible.
  • New gate-crossing/teaching events should append to sim.story.events (the same channel dialogue already uses), inheriting the existing capture/replay telemetry-suppression wiring (_replay_play/_capturing forcing gameplay_telemetry._enabled = false) automatically, and feeding Story Board for free.
  • Story Board beat numbering will shift. This design inserts a new first beat (move_intro) and splits/adds roughly seven more dialogue nodes across the run — every beat index after the first will differ from any pre-redesign capture. No code implication; just don’t try to reconcile a pre-redesign annotations.json against a post-redesign capture by index — treat any post-redesign review as a clean-slate first round.

Minor punch list (small, not gating the stage list)

Section titled “Minor punch list (small, not gating the stage list)”
  • Normalize every code snippet actually written into the codebase to the full guard form shown in the architecture section — no shorthand sim.story.tutorial_locked(...) without the preceding sim.story != null and outside /sim.
  • Stage 2’s ember_intro trim drops the heal-while-still line, and no stage re-teaches it proactively — it only survives via the existing reactive teach_heal beat during the Warden fight (hp <= 32% max), which won’t fire in a Tutorial run where the player never drops that low before the boss. Accept this as “heal is Warden-fight-only content” (confirm with Chris, see Open Questions) rather than silently implying Stages 3/4 cover it.
  • ember_e1’s encounter clear-dialogue repurposing (Stage 4) needs pulse_learn’s verbatim current text read before splicing dash content in — don’t discard existing approved copy.
  • Also read warden_intro’s exact current text before reusing “as-is” per Stage 11 — not retrieved verbatim by any research pass.

1. Stage 2 weapon identity — RESOLVED 2026-08-10: Blaster, via a Tutorial-only reward remap. See “Decisions confirmed by Chris” at the top of this doc.

3. Stage 7 multi-panel stack — RESOLVED 2026-08-10: prevented structurally, via ember_e1’s Tutorial-only solo_spawn mode (only one enemy alive at a time), with the cap-at-1 code kept as a cheap defensive backstop. See “Decisions confirmed by Chris” above.

  1. Stage 4/Pulse fold-in — is it acceptable for the dash lesson and the Pulse-weapon acknowledgement to share one beat, or does Pulse deserve its own moment? (The alternative is a genuinely new trigger, which this revision avoided inventing without a stronger reason.)
  2. Stage 12 — should Story mode (if/when re-exposed) ALSO have the Dimension Choice popup suppressed after a boss kill, matching the deliberate story != null-only gate proposed here? Or should Story mode keep the popup, meaning the gate should instead be _mode == "tutorial"-scoped at the main.gd call site rather than sim.gd-wide? This is the one place the design intentionally diverges from “always gate by tutorial_cap,” and it’s worth a direct decision rather than defaulting.
  3. Should a player who deliberately replays Tutorial (after tutorial_done = true, from the menu) get the full slow drip-feed dialogue-and-freeze sequence again, beat for beat, with no fast-forward — or should a known-taught replay skip/fast-path the dialogue? Current behavior (confirmed via _new_run()) is full replay every time, unconditionally.
  4. Heal mechanic — confirmed Warden-fight-only, taught reactively only if/when the player actually drops below 32% HP during the boss fight, with no proactive teaching moment anywhere else in the run. Acceptable, or does it need its own Stage-5-style reactive beat earlier in the run (first time HP drops below some threshold outside the boss fight)?
  5. 2-pilot Tutorial onboarding (each pilot gets their own pickups and gate acknowledgements) is out of scope for this revision — Stage 0 simply blocks co-op join during Tutorial instead. Worth building properly later, or is single-pilot Tutorial + full-access co-op Survival the permanent intended shape?

Pre-implementation checklist (gates that must be resolved by reading code, not assumed)

Section titled “Pre-implementation checklist (gates that must be resolved by reading code, not assumed)”
  • ui/ship_config_panel.gd weapon-ring build logic — does it reveal the Aim weapon before aim_intro fires? (Stage 3)
  • _check_teaching’s structural coupling to “boss alive” — can it cleanly generalize to run for the whole Tutorial run? (Stages 5–8)
  • main.gd’s HUD-update vs. dialogue-open frame ordering — does the HP bar/threat radar ever flash for one frame before its teaching beat opens? (Stages 5–6)
  • ThreatRadar.select_top_k()’s underlying enemy-presence signal — exact source, for Stage 6’s per-tick check.
  • Crystal-counter HUD element’s owning file and current gating status — unresearched entirely. (Stage 8)
  • _toggle_tactical_hud()’s implementation — does it track a separate bool the new force-visible-in-Tutorial override needs to set, rather than .visible directly?
  • Any enemies/hazards reachable between ember_entry and ember_e1’s encounter start that could deal damage or grant XP before Stages 4/5/6/7’s beats fire — assumed no, not verified against area_defs/room content.