Skip to content

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:

  1. Spawn any enemy or boss on demand (count + placement) to study it in the real game.
  2. Build custom enemies by freely mixing body silhouette × movement behaviour × ranged weapon × stat sliders × element — to discover which combinations make good foes.
  3. Edit live player stats (HP, speed, fire-rate, armor, damage, …).
  4. Isolate a test: pause the normal spawn director and clear the arena so only what you spawn is on the field.
  5. Survive to study: toggle player invulnerability and a time-scale (slow-mo / fast-forward).
  6. 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.

  • 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-Node pattern net/telemetry.gd already 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.new directly and never arms them, so the survival baseline 1432233777 / 2300319179 stays byte-identical. This mirrors the existing if 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.
  • 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.
┌─────────────────────────┐ ┌───────────────────────────┐ ┌──────────────────────────┐
│ 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.

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; seq monotonic 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 behind CONTROL_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 next seq for that code, insert. Body is text/plain JSON (CORS-simple, no preflight), matching the telemetry convention.
  • GET /poll?code=XXXX&after=<seq> → JSON array of {seq, cmd} with seq > after for 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} → upsert status.
  • GET /status?code=XXXX → latest status blob (panel reads).
  • POST /caps {code, blob} → upsert caps.
  • 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 its HTTPRequest children, posts /caps once.
  • Disabled until enable() is called; never polls in the shipped web demo or ordinary play.
  • _process(dt) — every POLL_INTERVAL (~1.0s): GET /poll?code&after=<cursor>; on response, for each {seq, cmd} call main.apply_dev_command(cmd) and advance the cursor; then POST /status with the current readout blob.
  • All failures ignored (a tool must never affect play). The pairing code is exposed to the HUD (main renders REMOTE ▸ <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 enemy type ×count at placement, around the player (reuses sim.spawn_story_enemy(name, pos, power)).
  • spawn_boss → call the matching boss method (_spawn_boss Warden / _spawn_boss2 / _spawn_funzo / _spawn_graviton / _spawn_eye).
  • spawn_customsim.dev_spawn_custom(spec, pos) ×count at placement.
  • clearsim.dev_clear_enemies().
  • pause_spawns / resume_spawns → set sim.dev_suppress_spawns.
  • invuln → set sim.player.dev_invuln.
  • timescale → set main._dev_timescale (render-side).
  • player_stat → guarded write to sim.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 — early return at the top of _spawn_enemies when true. (Boss spawns and the armed-enemy firing are independent of this — only the normal-enemy trickle pauses.)
  • PlayerState.dev_invuln: bool = falseSim.is_invulnerable() returns true if player.dev_invuln. Because is_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 through is_invulnerable) gets an explicit dev_invuln check 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_time so 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:

  1. Body silhouetteArchetypeRenderer.shape_for returns _TYPE_SHAPE[type_id] (17 shapes, SHAPE_TRIANGLE..SHAPE_MISSILE).
  2. Ranged weapon_update_shooters (zapper/scatterer/bomber) + the orbiter and lancer loops switch on type_id.

Decouple both:

  • EnemyPool.TYPE_CUSTOM = 22. Add two columns shape_id: PackedInt32Array, attack_id: PackedInt32Array, resized in _init and swap-removed in lockstep in add/remove_at (the documented data-oriented-pool extension pattern). Default -1 for non-custom enemies.
  • Sim.dev_spawn_custom(spec: Dictionary, pos: Vector2) -> void — adds an enemy with type_id = TYPE_CUSTOM, behavior = spec.behavior, the stat columns from spec, 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) uses enemies.shape_id[i] when type_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_ATTACK map for the normal armed types. Refactor the firing loops so each fires by attack_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 behavior column — dev_spawn_custom just sets it (walk/dash/skirmish/rush/ghost/homing; boss/homing-missile excluded 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" writes sim.player.<field> = value directly (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 raw PlayerState fields (hp for “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_mult only. (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 (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: 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 / 2300319179 unchanged.
  • /sim purity: ControlClient, the dispatcher, placement, and time-scale are render-side. Only plain-data flags + pool ops live in /sim.
  • Security: CONTROL_KEY gates 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.
  • /sim seams (TDD, headless GUT): dev_suppress_spawns halts the normal trickle; dev_clear_enemies() empties the pool; dev_invuln makes is_invulnerable() true and the player takes no contact/ranged/boss damage; dev_spawn_custom sets type_id/shape_id/attack_id/behavior/stat columns correctly and swap-removes them in lockstep; a custom enemy renders under the right shape partition (instance_count) and fires by its attack_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)”
  1. Relay worker + D1 + panel skeleton — endpoints, schema, fail-closed auth; panel shell that can issue a clear/pause no-op against a stub.
  2. /sim dev seamsdev_suppress_spawns, dev_invuln, dev_clear_enemies (TDD; determinism re-verified).
  3. ControlClient + dispatcher + arming — polling, status post, start-menu toggle, HUD code, preset/boss spawn + placement + time-scale.
  4. Custom-enemy decouplingTYPE_CUSTOM + shape_id/attack_id columns + renderer & firing dispatch refactor + dev_spawn_custom (TDD; the meatiest; determinism re-verified).
  5. Player stat editor + caps manifest — caps publish, player-stat command, panel builder + player-editor + readout wiring.
  6. Polish + on-device verification — neon panel pass, phone-drives-ATV end-to-end test.
  • 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.json stays a manual content edit (the tool exports the spec JSON to copy in).
  • Whether dev_clear_enemies should 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.