Skip to content

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

  • /sim purity: every file under sim/ extends RefCounted and uses NO Node / render / Input / Engine / Time / File / JSON APIs. File reading + parsing + validation live in content/, never in sim/. ContentDB is pure parsed data and may live in sim/.
  • Determinism is the keystone: the existing tests/test_determinism.gd 600-tick trace must remain byte-identical after this slice. Constant Sim_Const.DT ticking and the two RNG streams (rng, upgrade_rng) are unchanged. Never draw upgrade rolls from rng.
  • Fail-loud on bad data: invalid bible.json (bad schemaVersion, broken ref, missing required entry/field) → push_error with the exact problem list and refuse to boot. No hardcoded content fallback.
  • schemaVersion contract: engine supports schemaVersion == 1 only.
  • Data refresh is manual: data/bible.json is 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. enemy hp,speed,radius,contact_damage,xp_value. mod kind,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.

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’s SEED to JSON (reproducible generator + documented CLI refresh path).
  • sim/stat_effects.gdStatEffects: maps a stat-mod effect name + magnitude to a PlayerState mutation and a human label. Pure.
  • sim/content_db.gdContentDB: pure data holder built from a parsed bible Dictionary; typed getters for M1 categories.
  • content/content_loader.gdContentLoader: file read + JSON parse + validation; builds a ContentDB. Outside /sim.
  • tests/sim_content_fixture.gdSimContentFixture: test-only cached accessor returning the real ContentDB from res://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-backed roll_choices/apply/choice_display; drop ALL.
  • ui/level_up_panel.gdshow_choices(choices: Array[Dictionary]); drop the Upgrades.ALL dependency.
  • main.gd — load content at boot (fail-loud), inject into Sim, use sim.enemy_radius for 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 — thread content into Sim.new(...).
  • CLAUDE.md — document the data pipeline.

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 by tools/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.json
import { 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):

Terminal window
mkdir -p data && node tools/design-bible/scripts/export-seed.mjs > data/bible.json

Expected: data/bible.json created, no stderr.

  • Step 3: Verify it is valid JSON with the right shape

Run:

Terminal window
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 1
weapons ['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:

Terminal window
python3 -c "
import json
d=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, p
assert s['hp']==3 and s['speed']==70 and s['radius']==14 and s['contact_damage']==12 and s['xp_value']==1, s
print('M1 values match engine constants')
"

Expected: M1 values match engine constants (no AssertionError).

  • Step 5: Commit
Terminal window
git add tools/design-bible/scripts/export-seed.mjs data/bible.json
git 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 — fields damage_mult, fire_rate_mult, speed, pickup_radius, max_hp, hp).

  • Produces:

    • StatEffects.is_known(effect: String) -> bool
    • StatEffects.apply(effect: String, magnitude: float, player: PlayerState) -> void
    • StatEffects.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:

Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_stat_effects.gd -gexit

Expected: 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:

Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_stat_effects.gd -gexit

Expected: PASS (5/5).

  • Step 5: Commit
Terminal window
git add sim/stat_effects.gd tests/test_stat_effects.gd
git commit -m "feat(sim): StatEffects — data effect name -> PlayerState mutation + label"

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 := 1
    • ContentDB.new(data: Dictionary)data is the bible’s inner data object.
    • func weapon(id: String) -> Dictionary / func enemy(id: String) -> Dictionary — entry or {}.
    • func has_weapon(id: String) -> bool / func has_enemy(id: String) -> bool
    • func upgrades() -> Array — offerable stat mods (kind=="stat" and StatEffects.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:

Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_content_db.gd -gexit

Expected: FAIL — ContentDB not declared.

  • Step 3: Implement ContentDB

Create sim/content_db.gd:

class_name ContentDB
extends 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:

Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_content_db.gd -gexit

Expected: PASS (4/4).

  • Step 5: Commit
Terminal window
git add sim/content_db.gd tests/test_content_db.gd
git commit -m "feat(sim): ContentDB — pure data getters for M1 categories"

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; returns ContentDB or null (after push_error).
    • ContentLoader.load_from_path(path: String) -> ContentDB — reads + parses the file then load_from_dict; null on any failure.
    • ContentLoader.validate(raw: Dictionary) -> Array — list of problem strings (empty == valid).
    • SimContentFixture.db() -> ContentDB — cached real content from res://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:

Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_content_loader.gd -gexit

Expected: FAIL — ContentLoader not declared.

  • Step 3: Implement ContentLoader

Create content/content_loader.gd:

class_name ContentLoader
extends 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:

Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_content_loader.gd -gexit

Expected: PASS (9/9).

  • Step 6: Commit
Terminal window
git add content/content_loader.gd tests/sim_content_fixture.gd tests/test_content_loader.gd
git 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; drop ALL)
  • 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 from def.
    • Upgrades.roll_choices(rng: SeededRng, content: ContentDB, n: int) -> Array[String]
    • Upgrades.apply(id: String, content: ContentDB, player: PlayerState) -> void
    • Upgrades.choice_display(id: String, content: ContentDB) -> Dictionary{"name": String, "desc": String}
    • Sim.new(seed_value: int, content: ContentDB); public Sim.enemy_radius: float; Sim.content: ContentDB. Sim.roll_upgrade_choices(n) / Sim.apply_upgrade(id) signatures unchanged (content held internally).
    • Sim keeps consts ENEMY_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 WeaponPulse to take a def

Replace the head of sim/weapon_pulse.gd (the var block, lines 1-9) with:

class_name WeaponPulse
extends RefCounted
var cooldown: float
var proj_speed: float
var proj_radius: float
var base_damage: float
var proj_lifetime: float
var _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 Upgrades to 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 Sim to 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 = 6000
const PROJ_CAP: int = 4000
const GEM_CAP: int = 4000
const HASH_CELL: float = 64.0
const SPAWN_RING: float = 1100.0
const 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: SeededRng
var upgrade_rng: SeededRng
var player: PlayerState
var enemies: EntityPool
var projectiles: EntityPool
var gems: EntityPool
var hash: SpatialHash
var run_time: float = 0.0
var kills: int = 0
var game_over: bool = false
var spawner: SpawnDirector
var weapon: WeaponPulse
var proj_damage: float = 1.0
var _spawn_accum: float = 0.0
var pending_levelups: int = 0
var content: ContentDB
var enemy_radius: float
var _enemy_hp: float
var _enemy_speed: float
var _contact_dps: float
var _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.gd to 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:

Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit

Expected: 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
Terminal window
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.gd to 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:

Terminal window
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:

Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit

Expected: all pass, exit 0.

  • Step 5: Commit
Terminal window
git add main.gd ui/level_up_panel.gd
git 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:

Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit 2>&1 | tail -20

Expected: 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
Terminal window
git add CLAUDE.md
git commit -m "docs: document the data pipeline (game loads bible.json) + mark cycle 3 done"
  • Step 4: Final whole-suite + boot smoke confirmation

Run:

Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
godot --headless --path . --quit-after 180 2>&1 | grep -ci "SCRIPT ERROR"

Expected: suite exit 0; the second command prints 0.


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 ContentDB getters + offerable-upgrades filter → Task 3. ✓
  • §3.4 StatEffects (apply/describe/is_known) → Task 2. ✓
  • §3.5 Upgrades data-backed → Task 5 Step 2. ✓
  • §3.6 Sim/WeaponPulse/SpawnDirector (spawn unchanged) → Task 5 Steps 1,3. ✓
  • §3.7 main.gd boot 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 from content.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() returns Array of 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_radius public — written in _init, read in main.gd renderer config. ✓
  • Removed consts (ENEMY_RADIUS etc.) — only prior reference was main.gd:29, updated in Task 6. GEM_RADIUS retained, still referenced by main.gd gem renderer. ✓

No issues found.

Plan complete and saved to docs/superpowers/plans/2026-06-22-data-driven-content-loading.md.