Skip to content

Remote config (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. Code-writing subagents: use the bh-implementer agent (opus); review with bh-reviewer.

Goal: Ship the client + Worker half of remote config in the launch binary, and convert the first batch of sim tuning consts, so balance can change per build without an App Store build.

Architecture: Render-side Tunables sets registered GDScript static vars by name at run start (reset-to-defaults first); a BibleOverlay patches numeric fields of the parsed bible.json before ContentDB is built; RemoteConfig fetches /config?build=N&install=ID from the existing telemetry Worker, caches the last good patch, and owns the crash-loop guard. The Worker serves per-build patches from KV with an older-build fallback. /sim only gains static var declarations.

Tech Stack: Godot 4.7.1 / typed GDScript, GUT 9.7.1, Cloudflare Worker (JS, KV, D1), Node node:test, wrangler.

Spec: docs/superpowers/specs/2026-09-27-remote-config-design.md

  • /sim stays pure: no FileAccess/JSON/HTTP/OS in sim/*.gd. Sim files only declare static var tunables. All loading/fetching/setting lives in net/ or content/.
  • The pinned determinism baselines (tests/test_determinism_survival.gd: 4217109746 / 3312602286, and tests/test_determinism_checksum.gd) must NOT change in any task. A const → static var swap that moves them is a bug, not a re-pin.
  • GUT tests must never do real network or disk IO for remote config: tests/gut_pre_run.gd sets RemoteConfig.io_enabled = false (Task 3) and every test that applies a patch calls Tunables.reset() in after_each (static vars are process-global and would leak into the determinism tests).
  • tvOS: user:// is NOT writable on tvOS; write to user:// first, fall back to OS.get_cache_dir() (MetaStore’s pattern, meta/meta_store.gd:121-157).
  • Patches are numbers only (App Store guideline 2.5.2): no code, no added/removed content entries.
  • Default bounds: 0.1×–10× the bundled value (sign-aware); a bundled default of 0 needs explicit min/max.
  • Merge gate per task: bash scripts/gate.sh exit 0. Any sim-behaviour task also needs one clean scripts/soak.sh --runs=2 --ticks=18000 run.
  • Commit + git push origin main after every merge (two devs on main). Pull/rebase before starting each task.
  • Worker deploys: CLOUDFLARE_ACCOUNT_ID=06f5622d4192dcefefbe0da75d766bfc npx wrangler … from telemetry/ (see .claude/skills/bh-telemetry/SKILL.md). Never add account_id to wrangler.toml.
  • Endpoint base: https://bullet-heaven-telemetry.chris-allen-06f.workers.dev.
File Responsibility
net/tunables.gd (new) reset/apply/hash over registered static vars
net/tunables_registry.gd (new) the list of tunable entries + known non-tunable static vars
content/bible_overlay.gd (new) validate + apply category.id.field numeric patches to a raw bible dict
content/content_loader.gd (modify) add read_raw(path)
content/game_content.gd (new) the ONE render-side “bible + active overlay → ContentDB” loader for main.gd
net/local_json.gd (new) read/write a small JSON file, user:// then cache-dir fallback, IO flag
net/remote_config_state.gd (new) pure patch state machine: cache, pending, kill switch, blacklist, crash marker
net/remote_config.gd (new) Node: HTTP fetch, owns the state, static active id/hash/overlay
sim/meta_state.gd (modify) persist install_id
main.gd (modify) wire fetch, run-start apply, menu/codex content, crash guard, replay config
input/replay_recorder.gd (modify) store the run’s patch in the replay
net/telemetry.gd, net/gameplay_telemetry.gd, net/dash_encounter_telemetry.gd (modify) stamp config_id
telemetry/src/worker.js, telemetry/schema.sql, telemetry/wrangler.toml, telemetry/package.json (new), telemetry/test/config.test.mjs (new) /config route, KV, config_id columns, tests
tools/remote_config/dump_manifest.gd (new), scripts/publish-config.mjs (new), remote-config/ (new dir) manifest + validated publish
tools/soak/soak_runner.gd (modify) --config=path to soak a patch before publishing
sim/sim.gd, sim/elemental_system.gd (modify) batch 1 const → static var

Files:

  • Create: net/tunables.gd, net/tunables_registry.gd, tests/tunables_fixture.gd, tests/test_tunables.gd
  • Modify: tests/gut_pre_run.gd

Interfaces:

  • Produces: Tunables.reset(list: Array = TunablesRegistry.ENTRIES) -> void; Tunables.apply(patch: Dictionary, list: Array = TunablesRegistry.ENTRIES) -> Array (returns problem strings); Tunables.bounds(entry: Dictionary) -> Vector2; Tunables.default_of(key: String, list := TunablesRegistry.ENTRIES) -> Variant; Tunables.config_hash(bible_overlay: Dictionary, list := TunablesRegistry.ENTRIES) -> int. Entry shape: {"key": "Sim.BOSS_INTERVAL", "script": "res://sim/sim.gd", "name": "BOSS_INTERVAL", "min"?: float, "max"?: float}. TunablesRegistry.ENTRIES: Array, TunablesRegistry.NON_TUNABLE_STATIC_VARS: Array[String] ("<path>:<name>").

  • Step 1: Fixture + failing tests

tests/tunables_fixture.gd:

class_name TunablesFixture
extends RefCounted
# Test-only static vars for tests/test_tunables.gd (not a test script: no test_ prefix).
static var SPEED: float = 150.0
static var COUNT: int = 10
static var ZERO: float = 0.0
const P := "res://tests/tunables_fixture.gd"
const ENTRIES := [
{"key": "Fx.SPEED", "script": P, "name": "SPEED"},
{"key": "Fx.COUNT", "script": P, "name": "COUNT"},
{"key": "Fx.ZERO", "script": P, "name": "ZERO"},
{"key": "Fx.ZERO_BOUNDED", "script": P, "name": "ZERO", "min": -1.0, "max": 1.0},
]

tests/test_tunables.gd:

extends GutTest
const E := TunablesFixture.ENTRIES
func after_each() -> void:
Tunables.reset(E)
func test_apply_sets_value_and_reset_restores() -> void:
var problems := Tunables.apply({"Fx.SPEED": 99.5}, E)
assert_eq(problems, [])
assert_eq(TunablesFixture.SPEED, 99.5)
Tunables.reset(E)
assert_eq(TunablesFixture.SPEED, 150.0)
func test_apply_is_not_cumulative() -> void:
Tunables.apply({"Fx.SPEED": 99.5}, E)
Tunables.apply({"Fx.COUNT": 12}, E)
assert_eq(TunablesFixture.SPEED, 150.0, "second apply starts from defaults")
assert_eq(TunablesFixture.COUNT, 12)
func test_unknown_key_skipped_and_reported() -> void:
var problems := Tunables.apply({"Fx.NOPE": 1.0, "Fx.SPEED": 120.0}, E)
assert_eq(problems.size(), 1)
assert_string_contains(problems[0], "Fx.NOPE")
assert_eq(TunablesFixture.SPEED, 120.0, "valid keys still apply")
func test_out_of_bounds_rejected() -> void:
var problems := Tunables.apply({"Fx.SPEED": 1500.1}, E) # > 10x
assert_eq(problems.size(), 1)
assert_eq(TunablesFixture.SPEED, 150.0)
assert_eq(Tunables.apply({"Fx.SPEED": 14.9}, E).size(), 1, "< 0.1x rejected")
func test_non_numeric_rejected() -> void:
assert_eq(Tunables.apply({"Fx.SPEED": "fast"}, E).size(), 1)
assert_eq(TunablesFixture.SPEED, 150.0)
func test_int_tunable_needs_whole_number_and_stays_int() -> void:
assert_eq(Tunables.apply({"Fx.COUNT": 12.5}, E).size(), 1)
assert_eq(Tunables.apply({"Fx.COUNT": 12.0}, E), []) # JSON numbers arrive as float
assert_eq(typeof(TunablesFixture.COUNT), TYPE_INT)
assert_eq(TunablesFixture.COUNT, 12)
func test_zero_default_needs_explicit_bounds() -> void:
assert_eq(Tunables.apply({"Fx.ZERO": 0.5}, E).size(), 1, "no implicit bounds around 0")
assert_eq(Tunables.apply({"Fx.ZERO_BOUNDED": 0.5}, E), [])
assert_eq(TunablesFixture.ZERO, 0.5)
func test_config_hash_changes_with_values() -> void:
var h0 := Tunables.config_hash({}, E)
Tunables.apply({"Fx.SPEED": 120.0}, E)
assert_ne(Tunables.config_hash({}, E), h0)
Tunables.reset(E)
assert_eq(Tunables.config_hash({}, E), h0)
assert_ne(Tunables.config_hash({"weapons.fork.base_damage": 3.0}, E), h0)
  • Step 2: Run, expect FAIL (Tunables not defined)

Run: godot --headless --path . --import >/dev/null 2>&1; godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_tunables -gexit

  • Step 3: Implement

net/tunables_registry.gd:

class_name TunablesRegistry
extends RefCounted
# Every remotely-tunable sim value (spec docs/superpowers/specs/2026-09-27-remote-config-design.md §3.1).
# Converting a sim const: `const X: float = 1.0` -> `static var X: float = 1.0` in the sim file, plus
# one entry here. Key = "<Class>.<NAME>". Optional "min"/"max" override the default 0.1x-10x bounds
# (required when the bundled default is 0). tests/test_tunables_registry.gd keeps this list and the
# sim's static vars in lockstep.
const ENTRIES: Array = []
# Static vars in sim/*.gd that are runtime state, not tunables ("<path>:<name>").
const NON_TUNABLE_STATIC_VARS: Array[String] = [
"res://sim/meta_state.gd:buy_attempts",
"res://sim/meta_state.gd:buy_successes",
"res://sim/spawn_table.gd:_table",
]

net/tunables.gd:

class_name Tunables
extends RefCounted
# Render-side setter for registered sim `static var` tunables (spec §3.1). Called ONLY at run start,
# before Sim.new(). apply() always resets to the bundled defaults first, so values never compound.
# Bundled defaults are captured from the scripts the first time an entry is touched, before anything
# here has written to it.
static var _defaults: Dictionary = {} # key -> bundled default
static func _script(e: Dictionary) -> GDScript:
return load(String(e["script"])) as GDScript
static func _ensure_defaults(list: Array) -> void:
for e in list:
var key := String(e["key"])
if not _defaults.has(key):
_defaults[key] = _script(e).get(String(e["name"]))
static func default_of(key: String, list: Array = TunablesRegistry.ENTRIES) -> Variant:
_ensure_defaults(list)
return _defaults.get(key)
static func reset(list: Array = TunablesRegistry.ENTRIES) -> void:
_ensure_defaults(list)
for e in list:
_script(e).set(String(e["name"]), _defaults[String(e["key"])])
# x = min, y = max. Sign-aware 0.1x-10x unless the entry overrides; (NAN, NAN) = no valid range.
static func bounds(e: Dictionary) -> Vector2:
var d := float(_defaults.get(String(e["key"]), 0.0))
if e.has("min") and e.has("max"):
return Vector2(float(e["min"]), float(e["max"]))
if is_zero_approx(d):
return Vector2(NAN, NAN)
return Vector2(minf(d * 0.1, d * 10.0), maxf(d * 0.1, d * 10.0))
static func apply(patch: Dictionary, list: Array = TunablesRegistry.ENTRIES) -> Array:
reset(list)
var by_key := {}
for e in list:
by_key[String(e["key"])] = e
var problems: Array = []
for k in patch:
var key := String(k)
if not by_key.has(key):
problems.append("unknown tunable %s" % key)
continue
var e: Dictionary = by_key[key]
var v: Variant = patch[k]
if not (typeof(v) in [TYPE_INT, TYPE_FLOAT]):
problems.append("%s: not a number" % key)
continue
var b := bounds(e)
if is_nan(b.x):
problems.append("%s: default is 0, entry needs explicit min/max" % key)
continue
var f := float(v)
if f < b.x or f > b.y:
problems.append("%s: %s outside [%s, %s]" % [key, f, b.x, b.y])
continue
if typeof(_defaults[key]) == TYPE_INT:
if f != floorf(f):
problems.append("%s: int tunable got %s" % [key, f])
continue
_script(e).set(String(e["name"]), int(f))
else:
_script(e).set(String(e["name"]), f)
return problems
# Effective values of every tunable (in registry order) + the bible overlay (sorted keys).
static func config_hash(bible_overlay: Dictionary, list: Array = TunablesRegistry.ENTRIES) -> int:
var parts: Array = []
for e in list:
parts.append([String(e["key"]), _script(e).get(String(e["name"]))])
var keys := bible_overlay.keys()
keys.sort()
for k in keys:
parts.append([String(k), bible_overlay[k]])
return str(parts).hash()

tests/gut_pre_run.gd: append in run():

Tunables.reset() # every registered tunable starts at its bundled default
  • Step 4: Run, expect PASS. Same command.
  • Step 5: bash scripts/gate.sh → PASS. Commit feat(remote-config): Tunables core + registry, push.

Files:

  • Create: content/bible_overlay.gd, content/game_content.gd, tests/test_bible_overlay.gd
  • Modify: content/content_loader.gd (add read_raw, reuse it in load_from_path)

Interfaces:

  • Consumes: none.

  • Produces: BibleOverlay.apply(raw: Dictionary, overlay: Dictionary) -> Array (mutates raw["data"], returns problems); BibleOverlay.overlayable_fields(raw: Dictionary) -> Dictionary ("category.id.field" -> float default); ContentLoader.read_raw(path: String) -> Dictionary ({} on failure); GameContent.load_db(overlay: Dictionary) -> ContentDB; GameContent.BIBLE_PATH.

  • Step 1: Failing tests tests/test_bible_overlay.gd:

extends GutTest
func _raw() -> Dictionary:
return ContentLoader.read_raw(GameContent.BIBLE_PATH)
func _entry(raw: Dictionary, cat: String, id: String) -> Dictionary:
for e in raw["data"][cat]:
if e.get("id", "") == id:
return e
return {}
func test_patches_existing_numeric_field() -> void:
var raw := _raw()
var base := float(_entry(raw, "meta_upgrades", "vitality")["base_cost"])
var problems := BibleOverlay.apply(raw, {"meta_upgrades.vitality.base_cost": base * 2.0})
assert_eq(problems, [])
assert_eq(float(_entry(raw, "meta_upgrades", "vitality")["base_cost"]), base * 2.0)
func test_rejects_unknown_id_field_and_non_numeric() -> void:
var raw := _raw()
assert_eq(BibleOverlay.apply(raw, {"weapons.nope.base_damage": 1.0}).size(), 1)
assert_eq(BibleOverlay.apply(raw, {"weapons.blade.no_such_field": 1.0}).size(), 1)
assert_eq(BibleOverlay.apply(raw, {"weapons.blade.name": 1.0}).size(), 1, "string field")
assert_eq(BibleOverlay.apply(raw, {"weapons.blade.base_damage": "x"}).size(), 1)
assert_eq(BibleOverlay.apply(raw, {"bad_key": 1.0}).size(), 1)
func test_rejects_out_of_bounds() -> void:
var raw := _raw()
var d := float(_entry(raw, "weapons", "blade")["base_damage"])
assert_eq(BibleOverlay.apply(raw, {"weapons.blade.base_damage": d * 11.0}).size(), 1)
assert_eq(float(_entry(raw, "weapons", "blade")["base_damage"]), d, "unchanged")
func test_overlay_reaches_content_db() -> void:
var db := GameContent.load_db({"weapons.blade.base_damage": 7.5})
assert_not_null(db)
assert_eq(float(db.weapon("blade")["base_damage"]), 7.5)
assert_ne(float(GameContent.load_db({}).weapon("blade")["base_damage"]), 7.5, "fresh load each call")
func test_overlayable_fields_lists_numbers_only() -> void:
var f := BibleOverlay.overlayable_fields(_raw())
assert_true(f.has("weapons.blade.base_damage"))
assert_false(f.has("weapons.blade.name"))
assert_false(f.has("weapons.blade.live"), "bools are not numbers")
  • Step 2: Run -gselect=test_bible_overlay → FAIL.

  • Step 3: Implement.

content/content_loader.gd: add, and make load_from_path use it:

# Raw parsed bible (before validation), or {} on any read/parse failure. GameContent layers the
# remote-config overlay onto this before load_from_dict validates it.
static func read_raw(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
push_error("ContentLoader: file not found: %s" % path)
return {}
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(path))
if not (parsed is Dictionary):
push_error("ContentLoader: %s is not a JSON object" % path)
return {}
return parsed
static func load_from_path(path: String) -> ContentDB:
var raw := read_raw(path)
return null if raw.is_empty() else load_from_dict(raw)

content/bible_overlay.gd:

class_name BibleOverlay
extends RefCounted
# Remote-config patch over the parsed bible (spec §3.2). Keys are "category.id.field"; only EXISTING
# numeric fields of EXISTING entries change (no adds/removes: App Store 2.5.2, numbers only). Bounds
# are 0.1x-10x the bundled value; a bundled 0 can't be patched. Mutates raw["data"] in place.
static func _find(data: Dictionary, cat: String, id: String) -> Dictionary:
var v: Variant = data.get(cat, [])
if v is Array:
for e in v:
if e is Dictionary and String(e.get("id", "")) == id:
return e
return {}
static func _is_num(v: Variant) -> bool:
return typeof(v) in [TYPE_INT, TYPE_FLOAT]
static func apply(raw: Dictionary, overlay: Dictionary) -> Array:
var problems: Array = []
var data: Dictionary = raw.get("data", {})
for k in overlay:
var key := String(k)
var parts := key.split(".")
if parts.size() != 3:
problems.append("bible %s: key must be category.id.field" % key)
continue
var e := _find(data, parts[0], parts[1])
if e.is_empty():
problems.append("bible %s: no such entry" % key)
continue
if not e.has(parts[2]) or not _is_num(e[parts[2]]):
problems.append("bible %s: not an existing numeric field" % key)
continue
var v: Variant = overlay[k]
if not _is_num(v):
problems.append("bible %s: not a number" % key)
continue
var d := float(e[parts[2]])
if is_zero_approx(d):
problems.append("bible %s: bundled value is 0, not patchable" % key)
continue
var lo := minf(d * 0.1, d * 10.0)
var hi := maxf(d * 0.1, d * 10.0)
if float(v) < lo or float(v) > hi:
problems.append("bible %s: %s outside [%s, %s]" % [key, float(v), lo, hi])
continue
e[parts[2]] = float(v)
return problems
# Every patchable key with its bundled value (for the manifest tool).
static func overlayable_fields(raw: Dictionary) -> Dictionary:
var out := {}
var data: Dictionary = raw.get("data", {})
for cat in data:
if not (data[cat] is Array):
continue
for e in data[cat]:
if not (e is Dictionary) or String(e.get("id", "")) == "":
continue
for f in e:
if _is_num(e[f]) and not is_zero_approx(float(e[f])):
out["%s.%s.%s" % [cat, e["id"], f]] = float(e[f])
return out

(JSON true/false parse as TYPE_BOOL, so _is_num excludes them.)

content/game_content.gd:

class_name GameContent
extends RefCounted
# The ONE render-side loader main.gd uses for the bible: raw file + remote-config overlay -> ContentDB
# (spec §3.2). Tools and tests keep calling ContentLoader directly (bundled defaults).
const BIBLE_PATH := "res://data/bible.json"
static func load_db(overlay: Dictionary) -> ContentDB:
var raw := ContentLoader.read_raw(BIBLE_PATH)
if raw.is_empty():
return null
for p in BibleOverlay.apply(raw, overlay):
push_warning("remote config: " + String(p))
return ContentLoader.load_from_dict(raw)
  • Step 4: Run → PASS. Step 5: gate → PASS; commit feat(remote-config): bible overlay + GameContent loader; push.

Task 3: RemoteConfigState, LocalJson, RemoteConfig node

Section titled “Task 3: RemoteConfigState, LocalJson, RemoteConfig node”

Files:

  • Create: net/local_json.gd, net/remote_config_state.gd, net/remote_config.gd, tests/test_remote_config_state.gd
  • Modify: tests/gut_pre_run.gd

Interfaces:

  • Consumes: Tunables.apply/config_hash (Task 1).

  • Produces:

    • LocalJson.read(name: String) -> Dictionary, LocalJson.write(name: String, d: Dictionary) -> bool, static var LocalJson.io_enabled: bool.
    • RemoteConfigState (RefCounted): var store: Object (needs read() -> Dictionary, write(d: Dictionary)), boot() -> void, receive(patch: Dictionary) -> bool, peek_for_run() -> Dictionary, take_for_run() -> Dictionary, run_started(config_id: String), run_stable(), blacklist: Array, static func valid(patch: Dictionary) -> bool.
    • RemoteConfig (Node): signal changed, static var io_enabled := true, static var active_id := "default", static var active_hash := 0, static var active_patch: Dictionary = {}, var state: RemoteConfigState, func setup(build: int, install_id: String), func apply_for_run(override: Variant = null) -> Dictionary (applies Tunables, sets active_*, returns the patch), func next_bible_overlay() -> Dictionary, static func active_bible() -> Dictionary, func mark_run_started(), func mark_run_stable().
  • Step 1: Failing tests tests/test_remote_config_state.gd:

extends GutTest
class MemStore:
var d: Dictionary = {}
func read() -> Dictionary: return d.duplicate(true)
func write(x: Dictionary) -> void: d = x.duplicate(true)
var store: MemStore
var s: RemoteConfigState
func before_each() -> void:
store = MemStore.new()
s = RemoteConfigState.new()
s.store = store
s.boot()
func _p(id: String) -> Dictionary:
return {"config_id": id, "tunables": {}, "bible": {}}
func test_no_config_means_defaults() -> void:
assert_eq(s.take_for_run(), {})
func test_received_patch_is_used_and_cached() -> void:
assert_true(s.receive(_p("b36-001")))
assert_eq(s.take_for_run()["config_id"], "b36-001")
var s2 := RemoteConfigState.new()
s2.store = store
s2.boot()
assert_eq(s2.take_for_run()["config_id"], "b36-001", "survives restart via cache")
func test_empty_patch_is_kill_switch() -> void:
s.receive(_p("b36-001"))
assert_true(s.receive({}))
assert_eq(s.take_for_run(), {})
func test_malformed_patch_ignored() -> void:
s.receive(_p("b36-001"))
assert_false(s.receive({"tunables": {}}), "no config_id")
assert_false(s.receive({"config_id": "x", "tunables": []}), "tunables not a dict")
assert_eq(s.take_for_run()["config_id"], "b36-001")
func test_crash_marker_blacklists_on_next_boot() -> void:
s.receive(_p("b36-bad"))
s.run_started("b36-bad") # app dies before run_stable()
var s2 := RemoteConfigState.new()
s2.store = store
s2.boot()
assert_true(s2.blacklist.has("b36-bad"))
assert_eq(s2.take_for_run(), {}, "blacklisted config falls back to defaults")
assert_false(s2.receive(_p("b36-bad")), "refetch of the same id stays refused")
assert_true(s2.receive(_p("b36-good")))
func test_stable_run_clears_marker() -> void:
s.receive(_p("b36-001"))
s.run_started("b36-001")
s.run_stable()
var s2 := RemoteConfigState.new()
s2.store = store
s2.boot()
assert_false(s2.blacklist.has("b36-001"))
func test_default_run_writes_no_marker() -> void:
s.run_started("default")
var s2 := RemoteConfigState.new()
s2.store = store
s2.boot()
assert_eq(s2.blacklist, [])
func test_peek_does_not_consume() -> void:
s.receive(_p("b36-001"))
assert_eq(s.peek_for_run()["config_id"], "b36-001")
assert_eq(s.take_for_run()["config_id"], "b36-001")
  • Step 2: Run -gselect=test_remote_config_state → FAIL.

  • Step 3: Implement.

net/local_json.gd:

class_name LocalJson
extends RefCounted
# Small render-side JSON files. user:// first; tvOS can't write user:// (Documents is read-only there),
# so fall back to the cache dir, same as MetaStore (meta/meta_store.gd). The cache dir can be purged:
# only store things that are safe to lose (remote-config cache + crash marker).
static var io_enabled: bool = true # tests/gut_pre_run.gd turns this off
static func _paths(name: String) -> Array:
return ["user://" + name, OS.get_cache_dir().path_join(name)]
static func read(name: String) -> Dictionary:
if not io_enabled:
return {}
for p in _paths(name):
if FileAccess.file_exists(p):
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(p))
if parsed is Dictionary:
return parsed
return {}
static func write(name: String, d: Dictionary) -> bool:
if not io_enabled:
return false
var payload := JSON.stringify(d)
for p in _paths(name):
DirAccess.make_dir_recursive_absolute(String(p).get_base_dir())
var f := FileAccess.open(p, FileAccess.WRITE)
if f != null:
f.store_string(payload)
f.close()
return true
return false

net/remote_config_state.gd:

class_name RemoteConfigState
extends RefCounted
# Pure remote-config state (spec §3.3, §5): last good patch, a kill switch ({} received), a local
# blacklist of config ids that crashed the app, and the crash marker. `store` persists it (a
# LocalJson-backed object in the game, an in-memory one in tests).
var store: Object
var cached: Dictionary = {}
var pending: Dictionary = {}
var pending_set: bool = false
var blacklist: Array = []
var marker: String = ""
static func valid(patch: Dictionary) -> bool:
if patch.is_empty():
return true # kill switch
return patch.get("config_id") is String and String(patch["config_id"]) != "" \
and patch.get("tunables", {}) is Dictionary and patch.get("bible", {}) is Dictionary
func boot() -> void:
var d: Dictionary = store.read()
cached = d.get("cached", {}) if d.get("cached", {}) is Dictionary else {}
blacklist = d.get("blacklist", []) if d.get("blacklist", []) is Array else []
marker = String(d.get("marker", ""))
if marker != "":
if not blacklist.has(marker):
blacklist.append(marker)
marker = ""
_save()
func _save() -> void:
store.write({"cached": cached, "blacklist": blacklist, "marker": marker})
func receive(patch: Dictionary) -> bool:
if not valid(patch):
return false
if not patch.is_empty() and blacklist.has(String(patch["config_id"])):
return false
pending = patch
pending_set = true
cached = patch
_save()
return true
func peek_for_run() -> Dictionary:
var p: Dictionary = pending if pending_set else cached
if not p.is_empty() and blacklist.has(String(p.get("config_id", ""))):
return {}
return p
func take_for_run() -> Dictionary:
return peek_for_run()
func run_started(config_id: String) -> void:
if config_id == "" or config_id == "default":
return
marker = config_id
_save()
func run_stable() -> void:
if marker == "":
return
marker = ""
_save()

net/remote_config.gd:

class_name RemoteConfig
extends Node
# Fetches this build's remote-config patch at boot (spec §3.3), never blocking. main.gd calls
# apply_for_run() at the top of every run, before Sim.new(). A fetch that lands mid-run is only
# used from the next run. Silent in editor/headless and on any failure.
signal changed
const ENDPOINT := "https://bullet-heaven-telemetry.chris-allen-06f.workers.dev/config"
const FILE := "remote_config.json"
const STABLE_AFTER_S := 30.0
static var io_enabled: bool = true
static var active_id: String = "default"
static var active_hash: int = 0
static var active_patch: Dictionary = {}
class _Store:
func read() -> Dictionary: return LocalJson.read(RemoteConfig.FILE)
func write(d: Dictionary) -> void: LocalJson.write(RemoteConfig.FILE, d)
var state := RemoteConfigState.new()
var _http: HTTPRequest
func setup(build: int, install_id: String) -> void:
state.store = _Store.new()
state.boot()
if not io_enabled or OS.has_feature("editor") or DisplayServer.get_name() == "headless":
return
_http = HTTPRequest.new()
_http.timeout = 10.0
add_child(_http)
_http.request_completed.connect(_on_done)
_http.request("%s?build=%d&install=%s" % [ENDPOINT, build, install_id.uri_encode()])
func _on_done(result: int, code: int, _h: PackedStringArray, body: PackedByteArray) -> void:
if result != HTTPRequest.RESULT_SUCCESS or code != 200:
return
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if parsed is Dictionary and state.receive(parsed):
changed.emit()
# The patch the NEXT run will use (menu shop prices read this).
func next_bible_overlay() -> Dictionary:
var b: Variant = state.peek_for_run().get("bible", {})
return b if b is Dictionary else {}
static func active_bible() -> Dictionary:
var b: Variant = active_patch.get("bible", {})
return b if b is Dictionary else {}
# `override` (a replay's recorded patch) wins over the fetched/cached one.
func apply_for_run(override: Variant = null) -> Dictionary:
var patch: Dictionary = override if override is Dictionary else state.take_for_run()
var t: Variant = patch.get("tunables", {})
for p in Tunables.apply(t if t is Dictionary else {}):
push_warning("remote config: " + String(p))
active_patch = patch
active_id = String(patch.get("config_id", "default")) if not patch.is_empty() else "default"
active_hash = Tunables.config_hash(active_bible())
return patch
func mark_run_started() -> void:
state.run_started(active_id)
func mark_run_stable() -> void:
state.run_stable()

tests/gut_pre_run.gd run(): append

LocalJson.io_enabled = false
RemoteConfig.io_enabled = false
  • Step 4: Run → PASS. Step 5: gate; commit feat(remote-config): fetcher, cache, kill switch, crash-loop guard; push.

Files: Modify sim/meta_state.gd (field + to_dict/from_dict); Test tests/test_meta_install_id.gd.

Interfaces: Produces MetaState.install_id: String ("" until main assigns one). The id lives in MetaState so tvOS’s NSUserDefaults bridge (which mirrors meta.json) keeps it across cache purges.

  • Step 1: Failing test
extends GutTest
func test_install_id_round_trips() -> void:
var m := MetaState.new()
assert_eq(m.install_id, "")
m.install_id = "abc123"
var m2 := MetaState.new()
m2.from_dict(m.to_dict())
assert_eq(m2.install_id, "abc123")
func test_old_save_without_install_id_loads_empty() -> void:
var m := MetaState.new()
var d := m.to_dict()
d.erase("install_id")
m.from_dict(d)
assert_eq(m.install_id, "")
  • Step 2: run -gselect=test_meta_install_id → FAIL.
  • Step 3: In sim/meta_state.gd add var install_id: String = "" # anonymous random id for remote config (spec 2026-09-27 §3.3); generated render-side by main.gd, add "install_id": install_id to the to_dict() dict, and in from_dict() (after the schema-compat reset branch, alongside the other field reads) install_id = String(d.get("install_id", "")). Additive field, no schema bump (same as selected_ship).
  • Step 4: PASS. Step 5: gate; commit feat(meta): persist anonymous install_id; push.

Files: Modify main.gd, input/replay_recorder.gd; Test tests/test_remote_config_wiring.gd.

Interfaces: Consumes Tasks 1-4. ReplayRecorder.new(seed_v: int, config: Dictionary = {}), saved JSON gains "config".

  • Step 1: Failing test (proves the value reaches the sim through the real run-start order):
extends GutTest
const FX := TunablesFixture.ENTRIES
func after_each() -> void:
Tunables.reset()
Tunables.reset(FX)
RemoteConfig.active_patch = {}
func test_apply_for_run_sets_active_and_stamps() -> void:
var rc := RemoteConfig.new()
rc.state.store = RefCounted.new() # never touched: override path below
var patch := {"config_id": "b36-t1", "tunables": {}, "bible": {"weapons.blade.base_damage": 5.0}}
rc.apply_for_run(patch)
assert_eq(RemoteConfig.active_id, "b36-t1")
assert_eq(float(GameContent.load_db(RemoteConfig.active_bible()).weapon("blade")["base_damage"]), 5.0)
rc.free()
func test_replay_records_config() -> void:
var r := ReplayRecorder.new(7, {"config_id": "b36-t1"})
var path := "user://_test_replay_cfg.json"
r.save(path)
var d := ReplayLoader.load_file(path)
assert_eq(d.get("config", {}).get("config_id"), "b36-t1")
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
  • Step 2: run → FAIL (ReplayRecorder.new arity / no config).
  • Step 3: Implement.
    • input/replay_recorder.gd: add var config: Dictionary = {}; _init(seed_v: int, config_v: Dictionary = {}) sets both; save() writes {"seed": seed_value, "config": config, "ticks": ticks}.
    • main.gd:
      1. Near the other persistent nodes in _ready() (after meta = MetaStore.load_state() at ~line 335):
        if meta.install_id == "":
        meta.install_id = "%08x%08x%08x%08x" % [randi(), randi(), randi(), randi()]
        MetaStore.save_state(meta)
        remote_config = RemoteConfig.new() # persistent: fetches once per boot (spec 2026-09-27)
        add_child(remote_config)
        remote_config.setup(Sim_Const.BUILD, meta.install_id)
        remote_config.changed.connect(func() -> void: _menu_content_cache = null)
        and declare var remote_config: RemoteConfig with the other node fields.
      2. _new_run() (~line 851): replace var content := ContentLoader.load_from_path("res://data/bible.json") with
        # Remote config (spec 2026-09-27): tunables + bible overlay applied BEFORE Sim.new(), never mid-run.
        # A replay re-applies the patch it was recorded under.
        remote_config.apply_for_run(_replay_play.get("config") if not _replay_play.is_empty() else null)
        var content := GameContent.load_db(RemoteConfig.active_bible())
        Keep the existing if content == null: error branch (update its message to GameContent.BIBLE_PATH).
      3. _replay_rec = ReplayRecorder.new(run_seed, RemoteConfig.active_patch) if RECORD_REPLAY else null.
      4. Right after sim = Sim.new(...) add remote_config.mark_run_started(), and a field var _config_stable_marked := false reset to false there.
      5. In _process(delta) (line ~1606), where sim is non-null: if not _config_stable_marked and sim.run_time >= RemoteConfig.STABLE_AFTER_S: _config_stable_marked = true; remote_config.mark_run_stable().
      6. _menu_defs() (~line 762): _menu_content_cache = GameContent.load_db(remote_config.next_bible_overlay()).
      7. Codex (~line 350): leave on ContentLoader.load_from_path (bestiary text; numbers there are display-only and not in scope). Add a one-line comment saying so.
  • Step 4: run the test → PASS; boot smoke godot --headless --path . --quit-after 600 2>&1 | grep "SCRIPT ERROR" → nothing.
  • Step 5: gate + scripts/soak.sh --runs=2 --ticks=18000 clean; commit feat(remote-config): apply at run start, menu prices, crash guard, replay config; push.

Files: Modify telemetry/src/worker.js, telemetry/wrangler.toml; Create telemetry/package.json, telemetry/test/config.test.mjs.

Interfaces: Produces GET /config?build=N&install=ID → JSON patch ({} if none) with Cache-Control: public, max-age=300; exported resolveConfig(kv, build) -> Promise<string>; KV key format build-<N>.

  • Step 1: Failing test telemetry/test/config.test.mjs:
import { test } from "node:test";
import assert from "node:assert/strict";
import worker, { resolveConfig } from "../src/worker.js";
const kv = (m) => ({
get: async (k) => m[k] ?? null,
list: async ({ prefix }) => ({ keys: Object.keys(m).filter((k) => k.startsWith(prefix)).map((name) => ({ name })) }),
});
test("exact build wins", async () => {
assert.equal(await resolveConfig(kv({ "build-36": '{"config_id":"a"}', "build-35": '{"config_id":"b"}' }), 36), '{"config_id":"a"}');
});
test("falls back to nearest OLDER build, never newer", async () => {
const m = { "build-30": '{"config_id":"old"}', "build-40": '{"config_id":"new"}' };
assert.equal(await resolveConfig(kv(m), 36), '{"config_id":"old"}');
assert.equal(await resolveConfig(kv(m), 29), "{}");
});
test("no KV / bad build -> empty patch", async () => {
assert.equal(await resolveConfig(undefined, 36), "{}");
assert.equal(await resolveConfig(kv({}), 0), "{}");
});
test("route returns JSON + short cache", async () => {
const res = await worker.fetch(new Request("https://x/config?build=36&install=abc"), { CONFIG: kv({ "build-36": '{"config_id":"a"}' }) });
assert.equal(res.status, 200);
assert.equal(await res.text(), '{"config_id":"a"}');
assert.match(res.headers.get("Cache-Control"), /max-age=300/);
});

telemetry/package.json: { "private": true, "type": "module", "scripts": { "test": "node --test test/" } }

  • Step 2: cd telemetry && npm test → FAIL (resolveConfig not exported).
  • Step 3: Implement in worker.js (add GET /config to the header comment list):
// Remote config (spec docs/superpowers/specs/2026-09-27-remote-config-design.md §3.4): the patch for
// this exact build, else the nearest OLDER build's, else {}. `install` is accepted for future A/B
// cohorts and ignored for now.
export async function resolveConfig(kv, build) {
if (!kv || !(build > 0)) return "{}";
const exact = await kv.get(`build-${build}`);
if (exact) return exact;
const { keys } = await kv.list({ prefix: "build-" });
let best = -1;
for (const k of keys) {
const n = parseInt(k.name.slice(6), 10);
if (Number.isFinite(n) && n < build && n > best) best = n;
}
return best < 0 ? "{}" : ((await kv.get(`build-${best}`)) || "{}");
}

and inside fetch, before the /stats.json block:

if (req.method === "GET" && url.pathname === "/config") {
const body = await resolveConfig(env.CONFIG, intt(url.searchParams.get("build")));
return new Response(body, { headers: { ...CORS, "Content-Type": "application/json", "Cache-Control": "public, max-age=300" } });
}
  • Step 4: npm test → PASS.
  • Step 5: Create KV + deploy (from telemetry/): CLOUDFLARE_ACCOUNT_ID=06f5622d4192dcefefbe0da75d766bfc npx wrangler kv namespace create CONFIG → add the printed block to wrangler.toml:
    [[kv_namespaces]]
    binding = "CONFIG"
    id = "<id printed by wrangler>"
    then npx wrangler deploy. Verify: curl -s "https://bullet-heaven-telemetry.chris-allen-06f.workers.dev/config?build=36" → {}, and curl -sI … shows max-age=300.
  • Step 6: commit feat(telemetry): /config route backed by KV; push.

Files: Modify net/telemetry.gd (sample body ~line 120-135), net/gameplay_telemetry.gd (report_run body, feedback_body), net/dash_encounter_telemetry.gd (body ~line 103), telemetry/src/worker.js (4 INSERTs), telemetry/schema.sql; Test tests/test_config_stamp.gd, telemetry/test/stamp.test.mjs.

  • Step 1: Failing GUT test
extends GutTest
func after_each() -> void:
RemoteConfig.active_id = "default"
func test_feedback_body_carries_config_id() -> void:
RemoteConfig.active_id = "b36-t9"
var b := GameplayTelemetry.feedback_body("bug", "note", "s", "dev", null, "pad")
assert_eq(b["config_id"], "b36-t9")

and telemetry/test/stamp.test.mjs (fake D1 capturing binds):

import { test } from "node:test";
import assert from "node:assert/strict";
import worker from "../src/worker.js";
function fakeDB(sink) {
return { prepare: (sql) => ({ bind: (...a) => ({ run: async () => { sink.push({ sql, a }); } }) }) };
}
for (const [path, body] of [
["/sample", { build: 36, config_id: "b36-x" }],
["/gameplay", { build: 36, config_id: "b36-x" }],
["/encounter", { build: 36, config_id: "b36-x" }],
["/feedback", { build: 36, note: "hello", config_id: "b36-x" }],
]) {
test(`${path} stores config_id`, async () => {
const sink = [];
const res = await worker.fetch(new Request("https://x" + path, { method: "POST", body: JSON.stringify(body) }), { DB: fakeDB(sink) });
assert.equal(res.status, 200);
const ins = sink.find((s) => /INSERT/.test(s.sql));
assert.match(ins.sql, /config_id/);
assert.ok(ins.a.includes("b36-x"));
});
}
  • Step 2: both fail.
  • Step 3: Implement.
    • Add "config_id": RemoteConfig.active_id, to: the /sample body in net/telemetry.gd, the report_run body and feedback_body return dict in net/gameplay_telemetry.gd, and the encounter body in net/dash_encounter_telemetry.gd. (Hitch batches: not stamped, they join samples by session.)
    • worker.js: append config_id to each of the 4 INSERT column lists, one more ?, and bind str(s.config_id, 40) || null last. Read the /feedback INSERT first (starts ~line 130) and extend it the same way.
    • schema.sql: add config_id TEXT to the 4 CREATE TABLEs and a dated migration comment block:
      -- 2026-09-27 remote config:
      -- ALTER TABLE samples ADD COLUMN config_id TEXT;
      -- ALTER TABLE runs ADD COLUMN config_id TEXT;
      -- ALTER TABLE encounters ADD COLUMN config_id TEXT;
      -- ALTER TABLE feedback ADD COLUMN config_id TEXT;
  • Step 4: GUT -gselect=test_config_stamp + npm test → PASS.
  • Step 5: Migrate D1 BEFORE deploying (a deployed INSERT naming a missing column would fail every write): write the 4 ALTERs to /tmp/rc-migration.sql, npx wrangler d1 execute bullet-heaven-telemetry --remote --file /tmp/rc-migration.sql, confirm with PRAGMA table_info(runs) (grep config_id), then npx wrangler deploy. Smoke: POST a /feedback with session:"smoketest", confirm the row has config_id, delete it (bh-telemetry skill lines 160-165).
  • Step 6: gate; commit feat(telemetry): stamp config_id on samples, runs, encounters, feedback; push.

Task 8: Manifest tool + validated publish script

Section titled “Task 8: Manifest tool + validated publish script”

Files: Create tools/remote_config/dump_manifest.gd, scripts/publish-config.mjs, scripts/test/publish-config.test.mjs, remote-config/README.md; Modify .claude/skills/bh-appstore-release/SKILL.md (one step; gitignored local skill, edit in place in the main checkout) and scripts/release/release.sh (call the dump after the BUILD bump).

Interfaces: Manifest JSON remote-config/manifest/build-<N>.json = {"build": N, "tunables": {key: {"default": v, "type": "int"|"float", "min": x, "max": y}}, "bible": {key: {"default": v, "min": x, "max": y}}}. node scripts/publish-config.mjs <N> [--dry-run].

  • Step 1: Failing test scripts/test/publish-config.test.mjs:
import { test } from "node:test";
import assert from "node:assert/strict";
import { validate } from "../publish-config.mjs";
const manifest = {
tunables: { "Sim.BOSS_INTERVAL": { default: 80, type: "float", min: 8, max: 800 },
"Sim.BIOMASS_TARGET": { default: 400, type: "int", min: 40, max: 4000 } },
bible: { "weapons.blade.base_damage": { default: 3.9, min: 0.39, max: 39 } },
};
const ok = { config_id: "b36-001", note: "x", tunables: { "Sim.BOSS_INTERVAL": 90 }, bible: { "weapons.blade.base_damage": 4.2 } };
test("valid patch passes", () => assert.deepEqual(validate(ok, manifest), []));
test("empty patch (kill switch) passes", () => assert.deepEqual(validate({}, manifest), []));
test("rejects missing config_id, unknown keys, bounds, non-int", () => {
assert.equal(validate({ ...ok, config_id: "" }, manifest).length, 1);
assert.equal(validate({ ...ok, tunables: { "Sim.NOPE": 1 } }, manifest).length, 1);
assert.equal(validate({ ...ok, tunables: { "Sim.BOSS_INTERVAL": 900 } }, manifest).length, 1);
assert.equal(validate({ ...ok, tunables: { "Sim.BIOMASS_TARGET": 12.5 } }, manifest).length, 1);
assert.equal(validate({ ...ok, bible: { "weapons.blade.base_damage": "x" } }, manifest).length, 1);
});
  • Step 2: node --test scripts/test/ → FAIL.
  • Step 3: Implement.

tools/remote_config/dump_manifest.gd:

extends SceneTree
# Writes remote-config/manifest/build-<BUILD>.json: every knob THIS build accepts (spec §4).
# Run: godot --headless --path . -s res://tools/remote_config/dump_manifest.gd
func _init() -> void:
Tunables.reset()
var tun := {}
for e in TunablesRegistry.ENTRIES:
var d: Variant = Tunables.default_of(String(e["key"]))
var b := Tunables.bounds(e)
tun[e["key"]] = {"default": d, "type": "int" if typeof(d) == TYPE_INT else "float",
"min": null if is_nan(b.x) else b.x, "max": null if is_nan(b.y) else b.y}
var bib := {}
var fields := BibleOverlay.overlayable_fields(ContentLoader.read_raw(GameContent.BIBLE_PATH))
for k in fields:
var d := float(fields[k])
bib[k] = {"default": d, "min": minf(d * 0.1, d * 10.0), "max": maxf(d * 0.1, d * 10.0)}
var out := {"build": Sim_Const.BUILD, "tunables": tun, "bible": bib}
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("res://remote-config/manifest"))
var path := "res://remote-config/manifest/build-%d.json" % Sim_Const.BUILD
var f := FileAccess.open(path, FileAccess.WRITE)
f.store_string(JSON.stringify(out, " ", true))
f.close()
print("manifest -> %s (%d tunables, %d bible fields)" % [path, tun.size(), bib.size()])
quit()

scripts/publish-config.mjs:

#!/usr/bin/env node
// Validate remote-config/build-<N>.json against remote-config/manifest/build-<N>.json, then upload it
// to the telemetry Worker's KV as build-<N> (spec §4). Usage: node scripts/publish-config.mjs <N> [--dry-run]
import { readFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import path from "node:path";
const isNum = (v) => typeof v === "number" && Number.isFinite(v);
export function validate(patch, manifest) {
const p = [];
if (Object.keys(patch).length === 0) return p; // kill switch
if (typeof patch.config_id !== "string" || patch.config_id === "") p.push("config_id missing");
for (const [sect, table] of [["tunables", manifest.tunables], ["bible", manifest.bible]]) {
for (const [k, v] of Object.entries(patch[sect] || {})) {
const m = table[k];
if (!m) { p.push(`${sect}: unknown key ${k}`); continue; }
if (!isNum(v)) { p.push(`${sect}: ${k} not a number`); continue; }
if (m.min == null || v < m.min || v > m.max) { p.push(`${sect}: ${k}=${v} outside [${m.min}, ${m.max}]`); continue; }
if (m.type === "int" && !Number.isInteger(v)) p.push(`${sect}: ${k} must be a whole number`);
}
}
return p;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const n = parseInt(process.argv[2], 10);
if (!(n > 0)) { console.error("usage: publish-config.mjs <build> [--dry-run]"); process.exit(2); }
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const patchPath = path.join(root, `remote-config/build-${n}.json`);
const patch = JSON.parse(readFileSync(patchPath, "utf8"));
const manifest = JSON.parse(readFileSync(path.join(root, `remote-config/manifest/build-${n}.json`), "utf8"));
const problems = validate(patch, manifest);
if (problems.length) { console.error("REFUSED:\n - " + problems.join("\n - ")); process.exit(1); }
console.log(`ok: ${patch.config_id || "(kill switch)"} for build ${n}`);
if (process.argv.includes("--dry-run")) process.exit(0);
execFileSync("npx", ["wrangler", "kv", "key", "put", "--binding=CONFIG", "--remote", `build-${n}`, "--path", patchPath],
{ cwd: path.join(root, "telemetry"), stdio: "inherit",
env: { ...process.env, CLOUDFLARE_ACCOUNT_ID: "06f5622d4192dcefefbe0da75d766bfc" } });
console.log(`published build-${n}; live within ~5 min (max-age=300)`);
}

(scripts/test/ needs {"type":"module"}: name the files .mjs as above so no package.json is needed.)

remote-config/README.md: how to write a patch (shape, config_id convention b<build>-NNN, note field), generate the manifest, --dry-run then publish, soak it first with --config (Task 9), rollback = git revert + republish, kill switch = {}, and that a patch for build N also serves newer builds with no patch of their own.

scripts/release/release.sh: after the step that bumps Sim_Const.BUILD, run godot --headless --path . -s res://tools/remote_config/dump_manifest.gd and git add remote-config/manifest/. bh-appstore-release SKILL.md: one line in the build-bump section pointing at this.

  • Step 4: node --test scripts/test/ → PASS; run the dump tool → file written, bible field count > 0.
  • Step 5: commit feat(remote-config): manifest tool + validated publish script; push.

Files: Modify tools/soak/soak_runner.gd (arg parse ~line 12-30 and where each run builds its Sim), its header usage comment.

  • Step 1: Add to the usage comment # --config=path.json apply a remote-config patch (tunables + bible) to every run.
  • Step 2: After args parse:
var patch: Dictionary = {}
if args.has("config"):
patch = JSON.parse_string(FileAccess.get_file_as_string(String(args["config"])))
for p in Tunables.apply(patch.get("tunables", {})):
push_error("soak --config: " + String(p))
content = GameContent.load_db(patch.get("bible", {}))

(replacing the plain ContentLoader.load_from_path result when --config is given; place it after the existing var content := … line). Include "config_id": patch.get("config_id", "default") in the report’s top-level JSON.

  • Step 3: Verify: write /tmp/rc-test.json = {"config_id":"t","tunables":{},"bible":{"weapons.blade.base_damage":7.0}}, run scripts/soak.sh --runs=1 --ticks=3600 --config=/tmp/rc-test.json → report shows config_id: t, no errors. (Confirm scripts/soak.sh passes unknown --x=y through to the runner; if not, add --config to its pass-through list.)
  • Step 4: commit feat(soak): --config to test a remote-config patch; push.

Task 10: Conversion batch 1 (wave/boss/elite pacing + Fork split)

Section titled “Task 10: Conversion batch 1 (wave/boss/elite pacing + Fork split)”

Files: Modify sim/sim.gd, sim/elemental_system.gd, net/tunables_registry.gd; Create tests/test_tunables_registry.gd, tests/test_remote_config_sim_wiring.gd.

Recipe (deterministic): for each row, change the declaration’s leading const to static var (keep name, type annotation, value, comment exactly), and add a registry entry {"key": "<Class>.<NAME>", "script": "<path>", "name": "<NAME>"}. Verified 2026-09-27: none of these is referenced by another const, in a match pattern, or through an instance path other than Sim./ElementalSystem./bare name; static vars also work as default args and via instance access (probed on 4.7.1). Only the per-hit Fork read is inside a combat loop; it’s one read per split hit, not per entity, so no hoisting needed.

Key File Type Bundled value
Sim.BOSS_FIRST_TIME res://sim/sim.gd float 210.0
Sim.BOSS_INTERVAL sim.gd float 80.0
Sim.WAVE_PHASE_S sim.gd float 180.0
Sim.BOSS_PREP_S sim.gd float 10.0
Sim.BOSS_REST_S sim.gd float 10.0
Sim.BIOMASS_TRIGGER sim.gd int 100
Sim.BIOMASS_TARGET sim.gd int 400
Sim.WAVE_INCOMING_S sim.gd float 2.0
Sim.MAX_ALIVE_BRUTES sim.gd int 10
Sim.ELITE_WARDEN_TIME sim.gd float 90.0
Sim.ELITE_BOSS2_TIME sim.gd float 210.0
Sim.ELITE_WARDEN_BIOMASS sim.gd int 110
Sim.ELITE_BOSS2_BIOMASS sim.gd int 100
Sim.SWARM_BURST_TIME sim.gd float 180.0
Sim.SWARM_BURST_COUNT sim.gd int 70
Sim.BOSS_ADDS_DELAY_S sim.gd float 4.0
Sim.BOSS_ADD_RATE sim.gd float 1.0
Sim.LEECH_CAP_FRAC_PER_S sim.gd float 0.08
ElementalSystem.SPLIT_DMG_FRAC res://sim/elemental_system.gd float 0.5 (add "min": 0.05, "max": 1.0)
ElementalSystem.SPLIT_BOSS_DMG_FRAC elemental_system.gd float 0.25 (add "min": 0.0, "max": 1.0)
  • Step 1: Failing tests.

tests/test_tunables_registry.gd:

extends GutTest
# Registry <-> sim lockstep: every static var in sim/*.gd is either a registered tunable or listed as
# known runtime state, and every registry entry names a real static var.
func _sim_static_vars() -> Array:
var out: Array = []
var re := RegEx.create_from_string("(?m)^static var ([A-Za-z_][A-Za-z0-9_]*)")
for f in DirAccess.get_files_at("res://sim"):
if not f.ends_with(".gd"):
continue
var path := "res://sim/" + f
for m in re.search_all(FileAccess.get_file_as_string(path)):
out.append("%s:%s" % [path, m.get_string(1)])
return out
func test_every_sim_static_var_is_accounted_for() -> void:
var registered := {}
for e in TunablesRegistry.ENTRIES:
registered["%s:%s" % [e["script"], e["name"]]] = true
for sv in _sim_static_vars():
assert_true(registered.has(sv) or TunablesRegistry.NON_TUNABLE_STATIC_VARS.has(sv),
"%s is neither registered nor listed as non-tunable" % sv)
func test_every_entry_is_a_real_static_var() -> void:
var real := _sim_static_vars()
for e in TunablesRegistry.ENTRIES:
assert_true(real.has("%s:%s" % [e["script"], e["name"]]), "%s has no static var" % e["key"])
assert_true(String(e["key"]).ends_with("." + String(e["name"])))
func test_every_entry_has_a_usable_range() -> void:
Tunables.reset()
for e in TunablesRegistry.ENTRIES:
assert_false(is_nan(Tunables.bounds(e).x), "%s needs explicit min/max" % e["key"])

tests/test_remote_config_sim_wiring.gd:

extends GutTest
# Proves a tunable reaches the sim through the real path, and that reset restores the pinned baseline
# (spec §8). BIOMASS_TARGET is used because the first wave refill lands inside 600 ticks.
func after_each() -> void:
Tunables.reset()
func _run() -> int:
var s := Sim.new(1234, SimContentFixture.db())
s.enable_survival()
for i in range(600):
var dir := Vector2(cos(float(i) * 0.05), sin(float(i) * 0.03))
s.tick_single(InputState.new(dir.normalized() if dir.length() > 0.0 else dir))
return s.state_checksum()
func test_patch_changes_sim_and_reset_restores_baseline() -> void:
var base := _run()
assert_eq(base, 3312602286, "defaults = pinned survival baseline")
assert_eq(Tunables.apply({"Sim.BIOMASS_TARGET": 200.0}), [])
assert_ne(_run(), base, "patched value reaches the sim")
Tunables.reset()
assert_eq(_run(), base, "reset restores the baseline")
  • Step 2: Run both → FAIL (unknown tunable / registry empty). If, once batch 1 is in, BIOMASS_TARGET=200 turns out NOT to move the 600-tick checksum (the plan author expects the first refill inside 10 s but did not run it), switch the test to Sim.BIOMASS_TRIGGER or Sim.WAVE_INCOMING_S rather than lengthening the run.
  • Step 3: Apply the recipe to all 20 rows; fill TunablesRegistry.ENTRIES.
  • Step 4: Full gate. The two pinned determinism tests must pass unchanged; if either moves, a value or type was altered: diff and fix, never re-pin. Then scripts/soak.sh --runs=2 --ticks=18000 clean.
  • Step 5: Frame-time check — bh-deploy a dev build to the Apple TV 4K, play one 5-minute Survival run, compare samples.frame_ms p95 for this BUILD vs the previous one in D1 (bh-telemetry skill). Expect no measurable change (batch 1 has no per-entity reads).
  • Step 6: commit feat(remote-config): batch 1 tunables (wave/boss/elite pacing, Fork split); push.

Files: Create docs/architecture/remote-config.md (with title: frontmatter); Modify CLAUDE.md (one table row), docs/RELEASE-0.1.md (tick infra, list remaining batches), NEXT_TASKS.md.

  • Step 1: docs/architecture/remote-config.md: data flow (fetch → cache → apply at run start → stamp), how to convert a const (the recipe + hot-loop hoisting rule + “never referenced by another const / match”), how to publish (points at remote-config/README.md), the crash guard, the A/B-later note, and the privacy-label todo.
  • Step 2: CLAUDE.md “Subsystem architecture” table: add a row | Remote config (tunables, bible overlay, /config Worker route, publishing a balance patch) | docs/architecture/remote-config.md |.
  • Step 3: docs/RELEASE-0.1.md remote-config item: tick infra + batch 1 with commit hashes; list next batches (bosses, elites/elite_kit, enemy behaviours/attacks, drone director, remaining sim.gd) as follow-on plans. Add the privacy-label check to §1 release gates.
  • Step 4: commit docs(remote-config): architecture + release bookkeeping; push.