Skip to content

Bullet Heaven — weapons, loadout & build-craft

Bullet Heaven — weapons, loadout & build-craft

Section titled “Bullet Heaven — weapons, loadout & build-craft”

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”.

Weapon dock, drones, and hitscan lightning mid-run

The 7 weapons, at a glance:

Blade icon Pulse icon Nova icon Orbit icon Beam icon Turret icon Scatter icon

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 only active_weapon_ids tick; a fresh run starts ["pulse"]. The tick loops for wid in active_weapon_ids: _weapon_by_id[wid].update(self, dt). roll_upgrade_choices adds a "weapon:<id>" grant per un-owned weapon while a slot is free; apply_upgrade routes weapon: to grant_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.godot had 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] with window/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 (_input toggles _sticky; a STOP-filter hit-area toggles _hover). Rebuilds tiles only when the active-weapon count changes; data-driven on active_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.5t1.0+0.22t (SpawnDirector.rate_at); pulse base_damage 1→2 in seed.js (two-shots a swarmer); PlayerState.xp_to_next 10→5 (weapon #2 by ~5s); gem magnetism added in Sim._collect_gems (gems within GEM_MAGNET_RADIUS=240 drift to the player at GEM_MAGNET_SPEED=520, deterministic) so collection is reliable while kiting. Baseline re-pinned again to 3746855395/380627596.
  • Level-up offer rules (Sim.roll_upgrade_choices): GUARANTEES a weapon:<id> grant in the 3 choices while the player has <4 weapons (so weapons reliably drop); transformative mods (Overcharge/Catalyst/Lingering) are gated by _mod_eligible until _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/radius are PackedFloat32Array, _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_circle repulsion), seeks the nearest gem, avoids walls; pick_upgrade prefers weapon grants. main.gd runs it by default (_auto=true) and hands control to a human the instant input_router.poll().move_dir is 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). New class_name in the new ai/ dir tripped the stale-class-cache trap — run godot --headless --import after 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”

Level-up panel and build overview: weapons, DPS, and player stats

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 + SimMods entries projectiles_pierce/split_on_hit (previously loaded-but-unhandled); ProjPool gained pierce/split columns; _resolve_collisions lets a piercing shot survive its hit and a non-piercing shot fan split children (_spawn_split). Offered only when a projectile weapon (scatter/turret) is owned (_mod_eligible special-case). Adding to SimMods grew ContentDB.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 > 0 so 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 until DIFFICULTY_START (120s) then +DIFFICULTY_PER_MIN (0.5)/min; _spawn_enemies multiplies 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): EnemyPool gained a stable entity_id column (monotonic, swap-removed in lockstep, NOT in state_checksum); _update_shooters keys _shooter_timers by entity_id not 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 by Sim._resolve_collisions for free, no new renderer). Element blood. 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), innate blood, reuses BEHAVIOR_WALK. Spawns only at run_time ≥ 50s (SpawnDirector.pick_type band). Render LUT in main.gd resized to TYPE_BRUTE+1.
  • Mechanical per-weapon upgrades (build 20): each weapon now offers 2-4 distinct mods beyond power. The mechanics live in each weapon’s apply_mod(kind, mag) (keeps /sim purity + weapon-local state) — NOT a giant match in sim.gd. Sim._apply_weapon_mod just 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.gd turret render pool sized to WeaponTurret.MAX_TURRETS_CAP (4). Level-up preview now/after reads each weapon’s mod_now_after(kind, mag). Add a weapon mod = one row in WEAPON_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 gains evolved flag + evolve() (blade→Tempest 360°, pulse→Tesla Coil +5 chain, nova→Supernova, orbit→Event Horizon, beam→Lance, turret→Garrison). Sim._weapon_levels counts wm: 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_choices PRIORITISES an available evolution; apply_upgrade routes evolve:<wid>. ModState.has_any() added.
  • Boss spiral + enrage (build 22): BossState gained ATTACK_SPIRAL (4th attack, ATTACK_COUNT=4) + enraged/spiral_phase. Spiral fires BOSS_SPIRAL_ARMS × BOSS_SPIRAL_PER_ARM shots (varying speed → arms spread into lines; rotates BOSS_SPIRAL_STEP between casts) via existing enemy_proj; its telegraph reuses the generic charge-pulse (no render gap). Enrage latches below BOSS_ENRAGE_FRAC (40% HP) → telegraph/rest times × BOSS_ENRAGE_TIME_MULT (faster cadence) + spirals throw BOSS_ENRAGE_EXTRA_ARMS more. boss_render_info().enraged exposed.
  • ⚠️ 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 full cp tests/*.gd → tvOS (verification-only, zero deploy risk) → 56 scripts / 257 green at main’s baseline. Go-forward: when syncing main→tvOS, copy the WHOLE tests/ 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 install persists the new bundle even while the Apple TV is asleep; only launch needs an awake display (else FBSOpenApplicationServiceErrorDomain 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 terminate needs --pid, not a bundle id.)