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 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.
  • Sim.run_gold (GOLD_PER_KILL=1/kill, BOSS_GOLD=25/boss), 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.
  • 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/pickup_radius/move_speed. ContentDB.meta_upgrades() accessor.
  • ⚠️ Don’t unit-test the buy-WITH-SAVE pathMetaStore.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.
  • 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 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), 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); synergy Sim._decoy_synergy (in _damage_enemy: fight within DECOY_SYNERGY_RADIUS of 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 in DecoyState.extra, all pull aggro + pulse). Decoy stat upgrades go through StatEffects (decoy_power/decoy_lifeplayer.decoy_*_mult). Chosen in the shop: MetaState.selected_decoy + owns_decoy(); tapping an OWNED decoy:<type> unlock card EQUIPS it. 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, 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 + D1 runs table + 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’s banked_gold/gold will confirm. Phase-3 co-op layers on the same deterministic sim.