Bullet Heaven — meta-progression & storyline mode
Bullet Heaven — meta-progression & storyline mode
Section titled “Bullet Heaven — meta-progression & storyline mode”Extracted from
CLAUDE.mdon 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. SeeCLAUDE.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”

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(geometricbase_cost × cost_growth^level, -1 if maxed),can_afford/buy/is_maxed,apply_to(player, defs)(reuses theStatEffectsvocabulary 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, NEWmeta/dir): fail-safeload_state/save_statetouser://meta.json(missing/corrupt → freshMetaState, never errors). UsesFileAccess/JSONso it lives OUTSIDE/sim.Sim.run_gold(GOLD_PER_KILL=1/kill,BOSS_GOLD=25/boss), awarded in_sweep_dead. NOT insnapshot_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 onLevelUpPanel’s proven tvOS focus/nav (Siri Remote = joypad; explicit debounced nav +ui_accept/JOY_BUTTON_A). A buy routes throughMetaState.buy, persists viaMetaStore, and_rebuilds the cards.main.gdwiring: loadsmetaONCE in_ready(survives_new_run, which queue_frees children —metais a plain RefCounted member, not a child); appliesmeta.apply_to(sim.player, …)at run start OUTSIDE the sim (keeps the deterministic baseline unbuffed — the determinism test buildsSim.newdirectly with no meta bonuses);_bank_run()banksrun_goldonce per run (_run_bankedguard) 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/pickup_radius/move_speed.ContentDB.meta_upgrades()accessor. - ⚠️ Don’t unit-test the buy-WITH-SAVE path —
MetaStore.save_statewrites the REALuser://meta.json(headless tests share the editor’s user dir), which would clobber Chris’s actual save. TestMetaState.buy(pure) + the panel’s unaffordable (no-save) path instead. - Weapon unlocks (build 31): meta upgrades can be
type:"unlock"(skipped byapply_to, listed bypurchased_unlocks).Sim.locked_weapons(empty by default → all available, so tests/determinism are untouched) suppresses a weapon’s level-up offer;main.gdlocks{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.gdshowssim.run_gold(“N gold”, gold colour) under the kills counter, so the currency is visible mid-run. Render-only. - 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).
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 intick()is guarded byif 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 buildsSim.new(seed, content)and NEVER enables story, so the survival baseline4152236597/2325839371is byte-identical by construction. When extending story mode, keep every change behind the story null-check and re-verify the baseline. - Pure
/simtypes (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_dictconverts[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, andevents(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 == 0once all waves dispatched — works because story has no ring-spawn), reward grant, NPC meet-to-reward, secret discovery, end-room completion). Loadercontent/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_dictpush_errors + returns null. - Sim additions (all guarded/baseline-safe):
enable_story,_resolve_walls(circle-vs-AABB push-out for player + enemies vsstory.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_hpmakes 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 consumingsayevents,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),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 onstory.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.jsonby 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:truetriggers victory). Encounters: timedwavesof{type,count,delay}(types: any enemy name, orbosswithhp_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 (kindsecret,reward) with a bonus weapon. Rooms stack vertically (decreasing y = ascending). After editing, thetest_story_loaderreal-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) andbh-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/1267954985through all of these; re-pinned to4152236597/2325839371by task 10 per-enemy variation):- Dialogue PAUSES the sim. main skips
sim.tickwhiledialogue_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.gdsweeps a domain title card on region change. - Weapons are real PICKUPS (
Sim.weapon_pickups+render/weapon_pickup_renderer.gd): story starts WEAPONLESS (enable_storyclearsactive_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 viameta.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_doneflips once the player reaches region 2 (story.regions_seen.size()>1) or completes; main then passesenable_story(data, suppress_tutorial=true, randomize=true). Replays: tutorial-flagged dialogue ("tutorial":truein 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 freshrandi()seed each run. First run = fixed + fully taught. - Per-region toughness: encounter
powerfield 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 Wardenhp_mult0.25. - Decoy overhaul: flies an organic wander-seek (not an orbit); synergy
Sim._decoy_synergy(in_damage_enemy: fight withinDECOY_SYNERGY_RADIUSof your decoy → up to +100% ALL damage; 1.0 when no decoy so baseline-safe); boss missiles + walk-mobs chase the NEAREST decoy. Selectable decoy TYPES (Sim.DECOY_TYPES+Sim.decoy_type): basic / damage(Striker) / tank(Bulwark) / healer(Mender, heals you per pulse) / ultimate(Swarm — spawns companion decoys inDecoyState.extra, all pull aggro + pulse). Decoy stat upgrades go throughStatEffects(decoy_power/decoy_life→player.decoy_*_mult). Chosen in the shop:MetaState.selected_decoy+owns_decoy(); tapping an OWNEDdecoy:<type>unlock card EQUIPS it. HUD decoy bar crackles + “DECOY READY” flash when full. - Floating damage numbers (
fx/damage_numbers.gd):_damage_enemyemitsdmgnumfx_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_DMG44).boss_render_infoexposeswinding_fire/fire_charge/fire_active;BossRendererruns_fire_burst/_swing_bursttimers. FIXED bug:boss_render_info.max_hpused the 1500 constant — nowboss.max_hpso 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) viaStoryDirector._check_teachingwhile a boss is alive (fire-once).
- Dialogue PAUSES the sim. main skips
- 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, decoy type, weapons, per-enemy kills). 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+ D1runstable + dashboard sections (runs by build/mode, deepest story region, decoy usage). - 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’sbanked_gold/goldwill confirm. Phase-3 co-op layers on the same deterministic sim.