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. Writes are atomic with a.bakand a.corruptcopy: § Save write path.Sim.run_gold(per-type bibleenemies.<id>.gold: basic 1, heavy 3, boss 41, timesarea_reward_multandSim.GOLD_MULT, accumulated with a fractional remainder bySim._add_gold; seeremote-config.md§ Gold economy), 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.- Layout (2026-09-27 rebuild, was a carousel): header
[◀ BACK] SHOP <gold>, a left tab RAIL (one tab perShopCategories.ORDERcategory, 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 inMetaShopPanel.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 fromcard_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 mutedSHIPS_NOTEunder it. The scroll sits under an alpha mask (ScrollMask,clip_children) that fades content out overEDGE_FADEat the clip edges: a hard clip left descender tips as stray 1px dots. The post-buy toast hides when the reset dialog opens orsuspend()hands over to the Bay. The Drones tab is one section per class (tab_sections) plus a DRONE BAY header button that emitsbay_requested;main.gdthen callssuspend(), which now keeps the shop VISIBLE but inert behind the bay (it used to hide, leaving the bay floating over the start menu), andresume()refocuses the bay button. The Drones tab is one section per class (tab_sections) plus a DRONE BAY header button that emitsbay_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 asSectionInfo<title>in_rebuild_grid; a test forces every sub-category inDRONE_SUBCATEGORY_ORDERto have one, so a new class can’t ship unexplained). The legacydecoy-power/decoy-durationpair is NOT a Decoy item (Chris, 2026-09-27 plan2026-09-27-shop-economy-plan-2Task 1, done 2026-09-29):decoy_power_multscales Decoy/Bomber/Interceptor damage anddecoy_life_multscales 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 thedronesicon (was the decoy diamond). The Disruptor now actually deals its “little direct damage” (it never did, so “Disruptor: Power” was a dead purchase): oneDISRUPTOR_DMGchip per 0.5s field pulse indrone_disruptor, scaled bycfg.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.
- Layout (2026-09-27 rebuild, was a carousel): header
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/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_radiusin the Utility tab, which survival never reads (Sim.AUTO_COLLECT_XP, powerups off), so it was a dead purchase. Noweffect: "gold_mult", category Pilot, magnitude 1.08 compounding per level (5 levels = x1.469).MetaState.gold_mult(defs)(product ofmagnitude^levelover everygold_multdef) →MetaRunSetup.apply_statssetsSim.meta_gold_mult→Sim._add_goldmultiplies every run-gold source (kills, bosses, crystals) by it alongsideGOLD_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.StatEffectsdoesn’t knowgold_mult, soapply_toskips 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 inORDERand 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_deadpath, co-op, patched magnitude, card copy). - Shop-card copy reads numbers live:
ui/meta_copy.gd(MetaCopy.desc(def)). Ameta_upgradesdesc carries a token filled at display time from the def, so a patchedmeta_upgrades.<id>.magnitudenever leaves stale copy (standing rule,remote-config.md). The token must match how the consumer applies the magnitude:{pct}=(mag - 1) x 100for a compounding multiplier (Haste, Swiftness, Drone Power/Lifespan, Greed),{pct_less}=(1 - mag) x 100for a shrinking one (Thrusters),{mag_pct}=mag x 100for a linear1 + level x magstep (Drone Recharge, everydrone-<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.gdpins 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-rerollindata/bible.json(category Utility, effectlevelup_rerolls, magnitude 1, 3 levels, 150 / 300 / 600 gold).MetaState.rerolls_per_run(defs)(magnitude x level, summed) ->MetaRunSetup.apply_statssetsSim.meta_rerolls; each pilot spends its own budget (PlayerState.rerolls_used), so co-op gives every pilot the full count.StatEffectsdoes not knowlevelup_rerolls, soapply_toskips it. Skip needs no purchase. Card icon:ShopIcons._draw_reroll. The shop total grew by 1050 (tests/test_economy_career.gdcatalogue 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 anarchetypesarray on everymeta_upgrades,modsandweaponsentry indata/bible.json(separate from weapon mechanictagsand shopcategory).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:healadds Support,aim:kinds use explicit metadata,crystals:cards take the union of the weapon paths in their liveupgrade_effects()changes + progress rows) and builds neutral text chips. Shop cards show them in a band between the description and the price row (card_metricsbudgets the worst entry’s wrapped chip lines);LevelUpPanelputs the why-tag pill and each chip directly in oneHFlowContainer(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_hgives it the screen below the LEVEL header (up toFULL_FRACof the height,MIN_FULL_Hfloor 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).LegacyLevelUpPanelright-aligns them on the subtext line. Shop chips are 15 units (the shop floor); level-up chips useLevelUpPanel.ROLE_FONT(17). Device-profile tests (ShopLayoutFixturetv/iphone/ipad) pin the fit. Labels only: they never touch offer weights, scores, stats or/sim.tests/test_archetype_tags.gdenforces completeness. - ⚠️ 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. - Ratings prompt (RELEASE-1.1 §3a, 2026-10-14):
meta/review_prompt.gd(ReviewPrompt, render-side) decides;main.gdwires it._bank_runnotes every run a human flew on their own save (not attract/console-autopilot/learning/replay runs) intoMetaState.best_survival_by_mode({"normal", "hard"}, keyed byDifficulty.id(sim.difficulty); read viaMetaState.best_survival(mode_id)) +bosses_killed_totalviaReviewPrompt.note_run, which returns a positive reason: first-ever boss kill (fromsim.boss_log, kill time set), or a Survival best beating that same difficulty’s previous best and ≥MIN_BEST_S(120 s). A legacy singlebest_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 inMetaState.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_menufrom 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_askfires a one-shot timer (review_ask_delay_s, 1 s) and_on_review_ask_timeoutasks 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 asappstore(BuildChannel’s deliberate fallback) burns that BUILD’s ask for that tester; see the comment innet/build_channel.gd.try_askgates on BuildChannelappstore+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:DcStoreReviewsingleton (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 Macnative_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).
Save write path (2026-10-22)
Section titled “Save write path (2026-10-22)”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):
- writes
p.tmp, closes it, re-reads it and checks it is exactly the payload and a JSON object. A failure leavespuntouched and removes the temp file; - COPIES what it is about to replace (never moves it, so
pexists at every instant): a valid save goes top.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 top.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; - renames
p.tmpoverpin ONE atomic step. If the rename is refused, the verified payload is written straight intop(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 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. 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)”, onewall_layerMultiMesh onrender/shaders/story_wall.gdshadersharingborder_look.gdshaderincwithArenaBorder; 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 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); 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 theDecoyState.extracompanion machinery were all REMOVED; what’s left is the basic cfg (sentinel_cfg()) levelled through thedrone-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 throughStatEffects(decoy_power/decoy_life→player.decoy_*_mult). 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, 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+ D1runstable + 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’sbanked_gold/goldwill confirm. Phase-3 co-op layers on the same deterministic sim.
Tutorial lesson track (2026-09-24)
Section titled “Tutorial lesson track (2026-09-24)”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 bySim.enable_story(..., tutorial_cap: true)— i.e. only for the capped Tutorial. It isnulleverywhere 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) -> boolis 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’sLESSONSconstant; 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). Areach_roomstep 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/progresstrack 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()’sindex > owner or (index == owner and _intro_read)), never the tick it is merely queued/said.StoryState.promote_read()runs at the top of everySim.tick()and promotes everything insaid_pending(said last tick) intoread_dialogue. In the Tutorial that promotion is exactly “the player dismissed it”:main.gdfreezes 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 inTutorialLessons._begin_lesson) is deliberately promoted to read rather than left to hang the lesson forever.TutorialLessons._intro_readmirrorsread_dialogue.has(intro)each tick viarefresh(sim), called at the TOP ofStoryDirector.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
TutorialLessonsitself, 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 fromElementalSystem.damage_enemy(the ONE damage choke point), so it counts every hit/kill exactly once regardless of source.contextisSim.damage_context(a weapon id,"aim"for the Rift Bolt,"nova"). Driveshits/killssteps.on_upgrade_applied(sim, id)— called fromUpgradeSystem.apply_upgrade. Completes apickorpick_crystalstep the instant a card is confirmed, synchronously (not on the next tick), somain.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 besidestick/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 asheetstep. Likeapply_upgrade, this is a player choice made while the sim is frozen, not a new sim input class. XP itself is paid onbiomassvia the existingspawn_story_enemy(sim, type, pos, biomass_v)— see the “XP is paid on biomass” rule inCLAUDE.md’s Architecture section. Every scripted lesson spawn (TutorialLessons._spawn_for) goes through that call, never a bareenemies.add.
- Owned rooms/NPCs/weapons.
OWNED_ROOMS(ember_entry/ember_a1/ember_a2/ember_safe1),OWNED_NPCS(echo_intro/echo_gift) andLESSON_WEAPONS(blaster/nova) are the tutorial’s own content —StoryDirectorskips their normal room-beat/encounter/NPC-meet dialogue entirely inside them (owns_room/owns_npc), andTutorialLessons.npc_meetable/pickup_allowedgate when they can actually trigger, so nothing in an owned room fires early or out of order. A room NOT inOWNED_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_welcomenote now opens lesson 3 (DEV_WELCOME_LESSON), andmove_introis one line, so the player can move after ~3.5 s (tests/test_tutorial_lesson_zero.gdpins the budget). Two exits, both two-press confirms: the pause menu’s Skip tutorial row (PauseMenu.show_skip_tutorial, any lesson) and the top-leftTutorialSkipChipthrough 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 callmain._skip_tutorial():tutorial_done = true+ save, arun_endwith reasontutorial_skip, then the start menu.seen_survival_introis 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
LESSONSinsim/tutorial_lessons.gd(id, title, intro beat, room,open_gateif it seals one,unlocks: [feature], orderedsteps); write the intro beat indata/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_uidon’t already cover, add it toSTEP_KINDSand wire the match arms insim/tutorial_lessons.gd; then rungodot --headless --path . -s res://tools/story_auto/story_trace.gd -- --mode=tutorialand confirmlessons: 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.gdand any other automated/replay driver:- 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 asapply_upgradeitself) — neither is anInputStatefield, so a recorded replay can’t reproduce them. Any unattended driver (story_trace.gd’s_drive_ui,main.gd’s_drive_capture_uifor a live capture run) must callnote_ui(sim, "stats_sheet")itself whenever the current step is"sheet", or an unattended run stalls there forever. - 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.gdopen the sheet before ever attempting a pick for exactly this reason.
- UI notes and picks are not in the replay stream.
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, enumNORMAL/HARD, pure/sim).Sim.difficultydefaults toHARD. 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 registeredstatic vartunables (Difficulty.TUNABLE_NAMES), so the knob list and values are insim/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 byMain.run_difficulty_for:- A replay plays the mode in its header (
ReplayLoader.difficulty_of; no key = Hard).ReplayDriverdoes 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 theSimdefault HARD until co-op ships. - Soak cfg
difficultydefaults to"hard"(tools/soak/soak_run.gd).
- A replay plays the mode in its header (
- Save.
MetaState.survival_difficulty(“normal” for new saves) andbest_survival_by_mode(longest human run per mode; a legacy single best loads as Hard). - Telemetry.
runs.difficultycolumn, migrationtelemetry/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_changedmakes main save the pick at once.