Skip to content

Tutorial adversarial audit — one-thing-at-a-time, gating, text, cadence (2026-08-19)

Method: 14-agent adversarial Workflow (1 mapper -> 6 independent lenses -> 6 refuters -> 1 synthesizer), read-only, run 2026-08-19. 57 raw findings, 7 refuted by the verify pass, 50 survived. The headline 0-XP finding, the 134-char reveal-budget truncation, and the fixed 3.2s hold were re-verified by hand against source before this file was written.

Dark Cosmos Tutorial Audit — Report for Chris

Section titled “Dark Cosmos Tutorial Audit — Report for Chris”

The uncomfortable one first: the tutorial’s central lesson — kill things, earn XP, level up, choose an upgrade — cannot happen. spawn_story_enemy omits the biomass_v argument (sim/sim.gd:1067-1070), and XP is computed from biomass, not from the xp_value it does pass (sim/sim.gd:2358-2362). Every scripted enemy in ember_e1–ember_e4 is worth 0 XP. The Warden is the only thing in the run that pays anything. So levelup_intro (sim/story_director.gd:79) never fires until the final boss dies, the level-up modal stays gated shut (main.gd:1256-1259), crystal_intro (sim/story_director.gd:91) can never fire, and the player fights a 900 HP / 6-armor Warden with a completely un-upgraded loadout — Blaster doing maxf(5.2 - 6, 5.2*0.25) = 1.3 damage per shot (sim/elemental_system.gd:190). Then, when the Warden finally does pay its 400 XP (3 level-ups), the tutorial backstop at sim/story_director.gd:89 mini(pending_levelups, 1) silently deletes two of the three picks. The tests that “cover” this gate hand-set sim.player.pending_levelups = 1 (tests/test_story_director.gd:467), so they never touched the real path.

Everything below is secondary to that.


47 lines across 19 beats on the full path. At LINE_TIME = 3.2 (ui/dialogue_box.gd:10) that is ~150 seconds of frozen, unskippable-on-touch reading in a run with five combat rooms.

# Beat Teaches Mech. count Unlocks
1 move_intro (1 line) Left-stick movement 1 Story objective HUD label (main.gd:1578 → ui/hud.gd:199-203)
2 ember_intro (3) Auto-weapon exists / self-aims / scales 3 (+2 plot) Grants Blaster as a floor pickup (sim/story_director.gd:114-116, sim/sim.gd:1143)
3 echo_xp (4) XP gems, level-up freeze, card taxonomy, evolution 4 Nothing
4 aim_intro (1) — same tick as #3 Right-stick manual weapon, damage, must aim 1 _fire_aim (sim/sim.gd:2253-2255) + AIM tile (ui/weapon_panel.gd:141-143)
5 radar_intro (1) Threat radar ring 1 Nothing (radar is content-gated, ui/threat_radar.gd:225-235)
6 hp_intro (1) HP bar, its flash rule, low-HP border 3 Nothing (ui/hud.gd:192-196)
7 pulse_learn (4) Pulse ×3, positioning, warp button, i-frames, cooldown tell 6 / 2 systems _update_dash (sim/sim.gd:2132-2133)
8 levelup_intro (1) XP → upgrade pick (repeat of #3) 1 Level-up modal (main.gd:1256-1259)
9 echo_decoy (4) Decoy meter/aggro/pulse/when-to-spend + secret walls 5 Decoy charge + deploy (sim/drone_director.gd:107-112)
10 crystal_intro (1) Crystals upgrade weapons at thresholds 1 Nothing — fires after the mechanic is used (sim/story_director.gd:91)
11 ember_secret_found (3) — optional Secrets exist, Orbit weapon 2 Grants Orbit
12 echo_gift (5) — skippable Nova, element count, colour tell, ring AoE, get-in-close 5 Grants Nova
13 ash_taunt (2) — 0 —
14 skirm_taunt + null_2 (3, one block) — 0 —
15 warden_intro (2) — 0 —
16 teach_decoy (3) Decoy re-teach + stand-still heal 2 —
17 teach_heal (2) Stand-still heal (repeat of #16) 1 —
18 warden_fall (3) — (tells player to climb to the Hush) 0 —
19 tutorial_complete (3) Tutorial over, Survival is endless 1 Hand-off to Survival (main.gd:1231-1240)

Only four beats (move_intro, radar_intro, crystal_intro, levelup_intro) are genuinely single-mechanic.


Worst offender: the first arena is a 5-line, 6-mechanic block

Section titled “Worst offender: the first arena is a 5-line, 6-mechanic block”

_enter_room says the room’s on_enter and then falls straight through into the encounter’s dialogue in the same tick:

// sim/story_director.gd:151-157
elif room.has("on_enter") and room["on_enter"] != null:
_say(sim, String(room["on_enter"]))
var k := String(room.get("kind", ""))
if (k == "arena" or k == "boss") and room.has("encounter") ...
_start_encounter(sim, room)
// sim/story_director.gd:183-184
for dref in enc.get("dialogue", []):
_say(sim, String(dref))

ember_a1 is "on_enter": "echo_xp" (data/story.json:487) and its encounter ember_e1 is "dialogue": ["aim_intro"] (data/story.json:1290-1292). dialogue_box.consume flattens every event of the tick into one queue (ui/dialogue_box.gd:117-126), so the player’s first arena opens with 16.0 seconds of unbroken frozen text covering six mechanics, then 0.6s of play (data/story.json:1269 {"type":"swarmer","count":1,"delay":0.6}), then another 3.2s freeze for radar_intro (sim/story_director.gd:77-78). 19.2 of the first 19.8 seconds are frozen. Same shape at ember_a4 — "dialogue": ["skirm_taunt", "null_2"] (data/story.json:1353-1356) is one 3-line block.

  • pulse_learn — 6 mechanics, 2 unrelated systems. Lines 1-3 are the Pulse; line 4 (data/story.json:432) is the warp button, its i-frames and its cooldown tell. The dash gate key is named after the weapon (sim/sim.gd:2132-2133 keys on "pulse_learn"). It also fires in an empty arena — _finish_encounter only runs when sim.enemies.count == 0 (sim/story_director.gd:227-228), and the Pulse pickup drops after the line (sim/story_director.gd:263-268), so the player is told “that spark you earned is the Pulse” before receiving it, with nothing alive to try either system on.
  • echo_gift — 5 mechanics (data/story.json:147-151), the longest single beat, and skippable (see below).
  • echo_decoy — 5, the last of which (data/story.json:410 “some walls in here are lying”) is an unrelated subsystem that already has its own beat (ember_secret_found, data/story.json:206-213). The literal phrase “One more thing” is the author acknowledging the overrun. It then immediately spawns 3 swarmers at once — ember_e2 has no "solo_spawn" flag, unlike ember_e1 (data/story.json:1297).
  • echo_xp (data/story.json:419) teaches levelling; levelup_intro (data/story.json:115) teaches it again and is the beat that actually opens the gate.
  • teach_decoy (data/story.json:440-442) re-teaches echo_decoy; teach_heal (data/story.json:450-451) repeats teach_decoy’s own second line.
  • crystal_intro fires only after a crystal card has been picked — sim/upgrade_system.gd:249 is the only writer of crystals.counts, and sim/story_director.gd:91 triggers on it being non-empty. The mechanic is used before it is explained. Crystal cards are ~76% likely on the first panel (CRYSTAL_GRANT_CHANCE = 0.65 / CRYSTAL_SECOND_GRANT_CHANCE = 0.3, sim/upgrade_system.gd:67-68), and the panel renders a “CRYSTALS” wallet unconditionally (ui/level_up_panel.gd:287).

echo_gift requires flying within NPC_TALK_RADIUS = 130.0 (sim/story_director.gd:22) of (0,-2010) in a room whose rect is [-450,-2240,900,460] — arc out past |x| > 130 and you reach the Warden with no Nova. ember_secret_found is fully optional. Neither is required to set meta.tutorial_done (main.gd:1290-1291).

Same defect class as the 2026-08-12 BURST fix — and one of these was introduced by that fix.

Line Claim Reality
data/story.json:441, :450-451 “HOLD STILL a moment and you’ll mend” still_heal_mult defaults to 0.0 (sim/player_state.gd:31); stand_heal_rate returns 0.0 (sim/sim.gd:2111-2114). Only source is the field-medic gold-shop upgrade (data/bible.json), unbuyable on the forced-tutorial fresh save (main.gd:586-587). Standing still next to the Warden heals at the same 1 HP/s as moving — and will get them killed.
data/story.json:149 “Each element marks whatever it hits with its own color” main.gd:1744-1748: “Colour by the enemy’s INNATE element, NOT its active aura”. aura_element has two consumers, neither is _enemy_colors(). Introduced by commit c3c3a43, the BURST fix.
data/story.json:148 “Now you carry two elements” Blaster and the manual weapon are both aether (sim/pilot_arsenal.gd:88-90, data/bible.json); with Pulse and Nova that’s three, four with Orbit.
data/story.json:430 Pulse “leaps to others close by — chain lightning” sim/weapon_pulse.gd:10 var chain_count: int = 1. Needs 5 lightning crystals, impossible at that point.
data/story.json:417 “What you kill leaves light behind. Gather it.” AUTO_COLLECT_XP: bool = true (sim/sim.gd:219); gems.add sits in the dead else branch (sim/sim.gd:2380). No gems exist.
data/story.json:420 “Sharpen a weapon, or take a mod that bends the rules” RULESET_SURVIVAL returns before wmod_ids/evolve_ids are consulted (sim/upgrade_system.gd:111-132). Those cards cannot be rolled.
data/story.json:123 “Crystals build up as you fight — spend enough on a weapon” Crystals come only from card picks; sim/weapon_thresholds.gd:6 “Non-consuming: crystal counts are only READ here, never decremented”; the player picks elements, not weapons (sim/upgrade_system.gd:172-182). Stage 8 shipped despite the spec’s explicit **Do not implement until this gap is closed.** (docs/superpowers/specs/2026-08-10-tutorial-stage-gating-design.md:504-505).
data/story.json:182-185 “Above us: the Hush… keep climbing” The cap override covers only ember_portal’s on_enter (sim/story_director.gd:146-152); warden_fall is clear_dialogue (sim/story_director.gd:263-264) and was never remapped. One room later the player is told the run is over. Both beats also open with the identical line “The first Warden falls.”
data/story.json:107 Radar “points at danger you can’t see on screen yet” Trigger is sim.enemies.count > 0 (sim/story_director.gd:77) — the one swarmer spawns ~230px away inside a 1100×620 room (sim/story_director.gd:234,249-250), plainly on screen.

Control names are gamepad-only. data/story.json:73 “left stick”, :91 “right stick”, :432 “the warp” — ui/dialogue_box.gd:132-134 renders the raw string with no token substitution. On the Mac (the platform docs/RELEASE-0.1.md schedules the owed playtest on) the bindings are WASD / arrows / Space / Q (input/input_router.gd:7-10,35-38,87,97); on touch the buttons are labelled “WARP”/“DRONE” (ui/touch_controls.gd:31-32). The platform-aware component that solved this was deleted by this same redesign (main.gd:952). And the decoy — the one taught mechanic most likely to save a first-timer — never names its control at all (data/story.json:407-410, :440), while the touch button says DRONE, not DECOY.


2. Do the gates hold — mostly, with three real holes

Section titled “2. Do the gates hold — mostly, with three real holes”

The gate primitive is sound (sim/story_state.gd:75-76), and the sim-side guards are real and tested against the real call path (tests/test_story_director.gd:314-320, :340-350). The failures are all second entry points nobody gated.

Hole 1 — Drone Bay completely bypasses echo_decoy

Section titled “Hole 1 — Drone Bay completely bypasses echo_decoy”

sim/drone_director.gd:100-105’s comment claims it “Wraps the WHOLE block (accrual + deploy) … for robustness”. There is a second, ungated path into the same system:

// sim/drone_director.gd:433-436
func deploy_now(sim: Sim) -> void:
sim.drones.clear()
deploy_drones(sim, sim.player, current_loadout(sim))
sim.drone_charge = 0.0

Caller chain is fully live in the tutorial: main.gd:1121-1123 if input_router.consume_bay_open(): _open_bay_direct() → main.gd:473-478 (guard list has no tutorial and no dialogue condition) → main.gd:457-464 _on_bay_deploy() → deploy_now. Trigger is a 400 ms hold of the decoy button (input/input_router.gd:323,347-349), the exact thing a player does when a visible button appears dead. On iOS the DRONE button is drawn from tick 1 (ui/touch_controls.gd:62). A live Sentinel can be fielded in corridor 1.

Worse, that check sits above the tutorial read-freeze (main.gd:1127-1128), and drone_bay is layer = 82 vs dialogue’s layer = 50 — so the Bay covers a teaching beat while dialogue_box.advance(delta) keeps auto-advancing it in _process (main.gd:1424-1425). Beats are fire-once (sim/story_director.gd:287) and mark_dialogue_seen already released the gate, so the player loses the lesson permanently but keeps the unlock.

Hole 2 — damage-driven decoy recharge is ungated

Section titled “Hole 2 — damage-driven decoy recharge is ungated”
// sim/sim.gd:906-910
if dmg_taken > 0.0 and not drone_director.pilot_has_drone(self, pilot):
pilot.drone_charge = minf(pilot.drone_charge + dmg_taken * DECOY_RECHARGE_ON_DMG, 1.0)

No story reference. hp_intro firing (sim/story_director.gd:75-76) is itself proof the meter has moved off zero. The dock is force-visible (main.gd:963-966, ui/drone_dock.gd:177), so the player watches it fill from damage in ember_a1 and finds the button dead — then echo_decoy explains a meter that “fills over time”. The test that claims to cover this uses a fixture map with no encounters, so it only ever exercises drone_director.gd:108, never sim.gd:909 (tests/test_drones.gd:12-16, 116-121).

Hole 3 — shop-unlocked weapons arm at tick 0 of a tutorial run

Section titled “Hole 3 — shop-unlocked weapons arm at tick 0 of a tutorial run”
// main.gd:665-676
if _is_story(): # main.gd:596-597 — includes "tutorial"
...
for target in meta.purchased_unlocks(sim.content.meta_upgrades()):
sim.upgrade_system.grant_weapon(sim, sim.player, target)

No is_tutorial condition, and grant_weapon puts it straight in the arsenal (sim/upgrade_system.gd:329-336), overriding sim/sim.gd:964 active_weapon_ids.clear() # story starts weaponless. Reachable: bail out of the tutorial → play Survival → buy Scatter → next cold boot force-routes back into the tutorial (main.gd:586-587) with a weapon already firing before ember_intro says “this is your auto-weapon”.

  • main.gd:963-966 forces weapon_panel and drone_dock visible for the whole run — empty weapon circles and a 0% decoy ring through rooms 1-2.
  • iOS is worse: ui/touch_controls.gd:62 visible = Platform.is_touch() has no tutorial condition, so WARP, DRONE and the always-drawn aim pad (ui/touch_controls.gd:245) are all on screen and dead from tick 1. Every unlock the stage-gating redesign staged for controller players is defeated on touch.

Codex card fires on top of tutorial dialogue

Section titled “Codex card fires on top of tutorial dialogue”

_check_codex_encounters()’s guard list (main.gd:1978-1981) has no dialogue_box.is_showing(). Fresh save = nothing seen (sim/meta_state.gd:32), so the first swarmer fires a full-screen “NEW ENEMY” card on the exact frame radar_intro fires. Codex is layer = 24 under dialogue’s layer = 50. Both bind confirm (ui/codex_overlay.gd:197-212, ui/dialogue_box.gd:143-160); resolution is pure add_child order — dialogue_box is created in _new_run (main.gd:968) vs codex in _ready (main.gd:300), so the dialogue box wins and pressing A silently burns a teaching line instead of dismissing the card. Recurs for spider, skirmisher, the boss adds, and the Warden.

mark_dialogue_seen (sim/story_director.gd:287) flips the lock the instant _say runs. main.gd:1578 and main.gd:1590 both live in _process (main.gd:1314), which is not frozen by the tutorial freeze (that’s in _physics_process, main.gd:1127). Net: the AIM tile pops into the HUD while the player is on line 1 of echo_xp — four lines before the manual weapon is mentioned — and the objective label appears on the same frame as move_intro’s first character.

Every gate test enters through Sim.tick_single(); nothing calls _open_bay_direct, _on_bay_deploy or deploy_now in a tutorial_cap sim (tests/test_story_director.gd:314 and the surrounding block). The suite’s model of a gate is “one boolean on one sim function”, which is exactly the shape a render-initiated second entry point slips through.


The dim covers what the narration points at. ui/dialogue_box.gd:30 layer = 50 with _dim.color = Color(0.0, 0.01, 0.03, 0.55) full-rect (:36-41). The HP bar and radar are layer = 5 (ui/hud.gd:48, ui/threat_radar.gd:154); weapon panel and drone dock set no layer at all (default 1). So hp_intro, radar_intro, aim_intro and echo_decoy all say “look at that” while pulling a 55% veil over it. The team already knows the pattern — ui/region_title.gd:19 layer = 55 # above the dialogue dim (50).

On iOS the panel sits on the buttons it teaches. PANEL_W = 940 centre-bottom at -PANEL_H - 60.0 (ui/dialogue_box.gd:13,46-49) vs touch controls at layer = 30 (ui/touch_controls.gd:58) fanned around _aim_center() (:127-128) at FAN_RADIUS := 110.0, angles 252°/192° (:24-25,30-33). Design canvas is 1280/1.3 expanded on the long axis (ui/tokens.gd:16, main.gd:283, project.godot:14). On iPhone (~1200×554) the DRONE button centre (986,425) is entirely inside the panel and WARP’s lower half is covered — while pulse_learn line 4 says “Tap the warp”. Same on iPad. PANEL_W is also unclamped, leaving ~22px side margins on iPad vs 170px on TV.

Every line holds exactly 3.2s regardless of length. maxf(LINE_TIME, MIN_HOLD) is maxf(3.2, 1.2) (ui/dialogue_box.gd:10-12,135) — MIN_HOLD is dead code, appearing only on lines 12 and 135. The reveal budget is 3.2 × 42 = 134 chars. Exactly two authored lines exceed it, and both are the gate-releasing ones:

  • aim_intro’s only line, 159 chars (data/story.json:91) — pops at “…it won’t track for y”, cutting “ou, so it’s easy to miss.”
  • pulse_learn line 4, 159 chars (data/story.json:432) — pops at “…dark means it’s spent, bright mean”, cutting “s it’s charged and ready.” That is the entire dash cooldown tell.

Fire-once (sim/story_director.gd:287), so neither can ever be re-read. At the other end “Climb.” (6 chars) and “Here. It remembers fire.” (24) hold the full 3.2s. Effective reading rate varies ~4×.

No gap, no fade, no beat break. _pop_line() calls _show_current() which sets _t = 0.0 and keeps _root.visible = true (ui/dialogue_box.gd:190-198). In ember_a4 that runs Ash’s skirm_taunt straight into Null’s null_2 (data/story.json:1353-1356) — a speaker change with zero separation, the box just flickers a new colour mid-stream.

Typewriter reflows. _line_label.text = line.substr(0, shown) (ui/dialogue_box.gd:133-134) with AUTOWRAP_WORD_SMART (:96) re-wraps a different string each frame — the word straddling the row boundary hops down as it grows, ~2-3 visible snaps per line. Godot’s visible_characters exists for exactly this and isn’t used.

The “press to continue” hint is wrong and invisible. 12px at 70% alpha in mid-grey on near-black (ui/dialogue_box.gd:103-110, :51) — 18px on a 1080p TV, below tvOS Caption 2 (23pt), vs 23px body. And it describes the wrong action: advance is time-driven (:135), pressing calls request_skip() → _pop_line() (:139-141). The player can go faster, never slower — directly contradicting data/story.json:115 “take a moment to choose, there’s no rush.”

On touch it does nothing at all. request_skip() has one non-test caller (ui/dialogue_box.gd:150), fed by ui_accept + JOY_BUTTON_A only (:143-152). InputRouter.ensure_actions() only erases the Space binding (input/input_router.gd:105-110) and registers no touch event; ui/touch_controls.gd never emits ui_accept; consumes_unmatched_event deliberately exempts touch (:179-182). iPhone/iPad players sit through all ~150 seconds with an on-screen prompt saying “press to continue” that responds to nothing.


  • ~150s frozen across 47 lines vs five combat rooms. Roughly half the tutorial’s runtime is a stationary text box.
  • The first arena is 19.2/19.8s frozen (see §1).
  • The tutorial is completely silent. main.gd:545-548 if mode == "survival": audio.play_music("survival_loop") else: audio.stop_music(). Fresh saves bypass the menu (main.gd:361-362), so menu_theme never plays either, and the boss-music swap is survival-gated (main.gd:1215) — even the Warden fight has no track. First music the player hears is at the hand-off (main.gd:1241).
  • The boss fight is interrupted before it starts. _check_teaching runs at sim/story_director.gd:37 in the same tick _drive_encounter (line 36) spawns the boss, and boss_index() scans live enemies immediately. The condition is hp < max_hp, not “took damage now” (sim/story_director.gd:64) — so a player who arrives damaged (very likely; ember_e4’s reward is ember_heal_sm = 25 HP, data/story.json:1357) gets warden_intro (6.4s) → 1.2s of play → the Warden appears → 9.6s more frozen to explain being hurt, before the boss has swung. Then another 6.4s at 32% HP (TEACH_LOW_FRAC := 0.32).
  • Dying to the Warden locks the death screen. game_over is set at the bottom of the tick (sim/sim.gd:2785-2786) while _check_teaching runs at the top, so both fire on one tick. results.show_results calls refocus() (ui/results_panel.gd:148) but DialogueBox swallows every key and joypad button while its queue is non-empty (ui/dialogue_box.gd:179-182) and is the later sibling (main.gd:317 vs :968). The player’s first 2-3 presses on “RUN OVER” invisibly skip dialogue lines for 6-10 seconds instead.
  • A player who never moves is stuck forever. move_intro is one fire-once 3.2s line. ControlHint was deleted (main.gd:952-956) on the reasoning that staged dialogue makes it “fully redundant” — true only if the player is looking at the screen for those 3.2 seconds. ember_entry has no encounter, _auto = not _is_story() (main.gd:1045) so no AutoPlayer rescue, and the objective label falls through to the default var obj := "ASCEND" (ui/hud.gd:229). Static corridor, forever.
  • The hand-off is a same-frame hard cut. main.gd:1231-1240 → _new_run() builds a fresh Sim (main.gd:637) and main.gd:617-619 sets _tactical_hud_shown = false / hides both docks — reversing main.gd:963-966. Music starts from silence, all four earned weapons vanish, and _auto = not _is_story() starts the player’s first real run on autopilot under a “● AUTOPILOT ACTIVE” banner (main.gd:1058) that also auto-picks their first upgrade (main.gd:1447-1450) until they touch the stick. Reads as a crash. The tutorial never taught the Y/V HUD toggle either.

# Fix Size Re-pin? Playtest?
1 Pass biomass_v from spawn_story_enemy (sim/sim.gd:1062-1070, mirror _spawn_one at :1429/1450), then re-tune — bible biomass gives ~57 XP pre-Warden vs xp_to_next = 100 (sim/player_state.gd:12), so lower the story first threshold or raise counts for 2-3 picks before ember_a3. Replace the hand-set tests (tests/test_story_director.gd:467). Medium Yes Yes
2 Stop discarding level-ups — replace mini(...) at sim/story_director.gd:89 with a defer; main.gd:1256-1259’s dialogue_box.is_showing() guard already chains panels. Small Yes No
3 Fix the false-content lines: teach_decoy/teach_heal heal claim, echo_gift:148-149, pulse_learn:430, echo_xp:417,420, crystal_intro:123, warden_fall Hush contradiction (remap in _finish_encounter, sim/story_director.gd:263-264, same pattern as give_blade→give_blaster at :114-115), radar_intro:107. Medium No No
4 Close the Drone Bay bypass — add the tutorial/tutorial_locked("echo_decoy") condition to _open_bay_direct’s guard (main.gd:474-476), move the consume_bay_open() block below the freeze (main.gd:1121 → after :1128), and early-return in deploy_now (sim/drone_director.gd:433). Add a render-seam test. Small No No
5 Gate the damage recharge — hoist var decoy_locked := story != null and story.tutorial_locked("echo_decoy") before sim/sim.gd:906-910. Determinism-safe by construction (story is null in Survival + both baselines). Give tests/test_drones.gd a real enemy. Small No No
6 Make the hold adaptive — clampf(line.length() / CHARS_PER_SEC + 0.9, MIN_HOLD, 6.0) at ui/dialogue_box.gd:135. Structurally prevents any future over-length line shipping truncated. Split the two 159-char lines anyway. Small No Yes
7 Add touch skip — InputEventScreenTouch branch in ui/dialogue_box.gd:_input, before the consumes_unmatched_event catch-all. Small No Yes (iOS)
8 Guard the codex — add dialogue_box.is_showing() to main.gd:1978-1981 (and _check_teaser_event, main.gd:1994-1996); suppress first-encounter cards during the capped tutorial. Small No No
9 Split the first-arena block — defer _start_encounter (sim/story_director.gd:157) until the on_enter queue drains; move aim_intro off ember_e1’s dialogue onto its own reactive trigger; cut echo_xp lines 2-4 (duplicated in levelup_intro). Medium No Yes
10 Split pulse_learn into pulse_learn (lines 1-3) + a new warp_learn (line 4); repoint sim/sim.gd:2132 at "warp_learn" and fire it where there’s something to warp from. Small No Yes
11 Fix the boss-fight interruptions — gate the heal lines on still_heal_mult > 0.0, change teach_decoy’s trigger to a real damage edge during the fight, require sim.boss.hp < sim.boss.max_hp before either fires (sim/story_director.gd:61-67). Small No Yes
12 Mark beats seen on read, not on queue — _say defers mark_dialogue_seen until dialogue_box finishes the beat. Fixes the AIM tile popping four lines early and the objective label appearing with move_intro’s first character in one change. Medium No Yes
13 Stage the HUD — drop main.gd:963-966; reveal weapon_panel on Blaster collection and drone_dock at echo_decoy using tutorial_locked(). Small No Yes
14 Gate touch controls per-ability — pass lock state alongside set_ability_state (main.gd:1589), skip drawing + hit-testing locked buttons (ui/touch_controls.gd:256-258, :246-254). Medium No Yes (iOS)
15 Fix the dim — either raise HUD layers above 50 while dialogue shows (the ui/region_title.gd:19 pattern) or make the dim a bottom gradient. Reposition the panel to top on touch; clamp PANEL_W. Small No Yes
16 Play music in the tutorial — if mode == "survival" or mode == "tutorial": at main.gd:545, drop the survival restriction on the boss swap at main.gd:1215. Both tracks exist. Small No Yes
17 Soften the hand-off — restore the tactical HUD after _new_run() (main.gd:1239), set _auto = false, add a “SURVIVAL” title card. Small No Yes
18 Platform tokens in dialogue — {move}/{aim}/{dash}/{decoy} resolved in _show_current(); name the decoy’s control at data/story.json:408; align DRONE/DECOY terminology. Medium No Yes (Mac + iOS)
19 Idle backstop for move_intro — re-prompt after ~8-10s of zero input in ember_entry (sim/story_director.gd:74-92), or special-case obj in ui/hud.gd:226-243. Small No No
20 Clear dialogue on death — dialogue_box.clear() before results.show_results (main.gd:1265). Small No No
21 Scope shop unlocks — wrap main.gd:674-676 in if not is_tutorial:. Small No No
22 Inter-beat gap + speaker fade — tag queue entries with source dialogue_id in consume(), negative _t for a gap in _pop_line(); switch the typewriter to visible_characters (note tests/test_dialogue_box.gd:50-54 asserts on .text.length()); bump the hint to Tokens.FONT_SM and reword to “▶ skip”. Medium No Yes

Two beats — rime_free (data/story.json:216) and vex_ally — carry "tutorial": true but sit in hush_entry / lw_safe1, past the cap. Confirmed unreachable, along with their give_beam/give_scatter grants. Dead weight, not a bug.