Data-Driven Content Loading Implementation Plan
Data-Driven Content Loading Implementation Plan
Section titled “Data-Driven Content Loading Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace Milestone 1’s hardcoded GDScript content (weapon/enemy stats, upgrades) with content loaded and validated from the design bible’s exported JSON, with zero behavior change.
Architecture: A ContentLoader (outside /sim, does FileAccess+JSON+validation) reads res://data/bible.json and builds a pure ContentDB (inside /sim, no I/O). Sim.new(seed, content) reads weapon/enemy/upgrade values from the ContentDB instead of hardcoded constants. The bible’s seeded M1 values already match the game’s constants byte-for-byte, so the 600-tick determinism golden trace stays identical — that unchanged trace is the slice’s correctness proof.
Tech Stack: Godot 4.6.3 / typed GDScript, GUT 9.6.0 (headless tests), Node.js (data export from the design-bible tool), the existing tools/design-bible JSON format (schemaVersion: 1).
Global Constraints
Section titled “Global Constraints”/simpurity: every file undersim/extends RefCountedand uses NO Node / render / Input / Engine / Time / File / JSON APIs. File reading + parsing + validation live incontent/, never insim/.ContentDBis pure parsed data and may live insim/.- Determinism is the keystone: the existing
tests/test_determinism.gd600-tick trace must remain byte-identical after this slice. ConstantSim_Const.DTticking and the two RNG streams (rng,upgrade_rng) are unchanged. Never draw upgrade rolls fromrng. - Fail-loud on bad data: invalid
bible.json(badschemaVersion, broken ref, missing required entry/field) →push_errorwith the exact problem list and refuse to boot. No hardcoded content fallback. - schemaVersion contract: engine supports
schemaVersion == 1only. - Data refresh is manual:
data/bible.jsonis committed; refreshed by exporting from the tool (documented in CLAUDE.md). No build-time copy step. - TDD, DRY, YAGNI, frequent commits. Load the whole bible but surface typed getters only for the categories M1 consumes (weapons, enemies, stat mods).
- Field-name mapping (data → engine), exact: weapon
base_damage→base_damage,cooldown_s→cooldown,projectile_speed→proj_speed,projectile_radius→proj_radius,lifetime_s→proj_lifetime. enemyhp,speed,radius,contact_damage,xp_value. modkind,effect,magnitude,name. - Upgrade ids are now data-sourced and hyphenated:
damage,fire-rate,move-speed,pickup,max-hp(the bible’s ids). Treat ids as opaque strings.
File Structure
Section titled “File Structure”New files:
data/bible.json— committed snapshot of the design-bible export (the data contract).tools/design-bible/scripts/export-seed.mjs— node script that serializes the tool’sSEEDto JSON (reproducible generator + documented CLI refresh path).sim/stat_effects.gd—StatEffects: maps a stat-modeffectname + magnitude to aPlayerStatemutation and a human label. Pure.sim/content_db.gd—ContentDB: pure data holder built from a parsed bibleDictionary; typed getters for M1 categories.content/content_loader.gd—ContentLoader: file read + JSON parse + validation; builds aContentDB. Outside/sim.tests/sim_content_fixture.gd—SimContentFixture: test-only cached accessor returning the realContentDBfromres://data/bible.json.tests/test_stat_effects.gd,tests/test_content_db.gd,tests/test_content_loader.gd,tests/test_content_drives_sim.gd— new GUT tests.
Modified files:
sim/sim.gd—_init(seed, content); read enemy/weapon/upgrade content; drop the moved consts.sim/weapon_pulse.gd—_init(def: Dictionary).sim/upgrades.gd— data-backedroll_choices/apply/choice_display; dropALL.ui/level_up_panel.gd—show_choices(choices: Array[Dictionary]); drop theUpgrades.ALLdependency.main.gd— load content at boot (fail-loud), inject intoSim, usesim.enemy_radiusfor the renderer, build level-up display dicts.tests/test_upgrades.gd,tests/test_weapon_pulse.gd,tests/test_enemy_chase.gd,tests/test_sim_core.gd,tests/test_collision_damage.gd,tests/test_xp_levelup.gd,tests/test_spawn_director.gd,tests/test_determinism.gd— threadcontentintoSim.new(...).CLAUDE.md— document the data pipeline.
Task 1: Export and commit data/bible.json
Section titled “Task 1: Export and commit data/bible.json”Files:
- Create:
tools/design-bible/scripts/export-seed.mjs - Create:
data/bible.json(generated)
Interfaces:
-
Produces:
res://data/bible.json—{ "schemaVersion": 1, "data": { "elements":[…], "weapons":[…], "enemies":[…], "mods":[…], … } }, the exact shape exported bytools/design-bible. -
Step 1: Write the exporter script
Create tools/design-bible/scripts/export-seed.mjs:
// Serializes the design-bible SEED to game-ready JSON on stdout.// Reproducible generator for ../../../data/bible.json (committed data contract).// Usage: node tools/design-bible/scripts/export-seed.mjs > data/bible.jsonimport { SEED } from '../src/seed.js';
process.stdout.write(JSON.stringify(SEED, null, 2) + '\n');- Step 2: Generate the data file
Run (from the repo root):
mkdir -p data && node tools/design-bible/scripts/export-seed.mjs > data/bible.jsonExpected: data/bible.json created, no stderr.
- Step 3: Verify it is valid JSON with the right shape
Run:
python3 -c "import json;d=json.load(open('data/bible.json'));print('schemaVersion',d['schemaVersion']);print('weapons',[w['id'] for w in d['data']['weapons']]);print('enemies',[e['id'] for e in d['data']['enemies']]);print('mods',[m['id'] for m in d['data']['mods']])"Expected output:
schemaVersion 1weapons ['pulse', 'orbit', 'beam', 'nova', 'turret']enemies ['swarmer', 'tank', 'shooter', 'splitter', 'elite']mods ['damage', 'fire-rate', 'move-speed', 'pickup', 'max-hp', 'crit', 'pierce', 'split']- Step 4: Verify the M1 values match the current engine constants
Run:
python3 -c "import jsond=json.load(open('data/bible.json'))['data']p=[w for w in d['weapons'] if w['id']=='pulse'][0]s=[e for e in d['enemies'] if e['id']=='swarmer'][0]assert p['base_damage']==1.0 and p['cooldown_s']==0.6 and p['projectile_speed']==520 and p['projectile_radius']==6 and p['lifetime_s']==1.4, passert s['hp']==3 and s['speed']==70 and s['radius']==14 and s['contact_damage']==12 and s['xp_value']==1, sprint('M1 values match engine constants')"Expected: M1 values match engine constants (no AssertionError).
- Step 5: Commit
git add tools/design-bible/scripts/export-seed.mjs data/bible.jsongit commit -m "feat(data): commit bible.json export + node exporter script"Task 2: StatEffects — effect→stat mapper
Section titled “Task 2: StatEffects — effect→stat mapper”Files:
- Create:
sim/stat_effects.gd - Test:
tests/test_stat_effects.gd
Interfaces:
-
Consumes:
PlayerState(existing — fieldsdamage_mult,fire_rate_mult,speed,pickup_radius,max_hp,hp). -
Produces:
StatEffects.is_known(effect: String) -> boolStatEffects.apply(effect: String, magnitude: float, player: PlayerState) -> voidStatEffects.describe(effect: String, magnitude: float) -> String
-
Step 1: Write the failing test
Create tests/test_stat_effects.gd:
extends GutTest
func test_known_vocabulary() -> void: for e in ["damage_mult", "fire_rate_mult", "move_speed", "pickup_radius", "max_hp"]: assert_true(StatEffects.is_known(e), "%s should be known" % e) assert_false(StatEffects.is_known("crit_chance"), "crit_chance not implemented yet") assert_false(StatEffects.is_known("nonsense"))
func test_mul_effects_scale_the_right_field() -> void: var p := PlayerState.new() StatEffects.apply("damage_mult", 1.25, p) assert_almost_eq(p.damage_mult, 1.25, 0.0001) StatEffects.apply("fire_rate_mult", 1.20, p) assert_almost_eq(p.fire_rate_mult, 1.20, 0.0001) StatEffects.apply("move_speed", 1.12, p) assert_almost_eq(p.speed, 260.0 * 1.12, 0.001) StatEffects.apply("pickup_radius", 1.30, p) assert_almost_eq(p.pickup_radius, 90.0 * 1.30, 0.001)
func test_max_hp_adds_and_also_heals() -> void: var p := PlayerState.new() StatEffects.apply("max_hp", 25.0, p) assert_almost_eq(p.max_hp, 125.0, 0.001) assert_almost_eq(p.hp, 125.0, 0.001, "max_hp also heals by the same amount")
func test_unknown_effect_is_noop() -> void: var p := PlayerState.new() var before := p.damage_mult StatEffects.apply("crit_chance", 0.15, p) # not implemented -> no-op assert_almost_eq(p.damage_mult, before, 0.0001)
func test_describe_formats_percent_and_flat() -> void: assert_eq(StatEffects.describe("damage_mult", 1.25), "+25% damage") assert_eq(StatEffects.describe("fire_rate_mult", 1.20), "+20% fire rate") assert_eq(StatEffects.describe("move_speed", 1.12), "+12% move speed") assert_eq(StatEffects.describe("pickup_radius", 1.30), "+30% pickup radius") assert_eq(StatEffects.describe("max_hp", 25.0), "+25 max HP")- Step 2: Run the test to verify it fails
Run:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_stat_effects.gd -gexitExpected: FAIL — StatEffects not declared / identifier not found.
- Step 3: Implement
StatEffects
Create sim/stat_effects.gd:
class_name StatEffects
# Maps a stat-mod `effect` name to the PlayerState field it changes, how it# combines (mul/add), an optional secondary field to also add to, and a label# used to generate the level-up description. The DATA drives which upgrades# exist and how strong; this table drives the MECHANISM. Pure (no Node/Engine).const TABLE := { "damage_mult": {"field": "damage_mult", "op": "mul", "label": "damage"}, "fire_rate_mult": {"field": "fire_rate_mult", "op": "mul", "label": "fire rate"}, "move_speed": {"field": "speed", "op": "mul", "label": "move speed"}, "pickup_radius": {"field": "pickup_radius", "op": "mul", "label": "pickup radius"}, "max_hp": {"field": "max_hp", "op": "add", "also": "hp", "label": "max HP"},}
static func is_known(effect: String) -> bool: return TABLE.has(effect)
static func apply(effect: String, magnitude: float, player: PlayerState) -> void: if not TABLE.has(effect): push_error("StatEffects.apply: unknown effect '%s'" % effect) return var spec: Dictionary = TABLE[effect] var field: String = spec["field"] if spec["op"] == "mul": player.set(field, float(player.get(field)) * magnitude) else: # "add" player.set(field, float(player.get(field)) + magnitude) if spec.has("also"): var also: String = spec["also"] player.set(also, float(player.get(also)) + magnitude)
static func describe(effect: String, magnitude: float) -> String: if not TABLE.has(effect): return "" var spec: Dictionary = TABLE[effect] if spec["op"] == "mul": var pct := int(round((magnitude - 1.0) * 100.0)) return "+%d%% %s" % [pct, spec["label"]] return "+%s %s" % [_fmt(magnitude), spec["label"]]
static func _fmt(v: float) -> String: # Whole numbers render without a trailing ".0". if absf(v - round(v)) < 0.0001: return str(int(round(v))) return str(v)- Step 4: Run the test to verify it passes
Run:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_stat_effects.gd -gexitExpected: PASS (5/5).
- Step 5: Commit
git add sim/stat_effects.gd tests/test_stat_effects.gdgit commit -m "feat(sim): StatEffects — data effect name -> PlayerState mutation + label"Task 3: ContentDB — pure data holder
Section titled “Task 3: ContentDB — pure data holder”Files:
- Create:
sim/content_db.gd - Test:
tests/test_content_db.gd
Interfaces:
-
Consumes:
StatEffects.is_known(effect)(Task 2). -
Produces:
const ContentDB.SCHEMA_VERSION := 1ContentDB.new(data: Dictionary)—datais the bible’s innerdataobject.func weapon(id: String) -> Dictionary/func enemy(id: String) -> Dictionary— entry or{}.func has_weapon(id: String) -> bool/func has_enemy(id: String) -> boolfunc upgrades() -> Array— offerable stat mods (kind=="stat"andStatEffects.is_known(effect)), in document order.func upgrade(id: String) -> Dictionary— an offerable upgrade by id, or{}.
-
Step 1: Write the failing test
Create tests/test_content_db.gd:
extends GutTest
func _sample_data() -> Dictionary: return { "weapons": [ {"id": "pulse", "name": "Pulse", "base_damage": 1.0, "cooldown_s": 0.6, "projectile_speed": 520, "projectile_radius": 6, "lifetime_s": 1.4}, ], "enemies": [ {"id": "swarmer", "name": "Swarmer", "hp": 3, "speed": 70, "radius": 14, "contact_damage": 12, "xp_value": 1}, ], "mods": [ {"id": "damage", "name": "Sharpened", "kind": "stat", "effect": "damage_mult", "magnitude": 1.25}, {"id": "crit", "name": "Focus", "kind": "stat", "effect": "crit_chance", "magnitude": 0.15}, {"id": "pierce", "name": "Penetrator", "kind": "transformative", "effect": "projectiles_pierce", "magnitude": 1}, ], }
func test_weapon_and_enemy_getters() -> void: var db := ContentDB.new(_sample_data()) assert_eq(db.weapon("pulse")["name"], "Pulse") assert_eq(db.enemy("swarmer")["hp"], 3) assert_true(db.has_weapon("pulse")) assert_false(db.has_weapon("nope")) assert_true(db.has_enemy("swarmer")) assert_eq(db.weapon("missing"), {}, "missing weapon returns empty dict")
func test_upgrades_excludes_unknown_and_transformative() -> void: var db := ContentDB.new(_sample_data()) var ids: Array = [] for u in db.upgrades(): ids.append(u["id"]) assert_eq(ids, ["damage"], "only the stat mod with a known effect is offerable")
func test_upgrade_lookup() -> void: var db := ContentDB.new(_sample_data()) assert_eq(db.upgrade("damage")["effect"], "damage_mult") assert_eq(db.upgrade("crit"), {}, "non-offerable id returns empty")
func test_missing_category_is_empty_not_error() -> void: var db := ContentDB.new({"weapons": []}) assert_eq(db.upgrades(), []) assert_eq(db.enemy("swarmer"), {})- Step 2: Run the test to verify it fails
Run:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_content_db.gd -gexitExpected: FAIL — ContentDB not declared.
- Step 3: Implement
ContentDB
Create sim/content_db.gd:
class_name ContentDBextends RefCounted
# Pure parsed content. Constructed from an already-parsed bible `data` object;# does NO file/JSON I/O (that is ContentLoader's job, outside /sim). Treat as# immutable after construction. Iteration is over Arrays in document order, so# upgrade rolls stay deterministic.const SCHEMA_VERSION := 1
var _data: Dictionary
func _init(data: Dictionary) -> void: _data = data
func _entries(category: String) -> Array: var v: Variant = _data.get(category, []) return v if v is Array else []
func _by_id(category: String, id: String) -> Dictionary: for e in _entries(category): if e is Dictionary and e.get("id", "") == id: return e return {}
func weapon(id: String) -> Dictionary: return _by_id("weapons", id)
func enemy(id: String) -> Dictionary: return _by_id("enemies", id)
func has_weapon(id: String) -> bool: return not weapon(id).is_empty()
func has_enemy(id: String) -> bool: return not enemy(id).is_empty()
func upgrades() -> Array: # Offerable = a stat mod whose effect the engine can actually apply. # Excludes transformative mods and stat mods with no engine behavior yet # (e.g. crit_chance). The engine never offers an upgrade it cannot apply. var out: Array = [] for m in _entries("mods"): if m is Dictionary and m.get("kind", "") == "stat" and StatEffects.is_known(m.get("effect", "")): out.append(m) return out
func upgrade(id: String) -> Dictionary: for u in upgrades(): if u.get("id", "") == id: return u return {}- Step 4: Run the test to verify it passes
Run:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_content_db.gd -gexitExpected: PASS (4/4).
- Step 5: Commit
git add sim/content_db.gd tests/test_content_db.gdgit commit -m "feat(sim): ContentDB — pure data getters for M1 categories"Task 4: ContentLoader + test fixture
Section titled “Task 4: ContentLoader + test fixture”Files:
- Create:
content/content_loader.gd - Create:
tests/sim_content_fixture.gd - Test:
tests/test_content_loader.gd
Interfaces:
- Consumes:
ContentDB(Task 3),res://data/bible.json(Task 1). - Produces:
ContentLoader.load_from_dict(raw: Dictionary) -> ContentDB— validates; returnsContentDBornull(afterpush_error).ContentLoader.load_from_path(path: String) -> ContentDB— reads + parses the file thenload_from_dict;nullon any failure.ContentLoader.validate(raw: Dictionary) -> Array— list of problem strings (empty == valid).SimContentFixture.db() -> ContentDB— cached real content fromres://data/bible.json(test helper).
GUT gotcha (this project): GUT 9.6 fails any test in which an un-asserted push_error fires (error_tracker, default treat_push_error_as = FAILURE). The loader deliberately calls push_error on invalid data (the fail-loud problem list) — that is correct production behavior, keep it. The only test that calls load_from_dict on invalid data consumes the expected error with assert_push_error("invalid"). All other failure-path tests assert against validate()’s returned problem array (which does NOT push_error), so they need no consumption. Valid-data paths emit no error.
- Step 1: Write the failing test
Create tests/test_content_loader.gd:
extends GutTest
func _valid_raw() -> Dictionary: return { "schemaVersion": 1, "data": { "elements": [{"id": "lightning", "name": "Lightning"}], "weapons": [ {"id": "pulse", "name": "Pulse", "element": "lightning", "base_damage": 1.0, "cooldown_s": 0.6, "projectile_speed": 520, "projectile_radius": 6, "lifetime_s": 1.4}, ], "enemies": [ {"id": "swarmer", "name": "Swarmer", "hp": 3, "speed": 70, "radius": 14, "contact_damage": 12, "xp_value": 1}, ], "mods": [ {"id": "damage", "name": "Sharpened", "kind": "stat", "effect": "damage_mult", "magnitude": 1.25}, {"id": "fire-rate", "name": "Overclock", "kind": "stat", "effect": "fire_rate_mult", "magnitude": 1.20}, {"id": "move-speed", "name": "Thrusters", "kind": "stat", "effect": "move_speed", "magnitude": 1.12}, {"id": "pickup", "name": "Magnet", "kind": "stat", "effect": "pickup_radius", "magnitude": 1.30}, {"id": "max-hp", "name": "Plating", "kind": "stat", "effect": "max_hp", "magnitude": 25}, ], "evolutions": [], }, }
func test_valid_dict_builds_contentdb() -> void: var db := ContentLoader.load_from_dict(_valid_raw()) assert_not_null(db) assert_true(db.has_weapon("pulse")) assert_eq(db.upgrades().size(), 5)
func test_empty_optional_category_is_valid() -> void: assert_eq(ContentLoader.validate(_valid_raw()), [], "evolutions:[] must not be an error")
func test_bad_schema_version_rejected() -> void: var raw := _valid_raw() raw["schemaVersion"] = 2 var problems := ContentLoader.validate(raw) assert_true(problems.size() > 0) assert_string_contains(problems[0], "schemaVersion") # load_from_dict on invalid data returns null AND emits a loud push_error # (the boot-time problem list). GUT fails any test where an un-asserted # push_error fires, so we consume the expected error with assert_push_error. assert_null(ContentLoader.load_from_dict(raw)) assert_push_error("invalid")
func test_broken_element_ref_rejected() -> void: var raw := _valid_raw() raw["data"]["weapons"][0]["element"] = "litning" # typo, no such element var problems := ContentLoader.validate(raw) assert_true(problems.size() > 0) assert_string_contains(problems[0], "element")
func test_missing_required_entry_rejected() -> void: var raw := _valid_raw() raw["data"]["enemies"] = [] # swarmer gone var problems := ContentLoader.validate(raw) assert_true(_any_contains(problems, "swarmer"))
func test_missing_required_field_rejected() -> void: var raw := _valid_raw() raw["data"]["weapons"][0].erase("base_damage") var problems := ContentLoader.validate(raw) assert_true(_any_contains(problems, "base_damage"))
func test_non_numeric_required_field_rejected() -> void: var raw := _valid_raw() raw["data"]["enemies"][0]["hp"] = "lots" var problems := ContentLoader.validate(raw) assert_true(_any_contains(problems, "hp"))
func test_duplicate_id_rejected() -> void: var raw := _valid_raw() raw["data"]["weapons"].append(raw["data"]["weapons"][0].duplicate()) var problems := ContentLoader.validate(raw) assert_true(_any_contains(problems, "duplicate"))
func test_load_real_file() -> void: var db := ContentLoader.load_from_path("res://data/bible.json") assert_not_null(db, "committed bible.json must load") assert_true(db.has_weapon("pulse")) assert_true(db.has_enemy("swarmer")) assert_eq(db.upgrades().size(), 5, "exactly the 5 M1 stat upgrades are offerable")
func _any_contains(arr: Array, needle: String) -> bool: for s in arr: if (s as String).findn(needle) != -1: return true return false- Step 2: Run the test to verify it fails
Run:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_content_loader.gd -gexitExpected: FAIL — ContentLoader not declared.
- Step 3: Implement
ContentLoader
Create content/content_loader.gd:
class_name ContentLoaderextends RefCounted
# Reads + parses + validates the bible JSON and builds a ContentDB.# Lives OUTSIDE /sim because it touches FileAccess + JSON (forbidden in /sim).# Fail-loud: any problem -> push_error with the full list + return null.
const _REQUIRED_WEAPONS := ["pulse"]const _REQUIRED_ENEMIES := ["swarmer"]const _REQUIRED_MODS := ["damage", "fire-rate", "move-speed", "pickup", "max-hp"]const _WEAPON_NUM_FIELDS := ["base_damage", "cooldown_s", "projectile_speed", "projectile_radius", "lifetime_s"]const _ENEMY_NUM_FIELDS := ["hp", "speed", "radius", "contact_damage", "xp_value"]
static func load_from_path(path: String) -> ContentDB: if not FileAccess.file_exists(path): push_error("ContentLoader: file not found: %s" % path) return null var text := FileAccess.get_file_as_string(path) var parsed: Variant = JSON.parse_string(text) if not (parsed is Dictionary): push_error("ContentLoader: %s is not a JSON object" % path) return null return load_from_dict(parsed)
static func load_from_dict(raw: Dictionary) -> ContentDB: var problems := validate(raw) if not problems.is_empty(): push_error("bible.json invalid:\n - " + "\n - ".join(problems)) return null return ContentDB.new(raw["data"])
static func validate(raw: Dictionary) -> Array: var problems: Array = []
var version: Variant = raw.get("schemaVersion") if version != ContentDB.SCHEMA_VERSION: problems.append("schemaVersion %s (engine supports %d)" % [str(version), ContentDB.SCHEMA_VERSION])
var data_v: Variant = raw.get("data") if not (data_v is Dictionary): problems.append("missing 'data' object") return problems # nothing more can be checked var data: Dictionary = data_v
# Duplicate ids within each present category. for category in data.keys(): var entries: Variant = data[category] if entries is Array: var seen := {} for e in entries: if e is Dictionary: var id: String = e.get("id", "") if seen.has(id): problems.append("%s: duplicate id '%s'" % [category, id]) seen[id] = true
# Ref-integrity: weapons[].element -> elements[].id (empty allowed). var element_ids := {} for el in _arr(data, "elements"): if el is Dictionary: element_ids[el.get("id", "")] = true for w in _arr(data, "weapons"): if w is Dictionary: var elem: String = w.get("element", "") if elem != "" and not element_ids.has(elem): problems.append("weapons.%s.element -> missing elements '%s'" % [w.get("id", "?"), elem])
# Required entries present. _require_ids(problems, data, "weapons", _REQUIRED_WEAPONS) _require_ids(problems, data, "enemies", _REQUIRED_ENEMIES) _require_ids(problems, data, "mods", _REQUIRED_MODS)
# Required numeric fields on the consumed M1 entries. _require_numeric(problems, _find(data, "weapons", "pulse"), "weapons.pulse", _WEAPON_NUM_FIELDS) _require_numeric(problems, _find(data, "enemies", "swarmer"), "enemies.swarmer", _ENEMY_NUM_FIELDS)
return problems
static func _arr(data: Dictionary, category: String) -> Array: var v: Variant = data.get(category, []) return v if v is Array else []
static func _find(data: Dictionary, category: String, id: String) -> Dictionary: for e in _arr(data, category): if e is Dictionary and e.get("id", "") == id: return e return {}
static func _require_ids(problems: Array, data: Dictionary, category: String, ids: Array) -> void: for id in ids: if _find(data, category, id).is_empty(): problems.append("%s: required entry '%s' missing" % [category, id])
static func _require_numeric(problems: Array, entry: Dictionary, label: String, fields: Array) -> void: if entry.is_empty(): return # missing-entry already reported by _require_ids for f in fields: if not entry.has(f): problems.append("%s.%s missing" % [label, f]) elif not (typeof(entry[f]) in [TYPE_INT, TYPE_FLOAT]): problems.append("%s.%s not numeric" % [label, f])- Step 4: Create the test fixture
Create tests/sim_content_fixture.gd:
class_name SimContentFixture
# Test-only: the real ContentDB from the committed bible.json, loaded once.# Not a GutTest (no `test_` prefix) so the runner ignores it as a suite.static var _db: ContentDB = null
static func db() -> ContentDB: if _db == null: _db = ContentLoader.load_from_path("res://data/bible.json") assert(_db != null) # committed data must always be valid return _db- Step 5: Run the test to verify it passes
Run:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_content_loader.gd -gexitExpected: PASS (9/9).
- Step 6: Commit
git add content/content_loader.gd tests/sim_content_fixture.gd tests/test_content_loader.gdgit commit -m "feat(content): ContentLoader (validate+load, outside /sim) + test fixture"Task 5: Wire content into Sim, WeaponPulse, Upgrades (integration)
Section titled “Task 5: Wire content into Sim, WeaponPulse, Upgrades (integration)”This is the integrating task. Sim, WeaponPulse, Upgrades, and every Sim.new() call site are mutually coupled through construction, so they change together to keep the suite green at the commit boundary. Correctness is anchored by (a) a new test proving content actually drives the sim and (b) the unchanged determinism golden trace.
Files:
- Modify:
sim/weapon_pulse.gd(_init(def)) - Modify:
sim/upgrades.gd(data-backed; dropALL) - Modify:
sim/sim.gd(_init(seed, content); read content; drop moved consts) - Modify:
tests/test_upgrades.gd,tests/test_weapon_pulse.gd,tests/test_enemy_chase.gd,tests/test_sim_core.gd,tests/test_collision_damage.gd,tests/test_xp_levelup.gd,tests/test_spawn_director.gd,tests/test_determinism.gd - Create:
tests/test_content_drives_sim.gd
Interfaces:
-
Consumes:
ContentDB(Task 3),StatEffects(Task 2),SimContentFixture.db()(Task 4). -
Produces:
WeaponPulse.new(def: Dictionary)— reads the 5 weapon fields fromdef.Upgrades.roll_choices(rng: SeededRng, content: ContentDB, n: int) -> Array[String]Upgrades.apply(id: String, content: ContentDB, player: PlayerState) -> voidUpgrades.choice_display(id: String, content: ContentDB) -> Dictionary—{"name": String, "desc": String}Sim.new(seed_value: int, content: ContentDB); publicSim.enemy_radius: float;Sim.content: ContentDB.Sim.roll_upgrade_choices(n)/Sim.apply_upgrade(id)signatures unchanged (content held internally).Simkeeps constsENEMY_CAP,PROJ_CAP,GEM_CAP,HASH_CELL,SPAWN_RING,GEM_RADIUS. Removed:ENEMY_RADIUS,ENEMY_HP,ENEMY_SPEED,GEM_XP,CONTACT_DPS.
-
Step 1: Refactor
WeaponPulseto take a def
Replace the head of sim/weapon_pulse.gd (the var block, lines 1-9) with:
class_name WeaponPulseextends RefCounted
var cooldown: floatvar proj_speed: floatvar proj_radius: floatvar base_damage: floatvar proj_lifetime: floatvar _timer: float = 0.0
func _init(def: Dictionary) -> void: base_damage = float(def["base_damage"]) cooldown = float(def["cooldown_s"]) proj_speed = float(def["projectile_speed"]) proj_radius = float(def["projectile_radius"]) proj_lifetime = float(def["lifetime_s"])Leave nearest_enemy_index and update unchanged.
- Step 2: Refactor
Upgradesto be data-backed
Replace the entire contents of sim/upgrades.gd with:
class_name Upgrades
# Upgrade content now comes from the bible (ContentDB.upgrades()). This module# owns the roll + apply + display logic; StatEffects owns the per-effect# mechanism. No hardcoded upgrade list.
static func roll_choices(rng: SeededRng, content: ContentDB, n: int) -> Array[String]: var ids: Array[String] = [] for u in content.upgrades(): ids.append(u["id"]) # Fisher-Yates partial shuffle using the seeded rng (same algorithm as M1). for i in range(ids.size()): var j := rng.randi_range(i, ids.size() - 1) var tmp := ids[i] ids[i] = ids[j] ids[j] = tmp return ids.slice(0, mini(n, ids.size()))
static func apply(id: String, content: ContentDB, player: PlayerState) -> void: var u := content.upgrade(id) if u.is_empty(): push_error("Upgrades.apply: unknown upgrade '%s'" % id) return StatEffects.apply(u["effect"], float(u["magnitude"]), player)
static func choice_display(id: String, content: ContentDB) -> Dictionary: var u := content.upgrade(id) if u.is_empty(): return {"name": id, "desc": ""} return {"name": u.get("name", id), "desc": StatEffects.describe(u["effect"], float(u["magnitude"]))}- Step 3: Refactor
Simto read content
In sim/sim.gd:
(a) Replace the consts block (lines 4-13) with — keep caps + gem radius, drop the moved ones:
const ENEMY_CAP: int = 6000const PROJ_CAP: int = 4000const GEM_CAP: int = 4000const HASH_CELL: float = 64.0const SPAWN_RING: float = 1100.0const GEM_RADIUS: float = 8.0(b) Replace the var block + CONTACT_DPS const (lines 15-31) with — add content, the enemy-derived fields, and enemy_radius (public, the renderer reads it):
var rng: SeededRngvar upgrade_rng: SeededRngvar player: PlayerStatevar enemies: EntityPoolvar projectiles: EntityPoolvar gems: EntityPoolvar hash: SpatialHashvar run_time: float = 0.0var kills: int = 0var game_over: bool = falsevar spawner: SpawnDirectorvar weapon: WeaponPulsevar proj_damage: float = 1.0var _spawn_accum: float = 0.0var pending_levelups: int = 0
var content: ContentDBvar enemy_radius: floatvar _enemy_hp: floatvar _enemy_speed: floatvar _contact_dps: floatvar _gem_xp: float(c) Replace _init (lines 33-42) with:
func _init(seed_value: int, content_db: ContentDB) -> void: rng = SeededRng.new(seed_value) upgrade_rng = SeededRng.new(seed_value + 2654435769) player = PlayerState.new() enemies = EntityPool.new(ENEMY_CAP) projectiles = EntityPool.new(PROJ_CAP) gems = EntityPool.new(GEM_CAP) hash = SpatialHash.new(HASH_CELL) spawner = SpawnDirector.new() content = content_db weapon = WeaponPulse.new(content.weapon("pulse")) var e := content.enemy("swarmer") _enemy_hp = float(e["hp"]) _enemy_speed = float(e["speed"]) enemy_radius = float(e["radius"]) _contact_dps = float(e["contact_damage"]) _gem_xp = float(e["xp_value"])(d) In _spawn_enemies, change the enemies.add line to:
enemies.add(spawn_pos, Vector2.ZERO, enemy_radius, _enemy_hp)(e) In _move_enemies, change the move line to:
enemies.pos[i] += dir.normalized() * _enemy_speed * dt(f) In _resolve_collisions, change the query-radius line and the gem-spawn line:
var hits := hash.query_circle(ppos, projectiles.radius[pi] + enemy_radius, enemies) gems.add(enemies.pos[ei], Vector2.ZERO, GEM_RADIUS, _gem_xp)(g) In _check_player_hit, change the reach + damage lines:
var reach := player.radius + enemy_radius player.hp -= _contact_dps * dt * float(touching)(h) Change the two upgrade wrappers (lines 130-135) to thread content:
func roll_upgrade_choices(n: int) -> Array[String]: return Upgrades.roll_choices(upgrade_rng, content, n)
func apply_upgrade(id: String) -> void: Upgrades.apply(id, content, player) pending_levelups = maxi(pending_levelups - 1, 0)- Step 4: Update
test_weapon_pulse.gd
Replace its contents with (Sim built with fixture content; bare weapon built from a def):
extends GutTest
func _content() -> ContentDB: return SimContentFixture.db()
func test_fires_after_cooldown_toward_enemy() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(200, 0), Vector2.ZERO, 14.0, 3.0) for i in range(50): sim.tick(InputState.new(Vector2.ZERO)) assert_gt(sim.projectiles.count, 0, "weapon should have fired") assert_gt(sim.projectiles.vel[0].x, 0.0)
func test_no_fire_without_enemies() -> void: var w := WeaponPulse.new(_content().weapon("pulse")) var bare := Sim.new(1, _content()) bare.enemies.count = 0 w.update(bare, 1.0) assert_eq(bare.projectiles.count, 0)
func test_nearest_enemy_index() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(500, 0), Vector2.ZERO, 14.0, 3.0) sim.enemies.add(Vector2(50, 0), Vector2.ZERO, 14.0, 3.0) assert_eq(sim.weapon.nearest_enemy_index(sim), 1)- Step 5: Update
test_upgrades.gd
Replace its contents with (data-backed API; note hyphenated ids):
extends GutTest
func _content() -> ContentDB: return SimContentFixture.db()
func test_damage_upgrade_raises_mult() -> void: var p := PlayerState.new() Upgrades.apply("damage", _content(), p) assert_almost_eq(p.damage_mult, 1.25, 0.001)
func test_max_hp_upgrade_raises_cap_and_heals_bonus() -> void: var p := PlayerState.new() var before := p.max_hp var hp_before := p.hp Upgrades.apply("max-hp", _content(), p) assert_almost_eq(p.max_hp, before + 25.0, 0.001) assert_almost_eq(p.hp, hp_before + 25.0, 0.001, "max_hp also heals +25")
func test_roll_choices_distinct() -> void: var rng := SeededRng.new(3) var choices := Upgrades.roll_choices(rng, _content(), 3) assert_eq(choices.size(), 3) assert_eq(choices.size(), _unique(choices).size(), "choices must be distinct")
func test_choice_display_has_name_and_desc() -> void: var d := Upgrades.choice_display("damage", _content()) assert_eq(d["name"], "Sharpened") assert_eq(d["desc"], "+25% damage")
func test_sim_apply_upgrade_consumes_pending() -> void: var sim := Sim.new(1, _content()) sim.pending_levelups = 2 sim.apply_upgrade("move-speed") assert_eq(sim.pending_levelups, 1) assert_gt(sim.player.speed, 260.0)
func _unique(a: Array) -> Array: var seen := {} for x in a: seen[x] = true return seen.keys()- Step 6: Update the remaining Sim-constructing tests
In each of these files, change every Sim.new(<seed>) to Sim.new(<seed>, SimContentFixture.db()):
tests/test_enemy_chase.gd(1 call, line 4)tests/test_sim_core.gd(3 calls, lines 4, 12, 18)tests/test_collision_damage.gd(4 calls, lines 4, 17, 27, 35)tests/test_xp_levelup.gd(4 calls, lines 4, 13, 20, 31)tests/test_spawn_director.gd(1 call, line 23 —Sim.new(5)→Sim.new(5, SimContentFixture.db()))
(No other lines in these files change.)
- Step 7: Update
test_determinism.gdto inject content
Change the two construction sites to pass the real content (the trace must stay identical):
In _trace (line 4):
var sim := Sim.new(seed_value, SimContentFixture.db())In test_upgrade_rolls_do_not_perturb_sim_stream (lines 28-29):
var a := Sim.new(777, SimContentFixture.db()) var b := Sim.new(777, SimContentFixture.db())- Step 8: Write the new “content drives the sim” test
Create tests/test_content_drives_sim.gd:
extends GutTest
# Proves the sim's behavior is sourced from content, not hardcoded: a hotter# pulse (higher base_damage) kills more over the same ticks than stock content.func _hotter_pulse_content() -> ContentDB: var text := FileAccess.get_file_as_string("res://data/bible.json") var raw: Dictionary = JSON.parse_string(text) for w in raw["data"]["weapons"]: if w["id"] == "pulse": w["base_damage"] = 100.0 return ContentLoader.load_from_dict(raw)
func _run_kills(content: ContentDB, ticks: int) -> int: var sim := Sim.new(4242, content) for i in range(ticks): sim.tick(InputState.new(Vector2.ZERO)) return sim.kills
func test_higher_base_damage_increases_kills() -> void: var stock := _run_kills(SimContentFixture.db(), 600) var hot := _run_kills(_hotter_pulse_content(), 600) assert_gt(hot, stock, "pulse base_damage from content must change kill rate")- Step 9: Run the full suite — determinism trace must be unchanged
Run:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexitExpected: all tests pass, including test_determinism.gd with its pre-existing trace assertions (byte-identical). test_content_drives_sim.gd passes. Exit code 0.
- Step 10: Commit
git add sim/weapon_pulse.gd sim/upgrades.gd sim/sim.gd tests/git commit -m "feat(sim): content-driven Sim/WeaponPulse/Upgrades; determinism trace unchanged"Task 6: Wire main.gd boot load + level-up UI
Section titled “Task 6: Wire main.gd boot load + level-up UI”Files:
- Modify:
main.gd - Modify:
ui/level_up_panel.gd
Interfaces:
-
Consumes:
ContentLoader.load_from_path(Task 4),Sim.new(seed, content)+Sim.enemy_radius(Task 5),Upgrades.choice_display(Task 5). -
Produces:
LevelUpPanel.show_choices(choices: Array[Dictionary])where each entry is{"id": String, "name": String, "desc": String}. -
Step 1: Refactor the level-up panel to render display dicts
In ui/level_up_panel.gd, replace show_choices and _find (lines 17-38) with — the panel is now a pure renderer with no Upgrades dependency:
func show_choices(choices: Array[Dictionary]) -> void: for c in _box.get_children(): c.queue_free() var title := Label.new() title.text = "LEVEL UP — choose an upgrade" _box.add_child(title) for choice in choices: var id: String = choice["id"] var b := Button.new() b.text = "%s — %s" % [choice["name"], choice["desc"]] b.pressed.connect(func() -> void: chosen.emit(id)) _box.add_child(b) visible = true(Leave _ready, the chosen signal, and hide_panel unchanged.)
- Step 2: Refactor
main.gdto load content and inject it
In main.gd:
(a) Add a content field after the existing vars (after line 13, var _paused_for_levelup):
var content: ContentDB(b) Replace the seed/sim line and the enemy-renderer config (lines 21-22 and line 29). New _new_run head — load content first, fail loud, then build the sim:
Replace line 21-22:
# Deterministic-ish seed derived from a fixed base; replace with a chosen seed later. sim = Sim.new(20260621)with:
content = ContentLoader.load_from_path("res://data/bible.json") if content == null: push_error("main: failed to load res://data/bible.json — aborting run") return # Deterministic-ish seed derived from a fixed base; replace with a chosen seed later. sim = Sim.new(20260621, content)Replace line 29:
enemy_renderer.configure(Sim.ENEMY_RADIUS, Color.WHITE)with:
enemy_renderer.configure(sim.enemy_radius, Color.WHITE)(The gem_renderer.configure(Sim.GEM_RADIUS, …) line is unchanged — GEM_RADIUS is still a Sim const.)
(c) Replace _open_levelup (lines 82-84) to build display dicts from ids:
func _open_levelup() -> void: _paused_for_levelup = true var ids := sim.roll_upgrade_choices(3) var choices: Array[Dictionary] = [] for id in ids: var d := Upgrades.choice_display(id, content) choices.append({"id": id, "name": d["name"], "desc": d["desc"]}) level_up.show_choices(choices)- Step 3: Boot smoke test — game ticks with content, no script errors
Run:
godot --headless --path . --quit-after 180 2>&1 | grep -i "SCRIPT ERROR" && echo "FOUND ERRORS" || echo "clean boot"Expected: clean boot (no SCRIPT ERROR lines; the sim boots, loads content, and ticks ~180 frames).
- Step 4: Run the full suite again (UI change must not regress sim tests)
Run:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexitExpected: all pass, exit 0.
- Step 5: Commit
git add main.gd ui/level_up_panel.gdgit commit -m "feat(game): boot-load bible.json (fail-loud), inject content, data-driven level-up UI"Task 7: Documentation + final verification
Section titled “Task 7: Documentation + final verification”Files:
- Modify:
CLAUDE.md
Interfaces:
-
Consumes: everything above.
-
Step 1: Record the test count before, then run the full suite
Run:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit 2>&1 | tail -20Expected: exit 0; note the reported totals. The suite must now include the four new files (test_stat_effects, test_content_db, test_content_loader, test_content_drives_sim). Per the stale-class-cache gotcha, confirm the test count rose by their assertions (≈ +20 tests) — a silently lower count means the global class cache is stale (ensure main.tscn is present / run/main_scene resolves).
- Step 2: Update
CLAUDE.md— document the data pipeline
In CLAUDE.md, under the “Design Bible & Balance Tool” section, append:
## 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[].element` → `elements`), 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.- **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.Then, in the “Milestone 2 backlog” section, mark the integration done by changing the “Content:” line context — add at the top of that section:
- ✅ **Data-driven content loading (cycle 3) — DONE.** Weapons/enemies/upgrades load from `data/bible.json`; see "Data pipeline" above. Next: the elemental engine (cycle 4) reads element/reaction data the same way.- Step 3: Commit
git add CLAUDE.mdgit commit -m "docs: document the data pipeline (game loads bible.json) + mark cycle 3 done"- Step 4: Final whole-suite + boot smoke confirmation
Run:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexitgodot --headless --path . --quit-after 180 2>&1 | grep -ci "SCRIPT ERROR"Expected: suite exit 0; the second command prints 0.
Self-Review
Section titled “Self-Review”1. Spec coverage (against 2026-06-22-data-driven-content-loading-design.md):
- §2 architecture (loader outside /sim, ContentDB inside, injection) → Tasks 3,4,5,6. ✓
- §3.1
data/bible.json→ Task 1. ✓ - §3.2
ContentLoader(load_from_path/load_from_dict) → Task 4. ✓ - §3.3
ContentDBgetters + offerable-upgrades filter → Task 3. ✓ - §3.4
StatEffects(apply/describe/is_known) → Task 2. ✓ - §3.5
Upgradesdata-backed → Task 5 Step 2. ✓ - §3.6
Sim/WeaponPulse/SpawnDirector(spawn unchanged) → Task 5 Steps 1,3. ✓ - §3.7
main.gdboot load + inject + UI → Task 6. ✓ - §4 validation (all 6 checks) → Task 4 Step 3 + tests Step 1. ✓
- §5 tests (loader/db/stat_effects/upgrades/determinism unchanged/count guard) → Tasks 2-7. ✓
- §6 success criteria → identical behavior (Task 5 Step 9 determinism), content drives sim (Task 5 Step 8), fail-loud (Task 4 + Task 6 Step 2), /sim purity (loader in content/), suite+count (Task 7). ✓
- §7 out-of-scope respected (no elemental engine, transformative mods excluded, run_structure untouched). ✓
2. Placeholder scan: No TBD/TODO/“handle edge cases”/“similar to”. Every code step shows full code. ✓
3. Type consistency:
Sim.new(seed_value: int, content: ContentDB)— used identically in main + all tests. ✓WeaponPulse.new(def: Dictionary)— constructed in Sim fromcontent.weapon("pulse")and in test from same. ✓Upgrades.roll_choices(rng, content, n)/apply(id, content, player)/choice_display(id, content)— signatures consistent across Sim wrappers, main, tests. ✓ContentDB.upgrades()returnsArrayof mod dicts;upgrade(id)returns a single dict; consumed consistently. ✓StatEffects.apply(effect, magnitude, player)/describe(effect, magnitude)/is_known(effect)— consistent in Upgrades, ContentDB, tests. ✓Sim.enemy_radiuspublic — written in_init, read inmain.gdrenderer config. ✓- Removed consts (
ENEMY_RADIUSetc.) — only prior reference wasmain.gd:29, updated in Task 6.GEM_RADIUSretained, still referenced bymain.gdgem renderer. ✓
No issues found.
Execution Handoff
Section titled “Execution Handoff”Plan complete and saved to docs/superpowers/plans/2026-06-22-data-driven-content-loading.md.