Bullet Heaven — remote config
Bullet Heaven — remote config
Section titled “Bullet Heaven — remote config”Written 2026-09-27 at the end of the remote-config SDD build (spec
docs/superpowers/specs/2026-09-27-remote-config-design.md, ledger.superpowers/sdd/2026-09-27-remote-config/progress.md). This is the current architecture reference for this system, not a changelog — keep it current when you change the code. SeeCLAUDE.md§ “Subsystem architecture — read on demand”.
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 downloaded code, so it
stays inside App Store guideline 2.5.2. A patch can also replace sound effects with downloaded WAV
samples (data, not code): see § Sounds — remote sound packs. Publishing a patch: remote-config/README.md.
Data flow
Section titled “Data flow”boot/menu/resume/run start run start during the run────────────────────── ───────────────────── ───────────────RemoteConfig.setup()/ main.gd._new_run(): nothing — the patch .refresh() fetches remote_config.apply_for_run() never changes again GET /config?build=… → Tunables.apply(patch.tunables) until the NEXT run → validate → cache in → GameContent.load_db(bible) start. user://remote_config.json → Sim.new(content, …) (last-good patch, survives remote_config.mark_run_started() offline/error/timeout) (arms the crash marker if unproven) ...30s later... remote_config.mark_run_stable() (clears the marker, proves the id)
after each fetch (live builds) run start────────────────────────────── ─────────────────────RemoteSounds.sync(next patch) apply_for_run (records the run's patch) prune unreferenced blobs mark_run_started (crash marker) GET /sfx/<sha>.wav, 1 at a main: audio.set_overrides(remote_config.sound_overrides()) time → verify → cache (hash + parse cached blobs now; replaced, never merged)- Fetch happens at boot, on every return to menu, at the top of every
_new_run()(beforeapply_for_run), and onNOTIFICATION_APPLICATION_RESUMED/FOCUS_IN, all throttled toRemoteConfig.REFRESH_MIN_S(5 min,net/remote_config.gd). The run-start and resume refreshes exist so a patch or rollback reaches a player who chains runs (Redeploy / Climb again never pass through the menu) or only ever suspends the app. The response is async, so the run-start refresh only ever affects the next run. It never blocks boot or the menu (asyncHTTPRequest, silent on any failure; the Worker answers a KV failure with503 no-store, and any non-200 keeps the cached patch). A response that lands mid-run is only picked up at the next run start — a balance patch never mutates a run in progress. This is deliberate: it protects the balance philosophy (power grows across runs, not mid-run), keeps replays/co-op/telemetry attribution simple (one config per run, stamped once), and matches howMetaRunSetup.applyalready applies shop state once at run start. main.gd._new_run()callsremote_config.apply_for_run(override)beforeSim.new()— the same placeMetaRunSetup.applyalready sits.overrideis a replay’s recorded patch (non-null only during replay playback); when set, the fetched/cached patch is ignored and the crash marker is never armed (a replay isn’t a fresh boot on a config nobody has run yet).apply_for_runalways resets every tunable to its bundled default first (Tunables.reset), then applies the patch’stunables— never cumulative across runs or across patch changes.- The menu shop reads
remote_config.next_bible_overlay()(the patch queued for the next run) so prices shown before a run starts match what that run will actually charge.RemoteConfig.changedfires whenever a fetch changes the next-run patch;main._on_remote_config_changeddrops the cached menu content and, if the shop is open, callsMetaShopPanel.replace_defs()so displayed price == charged price == next-run price.
Sounds — remote sound packs (2026-10-13)
Section titled “Sounds — remote sound packs (2026-10-13)”Replace shipped sound effects for players on an already-released build, no App Store resubmission.
Data only (WAV samples through the existing AudioManager chain), so still inside guideline 2.5.2.
Publishing one: remote-config/README.md § “Publishing a sound”.
- Patch shape. Optional
sounds:{ "<sound key>": { "sha256": "<64 lowercase hex>", "bytes": n } }. A key is anyAudioManager.known_sound_keys()entry:SFX_KEYS, the death pools, the per-bossspawn_<boss>/enrage_<boss>slots, and every<key>_Nround-robin variant the build ships inaudio/sfx_v2/(so everyaudio/sfx_tiers.jsonkey; music is not included). The list is written into the manifest (sounds) bytools/remote_config/dump_manifest.gd. A manifest with nosoundslist (builds before this feature) makespublish-config.mjsrefuse anysoundssection. Caps: 1 MB per file, 8 MB for the section (over it, none of it applies), both checked at publish and again on the client. A non-objectsoundsmakesRemoteConfigState.validrefuse the whole patch, the same astunables/bible; a bad single entry or unknown key is dropped and logged. - Hosting.
GET /sfx/<sha256>.wavon the telemetry Worker (serveSfxintelemetry/src/worker.js), reading KV keysfx-<sha>from the sameCONFIGnamespace as the patches (KV values go to 25 MiB, so R2 isn’t needed; thesfx-prefix never matches thebuild-fallback listing).Cache-Control: public, max-age=31536000, immutable; the path must be exactly 64 lowercase hex +.wavbefore KV is read; a miss is404 no-store(a blob uploaded a moment later is never shadowed by a cached 404); a KV error is503 no-store.publish-config.mjsuploads missing blobs BEFORE the patch, so no client sees a patch whose blob 404s. - Client download (
net/remote_sounds.gd, a child ofRemoteConfig, live builds only). After every fetch, and at setup from the cached patch,sync(next-run patch): deletes every cached blob the patch no longer references (the{}kill switch empties the cache) and downloads every blob not already on disk at its expected size, one at a time viaHTTPRequest(never blocking boot or the menu). Cached blobs are NOT hashed or parsed at boot (see run start below). Each download is verified: byte length, SHA-256 (HashingContext) andRuntimeWav.check; anything that fails is not cached and the key plays bundled. A response for a blob the current patch no longer wants is discarded. A verified blob that can’t be written (full disk, refused dir) is kept in memory for the session, so it still plays and is not re-downloaded on every fetch. Cache dir (RemoteSounds.dir_for):<OS cache dir>/sfx_remote/on iOS and tvOS (Caches: regenerable data must not sit in iCloud-backed Documents, and tvOS can’t write user://; a purge only means a re-download),user://sfx_remote/elsewhere (cache dir if user:// isn’t writable). The HTTP layer is an injectablerequesterCallable; GUT driveson_response()directly, no network. A KV 404 right after a publish (propagation lag) is not retried until the next successful/configfetch (≤ 5 min). - Run start only.
apply_for_runrecords the run’s patch;main._new_runcallsAudioManager.set_overrides(remote_config.sound_overrides())right aftermark_run_started, and that first call resolvessounds.overrides_for(patch)(key →AudioStreamWAV, only for blobs cached AND verified: hash +RuntimeWav.parsehappen here, so a blob that somehow crashes the engine does so under the crash marker and gets itsconfig_idblacklisted; a cached file that fails the check is deleted and re-downloaded at the next sync).set_overridesreplaces the map every run (never cumulative). A blob that lands mid-run, or a new patch, waits for the next run; Dev builds’ mid-run refetch / consoleapply_livenever touch sounds. A replay’s recorded patch gets its own blobs if they are still cached, else bundled. A net (LAN) run applies{}→ all bundled. - Playback.
AudioManager._stream_forreturns the override first, so the override goes through the exact same chain:cue_shapetrims/pitches, jitter, tiers → bus (bus_for_keyon the base key: boss tier stays on the Stinger bus), throttles/cooldowns, round-robin (blaster_fire_2replaces only that variant; override every variant to replace them all) and_slot_or_fallback(an override for an empty boss slot counts as its own file, so it plays instead of the shared fallback key). Bank 2 only, like every other v2 pick:SFX_BANK = 1(the console old/new A/B) plays the original files. A Dev live audition (AudioManager._audition, dev-tools.md § Live SFX audition) sits above the overrides, in both banks, andset_overridesnever touches it. A replacement for a looping bundled stream (orbit_ambience) loops over its whole length, on a per-key copy (the cached stream can also back a one-shot key, which must not loop).publish-config.mjswarns (does not refuse) when a patch replaces only part of a round-robin family. Overrides can only replace keys the build already plays; they cannot add new round-robin variants. - Loader seam.
audio/runtime_wav.gd(RuntimeWav.parse/check/loop_whole) is the bytes →AudioStreamWAVstep, shared with the Dev live audition (net/sfx_audition.gd, which also reusesRemoteSounds.verify: byte count + SHA-256 +RuntimeWav.check). It accepts only 16-bit PCM, mono/stereo, 22.05–48 kHz, ≤ 1 MB (WAVE_FORMAT_EXTENSIBLE with a PCM sub-format included) and fillsAudioStreamWAV.format/stereo/mix_rate/dataitself after its own header parse, rather than callingAudioStreamWAV.load_from_buffer(): every byte is validated before the engine sees it, and nothing depends on a 4.4+ API in the tvOS fork.publish-config.mjswavProblemmirrors the same rules. Unverified on device: the runtime path (download, cache dir fallback, playback of a runtimeAudioStreamWAV) has only run headless on macOS 4.7.1; it is unverified on the tvOS/iOS 4.8 fork until a device run with a real patch. - Telemetry. No new stamp:
config_idalready identifies the patch, and thesoundsentries live in it. Not stamped: which blobs had finished downloading when a run started (a run on a newconfig_idcan still play some keys bundled). That needs a D1 column + Worker deploy; add it if a sound A/B ever needs per-run precision.active_hashdeliberately does NOT include sounds: it is also the LAN hello’s config check, and sounds are render-side (peers may differ in download state). - Privacy.
GET /sfx/<sha>.wavcarries no install id, session or query: nothing beyond what a/configfetch already sends (IP + UA to Cloudflare), so no new kind of data, no privacy-page or ASC label change.
Keying: Sim_Const.BUILD, not the store build number
Section titled “Keying: Sim_Const.BUILD, not the store build number”Patches are keyed by Sim_Const.BUILD (sim/constants.gd) — e.g. store build 36 is BUILD 204,
so its patch is remote-config/build-204.json / KV key build-204. Publishing under the App
Store build number reaches nobody. The Worker’s /config route (resolveConfig in
telemetry/src/worker.js) looks up the exact build key, then falls back to the nearest older
build that has a patch, then {} (bundled defaults) if none exists. A patch for build 204 also
serves build 205+ until that build gets its own patch or an explicit {} — see
remote-config/README.md § “A patch for build N also serves newer builds” before assuming an old
patch is inert once a new build ships.
Per-channel patches: node scripts/publish-config.mjs <N> --channel testflight writes KV
build-N@testflight, served only to that channel (lookup: build-N@ch → build-N → nearest
older, same order). Use it to trial a balance patch on TestFlight before App Store players get it.
The Worker stores channel truncated to 12 chars but does not whitelist it on write (only the
/config read path regex-checks it), so SQL shouldn’t assume only dev/testflight/appstore/web
appear in the channel column. Old rows (≤ build 206) have channel NULL.
Converting a const to a tunable
Section titled “Converting a const to a tunable”- In the sim file:
const X: float = 1.0→static var X: float = 1.0. Call sites don’t change./simstays pure — thestatic vardeclaration is the only thing that moves; all fetching, validation and setting lives innet/(render-side). - Add one entry to
net/tunables_registry.gd’sTunablesRegistry.ENTRIES:{"key": "Sim.X", "script": SIM, "name": "X"}. Bounds default to 0.1×–10× the bundled value; pass explicit"min"/"max"when the bundled default is 0 (Tunables.bounds()can’t derive a range from zero) or when the natural range isn’t symmetric around the default (seeElementalSystem.SPLIT_DMG_FRAC/SPLIT_BOSS_DMG_FRACfor both cases). A per-device knob that changes nothing deterministic (LockstepSession.JITTER_ROWS, the LAN client’s jitter buffer) adds"local": true:Tunables.config_hash()skips it, so two LAN peers may differ on it and still pass the join handshake, and the console may edit it mid-run on one peer (_dev_set_tunablesapplies a live layer that is local-only even in a networked run, viaRemoteConfig.live_is_local_only()/apply_liveon the run’s fixed override patch; any sim key in the layer still defers everything to the next run). Anything a Sim reads must NOT be local. - Hot-loop rule: if the read sits inside a per-entity loop, hoist it into a local once before the loop in the same commit. Cross-class static-var reads cost ~7× an inlined literal — negligible per call, not negligible across a 6,000-enemy loop.
- Never referenced by another const or a
matchpattern. Aconstthat other consts fold into at compile time, or that gates amatch/enum branch, can’t become astatic varwithout restructuring — leave those asconst.TunablesRegistry.NON_TUNABLE_STATIC_VARSdocuments the converse case: existingstatic vars insim/*.gdthat are runtime state, not tunables, so the registry-completeness test doesn’t demand they be registered. - Liveness check — prove the patch changes a real run before registering it. This is the
batch-1 lesson: registering a tunable is not proof it does anything. Batch 1 shipped 12 live
tunables out of 20 converted; the other 8 were reverted to
constafter a liveness check found they don’t affect a real run (see “Batch 1 — what shipped” below). Don’t hand-trace any more — run the probe:godot --headless --path . -s res://tools/remote_config/liveness_probe.gd -- --keys=<k1,k2,…>runs one deterministic headless soak run per key with only that key perturbed (through the realTunables.apply/GameContent.load_dbpaths) and diffs state digests against an unperturbed baseline — any divergence proves the key affects real gameplay (~4 s per 3-min run). It also probes bible fields (--keys=enemies.warden.hpor--bible-prefix=<prefix>) — the exact “manifest offers boss stats nothing reads” bug class the boss batch fixed is now caught mechanically before a patch ships. Modes that matter:--scenario-keys=Sim.ELITE_CHANCE=1.0amplification applied to baseline AND every run (e.g. forces every spawn to promote, so per-type elite knobs get exercised);--player=skilled|dumb— dumb gets hit (proves on-hit knobs), skilled survives (proves positional/pacing knobs);--invuln=1+--ticks=36000reaches brutes/bosses/dimensions but proves only POSITION-diverging keys —_check_pilot_hitskips the whole hit block while dev_invuln is set, so on-hit knobs (damage/knockback/effects) need a mortal run;--meta-levels=id=lvl,… --drone-loadout=a,b,…runs baseline AND every probe run with a shop save applied through the realMetaRunSetup.apply(asmain.gdstarts a run) — e.g.drone-slots=3,unlock-drone-bomber=1+--drone-loadout=sentinel,bomberfields non-default drones (the auto-players deploy whatever the loadout holds). Without it the probe only ever fields the default Sentinel.--area=<id>starts baseline and probe runs inside an area throughSim.enter_area(each Elemental Dimension:fire,ice,void_dim,blood,electricity,light,poison);--force-boss=warden|funzo|graviton|eyespawns that boss at tick 0 (position drawn fromsim.rng, identically in baseline and probe);--pilot-hp=Nstarts every pilot atmax_hp = hp = N, which keeps the run MORTAL (on-hit knobs still fire) but stops a dumb pilot dying before the boss reaches its enrage phase. Pair with a--scenario-keys=…_ENRAGE_FRAC=1.0to probe enrage-only knobs from the first tick. A key that reports NO-DIFF in a scenario is dead in that scenario — either genuinely dead (revert toconst, log it), or scenario-limited (trace it by hand; a shared path already proven LIVE by a sibling key plus a direct call-path trace is good evidence — batch 3 shipped 10 keys that way, see its evidence table below). A test that hand-sets thestatic varand checks it reads back correctly proves nothing about whether the real game ever reads it (same trap asbullet-heaven-story-mode-xp-was-zero).
- Regenerate the manifest (
godot --headless --path . -s res://tools/remote_config/dump_manifest.gd) soremote-config/manifest/build-<N>.jsonreflects the new knob. - Player-visible copy that states a tunable’s value must read it live, not hardcode a number.
ui/upgrade_copy.gd’s Fork card is the precedent — it derives its “half-damage” text fromElementalSystem.SPLIT_DMG_FRACat display time instead of a baked string, so a remote patch that changes the split fraction doesn’t leave stale copy on screen.
Level-up reroll + skip tunables (issue #77, 2026-10-20)
Section titled “Level-up reroll + skip tunables (issue #77, 2026-10-20)”UpgradeSystem.SKIP_XP_REFUND_FRAC (share of the XP bar a skip refunds, 0-1, read at each skip and by the
SKIP label), UpgradeSystem.REROLL_EXCLUDE_SHOWN (bool: a reroll leaves out the cards it replaces) and
LevelUpPanel.SKIP_CONFIRM_S (local, LIVE: the second-press window). The first two are sim keys, so they are
in config_hash and apply at the next run. The liveness probe (tools/remote_config/liveness_probe.gd) cannot
reach them (the autopilot never rerolls or skips); tests/test_levelup_reroll_skip.gd pins their wiring through
Tunables.apply instead.
Flags — bool kill switches (RC-1, 2026-10-01)
Section titled “Flags — bool kill switches (RC-1, 2026-10-01)”A registry entry with "type": "bool" is a flag: no bounds (true/false is the whole domain),
same fetch → reset → apply → config_hash pipeline as numeric tunables, same replay/telemetry
stamping. Live flags: Sim.DIMENSIONS_ENABLED (boss-death dimension-choice offers) and
DamageNumbers.ENABLED (floating combat text). Flags may point at render-side scripts —
tests/test_tunables_registry.gd checks each entry against its own script file, not only sim/.
The manifest types them "bool" with null min/max, publish-config.mjs accepts only JSON
true/false for them, and the liveness probe perturbs a flag by flipping its bundled default.
Render-side tunables (RC-4, 2026-10-01)
Section titled “Render-side tunables (RC-4, 2026-10-01)”Tunables don’t have to live in sim/ — the registry checks each entry against its own script
file, so render scripts can carry static var knobs too. The RC-4 set is the proven perf levers
from the BUILD 228-230 Apple TV fill-wall work:
-
QualityManager.ENABLED(bool) — the remote-enable flag for the adaptive quality ladder, replacingmain.gd’s oldADAPTIVE_QUALITYconst. Bundled default false (pin L0 everywhere); a published patch can switch the ladder on for a device-class perf regression without a store build.main.gdreads it at the two former const sites. -
QualityManager.DOWN_FPS/HARD_DROP_FPS/DOWN_DWELL/PROBE_DWELL/PROBE_BACKOFF/SETTLE/WARMUP/EMA_ALPHA— the ladder’s thresholds and timings (render/quality_manager.gd).CROSS_RULESkeepsHARD_DROP_FPS <= DOWN_FPS(mirrored inpublish-config.mjs); a violating pair keeps bundled defaults like any other invalid value. -
NovaBurstLook.LIFE_EVOLVED— the evolved corona’s linger time (fx/weapons/nova_burst_look.gd), a direct fill-cost dial on overlapping Supernovas. -
ElementPalette.WEAPON_TINTS(bool) /ElementPalette.WEAPON_LIGHT_SAT(0.3-1.0, bundled 0.48) — player weapon tints (2026-10-01): the light weapons (Beam, Railgun) drawn a pale gold instead of vivid()’s floored yellow, so they stop blending with Pulse’s lightning. The flag is the kill switch; the Debug Settings “Weapon Tints (A/B)” row is the separate session-stickyVisualLook.weapon_tints(Tunables reset every run start, so a Debug choice can’t live on a registered var). Liveness:tests/test_weapon_tints.gddrives a patched saturation throughFxManager.consume.
These never touch the sim, so the soak-based liveness probe can’t see them — liveness is pinned
by tests/test_render_remote_config.gd instead, which drives patched values through
QualityManager.tick, the real decision core. Render-side values are read per tick()/bind(),
so a patch takes effect at the next run start without extra wiring; the ladder stays off by
default until a patch flips ENABLED.
Bible fields (weapon/enemy/shop numbers in data/bible.json) don’t need a code conversion — every
existing numeric field on an existing entry is overlayable via content/bible_overlay.gd
(category.id.field, e.g. weapons.fork.base_damage) unless BibleOverlay.EXCLUDED_FIELDS
lists it. Since RC-3 (2026-10-01) the whitelisted display-copy strings are overlayable too —
BibleOverlay.STRING_FIELDS (weapons.name, enemies.name, mods.name, meta_upgrades.name,
meta_upgrades.desc): non-empty single-line, ≤200 chars (STRING_MAX, mirrored by
publish-config.mjs), stamped into config_hash via effective() like numbers. Machine-key
strings (mods.effect, meta_upgrades.effect, weapons.element/archetype/evolution) are NOT
copy and stay unpatched — rewriting one would be a code change, not data. The standing rule
applies harder here: copy that states a tunable’s value must read it live, so a patched desc must
not hardcode a number a tunables patch can move. Adding or removing fields/entries is still not
allowed (guideline 2.5.2). Numeric fields keep the same
two-pass validation as tunables: the value must be within 0.1×–10× of the bundled value (a bundled 0
can’t be patched — no bounds to derive), and a field in BibleOverlay.INT_FIELDS must be a whole
number (written back into the bible as an int).
INT_FIELDS / EXCLUDED_FIELDS are the one definition of bible field types and patchability:
BibleOverlay.apply(), effective() (the stamped hash), overlayable_fields() and
tools/remote_config/dump_manifest.gd (manifest "type": "int" + whole-number min/max, which
publish-config.mjs enforces) all read it. Rules are category.field (every entry) or
category.id.field (one entry). A GUT test fails if a rule names a field that doesn’t exist.
| Excluded | Why |
|---|---|
enemies.*.radius |
Structural: ElementalSystem.MAX_ENEMY_RADIUS (80) bounds the projectile hit query, so a bigger enemy silently loses hits. |
enemies.*.xp_value |
Dead: XP is paid on biomass; EnemyPool.xp_val is written, never read. |
weapons.*.level_max, weapons.*.pierce |
Dead: read nowhere (pierce comes from mods / weapon mods). |
elements.*.stacks_max, elements.*.per_stack_scale, reactions.*.base_magnitude, reactions.*.per_stack_scale |
Dead: only the unreachable Elemental.apply() (reactions archived) reads them, or nothing does. |
meta_upgrades.*.max_level |
Structural: sets drone/weapon slot counts (DroneDock, 1 + max_level) and the level cap saved levels are checked against. |
mods.damage.magnitude |
Dead: the global damage mod is never offered (roll_upgrade_choices skips it; damage is per-weapon). |
enemies.tank_missile.gold |
Dead: tank missiles never pay gold or XP (anti-farming). Its bundled 0 also makes it unpatchable. |
Int fields: weapons.*.projectile_count, enemies.*.pellets, enemies.*.orbit_shards,
enemies.*.biomass, meta_upgrades.*.base_cost, and mods.{pierce,split,overcharge}.magnitude
(they add to int ModState fields). When you add a bible field the sim reads through int(), or
one that is structural/dead, add it to the table and regenerate the manifest.
RC-8 — background theming (2026-10-01)
Section titled “RC-8 — background theming (2026-10-01)”The backgrounds are fully procedural, so theming them is pure data: no asset download pipeline.
RC-8 adds a "type": "color" tunable and registers 42 background knobs (17 numeric, 1 variant
switch, 24 colors).
The color type. A patch value is a hex string with the leading # (#rgb, #rgba,
#rrggbb, #rrggbbaa; 8 digits = RRGGBBAA, Godot’s Color.html order) or an
[r, g, b] / [r, g, b, a] float array. The array form exists because 8-bit hex can’t express
an overbright modulate: the Dimension tints run to 1.35 and hex would clamp them to 1.0. RGB
channels are capped at Tunables.COLOR_CHANNEL_MAX (4.0), alpha at 0..1. Tunables.parse_color
is the client check, isColor in publish-config.mjs mirrors it; anything else is rejected and
the key keeps its bundled default. Colors have no numeric bounds (Tunables.bounds returns
[NAN, NAN], like bool), go through the same reset → apply → config_hash pipeline (the hash
stringifies the effective Color), and the manifest types them "color" with null min/max. The
manifest default is #rrggbbaa when every channel fits 8-bit hex, else a rounded [r, g, b, a]
array (Tunables.color_to_json). The hex default is informational and 8-bit-quantised; send the
array form when exact values matter (the near-black NebulaBg grade loses precision in hex).
The liveness probe skips color entries (render-side, invisible to the soak digest) and refuses
them as --scenario-keys.
Keys. All are read when a backdrop is built, so a published patch lands at the next run start (Dev-build console edits land live, see RC-9 below). Both
NebulaBg and ArenaBackground are rebuilt every run by main._new_run (they are not on the
persistent-node list), so NebulaBg.apply_tunables() runs from its own _ready and needs no extra
call site. ArenaBackground snapshots its tunables into instance fields in set_variant, so the
per-frame paths (set_camera, warp streaks) never read a cross-class static.
| Script | Numeric | Color |
|---|---|---|
render/nebula_bg.gd (NebulaBg.*) |
PARALLAX 0..1 (default 0.1), RESOLUTION_SCALE 0.1..1 (fill-rate lever), INTENSITY 0..1.5, STAR_DENSITY 3..120 |
COL_LO, COL_HI (the default grade) |
render/arena_background.gd (ArenaBackground.*) |
STEP 50..400, AURORA_LAYERS 2..12, STAR_PARALLAX 0..1, NEBULA_PARALLAX 0..1 (default 0.1), WARP_STREAK_ALPHA 0..1, WARP_STREAK_WIDTH 0.5..6, NEBULA_CLOUD_SCALE_MIN/MAX 2..32 (cross-ruled MIN <= MAX), GALAXY_ARMS 1..6, GALAXY_CORE_STARS 30..1000, GALAXY_SCATTER_STARS 0..500, GALAXY_SPIRAL_TURNS 0.25..4, GALAXY_GLOW_SCALE 2..20, GALAXY_GLOW_ALPHA 0..1 |
LINE_COLOR, BORDER_COLOR (also ArenaBorder’s edge glow), STARFIELD_COLOR, GALAXY_CORE_COLOR, GALAXY_OUTER_COLOR, AURORA_HUE_1..3, NEBULA_CLOUD_HUE_1..4 |
sim/area_defs.gd (AreaDefs.*) |
TINT_HOME, TINT_AURORA, TINT_EMBER_REACH, TINT_FIRE, TINT_ICE, TINT_VOID_DIM, TINT_BLOOD, TINT_ELECTRICITY, TINT_LIGHT, TINT_POISON |
The upper caps are fill-rate caps: GALAXY_GLOW_SCALE 20 is the pre-2026-07-04 value the Apple
TV choked on, and RESOLUTION_SCALE 1.0 is the original full-resolution FBM cost.
ArenaBackground.FORCE_VARIANT (int, -1..4) is a seasonal switch. -1 (bundled) keeps
today’s data-driven pick: the area’s background name, or the random Home pool. 0..4 forces that
VARIANT_* everywhere: the run-start pick (ArenaBackground.forced_variant, called by
main._new_run) and every area change (set_variant_by_name). The per-area background strings
in AreaDefs._DEFS stay structural and are not tunable; the dev background switcher
(set_variant) ignores the force.
Dimension tints moved out of the _DEFS const (a const can’t hold a static var) into one
TINT_<AREA> static per area; AreaDefs.tint_for returns the static, with unknown ids following
Home as get_def does. They are plain data in /sim, read only by render/UI (main’s area change,
DimensionChoicePanel), never at tick time, so the determinism baselines are untouched.
NebulaBg grade precedence. VisualLook.nova_ground (the lit Nova ground) still wins while it
is on: _process pushes the gamma pre-compensated VisualLook.NOVA_COL_LO/HI and
NOVA_INTENSITY as before, and apply_tunables() never pushes the grade while the Nova ground
holds the uniforms. When the Nova ground switches off, _process falls back to the snapshotted
COL_LO/COL_HI/INTENSITY (bundled or patched) instead of the old hardcoded literals.
STAR_DENSITY is not part of the Nova grade and is always pushed. The default grade is pushed
raw, as the shipped grade always was: under HDR 2D it renders about gamma-2.2 darker than the
values say (see the HDR trap comment in nebula_bg.gd). Only the Nova ground is pre-compensated;
compensating the default grade would brighten the shipped look, which is a visual change, not a
remote knob.
Liveness is pinned by tests/test_bg_remote_config.gd: each test patches through the real
Tunables.apply, builds the backdrop the way _new_run does, and reads the result off the built
nodes (shader parameters, sprite scales/modulates, star counts, layer positions, ArenaBorder’s
edge_color). Draw-only colors (LINE_COLOR, BORDER_COLOR on the ArenaBackground rect, the
aurora hues, warp streak alpha/width) are checked at their set_variant snapshot, since headless
draw calls can’t be read back. Bundled values are identical to the old consts and shader uniform
defaults, so the default path is pixel-identical by construction.
RC-9 — background & environment live sweep (2026-10-07)
Section titled “RC-9 — background & environment live sweep (2026-10-07)”Every remaining hard-coded backdrop, arena-edge and WorldEnvironment value is a tunable: 87 new
keys (119 NebulaBg/ArenaBackground/ArenaBorder/EnvironmentLook (now ColorProfileSdr) keys with RC-8’s), plus a few variety knobs that default to a
no-op. Bundled values equal the old literals and the default render-RNG draw order is unchanged,
so the default look is pixel-identical: a fixed-fps --write-movie A/B of all five variants
against the pre-RC-9 commit was bit-identical under mobile (the Apple TV renderer) and differed
by at most 1/255 on 2-5 of 921,600 pixels under gl_compatibility (GLSL compiler float
contraction once literals became uniforms).
All of them are live mid-run (Dev builds, console set_tunables) through
LiveScope.HOOK_PREFIXES, which main._apply_live_config runs:
| Prefix | Hook | What main does |
|---|---|---|
NebulaBg. |
HOOK_NEBULA |
nebula_bg.apply_tunables() pushes every uniform (the Nova ground still wins while on) |
ArenaBackground. |
HOOK_ARENA |
arena_bg.rebuild(): regenerates with the same seed, so only the edited values move |
ArenaBorder. |
HOOK_BORDER (new) |
arena_border.apply_tunables(): uniforms + strip redraw |
EnvironmentLook. |
HOOK_ENV (new) |
EnvironmentLook.apply(world_env.environment, quality_manager.hdr_capable) |
ColorProfileSdr. / ColorProfileHdr. |
HOOK_ENV |
main._apply_color_profile() (EnvironmentLook.apply + ShaderGlobals.push) |
tests/test_env_border_tunables.gd fails if a background/environment key has no hook or main
stops handling one. Liveness is pinned by that file and tests/test_bg_remote_config.gd (patch
through Tunables.apply, then run the hook action on an already built node).
NebulaBg (render/nebula_bg.gd + render/shaders/nebula_bg.gdshader, the far full-screen FBM):
CLOUD_SCALE 2.4 (0.3..10), CLOUD_LO/CLOUD_HI 0.42/0.92 (0..1, cross-ruled), DRIFT_X/DRIFT_Y
0.002/0.004 (-0.05..0.05), FBM_OCTAVES 2 (1..4; the shader loops to a constant 4 with a break
so GLSL ES compiles; each octave is a noise sample per low-res pixel), STAR_CHANCE 0.05 (0..0.5,
pushed as star_threshold = 1 - chance), STAR_BIG_CHANCE 0.07 (0..1), STAR_RADIUS/STAR_BIG_RADIUS
0.12/0.17 (0.02..0.5), STAR_CORE_FRAC 0.35 (0..0.95), STAR_BRIGHTNESS/STAR_BIG_BRIGHTNESS 0/0
(0..2; 0.32/0.8 until RC-10 turned the shader stars off, both 0 skips the star math), TWINKLE_AMP 0.12 (0..1), TWINKLE_SPEED 0.7 (0..6), STAR_MARGIN 0.15 (0..0.5; spread is
1 - 2 * margin; a margin below the radius clips dots square). Variety: STAR_COLOR (white),
CLOUD_SQUARE 0 (0..1; 0 = the shipped screen-stretched cloud, 1 = aspect-correct like the stars),
COL_ACCENT + ACCENT_AMOUNT 0 (0..1) + ACCENT_SCALE 0.9 (0.1..8): a second low-frequency noise
field mixes the accent hue into the cloud; at 0 the branch is skipped, so it costs nothing.
ArenaBackground (render/arena_background.gd):
- Starfield:
STARFIELD_COUNT340 (0..1500),STAR_RADIUS_MIN/MAX0.7/1.5 (0.2..6),STAR_ALPHA_MIN/MAX0.14/0.5 (0..1),STAR_TWINKLE_SPEED_MIN/MAX0.5/1.4 (0..6). Shared star look (Starfield + Galaxy):STAR_TWINKLE_DEPTH0.25 (0..1,star_field.gdshaderuniform on the duplicated material and the warp-streak curve),STAR_SIZE_MULT1 (0.25..4, fill ~ square),STAR_EDGE_HARDNESS6 (1..32; the cached star texture rebuilds when it or the mipmap flag changes),STAR_MIPMAPS(bool, off: on = mipmapped texture +TEXTURE_FILTER_LINEAR_WITH_MIPMAPS, the candidate fix for sub-pixel star popping). Variety:STARFIELD_COLOR_2+STAR_COLOR_MIX0 (0..1): the fraction of Starfield stars in colour 2 is picked by its own RNG (seed + 0x5EED), drawn only when the mix is on, so the scatter never moves. - Nebula variant (organic default):
NEBULA_CLOUD_COUNT12 (0..24),NEBULA_CLOUD_ALPHA_MIN/MAX0.24/0.42 (0..1),NEBULA_PULSE_DEPTH0.3 (0..1),NEBULA_PULSE_SPEED0.5 (0..4; snapshotted,_processreads instance fields). - Nebula look params (the organic/cluster
VisualLook.nebula_lookoptions, 2026-10-07):NEBULA_ORGANIC_SCALE0.6 (0.1..2, organic sprite scale vs the disc’s),NEBULA_ORGANIC_CHURN0.035 (0..0.5 rad/s, max spin),NEBULA_CLUSTER_LOBES6 (int 1..16),NEBULA_LOBE_SCALE_MIN/MAX0.35/0.6 (0.05..2, cross-ruled),NEBULA_LOBE_SPREAD0.35 (0..2 of the disc half-size).
NebulaCloudTextures (render/nebula_cloud_textures.gd, the organic look’s CPU-baked cloud textures;
prefix mapped to HOOK_ARENA, so the rebuild re-requests shape(), which rebakes all four shapes
(~0.3 s) when signature() differs from the cached bake’s). The first seven are the CENTRE of a per-shape
roll of centre +- a fixed *_SPREAD const x SHAPE_VARIETY, drawn in the original order, so the bundled
bake is byte-identical (SHA-pinned in tests/test_nebula_look_tunables.gd): WARP 0.525 (0..2),
FILAMENT_FREQ 3.1 (0.5..12), FILAMENT_SHARPNESS 1.3 (0.3..6; the roll is floored at 0.05), EDGE_RAGGEDNESS
0.675 (0..2), BODY_FALLOFF 1.65 (0.1..8), DENSITY_THRESHOLD 0.14 (0..0.8), DENSITY_SOFTNESS 0.475
(0.05..1.5), SHAPE_VARIETY 1 (0..3). Direct values: CORE_ALPHA 0.62 (0..1), CORE_RADIUS 0.42 (0..1),
HAZE_FLOOR 0.30 (0..1, filament brightness = floor + (1 - floor) x ridge), EDGE_IN/EDGE_OUT 0.35/0.95
(0..1 / 0.05..1, cross-ruled). Left const: FEATHER_IN/OUT (the unwarped outer skirt that keeps the quad’s
square edge invisible), the per-shape spreads, the shape seeds, SIZE/SHAPE_COUNT.
- Galaxy:
GALAXY_ARM_SPREAD0.22 (0..1.5 rad),GALAXY_CORE_RADIUS60 (0..600),GALAXY_EXTENT0.5 (0.1..1 of ARENA_HALF),GALAXY_RADIAL_JITTER40 (0..300),GALAXY_CORE_STAR_R_MIN/MAX1.6/4.2 andGALAXY_SCATTER_STAR_R_MIN/MAX1.2/2.8 (0.2..8),GALAXY_CORE_STAR_A_MIN/MAX0.55/1.0 andGALAXY_SCATTER_STAR_A_MIN/MAX0.25/0.6 (0..1),GALAXY_TWINKLE_SPEED_MIN/MAX1.5/4.0 (0..8). - Grid:
GRID_LINE_WIDTH1 (0.5..6),GRID_BREATHE_DEPTH0.15 (0..1),GRID_BREATHE_SPEED0.8 (0..6); the grid material is now duplicated per build (it was the shared saved.tres). - Aurora:
AURORA_WAVE_AMP120 (0..600),AURORA_WAVE_FREQ0.0018 (0..0.02),AURORA_WAVE_SPEED0.6 (0..4),AURORA_ALPHA0.022 (0..0.2),AURORA_SHIMMER0.008 (0..0.1),AURORA_SHIMMER_SPEED0.7 (0..4),AURORA_WIDTH_MAX/MIN260/50 (2..600, cross-ruled; the max is the fill-cost lever).
ArenaBorder (render/arena_border.gd, arena_border.gdshader): INSIDE 160 (40..400; the halo
reach and the strips’ inner edge), FOG_DEPTH 420 (50..2000), FOG_COLOR (0.015, 0.02, 0.06),
GLOW_GAIN 1 (0..3), GLOW_WIDTH 1 (0.25..4; stretches the coloured halo, the white-hot core line
keeps its width, INSIDE still caps it). border_look.gdshaderinc gained border_look_w(..., glow_width);
the old border_look() forwards to it with 1.0, so story_wall.gdshader is unchanged.
(Superseded by the colour profiles, step 1: the keys are ColorProfileSdr.; old EnvironmentLook. keys are retired docs for builds up to 281+.) EnvironmentLook (render/environment_look.gd, new; main._new_run calls EnvironmentLook.apply(env, hdr)).
HDR (Metal): GLOW_INTENSITY 1.15 (0..4), GLOW_STRENGTH 1 (0..2), GLOW_BLOOM 0.05 (0..1),
GLOW_HDR_THRESHOLD 1 (0..4), GLOW_HDR_SCALE 1.5 (0..4), GLOW_LEVEL_1..7 at Godot 4.7’s defaults
0/0.8/0.4/0.1/0/0/0 (0..1; a non-zero level 5-7 adds blur passes), BRIGHTNESS 1 (0.5..2), CONTRAST
1.08 (0.5..2), SATURATION 1.14 (0..2). Both paths: GLOW_BLEND_MODE 0 (0..4, Environment.GlowBlendMode).
LDR (web): LDR_GLOW_INTENSITY 0.9 (0..4), LDR_GLOW_BLOOM 0.2 (0..1). apply() never writes
glow_enabled: QualityManager owns that switch (bloom at L0 only), so a live re-apply can’t fight the ladder.
Cross rules (mirrored in publish-config.mjs): NebulaBg.CLOUD_LO <= CLOUD_HI,
NebulaCloudTextures.EDGE_IN <= EDGE_OUT and every ArenaBackground *_MIN <= *_MAX pair above.
Left as const, with reason:
NebulaBgshader hash constants and the accent field’s smoothstep band/offset: structural noise plumbing, no visual meaning on their own.ArenaBackground.VARIANT_*, the three aurora bands (one perAURORA_HUE_*) and their base-height layout, the galaxy radial-jitter growth term (t + 0.15), the warp-streak length factor (0.6 + r * 0.25), the nebula-pulse phase offset per cloud: structural or already covered by a sibling knob.ArenaBorder.OUTSIDE(3200): geometry that must stay past any camera view.border_look.gdshaderincfalloff constants, pulse and fog-noise constants: shared with the story walls, so a tunable would retheme the tutorial too;GLOW_GAIN/GLOW_WIDTH/FOG_*cover the edge.VisualLook.NOVA_COL_LO/HI/INTENSITY: the Nova ground is a Debug Settings row (off by default), not the shipped look.ScreenFeedbackvignette (VIGNETTE_INNER/OUTER/MAX_ALPHA/FRAME_MARGIN): baked once into a texture whose frame margin is derived from them, so a live edit needs a rebake; damage-feedback values were out of scope.
Manifest: not regenerated on this branch (BUILD is still 262, whose manifest describes the
installed 262). Regenerate remote-config/manifest/build-<N>.json with dump_manifest.gd at the
BUILD bump that installs this, and redeploy the console Worker (control/src/tunable_docs.js changed).
RC-10 — far-star layer (2026-10-08)
Section titled “RC-10 — far-star layer (2026-10-08)”A screen-space far star layer, render/far_stars.gd (FarStars, CanvasLayer -1 between the NebulaBg
cloud at -2 and the world), replaces NebulaBg’s upscaled shader stars (NebulaBg.STAR_BRIGHTNESS and
STAR_BIG_BRIGHTNESS now default to 0, an approved visual change). Architecture: docs/architecture/rendering-and-performance.md
§ Background layer stack. main._new_run builds it every run (render-side randi() seed), feeds
set_camera each frame, mirrors arena_bg.warp_dir/warp_len through set_warp, and tints it on an
area change like the arena backdrop. Defaults are the look Chris approved on the Apple TV (the arena
starfield driven as far stars) at the count one screen tile needs.
All 20 keys are live through LiveScope.HOOK_FAR_STARS (prefix FarStars.): main calls
far_stars.rebuild(), which regenerates with the same seed and keeps the area tint. HOOK_TINT
(AreaDefs.TINT_*) re-tints it too.
| Key | Default | Bounds |
|---|---|---|
COUNT |
110 (stars per screen tile, ~85 on screen at 1280x720) | 0..800 |
PARALLAX |
0.1 (screen speed = p x camera) | 0..1 |
RADIUS_MIN / RADIUS_MAX |
0.8 / 1.4 px | 0.2..6 each, cross-ruled |
ALPHA_MIN / ALPHA_MAX |
0.25 / 0.75 | 0..1 each, cross-ruled |
SIZE_MULT |
1 | 0.25..4 |
EDGE_HARDNESS |
6 | 1..32 |
TWINKLE_SPEED_MIN / TWINKLE_SPEED_MAX |
0.5 / 1.4 rad/s | 0..6 each, cross-ruled |
TWINKLE_DEPTH |
0.15 | 0..1 |
COLOR |
#b8d9ff |
color |
COLOR_2 |
(1, 0.82, 0.62) | color |
COLOR_MIX |
0 | 0..1 |
BIG_CHANCE |
0 | 0..1 |
BIG_RADIUS_MULT |
1.8 | 1..4 |
BIG_ALPHA_MULT |
1.4 (alpha capped at 1) | 1..4 |
STREAK_ALPHA |
0.3 | 0..1 |
STREAK_LEN_MULT |
1 (x main’s WARP_STAR_STREAK 40 px) |
0..4 |
MIPMAPS |
true | bool |
BIG_CHANCE and COLOR_MIX roll from their own RNG (seed + 0x5EED, always drawn), so changing them
never moves the scatter; the wrap margin is always sized for a big star for the same reason (so
BIG_RADIUS_MULT, RADIUS_MAX, SIZE_MULT and STREAK_LEN_MULT do resize the tile and reshuffle
it). Cross rules (mirrored in publish-config.mjs): the three *_MIN <= *_MAX pairs. Pinned by
tests/test_far_stars.gd (patch through Tunables.apply, build the layer as main does, read the
MultiMesh / shader params; main wiring; live hook; the wrap math as FarStars.wrap_pos). The manifest
is not regenerated here; do it at the BUILD bump that installs this.
Shop prices and the refund ledger
Section titled “Shop prices and the refund ledger”meta_upgrades.*.base_cost / cost_growth are patchable, so owned levels can have been bought
under several price regimes. MetaState.reset_shop() therefore refunds MetaState.gold_spent —
the gold every successful buy() actually deducted, persisted in the save — never a re-price of
owned levels (total_spent(defs)), which would mint gold after a price rise and lose it after a
cut. Levels written without buy() (tests, economy-sim tools) never enter the ledger.
Only publish a shop-price patch for a BUILD that contains the ledger. Patches are keyed per
Sim_Const.BUILD (and served to newer builds by fallback, never older), so the safe rule is: price
patches target ledger builds only. A build without it would refund at the patched price.
Saves from before the ledger (or with a corrupt gold_spent: anything but a finite number in
0..MetaState.GOLD_SPENT_MAX) load with it unset (MetaState.GOLD_SPENT_UNSET, -1), and
MetaStore.load_from() migrates them once from the frozen pre-ledger price table
data/meta_prices_legacy.json (MetaStore.legacy_price_defs()) — the shop prices of every build
up to BUILD 204 / store build 36, taken from the bible at 9a224b7. Never edit or regenerate that
file: it records what old saves were charged, so a later bible price or id change (e.g. the
shop-economy Plan 2 renames) must not reach it; tests/test_refund_ledger.gd pins it. An owned id
the table doesn’t know counts 0 and is reported (MetaStore.last_migration_unpriced + a warning).
The migration persists only when the load was clean (same rule as ensure_install_id). It runs
inside the load because every real save passes through there; at boot that is before
RemoteConfig exists. The runs.meta_spent telemetry column and soak’s meta_spent report
gold_spent too, so they don’t move when prices are patched; total_spent() stays for the migration and the economy sim.
Batch 1 — what shipped (commits d1a2dfd, f355bc7, 5cc9180)
Section titled “Batch 1 — what shipped (commits d1a2dfd, f355bc7, 5cc9180)”12 live tunables: wave/boss/elite pacing (Sim.WAVE_PHASE_S, BOSS_PREP_S, BOSS_REST_S,
BIOMASS_TRIGGER, BIOMASS_TARGET, WAVE_INCOMING_S, MAX_ALIVE_BRUTES, BOSS_ADDS_DELAY_S,
BOSS_ADD_RATE, LEECH_CAP_FRAC_PER_S) and Fork/Penetrator split damage
(ElementalSystem.SPLIT_DMG_FRAC, SPLIT_BOSS_DMG_FRAC).
8 converted-then-reverted-to-const after the liveness check (kept exactly as they were,
registry entries removed, remote-config/manifest/build-204.json regenerated without them):
| Const | Why it’s dead |
|---|---|
Sim.BOSS_FIRST_TIME, Sim.BOSS_INTERVAL |
(Deleted 2026-09-29.) Only fed _next_boss_time, which was write-only — real boss timing is phase-driven (WAVE_PHASE_S → wind-down → BOSS_PREP_S), not these two. |
Sim.SWARM_BURST_TIME |
(Deleted 2026-09-29.) Read nowhere in the sim. |
Sim.SWARM_BURST_COUNT |
(Deleted 2026-09-29.) _spawn_swarm_burst() had no live caller (only a test). |
Sim.ELITE_BOSS2_TIME, Sim.ELITE_BOSS2_BIOMASS |
Behind const BOSS2_ENABLED := false — Boss2 is hidden from all auto-spawn. |
Sim.ELITE_WARDEN_TIME, Sim.ELITE_WARDEN_BIOMASS |
Only reachable in Aurora/Ember Reach, which real play never enters (dev panel only). |
The dead code itself (the unused fields/functions) is left in place under the feature freeze —
logged in NEXT_TASKS.md (“Dead sim pacing fields/functions”) for whoever removes it later.
Cross-key rule: keep Sim.BIOMASS_TRIGGER <= Sim.BIOMASS_TARGET (bundled 100 / 400). A trigger
above the target re-arms “WAVE INCOMING” every WAVE_INCOMING_S (2 s) while each wave spawns
nothing, because the field is already at target. Check this by eye before publishing any patch
touching either key — validated since batch 3: see TunablesRegistry.CROSS_RULES (both the
client and publish-config.mjs enforce it).
Remaining batches (spec §7, most-likely-to-tune first, each still needs its own conversion +
liveness probe + gate): enemy behaviours/attacks, the drone director, then the rest of sim.gd.
Batch 2 (Supernova + boss bible stats) and batch 3 (elites, below) are done. Launch ships with
whatever’s landed by then; anything not converted still needs an App Store build to change, as today.
Batch 3 — elites (2026-09-30)
Section titled “Batch 3 — elites (2026-09-30)”67 keys: the Sim.ELITE_* promotion/stat family (16, incl. the formerly const-folded
ELITE_CHARGER_HP_MULT/ELITE_CHARGER_CONTACT_MULT, now independent own-value knobs) plus the
EliteKit attack family (51). ELITE_XP_MULT is not among them — dead (multiplies only the
never-read xp_val column; elite reward rides biomass×HP-mult in _spawn_one). Structural bounds
to keep: ELITE_CONTACT_MULT/ELITE_CHARGER_CONTACT_MULT max 1.47 (elite Swarmer final hit must
stay under the Warden swing 44 — test_difficulty pins the bundled pair), ELITE_RADIUS_MULT
max 1.6 / ELITE_CHARGER_RADIUS_MULT max 3.0 (promotable base radii 48/26 must stay inside
MAX_ENEMY_RADIUS 80 or projectile hits go missing — same reason enemies.*.radius is
bible-excluded). Hot-loop hoists rode along: the homing turn rates (read once per tick in
_move_enemy_proj now), EliteKit’s update() cadence gates, and prowl_dir’s prowl/dive/hop
reads; the WEAVE reads stay in-branch on purpose (bounded by live elite count — see the comment
at enemy_behaviors.gd step_walk).
Liveness evidence (tools/remote_config/liveness_probe.gd, built with this batch):
- 57/67 mechanically LIVE — divergent digests across: mortal skilled 3-min runs (30),
ELITE_CHANCE=1.0-amplified skilled runs (+13), an invuln dumb 10-min coverage run (+1:WEB_HOLD_S). Wiring tests pin the Charger per-type mult independence and the kit fire/gate paths (test_elite_remote_config.gd). - 2 more pinned by wiring test (
ELITE_SCATTERER_HP_MULT/ELITE_BRUTE_HP_MULT): their elites never spawn in any single headless scenario (the probe run’s dimension visit swaps in a roster that filters them out), but the force-elite spawn test proves their_spawn_onebranches read patched values — and the scatterer/brute weights inSpawnTable+UNLOCK_ORDERconfirm they spawn in ordinary survival play. - 8 trace-verified (probe NO-DIFF everywhere; direct call paths + sibling keys on the same
path proven LIVE):
EliteKit.SCATTER_DMG/RIFT_DMG/RIFT_KNOCKBACK(on-hit values on the exact_shot→_check_pilot_hitpipeline that provedBLASTER_DMG/FROST_DMG/HOMING_DMG; the probe’s pilot never ate one of those specific shots),EliteKit.JOLT_S/SWARM_TURN_RATE(Warden swarm-drone path:boss_warden.gdsetsEFFECT_JOLT,sim.gd:_move_enemy_projreads the turn rate foris_homing==2), andSim.ELITE_WEAVE_FREQ/ELITE_WEAVE_AMP/WYRM_ELITE_HP_MULT(reachable only via kit-less elites = Wyrm segments, which are Void/Poison-dimension content — real play reaches Dimensions, single probe scenarios don’t). - Added 2026-10-07 (Toby playtest):
Sim.ELITE_ORBITER_HP_MULT(12) andSim.ELITE_GLASS_WISP_HP_MULT(6), bounds 1-40, NEW_SPAWNS scope. Every per-type elite HP override now lives in ONE helper,Sim._elite_hp_mult(tid), read by both_spawn_oneandspawn_story_enemy(they used to carry duplicate if/elif chains). Pinned by wiring testtest_elite_hp_orbiter_wisp.gd(patch → real spawn), not a probe run.
Also shipped with this batch: TunablesRegistry.CROSS_RULES — declarative cross-key rules
(Tunables.apply rejects the pair and keeps both bundled defaults; publish-config.mjs mirrors
the same rule pre-upload). First rule: Sim.BIOMASS_TRIGGER <= Sim.BIOMASS_TARGET. Add future
cross-key constraints there, never as eye-checks. Mirror trap: the publish-side check skips a
pair whose key isn’t in the manifest, so a casing/name typo in the publish-config.mjs mirror
(e.g. Funzo. for FunZo.) silently disables that rule with no error — when adding a mirror
entry, grep the registry for the exact key string.
FX v2 batch 2 — Beam (2026-10-01)
Section titled “FX v2 batch 2 — Beam (2026-10-01)”WeaponBeam.TURN_RATE (6.0, 0..30), WeaponBeam.LANCE_TURN_RATE (3.5, 0..30),
WeaponBeam.LANCE_COOLDOWN_S (0.25, 0.05..1.0), WeaponBeam.LANCE_DMG_MULT (2.5, 0.5..10). Off
values (today’s behaviour exactly): turn rates 0, Lance 0.1 / 1.0. Patch the two Lance keys
together to hold DPS. Liveness probe (default skilled scenario): TURN_RATE LIVE; the three Lance
keys NO-DIFF because that run never evolves the Beam (scenario-limited) — their live read is proven
by test_lance_cadence_through_the_real_evolve_and_tick_path (apply_upgrade("evolve:beam") +
Sim.tick), and LANCE_TURN_RATE shares TURN_RATE’s proven _slew path.
FX v2 batch 2 — Railgun (2026-10-01)
Section titled “FX v2 batch 2 — Railgun (2026-10-01)”WeaponRailgun.TELL_S (0.1, 0..0.5), WeaponRailgun.MARK_POP_FRAC (0.25, 0..2),
WeaponRailgun.MARK_POP_RADIUS (40, 10..160). Off values (today’s behaviour exactly): TELL_S 0
(no tell, aim at fire time) and MARK_POP_FRAC 0 (no pops; the radius is then unused). Liveness:
the shipped probe’s default soak never takes the Railgun, so all three report NO-DIFF there
(scenario-limited). A one-off run of the same probe idea with the Railgun granted at tick 0
(seed 1000, mortal skilled autopilot, real Tunables.apply + Sim.tick_single + level-ups,
10,800 ticks) diverged at the first checkpoint for each key: TELL_S=0, MARK_POP_FRAC=0 and
MARK_POP_RADIUS=120 all LIVE. The end-to-end GUT tests in tests/test_weapon_railgun.gd drive the
same keys through Sim.tick_single.
Orbit Shards shove (2026-10-02)
Section titled “Orbit Shards shove (2026-10-02)”WeaponOrbit.KB_SPEED (160, 0..1000), KB_LEVEL_STEP (0.2, 0..1), KB_LEVEL_CAP (int 8, 0..20),
KB_EVOLVED_MULT (1.4, 1..4), KB_ELITE_MULT (0.5, 0..1), PROJ_PUSH_ACCEL (1200, 0..10000).
Off values (pre-shove behaviour exactly): KB_SPEED 0 and PROJ_PUSH_ACCEL 0. Liveness: the
shipped probe’s soaks never take the Orbit (default skilled 10,800 ticks and --ticks=36000 --invuln=1 both NO-DIFF for every key: scenario-limited, same as Beam/Railgun). The live read is
proven end to end in tests/test_orbit_knockback.gd: patches go through the real Tunables.apply
and the orbit is granted / upgraded / evolved through upgrade_system, then driven by
Sim.tick_single (KB_SPEED 0 leaves the enemy in place, default shoves it; PROJ_PUSH_ACCEL 0
leaves bullet velocity untouched). The scale keys share shove_scale() with both.
Event Horizon warp (2026-10-07), evolved orbit only, every key 0 = off: WeaponOrbit.WARP_PROJ_ACCEL
(90, 0..3000), WARP_LENS_ACCEL (600, 0..10000), WARP_LENS_RADIUS (56, 0..200), WARP_ENEMY_SPEED
(30, 0..400), WARP_ENEMY_LENS_SPEED (60, 0..600). Read per tick in WeaponOrbit.update (live). Look:
OrbitShardsLook.EH_CORE (color, #ff1a12, blood red), EH_TINT_MIX (0.7, 0..1), WARP_GAIN (0.4, 0..3),
WARP_EDDIES (int 3, 0..7), EDDY_HALF (30, 4..120), pushed on change every orbit sync (live). Hum:
AudioManager.EH_HUM_PITCH (0.7, 0.3..1.5), EH_HUM_WOBBLE (0.035, 0..0.3), EH_HUM_WOBBLE_HZ (0.23,
0.02..4), read on every set_orbit_active call (live). Liveness proven end to end in
tests/test_event_horizon.gd (patches through Tunables.apply; the orbit is evolved through the real
crystal-threshold path and driven by Sim.tick_single); the shipped probe’s soaks never evolve the
Orbit, so it would report NO-DIFF (scenario-limited, same as the shove keys). The look also hides its
lens / eddies when the matching sim strengths are patched to 0, so the visual never claims a warp the
sim isn’t doing.
Batch 4 — enemy behaviours/attacks (2026-10-01)
Section titled “Batch 4 — enemy behaviours/attacks (2026-10-01)”92 keys: EnemyBehaviors (56 — skirmish/rush/ghost/accumulator/orbiter/leech/coil/flee/wyrm
behaviour and the non-kit elite powers), EnemyAttacks (21 — tank volleys, shooters, elite
shooter/scatterer/lancer/tank/orbiter/brute powers, Chronowell fire-clock slow, fleet-kite
fire), EnemyChipFire (15 — the whole ambient chip-fire director). Hot-loop hoists rode along
in 12 per-entity step/update functions (batch-3 pattern). Reverted 9 (batch-1 “a patch must
be able to reach a real run” rule): ELITE_ZAPPER_SPREAD (the Zapper is removed from every
spawn pool and Dimension roster — test_crystal_endless_tuning.gd pins that — so the key is
dead in real play) and CUSTOM_* ×8 (custom enemies spawn only via the dev Remote Playtest
Console, never auto-spawn). Deliberately left const: LEECH_OFFSET (Vector2 — Tunables is
numeric/bool only), CHARGER_CRUISE_WHILE_CHARGING (never-shipped behaviour gate),
WYRM_CONSTRAINT_MAX_ITER (loop safety cap, not a balance knob), SPEED_BY_TYPE/
_OWN_ATTACK (const-folded tables).
Structural bounds to keep: ACCUMULATOR_MAX_RADIUS max 80 (= ElementalSystem.MAX_ENEMY_RADIUS
— past it an Accumulator silently loses projectile hits; test_enemy_remote_config.gd pins the
pair), ELITE_CHARGER_CHAIN_CHARGE_S max 0.64 (must stay ≤ the Charger’s bible lunge duration
or the zero-gap chain breaks), ELITE_GHOST_SECOND_TELEGRAPH_S max 1.2 (the second-telegraph
clock starts at GHOST_TELEGRAPH_S - ELITE_GHOST_SECOND_TELEGRAPH_S; past the bundled 1.2 it
would start pre-elapsed), CHRONOWELL_ATK_SLOW_MULT max 1.0 (a “slow” mult above 1 SPEEDS UP
fire), the ELITE_*_MULT/_PELLET_MULT/_SPIN_MULT family floored at 1.0 (a buff knob below 1
is a stealth nerf), TANK_MISSILE_COUNT/ELITE_TANK_MISSILE_COUNT int caps 5/9. New CROSS_RULES
(7, all min≤max pairs, mirrored in publish-config.mjs + a node test): CHIP_RANGE,
ORBITER_ACCEL/ORBITER_FRICTION/ORBITER_WALK_S, WYRM_ACCEL/WYRM_FRICTION,
COIL_RADIUS.
Liveness evidence (probe + wiring, batch-3 evidence-table template):
- 30/92 mechanically LIVE — divergent digests: mortal skilled 3-min (
SHOOTER_FIRE_INTERVAL,SHOOTER_PROJ_SPEED, 14 of 15CHIP_*),ELITE_CHANCE=1.0-amplified (ELITE_CHARGER_LUNGE_SPEED_MULT,ELITE_CHARGER_CHAIN_CHARGE_S,ELITE_SHOOTER_LEAD_S), invuln dumb 10-min (ACCUMULATOR_GROW_RATE/ACCUMULATOR_MAX_RADIUS, the sixORBITER_ACCEL/FRICTION/WALK_Smin/max keys,DASH_INACCURACY_DEG,CHARGER_RECOVER_S, andCHIP_RATE_MAX— the chip rate only pegs the cap from ~240s, so the 3-min scenario can’t see it; diverges at checkpoint 9/20 in the 10-min run). - 62/92 pinned by wiring tests (
tests/test_enemy_remote_config.gd, 30 tests) through the real step/update functions with only spawn/timers forced (thetest_elite_remote_config.gdpattern). The probe CANNOT field these: an alive-by-type diagnostic run (newalive_by_type_checkpoints/wave_reachedfields on the SoakRun report — additive, digest- neutral) shows its auto-player scenarios only ever field {swarmer, shooter, charger, boss, centurion} at Home plus the Light-Dimension roster {lancer, orbiter, accumulator, centurion, glass-wisp, halo-chain}; the run stalls ~3 minutes on an unkillable (dumb) boss, picks the Light dimension, and wave-pace tops out at wave 12–16. Ghost/skirmisher/tank/leech/coil/ fleet-kite never spawn in ANY probe scenario; Wyrms are Void/Poison-roster content no auto-player picks; elite powers of those types doubly so; the on-hit elite damage keys (ELITE_SHOOTER_DAMAGE_MULT,ELITE_SCATTERER_DAMAGE_MULT,ELITE_BRUTE_SLAM_*,ELITE_LANCER_TWIN_OFFSET) need a mortal pilot to actually eat the hit in-window. All are live survival content (SpawnTableweights +UNLOCK_ORDER; Wyrm viaarea_defs.gdrosters), so they ship on force-path wiring evidence — this batch is exactly the scenario-blindness case “Open items” warned about; a roster/wave-pace probe scenario override is now the clear next tooling investment rather than more trace rows. One key is probe-blind by construction:ACCUMULATOR_WARN_FRAConly emitsfx_events(not instate_checksum) — its wiring test asserts theaccum_warnfx fires at the patched fraction.
Tooling shipped with this batch: the probe’s _perturb_tunable clamps to a CROSS_RULES
partner’s bundled default (a 10× perturbation of a min-side key would otherwise violate its own
pair rule and exit 2), and --scenario-keys now accepts true/false for bool entries
(Sim.DIMENSIONS_ENABLED=false keeps a long run out of a Dimension’s restricted roster).
Batch 5 — drone director (2026-10-01)
Section titled “Batch 5 — drone director (2026-10-01)”40 keys, all DroneDirector (sim/drone_director.gd; sim/drone_state.gd has no consts): the
Sentinel/decoy family (DECOY_LIFE/SPEED/RECHARGE_RATE/PULSE_RADIUS/BASE_DAMAGE/POWER/WANDER/ ACCEL, SENTINEL_REFLECT_PER_LEVEL/SHIELD_DMG/SHIELD_RADIUS), shared drone HP
(DRONE_BASE_HP, DRONE_RADIUS), and every Bomber (5), Interceptor (7), Disruptor (8) and
Logistics (7) knob. Nothing reverted — every key reaches a real run once the class is fielded.
The four cadences DECOY_PULSE_INTERVAL/BOMBER_CD/INTERCEPTOR_CD/LOGI_CD were left const
here (folded into the _DRONE_MAX_CD table) and converted in sweep S4 (2026-10-04): the table is
now _drone_max_cd(), so the dock’s cooldown_frac tracks a patched cadence. All four probe LIVE
(the Decoy one in the default run, the rest with --meta-levels/--drone-loadout), pinned by
test_remote_cadence_keys_reach_each_class_pulse_and_the_dock_cooldown. Deliberately left const:
the DRONE_* class-id Strings, the _KIND_* type-id arrays. Hot-loop hoists
rode along in drone_disruptor’s field loop, bomber_one_blast and the Interceptor strike loop.
enemy_speed_scale reads DISRUPTOR_SLOW_MULT per enemy but only for slowed ones (in-class read).
Structural bounds to keep: DISRUPTOR_SLOW_MULT 0.05..1.0 (above 1 a “slow” speeds enemies up),
INTERCEPTOR_CHAIN_FALLOFF 0.1..1.0 (above 1 damage grows per hop), BOMBER_HEAVY_BONUS 1..5
and INTERCEPTOR_CRIT_MULT 1..10 (a bonus below 1 is a stealth nerf), INTERCEPTOR_CRIT_PER_LEVEL
0..0.25 (4 shop levels = a certain crit), DISRUPTOR_WEAKEN_PER_LEVEL 0..0.5,
SENTINEL_REFLECT_PER_LEVEL 0..1.0, LOGI_OVERCHARGE_DRONE 0.04..1.0 (fraction of max HP).
No CROSS_RULES: the drone family has no min/max pairs.
Liveness evidence (probe + wiring, batch-3 template). The default probe only fields the Sentinel
(the auto-players deploy the instant the meter fills, and current_loadout falls back to one
basic Sentinel without a shop save), so this batch added the probe’s --meta-levels/
--drone-loadout flags (see “Converting a const” step 5):
- 10/40 LIVE in the default mortal skilled 3-min run — all 8
DECOY_*,DRONE_BASE_HP,DRONE_RADIUS. - +23 LIVE with
drone-slots=3, all four class unlocks and the novel-attr levels (sentinel-reflect/shield=4,bomber-payload=4,interceptor-chain/crit=4,disruptor-weaken=4,disruptor-suppress=1) and--drone-loadout=sentinel,bomber, interceptor,disruptor— all Bomber, Interceptor, Disruptor andSENTINEL_*keys. - +5 LIVE with
--drone-loadout=logistics,sentinel,bomber,interceptor(+drone-logistics- overcharge=4,drone-logistics-endurance=4for the overcharge cadence):LOGI_SPEED,LOGI_RADIUS,LOGI_DRONE_REPAIR,LOGI_REPAIR,LOGI_OVERCHARGE_CD. - 2 pinned by wiring test (
LOGI_OVERCHARGE_HEAL,LOGI_OVERCHARGE_DRONE): the overcharge path itself is proven LIVE byLOGI_OVERCHARGE_CD, but in the probe runs its bursts land on a full-HP pilot/drone (or 4 levels × 25 already tops the pilot up), so the clamp hides the patched value.tests/test_drone_remote_config.gddrives one burst throughupdate_dronesand asserts both patched amounts; a mutation check (literal 25.0 / 0.4 back in the code) fails it.
Finding (not changed here): at bundled values the first Logistics overcharge is due at
LOGI_OVERCHARGE_CD = 8 s, exactly when the drone’s 8 s DECOY_LIFE expires — update_drones
removes it first, so a bought drone-logistics-overcharge never fires unless the player also owns
drone-logistics-endurance (or a decoy_life_mult bonus). Verified headless: a lone Logistics
drone with overcharge=1 expires after 481 ticks with zero overcharge heal. A balance call, now
patchable remotely (LOGI_OVERCHARGE_CD below 8, or a longer DECOY_LIFE).
Batch 6 — rest of sim (2026-10-01)
Section titled “Batch 6 — rest of sim (2026-10-01)”186 keys shipped (205 converted, 19 reverted to const as dead): Sim 45, SpawnDirector 2
(DIFFICULTY_START, DIFFICULTY_PER_MIN; +6 exponential-scaling keys DIFFICULTY_EXP_*/CONTACT_EXP_*/SPEED_EXP_* added 2026-10-04, all default-off), BossWarden 34, FunZo 24, Eye 29, Graviton 25,
DimensionHazards 27. The manifest is now 421 tunables. The Sim set covers the warp
(DASH_*), player fire-rate cap and poison floor, stray recall (STRAY_MARGIN, recall timer),
flanker distances, still-heal, hazard cadence and the soft enemy cap. Boss keys are attack
cadence, projectile speeds/damage/lifetimes, enrage thresholds and multipliers; dimension keys are
the per-dimension hazard knobs (fire orbits and DoT, ice/void slow zones, laser cadence, etc.).
Excluded, not deferred: boss stat blocks (*_HP, *_RADIUS, *_ARMOR, *_SPEED,
*_CONTACT, *_XP) — the bible overlay already patches them. Deferred to a later batch: the
tutorial/story/meta/upgrade and weapon_* consts, elemental_system, glass_wisp,
halo_chain, beacon, chronowell, turret, aim and orbit modules, and the dead Boss2 family
(BOSS2_ENABLED is false). Structural consts (type ids, strings, array sizes, enum-like ints)
stay const.
ai/skilled_auto_player.gd’s const WARP_DIST = Sim.DASH_SPEED * Sim.DASH_TIME no longer
const-folds once those are static vars, so it is now SkilledAutoPlayer.warp_dist() — the AI’s
warp landing prediction follows a patched dash. Hot-loop hoists: _recall_stray_enemies, the
flank branch of EnemyBehaviors.step_walk, and FunZo.update_zones.
Reverted (dead in real play, traced by hand after a NO-DIFF):
SpawnDirector.BUILDUP_TIME/WAVE_PERIOD/SURGE_PERIOD/SURGE_LEN/SURGE_RATE— onlyrate_at/spawn_count_forread them, and the sim calls neither.Sim.GEM_MAGNET_RADIUS/GEM_MAGNET_SPEED—AUTO_COLLECT_XPis atrueconst, so the magnet branch never runs.Sim.WEAPON_PICKUP_RADIUS— story-reward pickups only.Sim.AIM_KNOCKBACK— test-only; the live value isWeaponAim.DEF_KNOCKBACK.BossWarden.BOSS_BARRAGE_COUNT/BOSS_MISSILE_COUNT/BOSS_SWING_DMG/BOSS_MISSILE_DMG— the tutorial/story (non-SV_*) Warden path only; survival uses theSV_*knobs.Eye.EYE_MULTIBEAM_COUNT— the multibeam code hardcodes 2 extra beams.FunZo.FUNZO_ZONE_DURATION— live zones never decrement it; only the post-death fast decay ends a zone, and that total isFUNZO_ZONE_FAST_DECAY_S.FunZo.FUNZO_ZONE_FAST_DECAY_S— the fast decay runs after the boss dies (visual only).Graviton.GRAVITON_BLOB_LIFETIME,FunZo.FUNZO_CONFETTI_LIFETIME—_boss_projspawns them withpersist = 1, which ignores the lifetime.DimensionHazards.LASER_ACTIVE_S— the hit check runs once when the laser fires; the active window is visual only.
Structural bounds to keep: Sim.FLANK_NEAR 0..400 and Sim.FLANK_FAR 420..1500 (disjoint, so
near can never pass far); Sim.STRAY_MARGIN minimum above SPAWN_BORDER_MARGIN (a recall margin
inside the spawn ring would recall every fresh spawn); every *_ENRAGE_FRAC 0..1;
FIRE_ORBIT_* counts as whole numbers.
New CROSS_RULES (10, mirrored in scripts/publish-config.mjs; a parity test in
scripts/test/publish-config.test.mjs now parses every rule out of the registry so the two can’t
drift): DASH_TIME ≤ DASH_IFRAMES (no vulnerable warp tail), HAZARD_INTERVAL_RANDOM_MIN ≤ MAX,
STILL_HEAL_BASE_DPS ≤ MAX_DPS, FUNZO_DASH_SPEED ≤ FUNZO_DASH_SPEED_MAX,
EYE_LASER_WINDOW_FLOOR ≤ EYE_LASER_WINDOW_S, GRAVITON_PULL_STRENGTH ≤ GRAVITON_PULL_STRENGTH_MAX,
FIRE_ORBIT_RADIUS/ANG_SPEED/COUNT _MIN ≤ _MAX, VOID_COUNT_MIN ≤ VOID_COUNT_MAX.
Liveness evidence (probe + wiring). This batch added the probe’s --area, --force-boss and
--pilot-hp flags (see “Converting a const” step 5):
- 21 LIVE in the default mortal skilled 3-min run (the baseline died at ~90 s, so only early
Sim/spawn keys reach a difference there). - +159 LIVE across scenario passes:
--force-bossper boss,--areaper dimension,--invuln=1 --ticks=36000for positional keys,--pilot-hp=100000dumb runs for on-hit and late-phase keys,--scenario-keys=<boss>_ENRAGE_FRAC=1.0for enrage-only keys, and--meta-levels=field-medic=1for the still-heal keys. 180/186 probe-LIVE in total. - 6 pinned by wiring test (probe-blind):
Sim.FIRE_RATE_CAP(the auto-players never stack enough fire-rate to reach the cap) andSim.POISON_MAXHP_FLOORintests/test_sim_remote_config.gd;FunZo.FUNZO_JESTER_CONTACTintests/test_boss_attack_remote_config.gd;DimensionHazards.FIRE_ORBIT_RADIUS_MIN,FIRE_ORBIT_DPS,FIRE_PARENT_DPSintests/test_hazard_remote_config.gd. Each drives the real path (upgrade pick,enter_area+ hit,spawn_jester+_check_pilot_hit,dimension_hazards.update) after aTunables.apply.
Batch 7 — the wide sweep (2026-10-04)
Section titled “Batch 7 — the wide sweep (2026-10-04)”132 keys shipped (manifest 500 → 632 once regenerated at the next BUILD bump; build-257.json is
left as is because it describes the installed build). Defaults identical: the seed-1234
determinism baselines did not move. Per file: BossWarden 9 (BULLET_DMG + the story/tutorial
stat block and attack counts), Eye 1 (EYE_LASER_LENGTH), Sim 4 (ENEMY_PROJ_OFFMAP_MARGIN,
SPAWN_RING, WYRM_BASE_SEGMENTS, WYRM_SEGMENT_SPACING), PlayerState 2 (KITE_SLOW_MULT,
BEAM_SLOW_MULT), DimensionHazards 13, ElementalSystem 7, AreaDefs 28, HaloChainDirector 6,
GlassWisp 8, BeaconDirector 4, SpawnTable 1, DroneDirector 4 (per-class cadences),
weapons 42 (WeaponAim 9, WeaponBeam 2, WeaponBlaster 1, WeaponChronowell 5,
WeaponMomentumCore 3, WeaponRailgun 5, WeaponOrbit 3, WeaponScythe 1, WeaponScatter 1,
WeaponScarTissue 1, WeaponWildfire 4, WeaponThunderWake 2, WeaponTurret 5) and
UpgradeSystem 3.
BossWarden.BULLET_DMG(17): every Warden bullet (barrage, second ring, spiral, fan, lunge burst) reads it instead of the sharedSim.SHOOTER_PROJ_DAMAGE. A patch that raisedSHOOTER_PROJ_DAMAGEfor the Warden’s sake now only moves regular shooters/skirmishers; retarget it toBossWarden.BULLET_DMG.SpawnDirector.*_EXP_MAX = 0means no design cap (_exp_mult); the registry minimum is 0. Bundled caps are unchanged. Hard safety ceilings still apply (HP_MULT_CEILING1e5,CONTACT_MULT_CEILING20,SPEED_MULT_CEILING2.25), so an uncapped curve never reaches float32 inf (inf HP is unkillable; inf x 0 = NaN never dies). Speed is 2.25, not 3: at 3 the worst-case elite Spider covers 61 px a tick, past its 48 px overlap window with the pilot. A cap in (0, 1) means scaling OFF, never uncapped. Pinned bytests/test_tunable_safety_bounds.gd.- Other safety bounds (review, same file pins them):
SPAWN_RING[850, 2000] (widest device view half-diagonal 734 +MAX_ENEMY_RADIUS;ARENA_HALF). Ice/Void hitboxes are clamped atDimensionHazards.IMPACT_RADIUS_CEILING(800 =ARENA_DESPAWN_MARGIN; bundled max 740).MARK_BONUSmin 0.01, and a compounded mark stops atWeaponRailgun.MARK_AMT_CEILING(1e4).BLOOD_SIZE_MULTmax is its bundled 1.15: Blood’s elite Brute is already past 80 px there. - Dead per-weapon bible fields are refused (
BibleOverlay.EXCLUDED_FIELDS,DEAD_WEAPON_FIELD): e.g. turretprojectile_speed/lifetime_s, orbit/beam/novaprojectile_*, scar_tissuearea, chronowelllifetime_s, blastermax_range, andprojectile_counton every weapon except orbit/blaster/scatter/momentum_core. The manifest of builds up to 257 still lists them. - Boss drift speed now reads the pool (
sim.enemies.speed[i], like the Warden), so bibleenemies.funzo/eye/graviton.speedare live (they probed NO-DIFF before; the drift read the consts). Same values at bundled content. - Behaviour-shape changes (identical at defaults):
DroneDirector._DRONE_MAX_CD→_drone_max_cd()(the dock follows a patched cadence);WeaponTurret.MAX_RANGE→max_range();RailgunMarkLook.stacks_forfollowsMARK_BONUS; Momentum Overdrive undoes the multiplier it applied; Chronowell/turret/orbit_initread their bible fields (cooldown_s, duration_s, area, projectile_count, projectile_radius) that were dead before;VOID_SPEED_MIN/MAXno longer derive fromICE_SPEED_MAX;AreaDefs.get_def()returns a copy with the live numbers. - Probe tooling:
liveness_probe.gd --start-upgrades=a+b(grants weapons/crystals first). Probe-blind keys are pinned by wiring tests:tests/test_warden_sweep_remote_config.gd,test_boss_speed_remote_config.gd,test_drone_remote_config.gd,test_sweep_s3_remote_config.gdand the S5 weapon tests (DEF_DEADZONE,MAX_TURRETS,STACK_DECAY_S,SCYTHE_BASE_PCT).
Left as const: structural (type/attack/phase ids, array sizes, pool caps, collision radii incl.
BOSS_RADIUS and SPAWN_BORDER_MARGIN, loop safety caps, String/Vector2/Dictionary tables),
survival boss stat fallbacks the bible already patches, dead values (BOSS_XP — xp_val is read
nowhere; ELITE_ZAPPER_SPREAD; the dev-only CUSTOM_*; the batch-6 dead list incl.
SpawnDirector.BUILDUP_TIME/WAVE_PERIOD/SURGE_*), Boss2, tutorial/story pacing and shop prices.
Open: FunZo’s radius grows to 127.5, past MAX_ENEMY_RADIUS (80), so shots at its rim can miss
the spatial-hash query. So can Blood’s elite Brute (up to 103 px with the stat roll).
ai/weapon_range.gd hardcodes ranges that go stale under a patch. The Disruptor cadence is an
inline 0.5, not a const.
Gold economy (2026-10-04)
Section titled “Gold economy (2026-10-04)”Every source of run gold is patchable, and all of it goes through one helper, Sim._add_gold(amount).
That helper multiplies by Sim.GOLD_MULT and adds the result to a float remainder, Sim._gold_frac.
Only whole coins move into run_gold, which stays an int because the HUD, the results panel,
banking (main.gd) and telemetry all read it. The remainder lives on the Sim, so it resets with
the run. Gold is not in snapshot_string/state_checksum, so the seed-1234 baselines did not move.
| What | Where | Bounds |
|---|---|---|
| Gold for one kill of a type: the whole payout, bosses included | bible enemies.<id>.gold (float), read once per content load into Sim._enemy_gold by _build_enemy_gold (also on a live bible swap) |
0.1x-10x bundled (overlay default) |
| Master multiplier on all run gold (kills, bosses, crystals) | Sim.GOLD_MULT (float, 1.0) |
[0.1, 5.0] |
Dimension boss-kill crystal bonus: flat, not scaled by reward_mult but scaled by GOLD_MULT |
Sim.CRYSTAL_GOLD_BONUS (now a float, 20.0) |
[0, 200] |
| Per-area multiplier on kill gold (and XP) | AreaDefs.<DIM>_REWARD_MULT (Batch 7) |
registry |
Shop Greed upgrade: xmagnitude per owned level on ALL run gold, applied once per run (not per pilot) |
bible meta_upgrades.greed.magnitude (1.08) via MetaState.gold_mult → Sim.meta_gold_mult at run start (MetaRunSetup); NEXT_RUN in LiveScope |
0.1x-10x bundled (overlay default) |
| Shop prices | bible meta_upgrades.<id>.base_cost / cost_growth |
see “Shop prices” above |
- Bundled values reproduce the old Home numbers exactly: basic = 1, the old “+2 heavy” list
(tank, brute, elite, skirmisher, bomber, lancer, orbiter, accumulator) = 3, every boss = 41 (the
old
BOSS_GOLD40 plus the per-kill 1 every boss also got),tank_missile= 0 (excluded inBibleOverlay.EXCLUDED_FIELDS: it never pays, by design).tests/test_gold_economy.gdrequires every bible enemy to carrygold, so a new enemy can’t ship paying 0 by omission (the biomass trap). Gold is looked up by TYPE at kill time, never a newEnemyPool.add()column. - Per-boss data, not a shared
BOSS_GOLD: every pooled boss already has a bible entry (the Warden, FunZo, Graviton and Eye;TYPE_BOSS2reads the dormantsentinelentry), so one field per type covers bosses the same way as everything else, and a patch can price one boss on its own. Dev-only custom enemies have no bible entry; they paySim.CUSTOM_ENEMY_GOLD(const 1). reward_multnow applies exactly. It used to be rounded per kill, which erased Nebula’s 1.25x on a basic kill and doubled it at Aurora’s 1.5x. Per kill, before -> after (on average): basic 1/1/2/1/1/1 -> 1/1.25/1.5/1.3/1.35/1.4, heavy 3/4/5/4/4/4 -> 3/3.75/4.5/3.9/4.05/4.2, boss 41/51/62/53/55/57 -> 41/51.25/61.5/53.3/55.35/57.4, for Home/Nebula/Aurora/1.3x dimensions (Fire, Ice, Blood, Electricity, Poison)/Light 1.35x/Void 1.4x. Home is unchanged.- Greed (2026-10-04) multiplies inside
_add_goldwithGOLD_MULT; it is 1.0 (exact identity) with no levels and is never set by the baselines. Its shop desc is the template"+{pct}% run gold per level", filled from the live magnitude byMetaCopy.desc— a patchedmeta_upgrades.<id>.descshould keep its token ({pct}/{pct_less}/{mag_pct}/{mag}, seemeta-and-story.md) rather than bake a number. Publish a tokenised desc only for builds that have those tokens (from BUILD 259); an older build shows the braces raw. - Removed keys:
Sim.GOLD_PER_KILLandSim.BOSS_GOLDare gone, not renamed (a patch carrying them now gets “unknown tunable” for that key only). Their console tooltips stay intools/tunable_docs/docs.jsonunder"retired"while installed builds still publish them. - Liveness (probe, 2026-10-04): LIVE
Sim.GOLD_MULT,enemies.{swarmer,shooter,elite,lancer,orbiter,accumulator,centurion,warden}.gold(Home,--invuln=1 --ticks=36000),Sim.CRYSTAL_GOLD_BONUSandenemies.funzo.gold(--area=fire --force-boss=funzo). Types Home never spawns (tank, brute, skirmisher, ghost) probe NO-DIFF there; they share the same table lookup, andtests/test_gold_economy.gddrives a tank kill and an overlay-patched swarmer and Eye through spawn ->damage_enemy->_sweep_dead.
Operator notice — the banner channel (RC-2, 2026-10-01)
Section titled “Operator notice — the banner channel (RC-2, 2026-10-01)”The Worker merges a global notice object (KV key notice, published by
scripts/publish-notice.mjs) into every /config response — including builds with no patch
({"notice": {...}} alone is a valid response; RemoteConfigState.valid() ignores the key). The
client normalizes/filters it per build (RemoteConfig.notice_for_build: min_build/max_build
window, style info/warn, text cap), exposes it as RemoteConfig.notice + notice_changed, and
the start menu shows it as a glass banner (StartMenu.set_notice). It is comms, not config:
excluded from config_id, the crash guard and config_hash; a Worker-side notice failure serves
the patch unmerged. Cached with the response, so the last-seen notice survives an offline boot.
Deploy note: the Worker side is additive — old builds ignore the extra notice member entirely.
CTA link (RC-6, 2026-10-01): the notice may carry an optional url (https:// only, ≤200
chars — enforced by both publish-notice.mjs and the client’s notice_for_build). The start menu
renders it as a tappable mono-cyan line under the banner text and opens it with OS.shell_open
— only on platforms with a browser (Platform.can_open_links(): everything but tvOS, which
hides the line rather than show a dead one). This is the remote-URLs answer for player-facing
links (Discord/support/privacy): the game binary has NO hardcoded user-facing URLs to replace —
the only hardcoded URLs are the Worker endpoints themselves, which can’t be remote-served
(bootstrap problem), and the store-listing links (privacy/support/marketing) live in App Store
Connect metadata, changeable without a build. A general links map was deliberately NOT built:
with no in-game link UI there’d be no consumer, and an unread knob is a liability (the liveness
rule). If an about/credits UI ever lands, extend this same notice-style channel then.
Features — top-level gates (2026-10-14)
Section titled “Features — top-level gates (2026-10-14)”A patch may carry "features": {"internet_coop": true}: an on/off gate for a whole feature, read
like notice and NOT a registered bool tunable. Why top-level: main._lan_open calls
apply_for_run({}) for every networked session (all tunables back to bundled defaults), a flag
tunable would enter config_hash (the join handshake) and only take effect at the next run start,
and the gate must be readable at the start menu. So it is not in config_hash and never touches
/sim.
- Client:
RemoteConfig.features(static,{}= all off) +features_changed.RemoteConfig.features_ofkeeps only keys inOnlineCoop.FEATURESwithboolvalues._seed_cached()sets it atsetup()from the cached patch, but ONLY when that cache was accepted by this same BUILD on this same channel (RemoteConfigState.cached_build/cached_channel, stamped byreceive();cache_matches()): iOS keeps the app container from a TestFlight install to an App Store install over it, so a TestFlight-only flip must not open a store build before its own fetch answers (final review I2, guideline 2.3.1). A mismatched or pre-stamp cache seeds{}(its tunables/bible still apply as before)._on_donere-reads it from the ACCEPTED patch (state.peek_for_run()afterreceive()), unlike the notice which reads the raw response: a blacklisted (crashed) patch can never open a gate.RemoteConfigState.valid()rejects a non-objectfeatures(the whole patch is refused). Gate check:OnlineCoop.enabled(dev, RemoteConfig.features)(Dev builds always true). - Manifest / publish:
dump_manifest.gdwrites"features": ["internet_coop"];publish-config.mjsrefuses a non-object, an unknown key, a non-bool value, and any non-emptyfeaturesfor a build whose manifest has nofeatureslist (it predates the gate). - Flip (Chris decision: TestFlight only until a reviewed version shows it, guideline 2.3.1):
publish a
--channel testflightpatch for the BUILD withfeaturesadded. A channel patch REPLACES the base patch for that channel, so it must also carry the base patch’stunables/bible/sounds(copyremote-config/build-<N>.json, addfeatures).publish-config.mjsenforces this: a patch with any feature settrueis REFUSED (exit 1, dry-run too) unless the channel istestflightordev. The plainbuild-<N>key (which the appstore channel falls back to),appstoreandweball refuse. The escape hatch--allow-store-featureexists only for the post-review flip (e.g. 1.2, a version submitted to App Review with the online co-op row visible); never pass it otherwise. It also REFUSES any featuretrueon every channel exceptdevunless the LIVE privacy page (https://darkcosmos.lumara.digital/privacy/) lists the key in adata-privacy-covers="..."attribute (bullet-heaven-siteprivacy/index.html, Multiplayer section forinternet_coop). An unreachable page refuses too, and there is no override flag: deploy the site’s privacy copy first. A new feature key needs its disclosure + marker on the site before it can be switched on. - Kill: republish that channel patch without
features(keeping its other sections).{}is the whole-patch kill switch for the channel and also drops its tunables/bible/sounds, so use it only as an all-off kill. Devices see it at the next refresh (boot / menu / resume, throttled). There is no TTL: a device that never reaches/configagain (offline) keeps its cached ON, the same as every other cached patch section, but only on the build + channel that fetched it. - The Worker needs no change: it serves the KV patch as stored (plus the merged
notice).
Live dev path (2026-10-04)
Section titled “Live dev path (2026-10-04)”Dev builds (RemoteConfig.dev_live = BuildConfig.dev_tools()) treat a balance patch as something
that can change during a run; other builds keep the next-run contract.
- Refetch.
RemoteConfig.dev_tickforces a/configfetch everyDEV_REFRESH_S(30 s) on a live platform. When the patch changes mid-run,main._on_remote_config_changedcallsrebase_on_next()(the new patch becomesbase_patch) and_apply_live_config(apply_live(), "fetch"), so it lands like a console edit. The top-rightPatchToast(Dev only) saysPatch <id> applied · <time>; it never fires for the boot fetch or at run start (fetches_done > 1). Non-dev builds leave the run alone and say nothing: store, TestFlight and web behave exactly as before. - Live layer. Console
set_tunablesedits sit on top ofbase_patch(effective_patch());apply_for_runre-layers them at every run start until a reset.next_bible_overlay()includes them. While a live layer existsmark_run_starteddoes not arm the crash marker, so a bad console value cannot blacklist the published patch. - Identity.
apply_liverecomputesactive_hashfrom the effective patch and suffixes the id with+live(LIVE_SUFFIX), setsRemoteConfig.live_edited, and main pushes aconfig_liveevent{run_time, patch, hash, source: "console"|"fetch", tunables, bible}(changed keys), so telemetry rows after the change are attributable. - Replays.
ReplayRecorder.note_config(patch)records the full effective patch with the tick it lands before (config_events); playback appliesReplayLoader.configs_at(data, tick)throughapply_live(recorded)before that tick, and a replay run otherwise refuses live edits. An edit in the very last recorded tick is not replayed (no tick follows it). - Scope.
LiveScope(net/live_scope.gd) is the list of what an edit cannot change on the next tick and why; main reports those keys asstatus.config.pending. - published_at + summary.
publish-config.mjsandpublish-notice.mjsstamppublished_at(unix seconds, UTC) onto the uploaded copy. A patch may carry an optional player-facingsummary(≤ 400 chars, validated at publish, capped inRemoteConfig.patch_info);notestays operator-only:RemoteConfig.patch_info(patch, allow_note)falls back to it only for Dev callers, so it never reaches a store player. The start-menu notice shows its time under the text on every build (PublishedAt.format: “Today 14:32” / “Yesterday 09:10” / “3 Oct 14:32”). The pause menu’s left-gutter block (PauseMenu.patch_lines(..., dev)) is a Dev-only readout: the run’sconfig_id+ time, the summary (or note),+ live edits; hidden ondefaultwith no edits and on every non-dev build. The summary is capped to the lines the gutter holds (ellipsis). All three usefont.sm(the 15px TV floor); fit is tested at the iPad, iPhone and TV profiles.
Crash guard
Section titled “Crash guard”RemoteConfigState (net/remote_config_state.gd) writes a marker to user:// (run_started)
naming the active config_id right after apply_for_run in _new_run — only if that id is not
yet in proven (spec §5: “before the first run under a new config_id”). After
RemoteConfig.STABLE_AFTER_S (30s) of live play, run_stable() clears the marker and adds the id to
proven (persisted in the same file), so later runs under it never arm and an unrelated early
crash can’t revert a config that already worked on this device. A normal run end, return to menu,
and backgrounding / losing focus / a window close (NOTIFICATION_APPLICATION_PAUSED, FOCUS_OUT,
WM_CLOSE_REQUEST — an iOS background-kill or Cmd-Q is not a crash) call run_ended(): the marker
is cleared but the id is not proven, so the next run under it arms again. blacklist and proven
are each capped at RemoteConfigState.MAX_IDS (32, oldest dropped). If the marker is still set at the next
boot, that config_id is blacklisted locally (RemoteConfigState.blacklist) and every future fetch
of that id is refused (receive()) until a different patch is published. A refused refetch of a
blacklisted id also clears pending and the cache: the server’s current answer is a config this
device can’t run, so the next run uses bundled defaults, not an older cached patch the server has
since replaced. The game falls back to bundled defaults and reports the blacklist event via telemetry (runs.config_blacklisted,
gameplay_telemetry.note_blacklisted). A replay’s recorded patch (override) never arms the
marker — it isn’t the fetched config, so it can’t be blamed for a crash the marker exists to catch.
Stamping
Section titled “Stamping”Every effective config carries active_id (config_id, or "default" for bundled values) and
active_hash (Tunables.config_hash() — a hash of every registered tunable’s effective value plus
the sorted bible entries this build actually applied, BibleOverlay.effective(): rejected entries
don’t count and int fields hash as ints). Both are stamped into telemetry samples, runs, encounters and feedback
rows (telemetry/src/worker.js, D1 columns config_id / runs.config_hash /
runs.config_blacklisted — migration comment in telemetry/schema.sql), and into the replay
header (ReplayRecorder.new(run_seed, RemoteConfig.active_patch)), so a replay reproduces under the
same values it was recorded with and D1 queries can separate results by config.
A/B later (designed-for, not built)
Section titled “A/B later (designed-for, not built)”The client already sends a stable install_id (MetaState.install_id, 128-bit random hex
generated once via MetaStore.ensure_install_id, persisted in the save, sent on every /config
request) and stamps whatever config_id it got. A/B cohorting is therefore a Worker-only change
later: hash install_id into a cohort, serve that cohort’s patch, split D1 queries by config_id.
No app build needed. The Worker already accepts and ignores install today.
Testing
Section titled “Testing”soak --config=<path> (tools/soak/soak_runner.gd) applies a patch (tunables + bible) to every
soak run and exits 2 on any invalid patch — unknown key, wrong type, out-of-bounds value, or a
non-object tunables/bible section — printing every problem to stdout (soak.sh hides stderr), so
a bad patch can’t silently soak on bundled defaults. This is also the tool to use for the liveness
check in step 5 above: soak with a patch that changes only the candidate key and confirm the report
differs from a default-config soak.
GUT covers: Tunables.apply (types, unknown keys, bounds, reset-to-defaults, non-cumulative);
BibleOverlay.apply (merge, rejection of added/removed entries, zero-bounds rejection); the fetcher
cache + offline fallback + mid-run deferral + throttled refresh (and the resume/focus-in wiring);
the crash-loop guard (arm only unproven ids, prove at 30 s, pause/focus-out/close clearing without
proving, blacklist-on-reboot, blacklisted refetch → defaults); bible field types/exclusions and the
effective-value hash; the open shop repricing on replace_defs; install id persistence; the wiring test (apply a
patch that changes one tunable → the pinned determinism checksum differs; reset → it returns —
proves the value reaches the sim through the real path, same style as the story-mode XP fix);
registry completeness (every static var in sim/*.gd is registered or listed in
NON_TUNABLE_STATIC_VARS, and vice versa); the Worker’s build-fallback chain, empty-patch default
cache header, and KV-failure 503 no-store (node --test in telemetry/); and
publish-config.mjs --dry-run rejecting unknown keys, wrong types, out-of-bounds values, fractional
int fields, and a non-object patch / tunables / bible.
Remote sound packs: test_runtime_wav.gd (PCM16 mono/stereo parse; truncated/garbage/wrong format/
rate/oversize refused), test_remote_sounds.gd (one-at-a-time download, sha/length/WAV mismatch
not cached, prune, kill switch, corrupted cache, stale response, mid-run patch applies only at the
next run start, replay patch), test_audio_remote_overrides.gd (through the real
AudioManager.play()/consume(): override stream with the bundled volume/pitch/bus, cue_shape,
round-robin variant, boss spawn/enrage slots, replace-not-merge, bank 1, looping),
telemetry/test/sfx.test.mjs (route, strict path, 404/503 no-store, no collision with /config),
and scripts/test/publish-config.test.mjs (WAV check, file resolution, caps, old manifests,
missing blobs).
Open items
Section titled “Open items”- ATV frame-time check for the BUILD 230 keys (batches 2-3) — pending with Chris (spec §7.3’s
per-batch gate: soak +
scripts/gate.share green, but the A12 Apple TV 4K frame-time comparison hasn’t been run for the Supernova/boss or elite keys yet; batch 1’s check passed 2026-09-28 on BUILD 205). Don’t publish a balance patch that changes pacing/spawn/elite pressure before this runs. Batch 3’s hot-loop hoists make it less likely to regress, not safe. - App Store privacy label.
install_idis a random per-install identifier sent with every/configrequest — before the next submission, check whether the privacy label needs “Identifiers → Device ID, Analytics, not linked” (tracked as a release gate indocs/RELEASE-0.1.md§1). RESOLVED (reset_shoprefunds at current prices.d0019f1, Chris chose “refund what was paid”): see “Shop prices and the refund ledger” above. Publish a shop-price patch (meta_upgrades.*.base_cost/cost_growth) only for a BUILD that contains the ledger — patches are keyed perSim_Const.BUILD, and a build without it re-prices on respec. No build shipped so far (up to 204) contains remote config at all, so the first remote-config build must carry the ledger (don’t upload one without it).RESOLVED (batch 3):Sim.BIOMASS_TRIGGER <= Sim.BIOMASS_TARGETisn’t validatedTunablesRegistry.CROSS_RULES+ thepublish-config.mjsmirror reject the pair on both sides.- No end-to-end test of
_new_run’s call order (apply_for_run→ arm marker →GameContent.load_db→Sim.new).main.gdisn’t headless-drivable with a live patch today, so a future reorder of that sequence would go uncaught by tests. - Liveness-probe false negatives are scenario-shaped. The probe proves LIVE conclusively but NO-DIFF only proves “dead in this scenario”. Batch 3’s evidence table (above) is the template: classify each NO-DIFF as spawn-gated (wire a force-path test), on-hit (needs a hit in-window), or content-gated (dimension/boss content a single scenario can’t always reach), and trace before reverting. If a future batch keeps hitting NO-DIFFs of the same shape, consider a dedicated probe scenario (dimension-pick override, boss-phase shortcut) instead of growing the trace list.
RC-10 — flat player armor (2026-10-07)
Section titled “RC-10 — flat player armor (2026-10-07)”Player armor stopped being a percentage (6%/point, 75% cap) and became a flat subtraction:
Sim.armor_apply(amount, armor) = max(amount - armor * ARMOR_FLAT_PER_POINT, amount * ARMOR_MIN_FRAC),
applied before the damage gate and never to DoTs (is_dot bypasses armor, so a dps * dt
tick is not floored to a chunk per tick). Both values are live tunables under Player & warp:
Sim.ARMOR_FLAT_PER_POINT (1.0, dmg) and Sim.ARMOR_MIN_FRAC (0.25, frac). The fraction floor is
what lets Armor Plating stay an unlimited pick: contact scaling caps at CONTACT_EXP_MAX, so a flat
floor of 1 would have made ~18 armor permanent swarm immunity. Set the floor toward 0 on the console
to feel the “minimum 1” variant. The stats sheet and level-up card quote Sim.armor_per_point().
Sound low-risk batch (2026-10-08)
Section titled “Sound low-risk batch (2026-10-08)”Render-side AudioManager tunables, group Sound in the console (tools/tunable_docs/docs.d/sound_low_risk.json). All are read per play, so every edit is live.
- Kill-pop camera gate:
DEATH_OFFSCREEN_DB(-12),DEATH_OFFSCREEN_CD_TICKS(8),DEATH_ONSCREEN_MARGIN(64 px). A kill inside the UN-padded camera view (main passes_camera_view_rect()) grown by the margin plays at full level on the shared death cooldown, so the margin is the whole band and 0 means exactly the screen. Enemy telegraph gating uses the same view grown byAudioManager.ENEMY_FX_VIEW_PAD(110 px), which is also main’s render cull pad. Off-screen kills are handled after every other event that tick (sorted last forPER_FRAME_CAP): at most one quiet pop perDEATH_OFFSCREEN_CD_TICKS, and they never use the on-screen cooldown. - Previously-silent weapons:
RAILGUN_/CHALICE_/THUNDER_WAKE_/WILDFIRE_/STASIS_WEB_+SFX_DB/SFX_PITCH, applied byAudioManager.cue_shape(kind)on top of the build-load fade. - Formerly-unused assets:
OVERCHARGE_SFX_DB(special_rally),AEGIS_SFX_DB(special_aegis, the Decoy’s Shield Burst),WAVE_CLEAR_DB(wave_clear, a Story encounter room cleared, throughAudioManager.consume_story).
SFX_COOLDOWN_TICKS stays a const (each AudioThrottle is built once in _ready), including its new wildfire_pop entry. The weapon-load fade counts only WEAPON_FIRE_KIND_FOR_WID weapons. Every bible weapon must be in that map or in LOAD_SILENT_WIDS. Liveness and mapping tests: tests/test_audio_coverage.gd. The four shipping weapons are driven through a real sim. Stasis Web is DevFlags-only, so its test uses the event shape.
Player death sequence (2026-10-11)
Section titled “Player death sequence (2026-10-11)”Render-side DeathSequence tunables (render/death_sequence.gd), group Death sequence in the
console (tools/tunable_docs/docs.d/death_sequence.json). ENABLED (bool) is the master switch: off
goes straight to the results card. Timings (WINDUP_S, TOTAL_S (now a minimum: results wait for the explosion’s derived end), SETTLE_S, FADE_S, SKIP_GRACE_S,
SLOWMO_SCALE, …), sizes (RING_RADIUS_FRAC, CORE_SIZE, SHARD_SIZE, …), counts
(SHARD_COUNT, SPARK_COUNT, EMBER_COUNT, DISSOLVE_MAX, FRACTURE_COUNT, ints) and intensities
(FLASH_ALPHA, GLOW_BOOST, SHAKE_PX, RIPPLE_STRENGTH, PUNCH_STRENGTH, …) are all registered;
colour-bearing knobs have *_HDR twins used while HDR output is on. Every key is read per frame or at
each death, so a console edit is live from the next death. The keys read in the BOOM’s spawn
functions (_spawn_fire, _spawn_crystals) are listed in LiveScope as reviewed LIVE exceptions. Pinned by
tests/test_death_sequence.gd (patches through Tunables.apply reach the real node; every
static var is registered and labelled). Not "local", like the other render looks. The manifest is
not regenerated here; do it at the BUILD bump that ships it.
Batch — App Clip Warden demo (2026-10-15)
Section titled “Batch — App Clip Warden demo (2026-10-15)”Four new keys on sim/clip_director.gd, read only while a ClipDirector is attached (the App Clip’s
clip mode; plan docs/superpowers/plans/2026-10-15-app-clip-warden.md):
| Key | Default | Scope | Evidence |
|---|---|---|---|
ClipDirector.CLIP_XP_MULT |
3.0 | LIVE (per kill, Sim._award_xp) |
test_xp_multiplier_is_a_live_tunable |
ClipDirector.CLIP_RAMP_S |
45 s | LIVE until the Warden spawns | test_ramp_holds_the_wave_loop, test_warden_spawns_at_ramp_end_with_explicit_biomass |
ClipDirector.CLIP_WARDEN_HP_SCALE |
1.0 | NEW_SPAWNS (baked at spawn) | test_warden_hp_scale_is_baked_at_spawn |
ClipDirector.CLIP_SWARM_DENSITY |
2.5 | LIVE (each wave + refill check) | test_swarm_density_refills_the_field_sooner, test_swarm_density_scales_the_wave_target |
ClipDirector.CLIP_PICK_GAP_S |
10.0 | LIVE (each level-up check) | test_level_ups_are_spaced, test_level_up_pacing_is_live |
The liveness probe reports all four NO-DIFF because it runs Survival: they are scenario-limited and
shipped on the wiring tests above. CLIP_SWARM_DENSITY scales Sim.BIOMASS_TRIGGER and the wave’s
biomass target together (the BIOMASS_TRIGGER <= BIOMASS_TARGET cross-rule still holds); it does not
lift Sim.SOFT_ENEMY_CAP.
Ship skins (2026-10-15)
Section titled “Ship skins (2026-10-15)”Render-side ShipSkin tunables (render/ship_skin.gd), group Ship skins in the console
(tools/tunable_docs/docs.d/ship_skin.json). All 28 keys are "local": true (render-only, per device)
and read per frame, so every edit is live mid-run. ShipSkin.LOOK (int 0-4) is the Dev-only skin A/B the
spec calls ship_skin: honoured only when BuildConfig.dev_tools(), so a published patch can never switch
an unreviewed skin on for App Store players (Apple 2.3.1). ShipSkin.BETA_LOOK (int 1-4) picks which look
the Beta skin draws; it only matters to players who own the skin, so patching it is a look patch. The
manifest is not regenerated here; do it at the BUILD bump that ships it.
Hull stats (issue #105, 2026-10-20)
Section titled “Hull stats (issue #105, 2026-10-20)”ShipBonuses per-hull base_hp, base_speed, base_radius and the headline magnitude are 28
registered tunables (ShipBonuses.<HULL>_BASE_HP|BASE_SPEED|BASE_RADIUS|BONUS). ShipBonuses.TABLE
stays the single source of the bundled numbers (each static var initialises from it) and all reads go
through ShipBonuses.live(ship, field). Values are unchanged; a tests/test_hull_tunables.gd case pins
defaults == TABLE. label_for rebuilds the “+10 Armor” style text from the live bonus when it differs.
Slot counts, class and names stay structural. Bounds are explicit per field; not liveness-probed (hull
stats are read at run start by apply_base/apply_to, covered by a wiring test through Tunables.apply).
The release manifest (remote-config/manifest/build-N.json) is regenerated at the next build bump.
Touch controls look (2026-10-27)
Section titled “Touch controls look (2026-10-27)”The iOS on-screen controls (ui/touch_controls.gd) are fully live-tunable, with defaults equal to the
literals they replaced (zero visual change):
- Colours are
touch.*UI tokens (ui/tokens/defaults.json), each aliasing the role it used before (touch.disc=@surface.void,touch.warp=@accent.main,touch.drone=@violet.soft,touch.aim.fill|ring|tick|knob|knob_ring,touch.move.ring|deep|wedge|knob|knob_ring|ghost,touch.sys.pause|feedback,touch.icon.hot,touch.nudge.ring). Retune one through the live-token path; the pad repaints viaTokenBind.redraw, and retuningtouch.warpleavesaccent.mainalone. - Numbers are 70
TouchControls.*static vars (Remote Console group “Touch controls”, labels intools/tunable_docs/docs.d/touch_controls.json): every alpha (*_A, 0..1), line width / offset in POINTS (*_W,ABILITY_HALO_PAD,LABEL_PT, multiplied by the device’s units-per-point), the idle fade (IDLE_FADE_MS,IDLE_EASE_MS,IDLE_ALPHA),READY_PULSE_MSand the drone-nudge rates. All"local". They are read in_draw/_process; the pad only redraws itself while animating, soLiveScope.HOOK_TOUCH(prefix ruleTouchControls.) hasmain._apply_live_configcallTouchControls.apply_tunables(), which repaints an idle pad at once. - Left const on purpose: hit and layout geometry (
MOVE_R_PT,AIM_R_PT,SYS_HIT_PT, the 44 pt hit floor,ABILITIESradii and angles,EDGE_PT,GAP_PT,TABLET_*) because tests pin the Apple 44 pt minimum and the bottom footprint other UI avoids; input feel (JOY_DEADZONE,JOY_ZONE_FRAC,SLIDE_SLOP,DRONE_HOLD_MS,DRONE_NUDGE_*); and the icon / glyph shape ratios inside the draw helpers (pure drawing proportions, not look knobs).
Not liveness-probed (render-only); tests/test_touch_controls_tunables.gd pins the defaults against the
old literals, the token aliases, and the real paths (Tunables.apply → _cluster_alpha / label_px,
and an idle pad’s draw firing after apply_tunables). The manifest is regenerated at the next build bump.