Remote Playtest Console — design
Remote Playtest Console — design
Section titled “Remote Playtest Console — design”Date: 2026-06-25 Status: Approved (design) — ready for implementation plan Type: Dev/tuning tool (render-side + cloud relay + static web panel). Not shipped to the public web demo.
A static web app (phone or laptop) that connects to a live survival playtest running on any platform — primarily the Apple TV — and lets the playtester:
- Spawn any enemy or boss on demand (count + placement) to study it in the real game.
- Build custom enemies by freely mixing body silhouette × movement behaviour × ranged weapon × stat sliders × element — to discover which combinations make good foes.
- Edit live player stats (HP, speed, fire-rate, armor, damage, …).
- Isolate a test: pause the normal spawn director and clear the arena so only what you spawn is on the field.
- Survive to study: toggle player invulnerability and a time-scale (slow-mo / fast-forward).
- Read back what the game reports: alive-count per enemy type, fps, run time, player HP.
The win is tuning enemy and player feel from real on-device sessions instead of editing bible.json + re-exporting + re-deploying for every experiment.
Why this shape
Section titled “Why this shape”- Cloud relay, not a local socket. The Apple TV (and the web wasm build) cannot accept an inbound connection, but they can both make outbound HTTP. So the game polls a tiny relay for queued commands and posts its status back — the exact
HTTPRequest-in-a-Nodepatternnet/telemetry.gdalready uses, run in reverse. This transport reaches every platform (ATV, web, Mac) with one codepath. - Determinism is preserved by construction. Every game-state mutation goes through guarded dev seams that default to off; the determinism test builds
Sim.newdirectly and never arms them, so the survival baseline1432233777/2300319179stays byte-identical. This mirrors the existingif story != null:null-object discipline. - The panel learns its vocabulary from the game. On enable, the game publishes a capabilities manifest (shapes, behaviours, attacks, stat fields + ranges, boss list) through the relay, so the panel auto-populates and cannot drift from the game’s enums.
Non-goals (YAGNI)
Section titled “Non-goals (YAGNI)”- Not a player-facing feature; not shipped in the public web demo, gated behind a key.
- No multi-user/coordination beyond a pairing code that scopes commands to one running instance.
- No persistence of custom enemies into
bible.json(presets live in the panel/localStorage; promoting a winner into the bible stays a manual content edit). - No sub-second latency engineering (long-poll / Durable Object). ~0.5–1s poll is fine for a tuning tool.
- Bosses are not composable (they are bespoke attack-pattern state machines) — they are whole-unit spawn buttons only.
Architecture & data flow
Section titled “Architecture & data flow” ┌─────────────────────────┐ ┌───────────────────────────┐ ┌──────────────────────────┐ │ Browser panel │ POST /cmd│ CF Worker │ GET │ Godot ControlClient │ │ (served by the worker, │────────► │ bullet-heaven-control │ /poll │ (render-side Node, │ │ mobile-first neon) │ │ + D1 (commands/status/ │◄───────│ OFF by default, │ │ │◄──────── │ caps tables) │ POST │ armed by a menu toggle)│ │ │ GET │ │ /status│ │ │ │ /status │ │ /caps │ polls ~1s, drains queue │ │ │ /caps │ │◄───────│ → main.apply_dev_command│ └─────────────────────────┘ └───────────────────────────┘ └──────────────────────────┘
Pairing: the game generates a short CODE (e.g. "AB12"), shows it on the HUD. The panel targets that CODE. The worker only returns commands tagged with the live CODE → commands reach exactly one running instance.
One-way data flow inside the game is unchanged: ControlClient (controller, render-side) → guarded dev seams on Sim/PlayerState → Sim ticks as normal → Render reads Sim.Components
Section titled “Components”A. Relay worker bullet-heaven-control (new CF Worker + D1)
Section titled “A. Relay worker bullet-heaven-control (new CF Worker + D1)”A new, dedicated worker — not bolted onto bullet-heaven-telemetry — because a “mutate my game” surface has a different security posture than anonymous public perf ingest and should be independently deployable/revocable.
D1 schema (schema.sql, all CREATE … IF NOT EXISTS, idempotent):
commands(code TEXT, seq INTEGER, payload TEXT, ts INTEGER)— append-only per code;seqmonotonic per code.status(code TEXT PRIMARY KEY, blob TEXT, ts INTEGER)— latest readout blob, upserted.caps(code TEXT PRIMARY KEY, blob TEXT, ts INTEGER)— latest capabilities manifest, upserted.- A periodic prune (delete rows older than ~1h) keeps the queue small; can be a best-effort delete-on-write rather than a cron.
Endpoints:
GET /→ the neon panel HTML. Fail-closed behindCONTROL_KEY(401 if env unset or query/cookie key mismatches; constant-time compare; strict CSP;esc()every interpolated value — stored-XSS hardening, exactly as the telemetry dashboard got).POST /cmd{code, cmd}→ assign nextseqfor that code, insert. Body istext/plainJSON (CORS-simple, no preflight), matching the telemetry convention.GET /poll?code=XXXX&after=<seq>→ JSON array of{seq, cmd}withseq > afterfor that code. The game advances its local cursor to the max seq it consumed; the worker may also delete consumed rows.POST /status{code, blob}→ upsertstatus.GET /status?code=XXXX→ latest status blob (panel reads).POST /caps{code, blob}→ upsertcaps.GET /caps?code=XXXX→ latest caps blob (panel reads to populate its controls).
Deploy: wrangler from the worker dir with CLOUDFLARE_API_TOKEN="$CF_LUMARA_DEPLOY_TOKEN" + CLOUDFLARE_ACCOUNT_ID="$CF_ACCOUNT_ID". CONTROL_KEY set as a worker secret; mirrored into ~/.secrets as BULLET_HEAVEN_CONTROL_KEY.
B. Game-side net/control_client.gd (ControlClient extends Node, render-side, NOT /sim)
Section titled “B. Game-side net/control_client.gd (ControlClient extends Node, render-side, NOT /sim)”Mirrors net/telemetry.gd:
enable(main: Node) -> void— generates a pairing code (%04X-ish), creates itsHTTPRequestchildren, posts/capsonce.- Disabled until
enable()is called; never polls in the shipped web demo or ordinary play. _process(dt)— everyPOLL_INTERVAL(~1.0s):GET /poll?code&after=<cursor>; on response, for each{seq, cmd}callmain.apply_dev_command(cmd)and advance the cursor; thenPOST /statuswith the current readout blob.- All failures ignored (a tool must never affect play). The pairing code is exposed to the HUD (
mainrendersREMOTE ▸ <code>while armed).
Arming: a “Remote Control” entry on the start menu (ui/start_menu.gd) and/or a debug key in main (e.g. F6). tvOS reaches it via the menu (no keyboard).
C. main.apply_dev_command(cmd: Dictionary) — the dispatcher (render-side)
Section titled “C. main.apply_dev_command(cmd: Dictionary) — the dispatcher (render-side)”Routes by cmd.kind:
spawn_preset→ spawn an existing enemytype×countatplacement, around the player (reusessim.spawn_story_enemy(name, pos, power)).spawn_boss→ call the matching boss method (_spawn_bossWarden /_spawn_boss2/_spawn_funzo/_spawn_graviton/_spawn_eye).spawn_custom→sim.dev_spawn_custom(spec, pos)×countatplacement.clear→sim.dev_clear_enemies().pause_spawns/resume_spawns→ setsim.dev_suppress_spawns.invuln→ setsim.player.dev_invuln.timescale→ setmain._dev_timescale(render-side).player_stat→ guarded write tosim.player(see E).
Placement helper (render-side): ring (evenly around player at radius R), ahead (in the player’s facing/aim direction at distance D), random (uniform in a band around the player). Uses a render-side RNG (randf), not the sim’s rng — so it never perturbs the deterministic spawn stream.
Time-scale (render-side, sim untouched): main._physics_process accumulates _ts_accum += _dev_timescale and runs sim.tick(input) floor(_ts_accum) times per physics frame (fast-forward = multiple ticks/frame; slow-mo = a tick every few frames). The sim still ticks the fixed Sim_Const.DT; only the number of ticks per real second changes, so a given input stream stays deterministic. Default 1.0.
D. /sim dev seams — plain data, default off (determinism baseline byte-identical)
Section titled “D. /sim dev seams — plain data, default off (determinism baseline byte-identical)”These are /sim-legal (plain bools + pool ops, no Node/Engine/Time/Input/File APIs). The determinism test never arms them.
Sim.dev_suppress_spawns: bool = false— earlyreturnat the top of_spawn_enemieswhen true. (Boss spawns and the armed-enemy firing are independent of this — only the normal-enemy trickle pauses.)PlayerState.dev_invuln: bool = false—Sim.is_invulnerable()returnstrueifplayer.dev_invuln. Becauseis_invulnerable()is the single guard already consulted at every player-damage site (contact, enemy_proj, shooter, boss swing/missiles/artillery, orbiter, lancer, …), this one flag covers them all. Contact-death path (which does not currently route throughis_invulnerable) gets an explicitdev_invulncheck too.Sim.dev_clear_enemies() -> void— swap-remove every enemy in the pool (descending), and reset boss bookkeeping so the rotation/economy doesn’t stall (clear any active boss index tracking; leave_next_boss_timeso a boss can spawn again, or expose a separate “respawn boss timer” — decided in the plan).
E. Custom-enemy decoupling — the meaty chunk
Section titled “E. Custom-enemy decoupling — the meaty chunk”Today an enemy’s stats (hp, radius, armor, speed, contact_dmg, xp), movement (behavior column) and element (base_element) are already per-enemy columns set at spawn — freely settable. Only two things are still bonded to the integer type_id:
- Body silhouette —
ArchetypeRenderer.shape_forreturns_TYPE_SHAPE[type_id](17 shapes,SHAPE_TRIANGLE..SHAPE_MISSILE). - Ranged weapon —
_update_shooters(zapper/scatterer/bomber) + the orbiter and lancer loops switch ontype_id.
Decouple both:
EnemyPool.TYPE_CUSTOM = 22. Add two columnsshape_id: PackedInt32Array,attack_id: PackedInt32Array, resized in_initand swap-removed in lockstep inadd/remove_at(the documented data-oriented-pool extension pattern). Default-1for non-custom enemies.Sim.dev_spawn_custom(spec: Dictionary, pos: Vector2) -> void— adds an enemy withtype_id = TYPE_CUSTOM,behavior = spec.behavior, the stat columns fromspec,base_element = spec.element,shape_id = spec.body,attack_id = spec.weapon.- Render dispatch:
ArchetypeRenderer.shape_for(type_id, shape_override)(or the sync loop) usesenemies.shape_id[i]whentype_id == TYPE_CUSTOM, else_TYPE_SHAPE[type_id]. No new shapes needed — custom enemies reuse the existing 17-shape vocabulary; the LUT/partition count is unchanged. - Firing dispatch: introduce an attack-id vocabulary
{0 none, 1 bolt, 2 pellet-fan, 3 bomb, 4 beam, 5 orbit-shards}and a_TYPE_ATTACKmap for the normal armed types. Refactor the firing loops so each fires byattack_id(normal enemy:_TYPE_ATTACK[type]; custom:enemies.attack_id[i]). The existing per-pattern fire code (bolt/fan/bomb/beam/shards) becomes the body of each attack-id branch — behaviour-preserving for the existing enemies. - Movement is already the
behaviorcolumn —dev_spawn_customjust sets it (walk/dash/skirmish/rush/ghost/homing;boss/homing-missileexcluded from the builder vocabulary).
Determinism: TYPE_CUSTOM is never spawned by the baseline run, the new columns default to -1, and the firing refactor is behaviour-preserving for existing types → baseline unchanged. Re-run the determinism test after the refactor to confirm.
F. Player stat editor (render-side, data-driven)
Section titled “F. Player stat editor (render-side, data-driven)”The panel renders one control per player stat; a player_stat command carries {field, value, mode}:
mode: "set"writessim.player.<field> = valuedirectly (guarded dev write).- Editable fields come from
StatEffects.TABLE(max_hp,fire_rate_mult,move_speed→speed,pickup_radius,armor,damage_mult,dash_cooldown_mult, decoy mults) plus rawPlayerStatefields (hpfor “Heal to full”,speed). The caps manifest publishes the editable list + sane min/max so the panel builds sliders without hardcoding. - Per-weapon damage mults: out of scope for v1 (damage is per-weapon since cycle 16); the editor exposes the global
damage_multonly. (Flagged as a possible follow-up.)
G. The panel (static HTML/JS, served by the worker, mobile-first neon)
Section titled “G. The panel (static HTML/JS, served by the worker, mobile-first neon)”Sections, all populated from /caps:
- Spawn grid — buttons grouped Swarm / Ranged / Special / Bosses.
- Count + placement — count chips (1/5/25/100 + free entry), placement radio (ring / ahead / random).
- Enemy builder — Body (shape) · Movement (behaviour) · Weapon (attack) dropdowns; HP / Speed / Radius / Contact / Armor sliders; element picker; “Spawn custom” + “Save preset” (localStorage).
- Isolate — Pause auto-spawns · Clear arena.
- Survivability — Invulnerable toggle · time-scale chips (0.25 / 0.5 / 1 / 2 / 4×).
- Player editor — slider per stat + “Heal to full”.
- Readout strip — alive-per-type, fps, run_time, hp (polled from
/status).
Layout is phone-first (you hold the phone, watch the TV). Neon styling to match the game.
Command & status protocol
Section titled “Command & status protocol”Command (panel → worker → game), cmd object:
{ "kind": "spawn_custom", "count": 5, "placement": "ring", "spec": { "body": 14, "behavior": 5, "weapon": 2, "hp": 120, "speed": 180, "radius": 22, "contact": 12, "armor": 4, "element": 3 } }{ "kind": "spawn_preset", "type": "bomber", "count": 3, "placement": "ahead" }{ "kind": "spawn_boss", "boss": "graviton" }{ "kind": "clear" }{ "kind": "pause_spawns", "on": true }{ "kind": "invuln", "on": true }{ "kind": "timescale", "value": 0.25 }{ "kind": "player_stat", "field": "max_hp", "value": 500, "mode": "set" }Status (game → worker → panel):
{ "code": "AB12", "fps": 50, "run_time": 73.2, "hp": 500, "hp_max": 500, "invuln": true, "paused": true, "timescale": 0.25, "alive": { "ghost": 3, "rusher": 25, "graviton": 1 } }Capabilities (game → worker → panel, posted once on enable):
{ "shapes": [{ "id": 0, "name": "triangle" }, …], // ArchetypeRenderer SHAPE_* "behaviors":[{ "id": 0, "name": "walk" }, …], // EnemyPool BEHAVIOR_* (boss/homing excluded) "attacks": [{ "id": 0, "name": "none" }, …], // attack-id vocabulary "presets": ["swarmer","tank",…], // _ENEMY_NAME_TO_TID keys (non-boss) "bosses": ["warden","boss2","funzo","graviton","eye"], "player_stats":[{ "field":"max_hp","label":"Max HP","min":1,"max":2000,"step":10 }, …], "build": <Sim_Const.BUILD> }Determinism & security
Section titled “Determinism & security”- Determinism: all seams default off; custom enemies never spawn in the baseline; placement uses a render-side RNG; the firing refactor is behaviour-preserving. The determinism + checksum tests are re-run after the custom-enemy chunk and must show
1432233777/2300319179unchanged. /simpurity: ControlClient, the dispatcher, placement, and time-scale are render-side. Only plain-data flags + pool ops live in/sim.- Security:
CONTROL_KEYgates the panel page (fail-closed, constant-time, CSP); the pairing code scopes commands to one instance;esc()on every interpolated status value (enemy names etc.) prevents stored-XSS in the panel. The poller is opt-in and never runs in the public web demo. - Safety note: the panel can spawn 100s of enemies — that is the point (perf stress test) — but the game keeps its own
SOFT_ENEMY_CAP-style ceilings for normal spawns; dev spawns deliberately bypass the cap. Document that a huge custom spawn can drop fps; the readout shows it.
Testing
Section titled “Testing”/simseams (TDD, headless GUT):dev_suppress_spawnshalts the normal trickle;dev_clear_enemies()empties the pool;dev_invulnmakesis_invulnerable()true and the player takes no contact/ranged/boss damage;dev_spawn_customsetstype_id/shape_id/attack_id/behavior/statcolumns correctly and swap-removes them in lockstep; a custom enemy renders under the right shape partition (instance_count) and fires by itsattack_id. Determinism + checksum tests unchanged.- Worker (node test): enqueue → poll-by-cursor returns only newer seqs; status/caps upsert; fail-closed auth (401 without the key). Mirror the telemetry worker’s test pattern.
- Panel + feel: manual on-device playtest (it is a tool). Verify a phone drives an ATV session end-to-end.
Phasing (implementation chunks, each via the bh-dev-chunk ritual)
Section titled “Phasing (implementation chunks, each via the bh-dev-chunk ritual)”- Relay worker + D1 + panel skeleton — endpoints, schema, fail-closed auth; panel shell that can issue a
clear/pauseno-op against a stub. /simdev seams —dev_suppress_spawns,dev_invuln,dev_clear_enemies(TDD; determinism re-verified).- ControlClient + dispatcher + arming — polling, status post, start-menu toggle, HUD code, preset/boss spawn + placement + time-scale.
- Custom-enemy decoupling —
TYPE_CUSTOM+shape_id/attack_idcolumns + renderer & firing dispatch refactor +dev_spawn_custom(TDD; the meatiest; determinism re-verified). - Player stat editor + caps manifest — caps publish, player-stat command, panel builder + player-editor + readout wiring.
- Polish + on-device verification — neon panel pass, phone-drives-ATV end-to-end test.
Open questions / deferred
Section titled “Open questions / deferred”- Per-weapon damage mults in the player editor (deferred to a follow-up; v1 exposes global
damage_mult). - Promoting a winning custom enemy into
bible.jsonstays a manual content edit (the tool exports the spec JSON to copy in). - Whether
dev_clear_enemiesshould also clear boss-spawned projectiles/zones (decided in the plan). - Long-poll / lower latency — not needed for v1; revisit only if 1s feels sluggish in practice.