Bullet Heaven — weapons, loadout & build-craft
Bullet Heaven — weapons, loadout & build-craft
Section titled “Bullet Heaven — weapons, loadout & build-craft”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”.

The 7 weapons, at a glance:
![]()
Loadout + interface overhaul (M2 cycle 11, DONE)
Section titled “Loadout + interface overhaul (M2 cycle 11, DONE)”Weapon-slot system + a proper anchored HUD/dock. Re-pinned determinism baseline (start-with-1 is a different run): snapshot_string().hash()=3746855395, state_checksum()=380627596.
- Loadout: start with 1 weapon, acquire up to
Sim.MAX_WEAPONS(6) at level-up. All five weapon instances still exist (sim.weapon/nova/orbit/beam/turret— tests + display read them), but onlyactive_weapon_idstick; a fresh run starts["pulse"]. The tick loopsfor wid in active_weapon_ids: _weapon_by_id[wid].update(self, dt).roll_upgrade_choicesadds a"weapon:<id>"grant per un-owned weapon while a slot is free;apply_upgraderoutesweapon:togrant_weapon.active_weapon_views()feeds the dock. Adding the weapon-grant pool changed the upgrade_rng draw sequence but NOT the determinism baseline (the baseline test never rolls upgrades) — only start-with-1 (fewer weapons ticking) moved it. - The “UI lands mid-screen” trap:
project.godothad no[display]section at all, so stretch was disabled — CanvasLayer UIs positioned in a hardcoded 1152×648 space rendered at the wrong spot (the weapon panel floated mid-field, HP bar overlapped the level). Fix:[display]withwindow/stretch/mode="canvas_items"+aspect="expand", then anchor every HUD/dock Control (set_anchors_preset(PRESET_TOP_LEFT/CENTER_TOP/TOP_RIGHT/CENTER_BOTTOM)) so they stick to real screen edges at any window size. Absolute pixel positioning in a CanvasLayer is the bug; anchors are the fix. - Weapon dock (
ui/weapon_panel.gd) is bottom-centre, compact by default (a row of element-coloured weapon glyphs), expands to full cards (name + stat) on hover OR Tab (_inputtoggles_sticky; a STOP-filter hit-area toggles_hover). Rebuilds tiles only when the active-weapon count changes; data-driven onactive_weapon_views(). - HUD (
ui/hud.gd) rebuilt with anchored Controls: HP bar top-left, level in its own badge (no overlap), timer top-centre, kills top-right; bigger fonts. - Early-game tuning (the start-with-1 opening, addressed): spawn ramp softened
2.0+0.5t→1.0+0.22t(SpawnDirector.rate_at); pulsebase_damage1→2 inseed.js(two-shots a swarmer);PlayerState.xp_to_next10→5 (weapon #2 by ~5s); gem magnetism added inSim._collect_gems(gems withinGEM_MAGNET_RADIUS=240drift to the player atGEM_MAGNET_SPEED=520, deterministic) so collection is reliable while kiting. Baseline re-pinned again to3746855395/380627596. - Level-up offer rules (
Sim.roll_upgrade_choices): GUARANTEES aweapon:<id>grant in the 3 choices while the player has<4weapons (so weapons reliably drop); transformative mods (Overcharge/Catalyst/Lingering) are gated by_mod_eligibleuntil_active_element_count() >= 2(“don’t offer reaction buffs before reactions are possible”). Weapon stats display one decimal (dmg 2.5) so a +25% buff is visible — note damage is already float end-to-end (enemies.data/radiusarePackedFloat32Array,_damage_enemy(amount:float)); the old “3 shots even with +25%” was just dmg-1 + a breakpoint, not an int-truncation bug. - Attract-mode auto-player (
ai/auto_player.gd,AutoPlayer): a CONTROLLER (not/sim) that reads sim state and returns a move dir — kites enemies (hash.query_circlerepulsion), seeks the nearest gem, avoids walls;pick_upgradeprefers weapon grants.main.gdruns it by default (_auto=true) and hands control to a human the instantinput_router.poll().move_diris non-zero; auto-picks level-ups after a 0.8s beat so the un-played demo plays itself instead of freezing on the choice screen. Does NOT affect determinism (the sim is deterministic given any input stream). Newclass_namein the newai/dir tripped the stale-class-cache trap — rungodot --headless --importafter adding a class in a new dir or the boot path parse-errors (Could not find type AutoPlayer) even though GUT passes.
Build-craft depth + evolutions + boss phase-2 + new content (M2 cycle 17, DONE) — builds 20-28
Section titled “Build-craft depth + evolutions + boss phase-2 + new content (M2 cycle 17, DONE) — builds 20-28”
Nine TDD’d chunks 2026-06-24/25, each committed to main + synced to tvOS + deployed to the TV. All determinism-neutral at the time (baseline was 4152236597/1267954985 — the baseline run is blade-only and never rolls upgrades; the boss is past the 40s window; new enemies are gated past 50s; difficulty scaling is ×1.0 inside the 120s grace window; the shooter fix and leech don’t touch the blade-only baseline). Re-pinned to 4152236597/2325839371 by task 10 (per-enemy variation).
- Projectile mods pierce/split (build 26):
ModState.projectile_pierce/projectile_split+SimModsentriesprojectiles_pierce/split_on_hit(previously loaded-but-unhandled);ProjPoolgained pierce/split columns;_resolve_collisionslets a piercing shot survive its hit and a non-piercing shot fansplitchildren (_spawn_split). Offered only when a projectile weapon (scatter/turret) is owned (_mod_eligiblespecial-case). Adding toSimModsgrewContentDB.upgrades()— three offer-set tests had to be updated (the offerable count + exact-set assertions); when you make a previously-unhandled mod offerable, expect those. - Leech (build 27):
ModState.lifesteal_per_kill+SimMods+ bible “Leech”; heals the player on each kill in_sweep_dead(capped at max HP, GUARDED by> 0so the default is a true no-op for the baseline which kills swarmers). - Endless difficulty scaling (build 28):
SpawnDirector.difficulty_mult(run_time)— 1.0 untilDIFFICULTY_START(120s) then +DIFFICULTY_PER_MIN(0.5)/min;_spawn_enemiesmultiplies spawned HP + contact damage by it. ×1.0 is an exact IEEE-754 no-op so the <10s baseline is byte-identical. - Shooter ghost-fire fix (build 23):
EnemyPoolgained a stableentity_idcolumn (monotonic, swap-removed in lockstep, NOT instate_checksum);_update_shooterskeys_shooter_timersbyentity_idnot pool index, so a swap-removed shooter’s slot no longer makes an unrelated enemy ghost-fire. - Scatter weapon (build 24): 6th weapon (
sim/weapon_scatter.gd) — a scattergun firing a fan of pellets via the projectile pool (collisions + reactions handled bySim._resolve_collisionsfor free, no new renderer). Elementblood. Mods power/pellets/spread; evolves → Fusillade. Full wiring per the new-weapon checklist above. - Brute enemy (build 25): 8th enemy (
EnemyPool.TYPE_BRUTE=8) — slow/huge/tanky walker (160 HP, armor 8, radius 30), innateblood, reusesBEHAVIOR_WALK. Spawns only at run_time ≥ 50s (SpawnDirector.pick_typeband). Render LUT inmain.gdresized toTYPE_BRUTE+1. - Mechanical per-weapon upgrades (build 20): each weapon now offers 2-4 distinct mods beyond
power. The mechanics live in each weapon’sapply_mod(kind, mag)(keeps/simpurity + weapon-local state) — NOT a giant match insim.gd.Sim._apply_weapon_modjust delegates:w.apply_mod(kind, _weapon_mod_mag(wid,kind)). Mods: blade arc/reach, pulse chain (chain-lightning via_nearest_unhit)/range, nova radius/rate, orbit shard/spin/reach, beam width/reach, turret count/rate. Tunable consts (orbit radius/spin, beam width/range, turret max/fire) became instance vars (consts kept as defaults);main.gdturret render pool sized toWeaponTurret.MAX_TURRETS_CAP(4). Level-up preview now/after reads each weapon’smod_now_after(kind, mag). Add a weapon mod = one row inWEAPON_MODS+ a case in that weapon’s two methods. - Weapon evolutions (build 21): the payoff.
sim/weapon_evolutions.gd(WeaponEvolutions) holds metadata (evolved name + desc) +REQUIRES_LEVEL=3. Each weapon gainsevolvedflag +evolve()(blade→Tempest 360°, pulse→Tesla Coil +5 chain, nova→Supernova, orbit→Event Horizon, beam→Lance, turret→Garrison).Sim._weapon_levelscountswm:upgrades per weapon;can_evolve(wid)gates on active + not-evolved +weapon_level >= REQUIRES_LEVEL+mods.has_any()(any transformative mod taken — the “commit a weapon path + a mod” combo).roll_upgrade_choicesPRIORITISES an available evolution;apply_upgraderoutesevolve:<wid>.ModState.has_any()added. - Boss spiral + enrage (build 22):
BossStategainedATTACK_SPIRAL(4th attack,ATTACK_COUNT=4) +enraged/spiral_phase. Spiral firesBOSS_SPIRAL_ARMS × BOSS_SPIRAL_PER_ARMshots (varying speed → arms spread into lines; rotatesBOSS_SPIRAL_STEPbetween casts) via existingenemy_proj; its telegraph reuses the generic charge-pulse (no render gap). Enrage latches belowBOSS_ENRAGE_FRAC(40% HP) → telegraph/rest times ×BOSS_ENRAGE_TIME_MULT(faster cadence) + spirals throwBOSS_ENRAGE_EXTRA_ARMSmore.boss_render_info().enragedexposed. - ⚠️ The tvOS repo had silently drifted BEHIND main — but only in TEST files. Cycle-16 synced the production code (sim/render/main identical) but left ~6 stale test files + 3 missing ones, so the tvOS suite ran 52 scripts / 9 failing with the OLD determinism baseline (
1998629725/261923457). Diffing every gameplay dir (not trusting “it’s reconciled”) surfaced it; fix was a fullcp tests/*.gd → tvOS(verification-only, zero deploy risk) → 56 scripts / 257 green at main’s baseline. Go-forward: when syncing main→tvOS, copy the WHOLEtests/dir too, not just the changed gameplay files — a partial copy hides a drifting baseline behind a green-looking but smaller suite. - Deploy reality:
devicectl installpersists the new bundle even while the Apple TV is asleep; onlylaunchneeds an awake display (elseFBSOpenApplicationServiceErrorDomain error 1 "System is asleep"). So a chunk genuinely ships to the device regardless — Chris sees the latest installed build on next wake. (devicectl … process terminateneeds--pid, not a bundle id.)