Skip to content

Bullet Heaven — content pipeline & elemental engine

Bullet Heaven — content pipeline & elemental engine

Section titled “Bullet Heaven — content pipeline & elemental engine”

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

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 a ContentDB (sim/content_db.gd, pure data). The loader lives OUTSIDE /sim because it uses FileAccess/JSON (forbidden in /sim); ContentDB is pure parsed data and lives in /sim. Sim.new(seed, content) reads weapon/enemy/upgrade values from it.
  • Validation (fail-loud): schemaVersion == 1 gate, ref-integrity (weapons[].elementelements), required entries (pulse/swarmer/the 5 stat mods), required numeric fields, duplicate-id detection. Bad data → push_error with the problem list + the game refuses to boot. Test seam: ContentLoader.load_from_dict(raw) (no file I/O).
  • Refresh data/bible.json when content changes: either the tool’s browser “Export JSON”, or node tools/design-bible/scripts/export-seed.mjs > data/bible.json. Commit it (git-diffable contract). When the tool bumps schemaVersion, update the loader in lockstep.
  • ⚠️ data/bible.json has DRIFTED AHEAD of seed.js — re-export is currently UNSAFE (it would regress content). The committed bible.json was hand-edited to add blade (the starter weapon), skirmisher (mini-boss), enemy color fields, and now scatter (weapon) + brute (enemy) — NONE of which are in tools/design-bible/src/seed.js. Running export-seed.mjs > data/bible.json would silently DROP all of them. Until seed.js is backfilled to match, ADD CONTENT BY HAND-EDITING data/bible.json directly (a tab-indented python3 json round-trip gives a clean one-entry diff — load, append to data.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 valid element ref + unique id. Wiring a new weapon = bible entry + WEAPON_ORDER + _init + WEAPON_MODS + WeaponEvolutions + dock glyph (ui/weapon_panel.gd) + active_weapon_views/_active_element_count arms. Wiring a new enemy = bible entry + EnemyPool.TYPE_* + _build_enemy_types (bump the resize) + 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 via sim/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 in StatEffects (so the seed’s crit mod is loaded but not yet offerable).
  • Still code, not data (no run_structure yet): 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 → open http://localhost:8080/ (ES modules need a server — NOT file://).
  • Test the logic core: cd tools/design-bible && node --test (31 tests, zero deps).
  • Architecture: pure-logic core (src/schema.js src/seed.js src/model.js src/persistence.js src/metrics.js) has NO DOM/localStorage — node-testable, storage injected. Only src/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 on schemaVersion, 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.

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): columns aura_element (-1=none), stacks, aura_remaining, which swap-remove in lockstep with the base columns. Adding columns to the swarm = subclass the pool + override add/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. No Sim dependency.
  • sim/status_effects.gd (StatusEffects): maps an element status name to a mechanism — burn→DoT, shock→damage-vulnerability. Data drives magnitude (status_base × stacks), the table drives mechanism. status_base is interpreted per kind (DoT = HP/s/stack; vuln = fractional/stack — that’s why lightning.status_base is 0.15, not the DoT-tuned 2).
  • Deferred death sweep: Sim._damage_enemy(ei, amount) only subtracts HP (× shock vuln); a single Sim._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 in seed.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 at Sim._init (pulse_element_idx/nova_element_idx).
  • Determinism: no RNG in the elemental path; test_determinism stays a property test (same seed → identical, now including an aura count in snapshot_string).
  • Nested-query caveat: a reaction burst queries the hash while a weapon iterates its own query_circle result — safe only because query_circle returns 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.

Run-global build modifiers chosen at level-up, deepening the elemental engine. Data-driven.

  • sim/mod_state.gd (ModState): pure data holder on Sim for 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 to Elemental.apply so that unit stays Sim-free.
  • sim/sim_mods.gd (SimMods): sibling of StatEffects — maps a transformative mod effect to a ModState mutation + label. The level-up dispatch (Upgrades.apply) routes a stat effect to StatEffects/player and a sim-mod effect to SimMods/ModState; ContentDB.upgrades() offers a mod iff it is known to one vocabulary (so the bible’s pierce/split, known to neither, stay excluded).
  • Mods: Overcharge (stack_bonus +1), Catalyst (reaction_damage_mult x1.5), Lingering (aura_duration_mult x1.5), all kind: transformative in bible.json, stackable (pick repeatedly). The elemental path reads ModState: extra stacks (capped at stacks_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 to SimMods.TABLE (and a ModState field 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)”

Plasma reaction — lightning meets fire, the flagship authored reaction A reaction triggering mid-run

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 bolt fx (FxManager _BoltNode, deterministic zigzag). Deployed turrets render a body (kinetic hex + barrel) at WeaponTurret.turret_positions().
  • Enemy innate elements: each enemy spawns with base_element (from seed.js: swarmer=fire, tank=cold, shooter=lightning, splitter=poison, elite=void) as a PERSISTENT base aura (EnemyPool.BASE_AURA_PERSIST sentinel). A weapon of a DIFFERENT element reacts on contact, so reactions fire constantly and enemy element matters. Elemental.decay reverts a worn-off applied aura to base_element. Enemies tint by element (the aura_element render 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 runs Elemental.apply, then _on_reaction (rate-limited by enemies.react_cd, REACTION_COOLDOWN=0.3) which: fires the burst, drops a terrain zone (Sim.zones, DoTs enemies inside, ZoneRenderer draws soft additive discs), primes nearby enemies (_seed_primed, aura chains), and emits a reaction fx carrying name → FxManager floats the reaction NAME (“PLASMA!”) + a ring. A hit on a primed enemy → _pop_primed mini-burst (bounded, no re-seed).
  • The hash must be fresh before the weapon looptick() now calls hash.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 aggregate snapshot_string converge between seeds (spawn COUNT is time-driven, everything dies) so test_different_seed_diverges now compares state_checksum (position-level) instead.