Skip to content

Bullet Heaven — Data-Driven Content Loading (Spec)

Bullet Heaven — Data-Driven Content Loading (Spec)

Section titled “Bullet Heaven — Data-Driven Content Loading (Spec)”

Date: 2026-06-22 Status: Approved (design) — pending spec review Type: Game integration — Milestone 2, Cycle 3 (per the design-bible spec’s build order)


Replace Milestone 1’s hardcoded GDScript content (weapon stats, enemy stats, upgrades) with content loaded from the design bible’s exported JSON. After this slice, tuning the game is a data edit in tools/design-bible, not a code change. This is the foundation every later M2 cycle (elemental engine, new weapons/enemies, meta-progression) reads from — so the bible becomes the single source of truth for content from this point on, never retrofitted later.

This slice is a pure refactor: zero behavior change. The bible’s seeded M1 values already match the game’s current constants byte-for-byte (verified: pulse weapon, swarmer enemy, all 5 upgrades). Loading them from JSON must therefore reproduce the existing 600-tick determinism trace byte-identically. That unchanged golden trace is the primary correctness proof for the whole slice.

Out of scope (later cycles): the elemental engine, behavior for the 4 non-pulse weapons / 4 non-swarmer enemies, transformative mods, and run_structure-driven spawn/difficulty (that category is unpopulated — Cycle 2 design work).


2. The load-bearing architectural constraint

Section titled “2. The load-bearing architectural constraint”

/sim is pure logic: every sim file extends RefCounted and uses no Node / render / Input / Engine / Time / File APIs. This is what keeps the sim headless-testable and deterministic. Therefore:

  • File reading + JSON parsing + validation happen OUTSIDE /sim (in content/, called from main.gd).
  • The parsed, validated result is a pure data object (ContentDB) that lives INSIDE /sim — it holds only plain parsed values and typed getters, does zero I/O, and is constructed from an already-parsed Dictionary. The sim reads content from it.
  • Sim.new(seed, content) receives the ContentDB by injection. The sim never touches the filesystem.
main.gd (Node)
└─ ContentLoader.load("res://data/bible.json") ← FileAccess + JSON + validate (OUTSIDE /sim)
│ on failure: push_error(problem list) + return null → main aborts boot
ContentDB ← pure RefCounted, plain data + typed getters (INSIDE /sim)
│ injected
Sim.new(seed, content) ← reads weapon/enemy/upgrade values from content

A snapshot of the design bible exported from tools/design-bible (its “Export JSON” button). Shape: { schemaVersion: 1, data: { elements:[…], weapons:[…], enemies:[…], mods:[…], … } } — exactly the tool’s export format. Committed to the game repo so it ships baked into the build at res://data/bible.json. Refreshed manually (export + commit) when content changes; the step is documented in the game CLAUDE.md.

3.2 content/content_loader.gd (NEW, outside /sim)

Section titled “3.2 content/content_loader.gd (NEW, outside /sim)”

class_name ContentLoader extends RefCounted. Static API:

  • static func load_from_path(path: String) -> ContentDB — opens the file via FileAccess, JSON.parse_string, runs validation (§4), and on success returns a ContentDB. On any failure: push_error with each problem on its own line and return null.
  • static func load_from_dict(raw: Dictionary) -> ContentDB — the validation + build half, with no file I/O. This is the unit-test seam: tests feed dictionaries directly (no fixture files needed) and assert the returned ContentDB or the captured problems.

Uses FileAccess/JSON — therefore deliberately not in /sim.

class_name ContentDB extends RefCounted. Pure data. Constructed from the validated raw Dictionary. Exposes typed getters only for the categories M1 consumes; the rest of the parsed document is retained but not surfaced (YAGNI — later cycles add getters as they wire categories in):

  • func weapon(id: String) -> Dictionary — the weapon entry (e.g. "pulse").
  • func enemy(id: String) -> Dictionary — the enemy entry (e.g. "swarmer").
  • func upgrades() -> Array — the offerable stat mods in document order (ordered = deterministic upgrade rolls). An upgrade is offerable iff kind == "stat" and its effect is in StatEffects’ known vocabulary (§3.4). This excludes transformative mods (no engine behavior yet) and stat mods the engine cannot yet apply — e.g. the seed’s crit mod (crit_chance), which has no PlayerState field. The result is exactly M1’s 5 upgrades; crit auto-appears once crit_chance + its StatEffects mapping exist. The engine never offers an upgrade it cannot apply.
  • func has_weapon(id) / has_enemy(id) -> bool.

Iteration order is document/array order (Godot preserves it), so determinism holds. No floats are re-derived — values pass through as authored.

3.4 sim/stat_effects.gd (NEW, inside /sim)

Section titled “3.4 sim/stat_effects.gd (NEW, inside /sim)”

class_name StatEffects. Bridges the data’s effect name + magnitude to the mechanism (which player stat, and ×mult vs +flat). Closed vocabulary the engine understands — the data drives which upgrades exist and how strong; the code drives how each applies:

effect (from data) player field op also
damage_mult damage_mult ×
fire_rate_mult fire_rate_mult ×
move_speed speed ×
pickup_radius pickup_radius ×
max_hp max_hp + also hp += magnitude (heal on pickup)
  • static func apply(effect: String, magnitude: float, player: PlayerState) -> void — applies per the table. Unknown effect → push_error and no-op (defensive; validation should have caught it).
  • static func describe(effect: String, magnitude: float) -> String — generates the level-up label (“+25% damage”, “+25 max HP”) from effect+magnitude, so there is no desc field to maintain. × effects render as +N% <label>; + effects render as +N <label>.

Becomes data-backed. roll_choices and apply operate over ContentDB.upgrades() instead of the hardcoded ALL:

  • static func roll_choices(rng, content, n) -> Array[String] — Fisher-Yates over the upgrade ids from content.upgrades() (same algorithm, same rng usage as today → identical roll sequence for a given rng state).
  • static func apply(id, content, player) -> void — looks up the upgrade in content, calls StatEffects.apply(effect, magnitude, player).
  • static func choice_label(id, content) -> StringStatEffects.describe(...) for UI.

3.6 Modified sim/sim.gd, sim/weapon_pulse.gd, sim/spawn_director.gd

Section titled “3.6 Modified sim/sim.gd, sim/weapon_pulse.gd, sim/spawn_director.gd”
  • Sim._init(seed_value, content: ContentDB) — stores content; constructs WeaponPulse.new(content.weapon("pulse")); reads the swarmer enemy entry once into typed locals (_enemy_hp, _enemy_speed, _enemy_radius, _contact_dps, _gem_xp) used by _spawn_enemies / _move_enemies / _check_player_hit in place of the ENEMY_* / CONTACT_DPS / GEM_XP consts.
  • WeaponPulse.new(def: Dictionary) — initializes cooldown, proj_speed, proj_radius, base_damage, proj_lifetime from def (base_damage, cooldown_s, projectile_speed, projectile_radius, lifetime_s). Same fields, sourced from data.
  • apply_upgrade / roll_upgrade_choices thread content through to Upgrades.
  • SpawnDirector is unchanged (spawn curve stays code this slice — no run_structure data exists).
  • Caps (ENEMY_CAP, PROJ_CAP, GEM_CAP, HASH_CELL, SPAWN_RING), XP-curve growth (1.35), arena size stay as code constants (engine tuning, not content; not yet in the bible schema).

At startup, var content := ContentLoader.load_from_path("res://data/bible.json"); if content == null, push_error and abort (the determinism/no-silent-failure discipline — a baked-in data file that fails to load is a build error, not a recoverable runtime state). Pass content into Sim.new(seed, content). Thread content into the level-up UI’s calls to roll_upgrade_choices / choice_label / apply_upgrade.


ContentLoader validates before building ContentDB. Each failure is collected; if any exist, push_error the full list and return null. Checks:

  1. Parse: file opens and JSON.parse_string succeeds; top level is a Dictionary with a data Dictionary.
  2. schemaVersion gate: raw.schemaVersion == ContentDB.SCHEMA_VERSION (1). A higher/lower version is a hard stop ("schemaVersion N (engine supports 1)").
  3. Ref-integrity (GDScript port of model.validate()): for the M1-consumed categories, every reference points at an existing entry — specifically weapons[].element (nullable/empty allowed) → elements[].id. Duplicate ids within a category are flagged. (Full all-category ref-walk mirrors the tool; empty/absent categories are skipped, not errors.)
  4. Required entries present: weapons contains "pulse"; enemies contains "swarmer"; mods contains the 5 stat upgrades’ ids. Missing → listed.
  5. Required fields present & numeric on the consumed entries (e.g. pulse has numeric base_damage, cooldown_s, …; swarmer has numeric hp, speed, radius, contact_damage, xp_value).
  6. Empty category arrays are valid (e.g. evolutions: []) — never an error.

Validation lives in load_from_dict, so it is fully unit-testable without files.

ID note: the bible mod ids use hyphens (fire-rate, move-speed, max-hp); the game’s old Upgrades.ALL used underscores. Ids now come from the data — the engine treats them as opaque strings, so the hyphenated ids are canonical going forward. Upgrade ids do not appear in the determinism trace (no upgrades are applied during the headless tick loop), so this does not affect the golden trace.


TDD throughout. New/updated GUT tests (headless):

  • test_content_loader.gdload_from_dict with: a valid minimal bible → returns ContentDB with the right getters; wrong schemaVersion → null + problem; broken weapons[].element ref → null + problem; missing pulse/swarmer → null + problem; missing required numeric field → null + problem; empty optional category (evolutions: []) → still valid. (No FileAccess in these — pure dict in.)
  • test_content_db.gd — getters return the right entries; upgrades() is in document order and excludes transformative mods; has_* correct.
  • test_stat_effects.gd — each of the 5 effects mutates the correct PlayerState field by the correct op; max_hp also raises hp; describe formats %-vs-flat correctly; unknown effect no-ops.
  • test_upgrades.gd (updated) — roll_choices over a ContentDB returns ids from the data; same rng → same sequence; apply routes through StatEffects.
  • test_determinism.gd (UNCHANGED assertion) — must still pass byte-identical. The test constructs a Sim with a fixed seed and a ContentDB built from the committed data/bible.json (loaded once via ContentLoader.load_from_path in the test’s setup, or from a checked-in dict mirroring it). The pre-existing golden trace string must match unchanged — this proves the data path reproduces the hardcoded path exactly.
  • Test count guard: note the new test files; after the run, confirm the reported test COUNT rose by the expected number (per the stale-class-cache gotcha — a green suite can silently run fewer tests than intended).

A boot smoke test (godot --headless --quit-after 120) must show no SCRIPT ERROR and the game ticking with content loaded.


  1. The game runs identically to M1, with weapon/enemy/upgrade values sourced from res://data/bible.json.
  2. The 600-tick determinism trace is byte-identical to before this slice.
  3. Editing a magnitude in data/bible.json (e.g. pulse base_damage) changes the game on next launch with no code edit.
  4. A malformed bible.json (bad schemaVersion, broken ref, missing required entry/field) makes the game refuse to boot and prints the exact problem list.
  5. /sim stays pure — no FileAccess/JSON/Engine APIs added to any sim/ file (ContentLoader is in content/).
  6. The full GUT suite passes, with the test count increased by the new files.

  • Elemental engine (auras/stacks/reactions) — Cycle 4.
  • Behavior for the orbit/beam/nova/turret weapons and tank/shooter/splitter/elite enemies — they load as data but are not instantiated/executed.
  • Transformative mods (pierce/split) — excluded from upgrades(); no engine behavior yet.
  • run_structure-driven spawn rate / difficulty / caps / XP curve — that category is empty; stays code until authored (Cycle 2 design + a later integration slice).
  • Build-time copy of the data file — manual export+commit chosen; no coupling added.
  • Hot-reload / runtime data editing — the file is baked into the build.

  • Determinism regression is the main risk — mitigated by the unchanged golden-trace assertion, which fails immediately if any loaded value diverges.
  • Dictionary iteration order: Godot preserves insertion/array order; upgrades() returns an Array (not Dictionary-key iteration) so roll order is stable.
  • Schema drift with the tool: the schemaVersion gate is the contract. When the tool bumps schemaVersion, the loader must be updated in lockstep — documented as a carry-forward.
  • Implementation branch: execute on a feature branch (not main) per the subagent-driven-development discipline; merge locally on completion.