Remote config (design)
Remote config — design
Section titled “Remote config — design”Date: 2026-09-27 · Status: approved in brainstorm (Chris), awaiting spec review
Release item: docs/RELEASE-0.1.md §2, “Remote config” (Chris-approved exception to the
feature freeze: the client half must be in the launch binary or it costs a build + review to add
later, and launch is exactly when fast balance changes matter).
1. Goal
Section titled “1. Goal”Change game balance (weapon/enemy stats, shop prices, sim tuning consts) for players already on a shipped build, without an App Store build or review. Numbers only: no code, no new features, which keeps it inside App Store guideline 2.5.2 (no downloaded code that changes functionality).
Non-goals for v0.1: A/B cohorts (designed-for, built later on the Worker only, §6), an admin web
UI, patch signing, mid-run changes, any downloaded scripts or .pck code.
2. What exists today (verified 2026-09-27)
Section titled “2. What exists today (verified 2026-09-27)”data/bible.jsonis parsed at every run start (main.gd→ContentLoader→ContentDB). It holds 16 weapons, 31 enemies and all 66 shop items (base_cost,cost_growth,magnitude).- Everything else is
constinsim/*.gd: 902 consts, ~790 of them numeric literals. ~88 are id/enum consts (BEHAVIOR_*,RULESET_*), some appear inmatchpatterns, and pool caps (ENEMY_CAPetc.) size arrays. Those stayconst. net/telemetry.gdalready talks to the Worker (bullet-heaven-telemetry…workers.dev) viaHTTPRequest. Telemetry carries a per-launch randomsession; there is no persistent install id.- Probe (stock Godot 4.7.1, headless): a GDScript
static varcan be set by name at runtime via(load(path) as GDScript).set("NAME", v)and every reader sees it. Cross-class static-var reads cost ~7× an inlined literal (~0.04 µs extra per read on an M5). Negligible per call, not negligible per entity in a 6,000-enemy loop, hence rule §3.1.3. - Weakest supported device: the tvOS minimum is now 27.0, which drops the A8 Apple TV HD, so the floor is the A12 Apple TV 4K (2nd gen). Frame-time checks target that.
3. Architecture
Section titled “3. Architecture”3.1 Tunables (render side, outside /sim)
Section titled “3.1 Tunables (render side, outside /sim)”- A registry of every converted tunable: script path, name, type (
int/float), bundled default, and bounds (min/max, default 0.1×–10× the bundled value, overridable per entry; for a bundled default of 0 the bounds must be set explicitly). - Conversion of a const is:
const X: float = 1.0→static var X: float = 1.0, plus one registry line. Call sites don’t change. - Hot-loop rule: a converted tunable read inside a per-entity loop is hoisted into a local once before the loop. The conversion batch that touches a module does this hoisting.
apply(patch): first resets every registered tunable to its bundled default, then applies the patch’stunablesentries. Unknown key → skip + log. Wrong type or out of bounds → skip + log. Never cumulative across runs or patch changes.config_hash(): a stable hash of the effective tunable values plus the bible overlay, for replay/co-op mismatch checks.- Called only at run start, before
Simis constructed (the same placeMetaRunSetup.applysits inmain.gd’s_new_run, soak and the economy sim). Nothing changes mid-run.
/sim stays pure: sim files only declare the static vars. All loading, fetching and setting
lives in net/ / a render-side autoload.
3.2 Bible overlay
Section titled “3.2 Bible overlay”The same patch carries a bible section keyed category.id.field (for example
weapons.fork.base_damage, meta_upgrades.vitality.base_cost). It is merged into the parsed bible
dictionary before ContentDB is built. Same validation as tunables: the id and field must exist,
types must match, and values must be within bounds (0.1×–10× by default). Adding or removing
entries is not allowed; only existing numeric fields can change.
3.3 RemoteConfig fetcher
Section titled “3.3 RemoteConfig fetcher”- At boot: async
GET /config?build=<BUILD>&install=<install_id>. Never blocks boot or the menu. - On success: validate, then store in
user://remote_config.jsonas the last good patch. - Offline / error / timeout: use the cached patch, else an empty patch (= bundled defaults).
- A response that arrives mid-run is held and applied at the next run start.
install_id: random 128-bit hex generated on first launch, persisted inuser://. Used only for config requests (future cohorting) and stamped where §3.5 says. Not linked to identity. ⚠️ Check whether the App Store privacy label needs “Identifiers → Device ID, Analytics, not linked” before the next submission.
3.4 Worker /config route
Section titled “3.4 Worker /config route”- New route in
telemetry/src/worker.js, patches stored in a new KV namespace. - Lookup: exact build → nearest older build that has a patch → empty patch
{}. A patch written for build 40 can therefore never reach build 36, which may lack its tunables. - Accepts
installnow and ignores it (§6). - Short cache headers (for example
max-age=300) so a rollback lands within minutes.
3.5 Stamping
Section titled “3.5 Stamping”Every patch has a config_id (for example b36-003). The effective config_id (or default)
and config_hash are stamped into every telemetry sample, gameplay/dash telemetry, the replay
header and the run record. D1 queries can then separate results by config. Replays record the
patch so an input replay reproduces under the same values.
4. Publishing
Section titled “4. Publishing”- Patches in git:
remote-config/build-<N>.json→{ "config_id": "...", "note": "...", "tunables": { "Sim.BOSS_INTERVAL": 90.0 }, "bible": { "weapons.fork.base_damage": 3.0 } }. - Manifest: a headless Godot tool dumps the
Tunablesregistry and every overlayable bible field (name, type, default, bounds) toremote-config/manifest/build-<N>.json. Generating it is a step inbh-appstore-release, so every shipped build has a record of exactly which knobs it has. scripts/publish-config.sh <N>: validates the patch against that build’s manifest (keys exist, types, bounds), then uploads it to KV. It refuses to publish an invalid patch.- Rollback =
git revert+ republish. Kill switch = publish{}→ everyone gets bundled defaults.
5. Safety
Section titled “5. Safety”- Validation happens twice (publish script + client). A typo like
1500for15.0is caught by the bounds check. - Crash-loop guard: before the first run under a new
config_id, write a marker touser://, and clear it after 30 s of stable play. If the marker is still there at boot, blacklist thatconfig_idlocally and use defaults (and report it via telemetry). - HTTPS only; no signing (Worker is ours, content is bounded numbers). Revisit if the config ever carries anything more than that.
- Determinism: pinned baselines and GUT never load a remote patch, so bundled defaults = today’s values and the checksum is untouched.
6. A/B later (designed-for, not built)
Section titled “6. A/B later (designed-for, not built)”Because the client already sends a stable install_id and stamps whatever config_id it got, A/B
is a Worker-only change: hash install_id into a cohort, serve that cohort’s patch, and the D1
queries split by config_id. No app build needed.
7. Rollout: infra first, then batches
Section titled “7. Rollout: infra first, then batches”- Infra:
Tunables, bible overlay, fetcher + cache, install id, stamping, crash-loop guard, Worker route + KV, manifest tool, publish script, tests. - Conversion batches, by module, most-likely-to-tune first: spawn director / wave pacing
(
sim.gd’s wave/boss/elite timing consts), Fork/Penetrator and weapon-rule consts, bosses, elites, enemy behaviours/attacks, then the rest. Launch ships with however far this has got; the rest still needs a build, as today. - Each batch’s gate: determinism checksum byte-identical (a pure const → static var swap must
not change it) →
scripts/gate.sh+ soak → frame-time check on the Apple TV 4K (A12) against the pre-batch build.
8. Testing
Section titled “8. Testing”- GUT:
Tunables.apply(types, unknown keys, bounds, reset-to-defaults, non-cumulative); bible overlay merge + rejection of added/removed entries; fetcher cache + offline fallback + mid-run deferral; crash-loop guard; install id persistence. - Wiring test: apply a patch that changes one tunable → the sim checksum differs from the pinned baseline; reset → the baseline returns. This proves the value reaches the sim through the real path (not a test that sets the state by hand; see the story-mode XP bug).
- Registry completeness test: every
static varinsim/*.gdis registered and vice versa. - Worker: build fallback chain, empty-patch default, cache header.
- Publish script: rejects unknown keys, wrong types, out-of-bounds values.