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.
Beat-by-beat flow
Section titled “Beat-by-beat flow”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.
1. One thing at a time — no
Section titled “1. One thing at a time — no”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.
Multi-mechanic beats
Section titled “Multi-mechanic beats”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-2133keys on"pulse_learn"). It also fires in an empty arena —_finish_encounteronly runs whensim.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_e2has no"solo_spawn"flag, unlikeember_e1(data/story.json:1297).
Duplicate / out-of-order teaching
Section titled “Duplicate / out-of-order teaching”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-teachesecho_decoy;teach_heal(data/story.json:450-451) repeatsteach_decoy’s own second line.crystal_introfires only after a crystal card has been picked —sim/upgrade_system.gd:249is the only writer ofcrystals.counts, andsim/story_director.gd:91triggers 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).
Two teaching beats can be missed entirely
Section titled “Two teaching beats can be missed entirely”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).
Content that is factually wrong
Section titled “Content that is factually wrong”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-436func deploy_now(sim: Sim) -> void: sim.drones.clear() deploy_drones(sim, sim.player, current_loadout(sim)) sim.drone_charge = 0.0Caller 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”.
Systems visible-but-inert from tick 1
Section titled “Systems visible-but-inert from tick 1”main.gd:963-966forcesweapon_panelanddrone_dockvisible 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.
Gates open on queue, not on read
Section titled “Gates open on queue, not on read”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.
Why these survived review
Section titled “Why these survived review”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.
3. Guidance text presentation and spacing
Section titled “3. Guidance text presentation and spacing”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_learnline 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.
4. Cadence
Section titled “4. Cadence”- ~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-548if mode == "survival": audio.play_music("survival_loop") else: audio.stop_music(). Fresh saves bypass the menu (main.gd:361-362), somenu_themenever 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_teachingruns atsim/story_director.gd:37in the same tick_drive_encounter(line 36) spawns the boss, andboss_index()scans live enemies immediately. The condition ishp < max_hp, not “took damage now” (sim/story_director.gd:64) — so a player who arrives damaged (very likely;ember_e4’s reward isember_heal_sm= 25 HP,data/story.json:1357) getswarden_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_overis set at the bottom of the tick (sim/sim.gd:2785-2786) while_check_teachingruns at the top, so both fire on one tick.results.show_resultscallsrefocus()(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:317vs: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_introis one fire-once 3.2s line.ControlHintwas 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_entryhas no encounter,_auto = not _is_story()(main.gd:1045) so no AutoPlayer rescue, and the objective label falls through to the defaultvar 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 freshSim(main.gd:637) andmain.gd:617-619sets_tactical_hud_shown = false/ hides both docks — reversingmain.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.
Prioritised fixes
Section titled “Prioritised fixes”| # | 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.