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)
1. Purpose
Section titled “1. Purpose”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(incontent/, called frommain.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-parsedDictionary. The sim reads content from it. Sim.new(seed, content)receives theContentDBby 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 content3. Components
Section titled “3. Components”3.1 data/bible.json (NEW, committed)
Section titled “3.1 data/bible.json (NEW, committed)”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 viaFileAccess,JSON.parse_string, runs validation (§4), and on success returns aContentDB. On any failure:push_errorwith each problem on its own line and returnnull.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 returnedContentDBor the captured problems.
Uses FileAccess/JSON — therefore deliberately not in /sim.
3.3 sim/content_db.gd (NEW, inside /sim)
Section titled “3.3 sim/content_db.gd (NEW, inside /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 iffkind == "stat"and itseffectis inStatEffects’ known vocabulary (§3.4). This excludes transformative mods (no engine behavior yet) and stat mods the engine cannot yet apply — e.g. the seed’scritmod (crit_chance), which has noPlayerStatefield. The result is exactly M1’s 5 upgrades;critauto-appears oncecrit_chance+ itsStatEffectsmapping 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_errorand 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 nodescfield to maintain.×effects render as+N% <label>;+effects render as+N <label>.
3.5 Modified sim/upgrades.gd
Section titled “3.5 Modified sim/upgrades.gd”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 fromcontent.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 incontent, callsStatEffects.apply(effect, magnitude, player).static func choice_label(id, content) -> String—StatEffects.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)— storescontent; constructsWeaponPulse.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_hitin place of theENEMY_*/CONTACT_DPS/GEM_XPconsts.WeaponPulse.new(def: Dictionary)— initializescooldown,proj_speed,proj_radius,base_damage,proj_lifetimefromdef(base_damage,cooldown_s,projectile_speed,projectile_radius,lifetime_s). Same fields, sourced from data.apply_upgrade/roll_upgrade_choicesthreadcontentthrough toUpgrades.SpawnDirectoris unchanged (spawn curve stays code this slice — norun_structuredata 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).
3.7 Modified main.gd
Section titled “3.7 Modified main.gd”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.
4. Validation (fail-loud)
Section titled “4. Validation (fail-loud)”ContentLoader validates before building ContentDB. Each failure is collected; if any exist, push_error the full list and return null. Checks:
- Parse: file opens and
JSON.parse_stringsucceeds; top level is a Dictionary with adataDictionary. - schemaVersion gate:
raw.schemaVersion == ContentDB.SCHEMA_VERSION(1). A higher/lower version is a hard stop ("schemaVersion N (engine supports 1)"). - Ref-integrity (GDScript port of
model.validate()): for the M1-consumed categories, every reference points at an existing entry — specificallyweapons[].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.) - Required entries present:
weaponscontains"pulse";enemiescontains"swarmer";modscontains the 5 stat upgrades’ ids. Missing → listed. - Required fields present & numeric on the consumed entries (e.g. pulse has numeric
base_damage,cooldown_s, …; swarmer has numerichp,speed,radius,contact_damage,xp_value). - 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.
5. Testing
Section titled “5. Testing”TDD throughout. New/updated GUT tests (headless):
test_content_loader.gd—load_from_dictwith: a valid minimal bible → returnsContentDBwith the right getters; wrongschemaVersion→ null + problem; brokenweapons[].elementref → null + problem; missingpulse/swarmer→ null + problem; missing required numeric field → null + problem; empty optional category (evolutions: []) → still valid. (NoFileAccessin 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 correctPlayerStatefield by the correct op;max_hpalso raiseshp;describeformats%-vs-flat correctly; unknown effect no-ops.test_upgrades.gd(updated) —roll_choicesover aContentDBreturns ids from the data; same rng → same sequence;applyroutes throughStatEffects.test_determinism.gd(UNCHANGED assertion) — must still pass byte-identical. The test constructs aSimwith a fixed seed and aContentDBbuilt from the committeddata/bible.json(loaded once viaContentLoader.load_from_pathin 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.
6. Success criteria
Section titled “6. Success criteria”- The game runs identically to M1, with weapon/enemy/upgrade values sourced from
res://data/bible.json. - The 600-tick determinism trace is byte-identical to before this slice.
- Editing a magnitude in
data/bible.json(e.g. pulsebase_damage) changes the game on next launch with no code edit. - A malformed
bible.json(bad schemaVersion, broken ref, missing required entry/field) makes the game refuse to boot and prints the exact problem list. /simstays pure — noFileAccess/JSON/Engine APIs added to anysim/file (ContentLoaderis incontent/).- The full GUT suite passes, with the test count increased by the new files.
7. Out of scope (explicit)
Section titled “7. Out of scope (explicit)”- 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.
8. Risks & notes
Section titled “8. Risks & notes”- 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
schemaVersiongate is the contract. When the tool bumpsschemaVersion, 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.