Bullet Heaven — content pipeline & elemental engine
Bullet Heaven — content pipeline & elemental engine
Section titled “Bullet Heaven — content pipeline & elemental engine”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”.
Data pipeline — game ↔ bible (M2 cycle 3, DONE)
Section titled “Data pipeline — game ↔ bible (M2 cycle 3, DONE)”The game loads content from res://data/bible.json (committed), NOT hardcoded GDScript. Pipeline:
- Loader:
content/content_loader.gd(ContentLoader) reads + parses + validates the JSON and builds aContentDB(sim/content_db.gd, pure data). The loader lives OUTSIDE/simbecause it usesFileAccess/JSON(forbidden in/sim);ContentDBis pure parsed data and lives in/sim.Sim.new(seed, content)reads weapon/enemy/upgrade values from it. - Validation (fail-loud):
schemaVersion == 1gate, ref-integrity (weapons[].element→elements), required entries (pulse/swarmer/the 5 stat mods), required numeric fields, duplicate-id detection. Bad data →push_errorwith the problem list + the game refuses to boot. Test seam:ContentLoader.load_from_dict(raw)(no file I/O). - Refresh
data/bible.jsonwhen content changes: either the tool’s browser “Export JSON”, ornode tools/design-bible/scripts/export-seed.mjs > data/bible.json. Commit it (git-diffable contract). When the tool bumpsschemaVersion, update the loader in lockstep. - ⚠️
data/bible.jsonhas DRIFTED AHEAD ofseed.js— re-export is currently UNSAFE (it would regress content). The committedbible.jsonwas hand-edited to addblade(the starter weapon),skirmisher(mini-boss), enemycolorfields, and nowscatter(weapon) +brute(enemy) — NONE of which are intools/design-bible/src/seed.js. Runningexport-seed.mjs > data/bible.jsonwould silently DROP all of them. Until seed.js is backfilled to match, ADD CONTENT BY HAND-EDITINGdata/bible.jsondirectly (a tab-indentedpython3json round-trip gives a clean one-entry diff — load, append todata.weapons/data.enemies,json.dump(indent='\t', ensure_ascii=False)). The loader only enforces numeric fields on the REQUIRED ids (pulse/swarmer), so a new weapon/enemy just needs a validelementref + uniqueid. Wiring a new weapon = bible entry +WEAPON_ORDER+_init+WEAPON_MODS+WeaponEvolutions+ dock glyph (ui/weapon_panel.gd) +active_weapon_views/_active_element_countarms. Wiring a new enemy = bible entry +EnemyPool.TYPE_*+_build_enemy_types(bump theresize) +SpawnDirector.pick_type(gate past 10s to keep the determinism baseline) +main.gd_build_enemy_type_colors(size the LUT to the new max type id). - Upgrade ids are the bible’s (hyphenated):
damage,fire-rate,move-speed,pickup,max-hp. Stat-mod effects map to player stats viasim/stat_effects.gd(StatEffects); the data drives which upgrades exist + magnitudes, the table drives the mechanism. An upgrade is offered only if its effect is inStatEffects(so the seed’scritmod is loaded but not yet offerable). - Still code, not data (no
run_structureyet): spawn-rate curve (SpawnDirector), entity caps, XP-curve growth, arena size, gem radius.
Design Bible & Balance Tool — tools/design-bible/
Section titled “Design Bible & Balance Tool — tools/design-bible/”The content/systems source-of-truth is a dependency-free static web tool at tools/design-bible/ (its own README.md). It edits 16 content categories (elements, the 14×14 elemental reaction matrix, weapons, mods, enemies, …) with schema-driven editors + live computed balance metrics (DPS/effHP/TTK), localStorage persistence, and versioned JSON export (schemaVersion: 1).
- Run it:
cd tools/design-bible && python3 -m http.server 8080→ openhttp://localhost:8080/(ES modules need a server — NOTfile://). - Test the logic core:
cd tools/design-bible && node --test(31 tests, zero deps). - Architecture: pure-logic core (
src/schema.jssrc/seed.jssrc/model.jssrc/persistence.jssrc/metrics.js) has NO DOM/localStorage — node-testable, storage injected. Onlysrc/app.js+src/views/*touch the DOM. Schema-driven: adding a field/category is a data change, not a UI rewrite. - Design: the deep elemental system (hybrid auras+stacks+reactions, 14 elements, 91-pair matrix), full taxonomy, meta-progression (currency+unlocks+ascension), and the exploratory genre-bending “mode-shell” seam are specced in
docs/superpowers/specs/2026-06-21-design-bible-design.md. - NEXT cycle (not yet built): wire the tool’s exported JSON into the Godot game’s
/data(replace M1’s GDScript-literal content with data-driven loading), then implement the elemental engine in-game. Carry-forward: gate the Godot loader onschemaVersion, run ref-integrity (model.validate()) at load, treat empty category arrays as valid, and add a validate-on-export warning before this JSON becomes the live pipeline.
Elemental engine (M2 cycle 4, DONE)
Section titled “Elemental engine (M2 cycle 4, DONE)”In-game auras/stacks/reactions, data-driven from bible.json. Pipeline:
- Per-enemy single-active-aura lives on
sim/enemy_pool.gd(EnemyPool extends EntityPool): columnsaura_element(-1=none),stacks,aura_remaining, which swap-remove in lockstep with the base columns. Adding columns to the swarm = subclass the pool + overrideadd/remove_at; a side-car array that doesn’t swap together silently aliases element state onto the wrong enemy. sim/elemental.gd(Elemental): pure apply/reinforce/react/decay state machine. A different element on an existing aura reacts (returns a reaction event; the Sim turns it into a burst) then replaces the aura. NoSimdependency.sim/status_effects.gd(StatusEffects): maps an elementstatusname to a mechanism —burn→DoT,shock→damage-vulnerability. Data drives magnitude (status_base × stacks), the table drives mechanism.status_baseis interpreted per kind (DoT = HP/s/stack; vuln = fractional/stack — that’s whylightning.status_baseis 0.15, not the DoT-tuned 2).- Deferred death sweep:
Sim._damage_enemy(ei, amount)only subtracts HP (× shock vuln); a singleSim._sweep_dead()at tick end drops gems / counts kills / removes. ALL damage sources (pulse collision, nova AoE, Plasma burst, burn DoT) route through_damage_enemy, so no phase removes an enemy mid-query and the hash never goes stale. This replaced M1’s inline collision-death. - Reactions realized as
Sim._reaction_burst(AoE via the spatial hash).REACTION_BURST_RADIUS/GENERIC_*are Sim constants (not in the reaction schema). Plasma (lightning+fire, both directions authored inseed.js) is the headline; unauthored pairs use the generic fallback. - Two weapons auto-fire from start:
WeaponPulse(lightning projectile) +sim/weapon_nova.gd(WeaponNova, fire AoE pulse). Element ids resolve to indices once atSim._init(pulse_element_idx/nova_element_idx). - Determinism: no RNG in the elemental path;
test_determinismstays a property test (same seed → identical, now including an aura count insnapshot_string). - Nested-query caveat: a reaction burst queries the hash while a weapon iterates its own
query_circleresult — safe only becausequery_circlereturns a fresh array. If result-array pooling is added (perf backlog), bursts must snapshot/defer. - Tick order: spawn → move enemies → pulse → nova → move projectiles → resolve collisions → status+decay → sweep dead → collect gems → player hit. Every hash-querying phase rebuilds first; one sweep removes all dead.
Transformative mods (M2 cycle 5, DONE)
Section titled “Transformative mods (M2 cycle 5, DONE)”Run-global build modifiers chosen at level-up, deepening the elemental engine. Data-driven.
sim/mod_state.gd(ModState): pure data holder onSimfor the run’s modifiers —stack_bonus,reaction_damage_mult,aura_duration_mult. NO-OP DEFAULTS (0 / 1.0) keep an un-modded run byte-identical (determinism). It is plain data, passed toElemental.applyso that unit staysSim-free.sim/sim_mods.gd(SimMods): sibling ofStatEffects— maps a transformative modeffectto aModStatemutation + label. The level-up dispatch (Upgrades.apply) routes a stat effect toStatEffects/player and a sim-mod effect toSimMods/ModState;ContentDB.upgrades()offers a mod iff it is known to one vocabulary (so the bible’spierce/split, known to neither, stay excluded).- Mods: Overcharge (
stack_bonus +1), Catalyst (reaction_damage_mult x1.5), Lingering (aura_duration_mult x1.5), allkind: transformativeinbible.json, stackable (pick repeatedly). The elemental path readsModState: extra stacks (capped atstacks_max), longer auras (aura_decay_s x mult), bigger bursts (_reaction_burst x reaction_damage_mult). - Adding a new sim mod = data + one table row: add the mod to
seed.js(re-export), add its effect toSimMods.TABLE(and aModStatefield if new). No new dispatch. - DEFERRED: weapon evolutions (need this mod system first); projectile-mechanic mods (
pierce/split).
Elemental reaction overhaul + lightning/turret (M2 cycle 12-13, DONE)
Section titled “Elemental reaction overhaul + lightning/turret (M2 cycle 12-13, DONE)”

Current determinism baseline (seed 1234, 600 ticks): snapshot_string().hash()=3746855395, state_checksum()=380627596.
- Pulse is now an instant hitscan lightning STRIKE (not a traveling projectile): zaps the nearest enemy + applies element inline + emits a jagged
boltfx (FxManager_BoltNode, deterministic zigzag). Deployed turrets render a body (kinetic hex + barrel) atWeaponTurret.turret_positions(). - Enemy innate elements: each enemy spawns with
base_element(fromseed.js: swarmer=fire, tank=cold, shooter=lightning, splitter=poison, elite=void) as a PERSISTENT base aura (EnemyPool.BASE_AURA_PERSISTsentinel). A weapon of a DIFFERENT element reacts on contact, so reactions fire constantly and enemy element matters.Elemental.decayreverts a worn-off applied aura tobase_element. Enemies tint by element (theaura_elementrender path); gems recoloured white (ElementPalette.GEM) to read as loot, not an element. Status DoT + shock-vuln are gated to FOREIGN auras (el != base_element[i]) so an enemy’s innate element never self-damages. - One reaction path: every weapon hit routes through
Sim._apply_element(ei, element_idx)(pulse/nova/orbit/beam all call it; projectile collisions too). It runsElemental.apply, then_on_reaction(rate-limited byenemies.react_cd,REACTION_COOLDOWN=0.3) which: fires the burst, drops a terrain zone (Sim.zones, DoTs enemies inside,ZoneRendererdraws soft additive discs), primes nearby enemies (_seed_primed, aura chains), and emits areactionfx carryingname→ FxManager floats the reaction NAME (“PLASMA!”) + a ring. A hit on a primed enemy →_pop_primedmini-burst (bounded, no re-seed). - The hash must be fresh before the weapon loop —
tick()now callshash.rebuild(enemies)before weapons run, because hitscan pulse reacts immediately (querying the hash for burst/zone/prime) instead of deferring to_resolve_collisions. - ⚠️ Balance: reactions are currently VERY lethal (every weapon hit on a differently-elemented enemy reacts → constant AoE bursts + terrain DoT; ~350 kills/min, player untouched). Great spectacle, but trivializes challenge — likely needs a tune (lower burst magnitude /
ZONE_DPS, or only headline pairs react). Also made the aggregatesnapshot_stringconverge between seeds (spawn COUNT is time-driven, everything dies) sotest_different_seed_divergesnow comparesstate_checksum(position-level) instead.