Skip to content

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

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.json is 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 const in sim/*.gd: 902 consts, ~790 of them numeric literals. ~88 are id/enum consts (BEHAVIOR_*, RULESET_*), some appear in match patterns, and pool caps (ENEMY_CAP etc.) size arrays. Those stay const.
  • net/telemetry.gd already talks to the Worker (bullet-heaven-telemetry…workers.dev) via HTTPRequest. Telemetry carries a per-launch random session; there is no persistent install id.
  • Probe (stock Godot 4.7.1, headless): a GDScript static var can 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.
  1. 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).
  2. 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.
  3. 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.
  4. apply(patch): first resets every registered tunable to its bundled default, then applies the patch’s tunables entries. Unknown key → skip + log. Wrong type or out of bounds → skip + log. Never cumulative across runs or patch changes.
  5. config_hash(): a stable hash of the effective tunable values plus the bible overlay, for replay/co-op mismatch checks.
  6. Called only at run start, before Sim is constructed (the same place MetaRunSetup.apply sits in main.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.

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.

  • 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.json as 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 in user://. 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.
  • 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 install now and ignores it (§6).
  • Short cache headers (for example max-age=300) so a rollback lands within minutes.

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.

  • 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 Tunables registry and every overlayable bible field (name, type, default, bounds) to remote-config/manifest/build-<N>.json. Generating it is a step in bh-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.
  • Validation happens twice (publish script + client). A typo like 1500 for 15.0 is caught by the bounds check.
  • Crash-loop guard: before the first run under a new config_id, write a marker to user://, and clear it after 30 s of stable play. If the marker is still there at boot, blacklist that config_id locally 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.

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.

  1. Infra: Tunables, bible overlay, fetcher + cache, install id, stamping, crash-loop guard, Worker route + KV, manifest tool, publish script, tests.
  2. 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.
  3. 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.
  • 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 var in sim/*.gd is registered and vice versa.
  • Worker: build fallback chain, empty-patch default, cache header.
  • Publish script: rejects unknown keys, wrong types, out-of-bounds values.