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”-
Stage 2 teaches the Blaster, not the Blade. Both are granted via the same shared
echo_introNPC 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 ontutorial_cap, not in the shared reward data.sim/story_director.gd’s NPC-interaction handler (~line 82, currentlysim.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’srewardsarray:{ "id": "give_blaster", "kind": "weapon", "weapon": "blaster" }grant_weapon’s only preconditions are the weapon existing inweapon_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’secho_introresolves to Blaster. Stage 2’s intro text is unchanged (it never named the weapon). -
ember_e1spawns 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’sember_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 ontutorial_capso it only changes Tutorial’s pacing, never Story’s:# sim/story_director.gd::_drive_encounter(), replacing the unconditional timer countdownif _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 readelse:_wave_timer -= dtif _wave_timer <= 0.0:_spawn_wave(sim, waves[_wave_index], _wave_index)_wave_index += 1if _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.0data/story.json: add"solo_spawn": truetoember_e1’s encounter object. Because the check istutorial_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 ontutorial_capat the code site, never by forking the shared content data itself. Determinism-safe:sim.enemies.countis 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)”ui/weapon_panel.gd’s Aim-tile gate (Stages 3/10) now has an explicitsim.story != nullguard. The first draft’s snippet would null-dereference every frame in Survival.- Every new per-tick teaching check (Stages 5–8) now calls
sim.story.tutorial_locked(id)— never a hand-rolledseen_dialogue.has(id)check — becausetutorial_locked()already folds intutorial_cap.story_director.update()runs for ANYstory != nullrun, Story mode included, so a hand-rolled check that skipstutorial_capwould leak new narration into ordinary (non-tutorial) Story mode. ui/hud.gdandui/threat_radar.gdneed 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.- Stage 4 (dash) is now gated inside
/sim(sim/sim.gd::_update_dash), not inmain.gd. Gating upstream ofsim.tick()only protects call sites that route through that one point inmain.gd— it’s silently bypassed by anything that callssim.tick()/tick_single()directly (a tool, a test, a future AI tutorial-player). Moving the check into_update_dashitself matches Stage 3’s pattern and covers every caller by construction. - Stage 3’s trigger point was wrong.
ember_a1’son_enteris NOT empty — it already firesecho_xp(leveling orientation text), confirmed directly againstdata/story.json:442. Left untouched (see the corrected trigger map below);aim_intromoves to a genuinely empty slot. - The orphaned Pulse-weapon beat (
ember_e1clear →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. - 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_modeguard today, so a second controller pressing A mid-Tutorial would drop in a pilot with an emptyactive_weapon_idsand no path to any of the pickups. ui/control_hint.gdis now explicitly addressed — removed for Tutorial mode, since the staged dialogue sequence now teaches everything it used to summarize in one blanket toast.- 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.
- Story Board’s
capture.gddeadlock 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) andcapture.gdhangs forever with zero output. - 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 beforeaim_introhas taught it.
Gating architecture (revised)
Section titled “Gating architecture (revised)”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 whenstory != null— e.g.sim/story_director.gd’s ownupdate(), or a function onSimitself that already has astoryfield): 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, anyui/*.gd— these run in EVERY mode, including Survival, wheresim.storyis alwaysnull):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’sif sim.story != null and sim.story.tutorial_locked("<beat_id>"):<hide / suppress>weapon_panel.gdsnippet.
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 conditionif 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.)
Stage 1 — Movement (left stick)
Section titled “Stage 1 — Movement (left stick)”- 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 withmove_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_idsempty 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-948currently instantiatesControlHintunconditionally 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,ControlHintis fully redundant in Tutorial. Fix: delete theif _mode == "tutorial": control_hint = ControlHint.new()block atmain.gd:942-948(or gate it to never fire when_mode == "tutorial"). ConfirmControlHinthas no other live caller before deleting the class outright — if none, remove the file too.- Implementation:
- Author
move_introdialogue node indata/story.json, attached toember_entry’s currently emptyon_enter. ui/hud.gd: addsuppress_story_label: bool, driven bymain.gdoffsim.story.tutorial_locked("move_intro")(mode-gated form, safe without an extra null check since it’s only ever assigned insideif _mode == "tutorial": ..., wheresim.storyis 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
ControlHintfor Tutorial mode (main.gd:942-948), per above.
- Author
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_introinember_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_bladereward 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()clearsactive_weapon_idsto 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_blasterreward appends"blaster"toactive_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_giftinember_safe1(give_nova) and the optionalember_secret1(give_orbit) both add further auto weapons the same waygive_bladedoes. 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_introdialogue node indata/story.jsondown 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).
- Trim the existing combined
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’son_enteris NOT empty (it firesecho_xp, confirmed directly indata/story.json:442— the first draft’s “confirmed empty” claim was wrong and is not reused here). New trigger: encounterember_e1’s START dialogue, repurposing the existing placeholder idnull_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_xpatember_a1’son_enteris 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 sameSim.tick()call, somark_dialogue_seen("aim_intro")can flipseen_dialogueon the exact tickaim_introfires, before that same tick’s_fire_aimcall 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"):returnui/weapon_panel.gd: gate_aim_root.visibleand its per-frame stat read — with the null-check the first draft’s snippet was missing, sinceupdate_panel()runs in every mode including Survival wheresim.storyis alwaysnull:if sim.story != null and sim.story.tutorial_locked("aim_intro"):_aim_root.visible = falseelse:_aim_root.visible = true# existing per-frame stat read, unchangeddata/story.json: new dialogue nodeaim_intro, attached toember_e1’s encounterdialoguefield (replacingnull_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 livesim) 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 beforeaim_introhas fired, that still spoils the beat. If it does, add the samesim.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_1slot is now used by Stage 3. New trigger: encounterember_e1’s CLEAR dialogue, repurposing the existingpulse_learnnode (currently paired with rewardgive_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 indata/story.jsonverbatim (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. Ifpulse_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(wasmain.gdin the first draft). Gating upstream ofsim.tick()inmain.gdonly protects the one call path that routes through that exact line — it’s silently bypassed by any future caller ofsim.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 forinput2in 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, sincedash_cditself 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/repointember_e1’sclear_dialoguenode (currentlypulse_learn) to carry both the existing Pulse-weapon content and the dash text above, under iddash_intro(or keep the idpulse_learnand just extend its text — either is fine,tutorial_locked()just needs to reference whichever id actually fires here).
Stage 5 — HP bar / low-HP effects
Section titled “Stage 5 — HP bar / low-HP effects”- Trigger: first tick
pilot.hp < pilot.max_hpfor the tutorial pilot — a new per-tick condition generalizing the existing boss-only_check_teachingpattern 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_groupinvisible until real damage,_hp_flash_tstarts 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:Usesif pilot.hp < pilot.max_hp and story.tutorial_locked("hp_intro"):_say(sim, "hp_intro")tutorial_locked(), not a hand-rolledseen_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 insidestory_director.update(), which itself only runs whenstory != null,tutorial_locked()alone is sufficient here.data/story.json: new dialogue nodehp_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 inmain.gd’s_process()/_physics_process()— if HUD update runs first, the HP bar could flash visible for one frame beforehp_intro’s dialogue box opens. Verify directly; don’t assume.
Stage 6 — Threat radar
Section titled “Stage 6 — Threat radar”- 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()setsvisible = falsewhenever 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, callingstory.tutorial_locked("radar_intro"), testing the underlying enemy-presence signalThreatRadar.select_top_k()reads (exact source not confirmed — required check before implementation: locate the real signal, likelysim.enemies/EntityPooloccupancy, by readingui/threat_radar.gddirectly).data/story.json: new dialogue noderadar_intro.
Stage 7 — Level-up / upgrade cards
Section titled “Stage 7 — Level-up / upgrade cards”- Trigger: first tick the tutorial pilot’s XP crosses the first level-up threshold — the tick
_next_leveling_pilot()would first return non-null, duringember_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_levelupskeeps incrementing in/simexactly as today; ONLY themain.gdpanel-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 openingLevelUpPanelat two places inmain.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 beforelevelup_introhas 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_e1gets a Tutorial-onlysolo_spawnmode 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” ANDstory.tutorial_locked("levelup_intro")— calling_say(sim, "levelup_intro").main.gd:~1231(the primary open check only, per above): addand not (_mode == "tutorial" and sim.story.tutorial_locked("levelup_intro")).data/story.json: new dialogue nodelevelup_intro.sim/story_director.gd’s_drive_encounter()/data/story.json’sember_e1entry: thesolo_spawnwave-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.gdhangs 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 ~1439if (_auto or _capturing) and _auto_pick_timer > 0.0: ... _on_upgrade_chosen(...) # same for the dimension-choice popup auto-dismissor 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.
Stage 8 — Crystal / currency counter
Section titled “Stage 8 — Crystal / currency counter”- 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.gdor a dedicated script, tied toWeaponThresholds/elemental_systemcrystal 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 reactivetutorial_locked()pattern as Stages 5–6/7 instory_director.gd.
Stage 9 — Decoy / companion drone
Section titled “Stage 9 — Decoy / companion drone”- Trigger: existing
ember_a2roomon_enter→echo_decoydialogue, fires when the player crosses into arena 2 after clearingember_e1. Unchanged from the first draft. - Intro text: use
echo_decoy’s existing text verbatim — readdata/story.jsondirectly before implementing, don’t rewrite from memory. - Gated before: currently fires unconditionally once
pilot.drone_charge >= 1.0ANDinput.decoyis pressed (sim/drone_director.gd:100-106) — the 25s charge meter fills from tick 1 regardless of room/story progress, so a player lingering nearember_entry/ember_a1could deploy before reachingember_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 followingif 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:Use the existing dialogue id# 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.0echo_decoydirectly as the gate key (already fires at the right trigger) rather than inventing a separatedecoy_introid.
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_rootvisibility, 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.gdchange and the shared dock-visibility fix.
Stage 11 — Ember Warden boss fight
Section titled “Stage 11 — Ember Warden boss fight”- Trigger:
ember_wardenroomon_enter+ encounterember_ewstart 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_completeis read. - Gated before: currently NOT reliably suppressed.
_roll_dimension_choice()is gated only onareas_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 != nullalone, NOTstory != 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 missedtutorial_capleak: 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 inmain.gdinstead of touchingsim.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_completedialogue, read once at theember_portalroom. - Gated before: N/A — this is the closing beat.
- Unlocked after: player is handed into a brand-new Survival
Simwheresim.storyis null. Every gate in Stages 1–10 checkssim.story != null(or, inside/sim, relies on the fact thattutorial_locked()internally requiresstoryto be a non-nullStoryStatewithtutorial_cap == true) first, so all evaluate false immediately in the newSim— 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, orTimeAPI 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 offmain.gd/render-side timing. - None of the proposed gates draw from any of
Sim’s four seeded RNG streams — no re-pin oftests/test_determinism_survival.gd’s baseline is expected, but run a full soak + GUT suite after implementation anyway, since several changes touch sharedsim/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 fromsnapshot_string()/state_checksum(). - No new
InputStatecolumns needed — every gate reads existing story/pilot state — soReplayRecorder’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/_capturingforcinggameplay_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-redesignannotations.jsonagainst 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 precedingsim.story != null andoutside/sim. - Stage 2’s
ember_introtrim drops the heal-while-still line, and no stage re-teaches it proactively — it only survives via the existing reactiveteach_healbeat 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) needspulse_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.
Open questions for Chris
Section titled “Open questions for Chris”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.
- 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.)
- 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 themain.gdcall site rather thansim.gd-wide? This is the one place the design intentionally diverges from “always gate bytutorial_cap,” and it’s worth a direct decision rather than defaulting. - 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. - 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)?
- 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.gdweapon-ring build logic — does it reveal the Aim weapon beforeaim_introfires? (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.visibledirectly? - Any enemies/hazards reachable between
ember_entryandember_e1’s encounter start that could deal damage or grant XP before Stages 4/5/6/7’s beats fire — assumed no, not verified againstarea_defs/room content.