Skip to content

Bullet Heaven — meta-progression & storyline mode

Bullet Heaven — meta-progression & storyline mode

Section titled “Bullet Heaven — meta-progression & storyline mode”

Extracted from CLAUDE.md on 2026-07-04 to keep the always-loaded file lean. This is the current architecture reference for these systems, not a changelog — the “M2 cycle N, DONE” headings document present code. Keep it current when you change the code. See CLAUDE.md § “Subsystem architecture — read on demand”.

Meta-progression (M2 cycle 18, DONE) — builds 29-32

Section titled “Meta-progression (M2 cycle 18, DONE) — builds 29-32”

The gold shop — Pilot, Drones, Arsenal, Utility (Utility is empty since Greed moved to Pilot) Drone/decoy upgrade tree spend Drones deployed in a run Drone army concept art

Persistent between-run progression: earn gold in a run → bank it → spend on permanent upgrades → they apply to every future run. The save survives sessions (browser IndexedDB on web, user:// file on tvOS).

  • sim/meta_state.gd (MetaState, /sim-pure): banked_gold + levels (id→level). cost (geometric base_cost × cost_growth^level, -1 if maxed), can_afford/buy/is_maxed, apply_to(player, defs) (reuses the StatEffects vocabulary so meta bonuses share the in-run stat mechanism), to_dict/from_dict. NO file/Node APIs → unit-tested headless.
  • meta/meta_store.gd (MetaStore, render-side, NEW meta/ dir): fail-safe load_state/save_state to user://meta.json (missing/corrupt → fresh MetaState, never errors). Uses FileAccess/JSON so it lives OUTSIDE /sim. Writes are atomic with a .bak and a .corrupt copy: § Save write path.
  • Sim.run_gold (per-type bible enemies.<id>.gold: basic 1, heavy 3, boss 41, times area_reward_mult and Sim.GOLD_MULT, accumulated with a fractional remainder by Sim._add_gold; see remote-config.md § Gold economy), awarded in _sweep_dead. NOT in snapshot_string/state_checksum — that’s why the determinism baseline held despite the baseline run killing swarmers (it increments run_gold but the checksum ignores it).
  • ui/meta_shop_panel.gd (MetaShopPanel): the game-over screen IS the shop (summary + banked gold + a buyable card per upgrade + a Play Again card). Modelled on LevelUpPanel’s proven tvOS focus/nav (Siri Remote = joypad; explicit debounced nav + ui_accept/JOY_BUTTON_A). A buy routes through MetaState.buy, persists via MetaStore, and _rebuilds the cards.
    • Layout (2026-09-27 rebuild, was a carousel): header [◀ BACK] SHOP <gold>, a left tab RAIL (one tab per ShopCategories.ORDER category, each with a gold badge = affordable count, refreshed in place after a buy; Drones counts its affordable SECTIONS, not items, and every badge caps at “9+” (badge_count/badge_text)), and a scrolling card GRID for the selected tab. Pure static geometry in MetaShopPanel.layout_for(vp, insets, touch, upt, defs): safe-area aware, 44pt touch targets, columns 2 iPhone / 3 iPad / 4 TV (column_count), one card size per device from card_metrics (worst description in the bible). Cards sort owned/maxed last (sort_for_grid). A tab with fewer cards than columns centres its row (sparse_row_x); Ships adds a muted SHIPS_NOTE under it. The scroll sits under an alpha mask (ScrollMask, clip_children) that fades content out over EDGE_FADE at the clip edges: a hard clip left descender tips as stray 1px dots. The post-buy toast hides when the reset dialog opens or suspend() hands over to the Bay. The Drones tab is one section per class (tab_sections) plus a DRONE BAY header button that emits bay_requested; main.gd then calls suspend(), which now keeps the shop VISIBLE but inert behind the bay (it used to hide, leaving the bay floating over the start menu), and resume() refocuses the bay button. The Drones tab is one section per class (tab_sections) plus a DRONE BAY header button that emits bay_requested; each Drones section title carries a one-line what-it-does/what-it’s-good-for blurb (ShopCategories.DRONE_SECTION_INFO → drone_section_info(), rendered as SectionInfo<title> in _rebuild_grid; a test forces every sub-category in DRONE_SUBCATEGORY_ORDER to have one, so a new class can’t ship unexplained). The legacy decoy-power/decoy-duration pair is NOT a Decoy item (Chris, 2026-09-27 plan 2026-09-27-shop-economy-plan-2 Task 1, done 2026-09-29): decoy_power_mult scales Decoy/Bomber/Interceptor damage and decoy_life_mult scales every class’s lifespan, so they were renamed “Drone Power”/“Drone Lifespan” in bible.json (ids unchanged — saves/telemetry/economy-sim/refund-ledger keys), routed to the General drone section and given the drones icon (was the decoy diamond). The Disruptor now actually deals its “little direct damage” (it never did, so “Disruptor: Power” was a dead purchase): one DISRUPTOR_DMG chip per 0.5s field pulse in drone_disruptor, scaled by cfg.dmg (the Power card) through the shared damage pipeline. RESET sits alone at the FOOT of the rail (layout_for’s "reset" rect, below every tab) and reuses the confirm dialog; there is no BACK or RESET card. It is deliberately out of the way (2026-10-04, Chris: it used to be one “up” from the opening focus, so players were pushed onto it): up from the first tab goes to BACK, and RESET is reached only by down past the last tab. Nav: D-pad in the grid (left of column 0 goes to the rail), LB/RB and Q/E switch tabs, accept buys, back closes; touch taps buy and drags scroll. Device profiles for tests: tests/shop_layout_fixture.gd.
  • main.gd wiring: loads meta ONCE in _ready (survives _new_run, which queue_frees children — meta is a plain RefCounted member, not a child); applies meta.apply_to(sim.player, …) at run start OUTSIDE the sim (keeps the deterministic baseline unbuffed — the determinism test builds Sim.new directly with no meta bonuses); _bank_run() banks run_gold once per run (_run_banked guard) and saves.
  • 5 meta upgrades in bible.json (meta_upgrades, hand-added — empty before): vitality/bulwark/haste/greed/swiftness → max_hp/armor/fire_rate_mult/gold_mult/move_speed. ContentDB.meta_upgrades() accessor.
  • Greed = run-gold multiplier (2026-10-04, Chris: “put greed in pilot and make it work”). It was pickup_radius in the Utility tab, which survival never reads (Sim.AUTO_COLLECT_XP, powerups off), so it was a dead purchase. Now effect: "gold_mult", category Pilot, magnitude 1.08 compounding per level (5 levels = x1.469). MetaState.gold_mult(defs) (product of magnitude^level over every gold_mult def) → MetaRunSetup.apply_stats sets Sim.meta_gold_mult → Sim._add_gold multiplies every run-gold source (kills, bosses, crystals) by it alongside GOLD_MULT. Co-op: applied ONCE per run, never per pilot: gold is one shared wallet (Sim.run_gold) and Greed is the account’s upgrade. StatEffects doesn’t know gold_mult, so apply_to skips it (it is a Sim field, not a PlayerState stat). Saves that owned Greed levels keep them (no refund, it now works). The Utility tab auto-hides while empty (ShopCategories.present); it stays in ORDER and now holds the Reroll item (below). Card icon: a bold G (ShopIcons._draw_gold_g; the earlier coin read as a power button). Tests: tests/test_greed_gold.gd (real MetaRunSetup → spawn → _sweep_dead path, co-op, patched magnitude, card copy).
  • Shop-card copy reads numbers live: ui/meta_copy.gd (MetaCopy.desc(def)). A meta_upgrades desc carries a token filled at display time from the def, so a patched meta_upgrades.<id>.magnitude never leaves stale copy (standing rule, remote-config.md). The token must match how the consumer applies the magnitude: {pct} = (mag - 1) x 100 for a compounding multiplier (Haste, Swiftness, Drone Power/Lifespan, Greed), {pct_less} = (1 - mag) x 100 for a shrinking one (Thrusters), {mag_pct} = mag x 100 for a linear 1 + level x mag step (Drone Recharge, every drone-<class>-<attr>), {mag} for a flat add (Vitality, Bulwark). A desc with no token is shown verbatim. Every numeric desc is templated (2026-10-04); tests/test_greed_gold.gd pins that bundled magnitudes render the old copy and that no desc bakes a percent.
  • Reroll = level-up rerolls per run (issue #77, 2026-10-20, Chris: ONE shop item, tiers 1 / 2 / 3). levelup-reroll in data/bible.json (category Utility, effect levelup_rerolls, magnitude 1, 3 levels, 150 / 300 / 600 gold). MetaState.rerolls_per_run(defs) (magnitude x level, summed) -> MetaRunSetup.apply_stats sets Sim.meta_rerolls; each pilot spends its own budget (PlayerState.rerolls_used), so co-op gives every pilot the full count. StatEffects does not know levelup_rerolls, so apply_to skips it. Skip needs no purchase. Card icon: ShopIcons._draw_reroll. The shop total grew by 1050 (tests/test_economy_career.gd catalogue pin 21701 -> 22751). Economy impact: docs/research/2026-10-20-reroll-item-economy.md. Tests: tests/test_reroll_shop_item.gd.
  • Career archetype tags (2026-10-04, spec docs/superpowers/specs/2026-10-04-career-archetype-tags-design.md): descriptive Tank / Support / Damage / Utility roles plus the cross-cutting Drone tag. Authored as an archetypes array on every meta_upgrades, mods and weapons entry in data/bible.json (separate from weapon mechanic tags and shop category). ui/archetype_tags.gd (ArchetypeTags, render-side, read-only) resolves shop defs (for_meta) and in-run card ids (for_card: generic mods, weapon:/wm:/evolve: inherit the weapon, wm:chronowell:heal adds Support, aim: kinds use explicit metadata, crystals: cards take the union of the weapon paths in their live upgrade_effects() changes + progress rows) and builds neutral text chips. Shop cards show them in a band between the description and the price row (card_metrics budgets the worst entry’s wrapped chip lines); LevelUpPanel puts the why-tag pill and each chip directly in one HFlowContainer (TagRow) so a long row wraps instead of widening the card (a one-line row pushed the iPad strip off-screen). Since 1.1 (big cards) the full strip has no fixed height: LevelUpPanel.full_strip_h gives it the screen below the LEVEL header (up to FULL_FRAC of the height, MIN_FULL_H floor outside the tutorial), the header clears the HUD HP block and the tutorial objective panel (top_clear_for), and opening Ship stats collapses every card to a title line (compact_h). LegacyLevelUpPanel right-aligns them on the subtext line. Shop chips are 15 units (the shop floor); level-up chips use LevelUpPanel.ROLE_FONT (17). Device-profile tests (ShopLayoutFixture tv/iphone/ipad) pin the fit. Labels only: they never touch offer weights, scores, stats or /sim. tests/test_archetype_tags.gd enforces completeness.
  • ⚠️ Don’t unit-test the buy-WITH-SAVE path — MetaStore.save_state writes the REAL user://meta.json (headless tests share the editor’s user dir), which would clobber Chris’s actual save. Test MetaState.buy (pure) + the panel’s unaffordable (no-save) path instead.
  • Weapon unlocks (build 31): meta upgrades can be type:"unlock" (skipped by apply_to, listed by purchased_unlocks). Sim.locked_weapons (empty by default → all available, so tests/determinism are untouched) suppresses a weapon’s level-up offer; main.gd locks {scatter} by default and erases it once the unlock is purchased. The shop shows unlock cards as OWNED. Scatter is now gated by default in real runs (120g) — a fresh save starts with blade + 5 grantable weapons.
  • HUD gold readout (build 32): hud.gd shows sim.run_gold (“N gold”, gold colour) under the kills counter, so the currency is visible mid-run. Render-only.
  • Ratings prompt (RELEASE-1.1 §3a, 2026-10-14): meta/review_prompt.gd (ReviewPrompt, render-side) decides; main.gd wires it. _bank_run notes every run a human flew on their own save (not attract/console-autopilot/learning/replay runs) into MetaState.best_survival_by_mode ({"normal", "hard"}, keyed by Difficulty.id(sim.difficulty); read via MetaState.best_survival(mode_id)) + bosses_killed_total via ReviewPrompt.note_run, which returns a positive reason: first-ever boss kill (from sim.boss_log, kill time set), or a Survival best beating that same difficulty’s previous best and ≥ MIN_BEST_S (120 s). A legacy single best_survival_s (Dev saves only) loads as the Hard best. MetaState.survival_difficulty ("normal"/"hard", missing or invalid → "normal") saves the chosen Survival difficulty for the next run; one gold wallet is shared across both. Tutorial runs record nothing. The reason is saved in MetaState.review_pending (so quitting from the death card keeps a first boss kill; an ordinary later run does not clear it) and consumed only after _return_to_menu from a FINISHED (banked, _run_banked) run. That menu return is the one press that leads to an ask, and not in its frame: _schedule_review_ask fires a one-shot timer (review_ask_delay_s, 1 s) and _on_review_ask_timeout asks only if the same start menu is still up and uncovered (no run started, Shop not open); otherwise the reason stays pending. A mid-run pause-menu “Quit to menu” (run not banked) never asks, so an earlier run’s moment cannot surface after an abandoned run. Never at the death card, never mid-run, never the boot menu (Apple: not at launch), and no other button (results-card Shop etc.) asks. It is spent even when the gate says no. Dev proof of the native path: the console command {kind:"review_prompt_test"} (main._review_prompt_test_command, Dev builds only, menu or run) calls the native request directly with no gates and never touches meta; the toast says whether the request was made. Dev-signed builds always show Apple’s sheet, so this is how the scene lookup + StoreKit call is verified on iPhone/iPad before a store build. A TestFlight install misread as appstore (BuildChannel’s deliberate fallback) burns that BUILD’s ask for that tester; see the comment in net/build_channel.gd. try_ask gates on BuildChannel appstore + OS.get_name() iOS/macOS + MetaState.review_asked_build != Sim_Const.BUILD, and marks the build only when the native call was actually made. Native: DcStoreReview singleton (request_review(), SKStoreReviewController) compiled into the DcHaptics GDExtension (platform/tvos/addons/dc_haptics/src/dc_store_review.mm), so it exists in the iOS export only; the macOS export excludes that addon (and its dylib is arm64-only), so on Mac native_request() is a no-op until the addon ships there. Only Apple’s system prompt is ever used (guideline 5.6.1: no custom pre-prompt). Tests: tests/test_review_prompt.gd (real Warden kill → _bank_run → _return_to_menu).
  • NEXT: more unlocks (characters/weapons); a start-screen entry to the shop (currently game-over only); optionally a banked total on the HUD (it currently shows gold earned THIS run).

MetaStore.save_state no longer truncates meta.json in place (one kill mid-write left an empty file and a wiped progression). Each write to a location p (user://meta.json, or the tvOS cache copy):

  1. writes p.tmp, closes it, re-reads it and checks it is exactly the payload and a JSON object. A failure leaves p untouched and removes the temp file;
  2. COPIES what it is about to replace (never moves it, so p exists at every instant): a valid save goes to p.bak; a primary that is not readable (garbage, not a JSON object, or a schema this build cannot read, i.e. a newer build’s save) goes to p.corrupt. A later corrupt file does not replace an earlier .corrupt: the first copy is kept as the evidence, and if it cannot be written the replace is refused. An empty primary (zero bytes) has nothing to keep and never overwrites an existing .bak; a file of NULs is not empty, it is unreadable, so it is parked;
  3. renames p.tmp over p in ONE atomic step. If the rename is refused, the verified payload is written straight into p (the old behaviour), with the copies from step 2 as the safety net.

load_from reads p first. An absent p is skipped and its .bak is never read (deleting a save on purpose must not resurrect an old one). A p that exists but is empty or not a JSON object loads p.bak when that is a valid save (last_load_recovered, last_load_path = the .bak, and the same post-load steps as a normal load). A JSON object with a schema this build cannot read is NOT recovered from .bak (downgrade safety): it loads as a fresh save with last_load_compatible = false, which the guarded writers respect. .tmp, .bak and .corrupt are never loaded as a primary and are inert to the tvOS dummy.mm bridge, which mirrors only Caches/meta.json by exact path.

Scope: this guards a killed process (force-quit, crash, memory kill). It does NOT cover an OS crash or power loss: the engine offers no fsync. On tvOS the cache-dir fallback is used only when the temp file cannot be OPENED (a read-only Documents); a write that opens fine but fails verification fails visibly instead of leaving a newer copy in the cache that user:// would shadow at the next boot. On the web build a write close only flags the IndexedDB sync (idb_needs_sync in Godot’s platform/web/os_web.cpp), which starts on the next main-loop iteration, after the rename, so what is persisted is the renamed tree (read from the engine source, not yet exercised in a browser). Who can still overwrite an unreadable save: the guarded writers (save_prefs, set_haptics, set_hp_bar_always, set_share_diagnostics, ensure_install_id, the start-menu difficulty chip) skip it when last_load_compatible is false; the plain save_state callers (among them run banking, shop buy/reset, the drone bay, the ship picker, the codex) do not, but the original bytes are then kept in .corrupt.

Diagnostics: MetaStore.last_write_error holds the failing step’s own error (FileAccess.get_open_error() is the latest open, 0 after any success, so it cannot name it) and feeds last_save_error, which the on-device SAVE DEBUG overlay shows. debug_info() adds bak_exists and last_load_recovered; a load_path ending .bak means a recovery. Tests drive MetaStore.fault (after_tmp_partial, after_tmp_full, verify_fails, after_bak, after_corrupt_aside, rename_fails; empty in production): tests/test_meta_store_atomic.gd kills the write at every point on both a user:// and an absolute path and asserts the primary is the old or the new save; tests/test_difficulty_chip_save.gd taps the chip after a real load_from of an unreadable file and asserts that file’s bytes are untouched.

Ship skins (2026-10-15, plan 2026-10-15-beta-skin)

Section titled “Ship skins (2026-10-15, plan 2026-10-15-beta-skin)”

MetaState.owned_skins (Array[String], grant order, no duplicates) + equipped_skin (“” = none, else an owned id) hold cosmetic skins, saved as the owned_skins / equipped_skin keys. They are cosmetic only, never read by Sim.tick, and are granted, never bought: reset_shop() keeps them. KNOWN_SKINS (["beta"]) whitelists what a loaded save may contain; corrupt values salvage per field like the rest of from_dict(). API: owns_skin(id), equip_skin(id) (“” always succeeds; an unowned id is refused) and grant_skin(id). Grant contract: MetaState.grant_skin(id) -> bool is idempotent (true only when newly added; an owned or unknown id changes nothing; equips it when nothing is equipped) and does not save; the caller runs MetaStore.save_state(meta) on true. Pinned by tests/test_meta_skins.gd.

Where skins surface: the render side is render/ship_skin.gd (ShipSkin, an overlay over pilot 1’s hull; see rendering-and-performance.md § Ship skins, and remote-config.md § Ship skins for its tunables). The pre-run hull picker (ui/ship_config_panel.gd) shows a SKIN ‹ BETA › row only once a skin is owned and calls equip_skin from it (pinned by tests/test_ship_config_skin_row.gd). Dev builds can grant one without the App Clip through the console command {kind:"grant_skin", id:"beta"} (main._grant_skin_command: the same grant_skin contract, saves only a new grant, draws from the next run; store builds refuse it).

Storyline mode — “Ascent: The Spectrum” (M2 cycle 19, DONE) — builds 33-46

Section titled “Storyline mode — “Ascent: The Spectrum” (M2 cycle 19, DONE) — builds 33-46”

A second game MODE (alongside survival): a hand-authored, gradually-escalating campaign you fly through — a connected map of rooms (corridors / locked arenas / safe rooms / secret pockets / boss rooms), talking enemies, NPCs you meet who give you weapons, locked rooms you fight out of, and hidden secret areas. Four ascending elemental Domains (Ember Reach → The Hush → Live Wire → The Null Core), each ending in a Warden boss; allies Echo/Rime/Vex and the antagonist the Null carry a full dialogue arc. Design spec: docs/superpowers/specs/2026-06-25-storyline-mode-design.md; build plan/tracker: docs/superpowers/plans/2026-06-25-storyline-mode.md. Runs live on the Apple TV (BUILD 34+) and is reachable from the new launch menu.

  • The load-bearing rule — a null-object seam keeps survival byte-identical. Sim.story: StoryState = null (default null = survival). Sim.enable_story(StoryData) switches a run into story mode AFTER construction. EVERY story hook in tick() is guarded by if story != null: / if story == null: — ring-spawn + survival auto-boss are skipped, _resolve_walls() runs, story_director.update() runs, and the weapon level-up picker is suppressed (roll_upgrade_choices), all only in story mode. The determinism test builds Sim.new(seed, content) and NEVER enables story, so the survival baseline 4152236597/2325839371 is byte-identical by construction. When extending story mode, keep every change behind the story null-check and re-verify the baseline.
  • Pure /sim types (RefCounted, no Node/Engine/File/JSON): sim/story_data.gd (StoryData — the authored map: rooms/doors → AUTO-GENERATED solid wall rects + sealable gate rects; from_dict converts [x,y,w,h]→Rect2; holds regions/speakers/dialogue/encounters/rewards/npcs keyed by id), sim/story_state.gd (StoryState — live run state: current_room, sealed_gates, cleared_rooms, found_secrets, talked_npcs, seen_dialogue, complete, and events (per-tick render signals, EXCLUDED from snapshot/checksum like fx_events)), sim/story_director.gd (StoryDirector — the per-tick brain: room tracking + on_enter triggers, gate seal/open, scripted encounter waves, clear detection (enemies.count == 0 once all waves dispatched — works because story has no ring-spawn), reward grant, NPC meet-to-reward, secret discovery, end-room completion). Loader content/story_loader.gd (StoryLoader, OUTSIDE /sim — FileAccess/JSON) is fail-loud: validates schemaVersion + ref-integrity (rooms/doors/encounters/dialogue/rewards/npcs) + start_room; validate(raw) returns problems with NO push_error (test seam), load_from_dict push_errors + returns null.
  • Sim additions (all guarded/baseline-safe): enable_story, _resolve_walls (circle-vs-AABB push-out for player + enemies vs story.active_wall_rects()), spawn_story_enemy(name,pos) (name→TYPE_*), spawn_story_boss(pos, hp_mult) (reuses the pooled Warden so all weapons damage it free; BossState.max_hp makes enrage relative to scaled HP), grant_story_reward(id) (weapon/heal). PlayerState.clamp_arena (story sets false so the map can exceed ±2000; survival keeps the clamp).
  • Render/UI (inert when story==null, so survival unperturbed): ui/dialogue_box.gd (typewriter speaker box consuming say events, consume() once/tick in _physics_process, advance() per frame), render/story_map_renderer.gd (region-tinted room outlines + neon walls + pulsing SEALED gates + faux-wall over secret doors + NPC diamonds/name-tags; z_index -2. Since 2026-10-02 the walls, sealed gates and secret false walls draw in the Survival arena-edge look by default — VisualLook.story_walls_v2, Debug Settings “Tutorial Walls (A/B)”, one wall_layer MultiMesh on render/shaders/story_wall.gdshader sharing border_look.gdshaderinc with ArenaBorder; see rendering-and-performance.md), ui/start_menu.gd (launch mode picker Story/Survival, tvOS-safe joypad nav), HUD objective readout (region + CLEAR THE ROOM / SAFE ROOM / WARDEN), ResultsPanel.show_victory (ASCENT COMPLETE on story.complete). main.gd: starts in the menu (sim null → process loops guarded), headless auto-starts survival (so boot smoke still exercises a run), restart replays the chosen mode.
  • Authoring content is DATA — edit data/story.json by hand (tab-indented python round-trip: load → append → json.dump(indent='\t', ensure_ascii=False); NO code needed for new rooms/enemies/dialogue). Room kinds: corridor/arena/safe/boss/secret/portal (+ is_end:true triggers victory). Encounters: timed waves of {type,count,delay} (types: any enemy name, or boss with hp_mult), dialogue (on-start) + clear_dialogue (on-clear), reward. NPCs: meet within talk-radius → dialogue + once-reward. Secret doors: gate:"" + secret:true = passable (no gate wall) but rendered as a false wall → a hidden room (kind secret, reward) with a bonus weapon. Rooms stack vertically (decreasing y = ascending). After editing, the test_story_loader real-file test re-validates the whole campaign — update its room/region/reward counts if you change them, and it fails loud if any ref breaks.
  • Skills (local .claude/skills/, gitignored — work locally): bh-dev-chunk (the per-chunk build/TDD/import/boot/count/determinism/commit ritual) and bh-deploy (main→tvOS sync + export-pack + xcodebuild + devicectl install; the tvOS repo GITIGNORES gameplay source — it’s a build shell, main is the source of truth, so a gameplay-only deploy shows “nothing to commit” there, which is correct).
  • Built since (builds 36-46), all guarded story-only or render-side (survival baseline was 4152236597/1267954985 through all of these; re-pinned to 4152236597/2325839371 by task 10 per-enemy variation):
    • Dialogue PAUSES the sim. main skips sim.tick while dialogue_box.is_showing() in story mode (so the player reads); a confirm press (request_skip) advances a line. DialogueBox dims the field + shows a colour-coded speaker PORTRAIT (initial in the speaker tint). ui/region_title.gd sweeps a domain title card on region change.
    • Weapons are real PICKUPS (Sim.weapon_pickups + render/weapon_pickup_renderer.gd): story starts WEAPONLESS (enable_story clears active_weapon_ids); grant_story_reward(reward, drop_pos) DROPS a glowing pickup (at the NPC / cleared-room centre / secret room) you fly over to collect. Shop-unlocked weapons (e.g. scatter) are granted at story start in main via meta.purchased_unlocks — a shop unlock did NOTHING in story before (story weapons are pickups, not picker offers).
    • Tutorial-once + randomized replays (metroidvania re-run loop). MetaState.tutorial_done flips once the player reaches region 2 (story.regions_seen.size()>1) or completes; main then passes enable_story(data, suppress_tutorial=true, randomize=true). Replays: tutorial-flagged dialogue ("tutorial":true in story.json) is suppressed, enemy wave types are randomised (StoryDirector.RANDOM_POOL), weapon drops are shuffled (Sim._build_weapon_remap, seeded), and main picks a fresh randi() seed each run. First run = fixed + fully taught.
    • Per-region toughness: encounter power field scales spawned-enemy HP + contact damage (spawn_story_enemy(name,pos,power)); Hush ~3.0, Live Wire ~4, Null ~5 (tune in story.json — I’m GUESSING without telemetry). Warden rooms spill random adds (_spawn_boss_adds) and tanks spawn with random escorts (so they aren’t lone sponges). Ember Warden hp_mult 0.25.
    • Decoy overhaul: flies an organic wander-seek (not an orbit); boss missiles + walk-mobs chase the NEAREST decoy. (The proximity damage-synergy bonus this section used to describe was removed 2026-07-11 — a reactions-mode-era mechanic no longer needed once drones carry their own value; see docs/superpowers/specs/2026-07-11-p2-drone-decoy-design.md.) The decoy is ONE basic low-damage taunter, full stop (Chris, 2026-09-30: “the different types of decoy don’t really make sense”) — the selectable Striker/Bulwark/Mender/Swarm type cards, DECOY_TYPES, Sim.decoy_type, MetaState.selected_decoy/owns_decoy() and the DecoyState.extra companion machinery were all REMOVED; what’s left is the basic cfg (sentinel_cfg()) levelled through the drone-sentinel-* stat cards. Saves that bought type cards load fine (the orphaned levels sit inert; the gold still refunds via the full-shop Reset — the ledger never keyed on ids). Decoy stat upgrades go through StatEffects (decoy_power/decoy_life → player.decoy_*_mult). HUD decoy bar crackles + “DECOY READY” flash when full.
    • Floating damage numbers (fx/damage_numbers.gd): _damage_enemy emits dmgnum fx_events for significant hits (>= DMGNUM_MIN, capped/tick); big reaction hits flash gold. Consumed once/tick in main; works in BOTH modes.
    • Boss telegraphs/juice: the fire (barrage) attack gets a 3s vibrating-angry wind-up + a crackling fire ERUPTION; the swing is an aggressive slash + harder (BOSS_SWING_DMG 44). boss_render_info exposes winding_fire/fire_charge/fire_active; BossRenderer runs _fire_burst/_swing_burst timers. FIXED bug: boss_render_info.max_hp used the 1500 constant — now boss.max_hp so the HUD boss bar reads right for scaled story Wardens. The Warden fight TEACHES the decoy (on first damage) + hold-still-heal (on low HP) via StoryDirector._check_teaching while a boss is alive (fire-once).
  • Gameplay telemetry (build 46) — we now collect real balance data; STOP tuning blind. net/gameplay_telemetry.gd (GameplayTelemetry, render-side, active on tvOS + web (not editor/headless), persistent across runs) POSTs a per-run summary at run-end (mode, run_time, level, kills, gold, DPS, reactions, region reached, weapons, per-enemy kills; a per-run decoy-type field existed until the types were removed 2026-09-30). Sim counters feeding it: dmg_dealt_total/reactions_total/kills_by_type (NOT in the checksum). Backend = the same telemetry worker (see “## Performance telemetry”): POST /gameplay + D1 runs table + dashboard sections (runs by build/mode, deepest story region).
  • Deferred: per-Warden attack variety (all 4 Wardens share one BossState attack set); a start-screen → shop entry; the WEB demo is NOT redeployed (the start menu changed the public demo from auto-attract → menu — flagged for Chris). Persistence on tvOS: the meta save (user://meta.json, saved on buy + run-end) has NO code bug found; if progress resets between builds it’s a tvOS container-clear (platform), and the new telemetry’s banked_gold/gold will confirm. Phase-3 co-op layers on the same deterministic sim.

Replaced the 2026-08-19/2026-08-10 beat-gated Tutorial (see the “Tutorial friendliness pass” and “Tutorial stage-gating redesign” entries in CLAUDE.md’s Current status) with a ten-lesson track: one unlock and one checked objective per lesson, in a fixed order, nothing usable before its own lesson says so. Spec: docs/superpowers/specs/2026-09-24-tutorial-lesson-track-design.md; plan: docs/superpowers/plans/2026-09-24-tutorial-lesson-track.md.

  • Ownership. Sim.tutorial: TutorialLessons (sim/tutorial_lessons.gd, pure /sim, RefCounted, no RNG draws) is created by Sim.enable_story(..., tutorial_cap: true) — i.e. only for the capped Tutorial. It is null everywhere else (Survival, ordinary Story mode, both determinism baselines), so this whole system is determinism-safe by the same null-object seam the rest of story mode uses. Sim.tutorial_locks(feature: String) -> bool is the one query every other system uses to ask “is this still locked” — tutorial != null and not tutorial.is_unlocked(feature).
  • The ten lessons. The full table — id, title, intro beat, room, gate it opens, feature it unlocks, and its ordered steps — lives in sim/tutorial_lessons.gd’s LESSONS constant; read it there, not here, it drifts. In order: WAKE UP (move) → BLASTER (weapon_dock) → LEVEL UP (levelup) → SHIP STATS (stats_sheet) → RIFT BOLT (aim) → WARP (warp) → DECOY (decoy, meter + Bay) → CRYSTALS (crystals) → NOVA (nova) → THE WARDEN (nothing new — Ash/The Null room fights + the boss itself). A reach_room step is pure navigation glue at the start of lessons 3, 6, 9 and 10 (a sealed door the player is never asked to solve the lesson’s real objective behind); it unlocks nothing. TutorialLessons.index/step/progress track where the run is; current_step_kind() is what every gate and hook below reads.
  • Read-based gating, not queue-based. A lesson’s features unlock the tick its intro dialogue has been READ (is_unlocked()’s index > owner or (index == owner and _intro_read)), never the tick it is merely queued/said. StoryState.promote_read() runs at the top of every Sim.tick() and promotes everything in said_pending (said last tick) into read_dialogue. In the Tutorial that promotion is exactly “the player dismissed it”: main.gd freezes the sim (_mode=="tutorial" and dialogue_box.is_showing()) whenever a line is up, so tick N+1 — the tick that promotes tick N’s said beat to read — cannot run until every queued line has actually been seen. This is the fix for the 2026-08-19 audit’s “gates open on queue, not on read” hole: a beat that was said but never read (e.g. a fresh-save first tick, or a hardening fallback in TutorialLessons._begin_lesson) is deliberately promoted to read rather than left to hang the lesson forever. TutorialLessons._intro_read mirrors read_dialogue.has(intro) each tick via refresh(sim), called at the TOP of StoryDirector.update() — before rooms/NPCs/encounters run — so a beat read this tick gates everything else consistently within that same tick.
  • The hooks. Three call sites feed the lesson track; none of them live inside TutorialLessons itself, because the sim’s one damage/upgrade/UI choke points already exist elsewhere and a second one would be a second place to get it wrong:
    • on_enemy_hit(context, hp_before, hp_after) — called from ElementalSystem.damage_enemy (the ONE damage choke point), so it counts every hit/kill exactly once regardless of source. context is Sim.damage_context (a weapon id, "aim" for the Rift Bolt, "nova"). Drives hits/kills steps.
    • on_upgrade_applied(sim, id) — called from UpgradeSystem.apply_upgrade. Completes a pick or pick_crystal step the instant a card is confirmed, synchronously (not on the next tick), so main.gd’s level-up-chain logic sees the new state before deciding whether to open another panel.
    • note_ui(sim, event) — the ONE UI→sim write besides tick/apply_upgrade (global constraint: UI may otherwise only READ sim state). Currently one event, "stats_sheet", fired when the player opens Ship stats (the level-up panel’s Y/Tab sheet on lesson 4, the pause menu’s Ship stats on lesson 8) — completes a sheet step. Like apply_upgrade, this is a player choice made while the sim is frozen, not a new sim input class. XP itself is paid on biomass via the existing spawn_story_enemy(sim, type, pos, biomass_v) — see the “XP is paid on biomass” rule in CLAUDE.md’s Architecture section. Every scripted lesson spawn (TutorialLessons._spawn_for) goes through that call, never a bare enemies.add.
  • Owned rooms/NPCs/weapons. OWNED_ROOMS (ember_entry/ember_a1/ember_a2/ember_safe1), OWNED_NPCS (echo_intro/echo_gift) and LESSON_WEAPONS (blaster/nova) are the tutorial’s own content — StoryDirector skips their normal room-beat/encounter/NPC-meet dialogue entirely inside them (owns_room/owns_npc), and TutorialLessons.npc_meetable/pickup_allowed gate when they can actually trigger, so nothing in an owned room fires early or out of order. A room NOT in OWNED_ROOMS (ember_a3/ember_a4/ember_warden/ember_portal, lesson 10’s territory) plays its normal Story-mode room content — that’s how Ash and The Null get introduced on the way to the Warden, matching the plan’s “lesson 10 teaches nothing new” decision. pickup_allowed(wid, sim) also lets a lesson weapon be picked up any time once its own lesson is behind the player (or it’s already owned), so a pickup can never be stranded on the floor by a skip/reorder.
  • Lesson 1 opens on one short beat, and the tutorial can be skipped (2026-10-14). Store 1.0.1 telemetry: 8 installs started the forced tutorial, 2 got past lesson 1, which then opened on ~20 s of frozen reading. The team’s dev_welcome note now opens lesson 3 (DEV_WELCOME_LESSON), and move_intro is one line, so the player can move after ~3.5 s (tests/test_tutorial_lesson_zero.gd pins the budget). Two exits, both two-press confirms: the pause menu’s Skip tutorial row (PauseMenu.show_skip_tutorial, any lesson) and the top-left TutorialSkipChip through lesson 1 (tap/click, or the menu key while a dialogue shows: the dialogue swallows that key otherwise, so main adds the chip after the dialogue box to see input first). Both call main._skip_tutorial(): tutorial_done = true + save, a run_end with reason tutorial_skip, then the start menu. seen_survival_intro is left false, so the first Survival run still gets the one-time note.
  • Adding a new lesson (or a step to an existing one): add a row to LESSONS in sim/tutorial_lessons.gd (id, title, intro beat, room, open_gate if it seals one, unlocks: [feature], ordered steps); write the intro beat in data/story.json (speaker + role/emblem/side, 1-2 lines, no em dashes, true to what the sim actually does — read the code behind every claim); if the step needs a detection kind _step_complete()/on_enemy_hit/ on_upgrade_applied/note_ui don’t already cover, add it to STEP_KINDS and wire the match arms in sim/tutorial_lessons.gd; then run godot --headless --path . -s res://tools/story_auto/story_trace.gd -- --mode=tutorial and confirm lessons: ok — it drives every objective for real (see below), prints each lesson’s start/done tick, and fails loud on an out-of-order or never-unlocked feature.
  • Two harness rules, both load-bearing for tools/story_auto/story_trace.gd and any other automated/replay driver:
    1. UI notes and picks are not in the replay stream. note_ui() and a level-up pick are player choices made while the sim is frozen (same as apply_upgrade itself) — neither is an InputState field, so a recorded replay can’t reproduce them. Any unattended driver (story_trace.gd’s _drive_ui, main.gd’s _drive_capture_ui for a live capture run) must call note_ui(sim, "stats_sheet") itself whenever the current step is "sheet", or an unattended run stalls there forever.
    2. Drive “sheet” steps before picks, always. Lesson 4 blocks confirming a level-up pick until the stats sheet has been opened once on that panel (panel_options().sheet_first, LevelUpPanel._confirm()’s refusal + nudge) — a driver that picks first and opens the sheet second stalls the same way a real player would. StoryAutoPlayer/story_trace.gd open the sheet before ever attempting a pick for exactly this reason.

Difficulty modes (Survival Normal / Hard, 1.1)

Section titled “Difficulty modes (Survival Normal / Hard, 1.1)”

Survival has two modes. Spec: docs/superpowers/specs/2026-10-15-difficulty-modes-design.md; plan: docs/superpowers/plans/2026-10-15-difficulty-modes.md.

  • Where it lives. sim/difficulty.gd (class_name Difficulty, enum NORMAL/HARD, pure /sim). Sim.difficulty defaults to HARD. The Normal knobs (spawn-pressure ramp, shot and Charger damage, Charger telegraph, XP boost, softer bosses, longer hit grace, bigger hull, the gold hooks) are registered static var tunables (Difficulty.TUNABLE_NAMES), so the knob list and values are in sim/difficulty.gd, not repeated here. Difficulty.applies(sim) is false in Story/Tutorial, so those never change.
  • Hard = identity. Every multiplier is 1.0 on Hard, i.e. today’s tuning, which keeps every determinism baseline, soak and economy-sim number where it was. Gold is 1.0x in both modes.
  • Run start. Difficulty.apply_run_start(sim) runs once after MetaRunSetup + enable_survival (main _new_run, SoakRun) and applies Normal’s bigger hull. The mode is chosen by Main.run_difficulty_for:
    • A replay plays the mode in its header (ReplayLoader.difficulty_of; no key = Hard). ReplayDriver does the same.
    • Learning-autopilot runs and perf_bench / auto_showcase are pinned HARD (archive and perf baselines stay comparable).
    • An unattended attract start (_unattended_start) shows NORMAL.
    • Otherwise a human store Survival run uses the saved pick.
    • Co-op: the net co-op sim is built by NetCoopRun.build_sim, which does not take the pick, so it keeps the Sim default HARD until co-op ships.
    • Soak cfg difficulty defaults to "hard" (tools/soak/soak_run.gd).
  • Save. MetaState.survival_difficulty (“normal” for new saves) and best_survival_by_mode (longest human run per mode; a legacy single best loads as Hard).
  • Telemetry. runs.difficulty column, migration telemetry/migrations/2026-10-15-runs-difficulty.sql (apply remote before the Worker deploy per the telemetry rules).
  • UI. ui/start_menu.gd: the NORMAL / HARD chip on the Survival row. Left picks NORMAL and right picks HARD on a pad or remote while the row is selected (a press toward the lit mode does nothing), a tap/click on the chip toggles it; difficulty_changed makes main save the pick at once.