Skip to content

Remote Playtest Console Implementation Plan

Remote Playtest Console Implementation Plan

Section titled “Remote Playtest Console Implementation Plan”

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: A static web app connects to a live survival playtest on any platform (primarily Apple TV) via a CF Worker relay the game polls, letting the playtester spawn any enemy/boss, build custom enemies (body × movement × weapon × stats × element), edit live player stats, isolate (pause spawns + clear), survive (invuln + time-scale), and read back alive-counts/fps/hp.

Architecture: The game polls a dedicated CF Worker (bullet-heaven-control + D1) for queued commands and posts its status/capabilities back — the net/telemetry.gd outbound-HTTP pattern run in reverse, so it reaches tvOS/web/Mac with one codepath. Every game-state mutation goes through guarded dev seams on Sim/PlayerState that default off, so the determinism baseline stays byte-identical. The panel learns its vocabulary from a capabilities manifest the game publishes, so it never drifts from the game’s enums.

Tech Stack: Godot 4.6.3 / typed GDScript (game), GUT 9.6 (tests), Cloudflare Worker + D1 + Node node:test (relay), vanilla HTML/JS (panel).

  • /sim purity: every file under sim/ extends RefCounted and uses NO Node/render/Input/Engine/Time/File/JSON APIs. ControlClient, the dispatcher, placement, and time-scale are render-side. Only plain-data flags + pool ops live in /sim.
  • Determinism is the keystone: the sim ticks fixed Sim_Const.DT (1/60); all sim randomness flows through Sim.rng. Current baseline (seed 1234, 600 ticks), pinned in tests/test_determinism_checksum.gd: snapshot_string().hash() = 1432233777, state_checksum() = 2300319179. Every game-side task must re-run the determinism + checksum tests and leave these UNCHANGED. All dev seams default off and custom enemies never spawn in the baseline, so this holds by construction.
  • Two RNG streams: rng (spawns/sim) and upgrade_rng (upgrade rolls). Dev placement uses a RENDER-side RNG (randf), never sim.rng — drawing from sim.rng would desync the spawn stream.
  • Per-tick render-read columns (recomputed every tick) are excluded from snapshot_string()/state_checksum(). New dev flags/columns that the baseline never touches stay out of the checksum too.
  • bh-dev-chunk ritual: every game-side task is a chunk — build → TDD → godot --headless --import → boot-check (grep stderr for SCRIPT ERROR) → test-count guard (scripts/check-test-count.sh) → determinism → commit. Invoke the bh-dev-chunk skill at the start of each game-side task.
  • CF deploy: wrangler from the worker dir with CLOUDFLARE_API_TOKEN="$CF_LUMARA_DEPLOY_TOKEN" AND CLOUDFLARE_ACCOUNT_ID="$CF_ACCOUNT_ID" (9106 = missing account id). text/plain request bodies keep CORS simple (no preflight).
  • Security: the panel page is fail-closed behind CONTROL_KEY (401 if env unset or key mismatches; constant-time compare; strict CSP; esc() every interpolated value). The pairing code scopes commands to one running instance. The poller is opt-in and never runs in the public web demo or normal play.
  • Not shipped to the public web demo. This is a dev/tuning tool.
  • Game-logic changes land HERE first, then re-copy to ~/Claude/bullet-heaven-tvos/ (via the bh-deploy skill) so the ATV build can be controlled — but deployment is out of scope for these tasks (done once the tool is verified).

New — relay worker (control/):

  • control/wrangler.toml — worker config + D1 binding (mirror telemetry/wrangler.toml).
  • control/schema.sqlcommands/status/caps tables (idempotent).
  • control/src/queue.js — pure logic: nextSeq, commandsAfter, keyOk — node-tested.
  • control/src/worker.js — fetch handler wiring queue.js to D1; serves the panel at GET /.
  • control/src/panel.html.js — exports the panel HTML string (built up across Tasks 1, 5, 6).
  • control/test/queue.test.mjsnode:test for queue.js.
  • control/README.md — ops notes (deploy, key, pairing).

New — game side:

  • net/control_client.gdControlClient extends Node: poll/drain/status/caps. Render-side.

Modified — game side:

  • sim/sim.gd — dev seams: dev_suppress_spawns, dev_clear_enemies(), is_invulnerable() (+dev_invuln), dev_spawn_custom(), custom-attack pass, attack-id constants.
  • sim/player_state.gddev_invuln: bool = false.
  • sim/enemy_pool.gdTYPE_CUSTOM = 22; shape_id/attack_id columns (resize/add/remove lockstep).
  • render/archetype_renderer.gdshape_for consults shape_id for custom enemies.
  • main.gd — create/persist ControlClient; apply_dev_command(cmd); render-side time-scale in _physics_process; build + publish caps; HUD pairing-code; F6/menu arm.
  • ui/start_menu.gd — a “Remote Control” affordance (optional secondary entry).

New — tests:

  • tests/test_dev_seams.gd — suppress/clear/invuln.
  • tests/test_custom_enemy.gd — columns, spawn, render dispatch, custom firing.

Task 1: Relay worker bullet-heaven-control + D1 + fail-closed auth + panel shell

Section titled “Task 1: Relay worker bullet-heaven-control + D1 + fail-closed auth + panel shell”

Files:

  • Create: control/wrangler.toml, control/schema.sql, control/src/queue.js, control/src/worker.js, control/src/panel.html.js, control/test/queue.test.mjs, control/README.md

Interfaces:

  • Produces: HTTP endpoints POST /cmd {code,cmd}, GET /poll?code&after, POST /status {code,blob}, GET /status?code, POST /caps {code,blob}, GET /caps?code, GET /?key= (panel). queue.js: nextSeq(rows), commandsAfter(rows, after), keyOk(provided, expected).

  • Step 1: Write the failing test for the pure queue logic

Create control/test/queue.test.mjs:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { nextSeq, commandsAfter, keyOk } from '../src/queue.js';
test('nextSeq is 1 on empty, else max+1', () => {
assert.equal(nextSeq([]), 1);
assert.equal(nextSeq([{ seq: 1 }, { seq: 4 }, { seq: 2 }]), 5);
});
test('commandsAfter returns only newer seqs, ascending', () => {
const rows = [{ seq: 3, payload: 'c' }, { seq: 1, payload: 'a' }, { seq: 2, payload: 'b' }];
assert.deepEqual(commandsAfter(rows, 1).map(r => r.seq), [2, 3]);
assert.deepEqual(commandsAfter(rows, 3), []);
});
test('keyOk is constant-time-ish equality and fails closed', () => {
assert.equal(keyOk('s3cret', 's3cret'), true);
assert.equal(keyOk('s3cret', 'wrong'), false);
assert.equal(keyOk('s3cret', ''), false);
assert.equal(keyOk('', 's3cret'), false); // expected unset → closed
assert.equal(keyOk(null, 's3cret'), false);
});
  • Step 2: Run it to verify it fails

Run: cd control && node --test Expected: FAIL — Cannot find module '../src/queue.js'.

  • Step 3: Implement control/src/queue.js
// Pure relay logic — no D1, no Worker globals — so it's node-testable.
export function nextSeq(rows) {
let max = 0;
for (const r of rows) if (r.seq > max) max = r.seq;
return max + 1;
}
export function commandsAfter(rows, after) {
return rows
.filter(r => r.seq > after)
.sort((a, b) => a.seq - b.seq);
}
// Fail-closed constant-time-ish compare. Expected unset/empty → always false.
export function keyOk(expected, provided) {
if (!expected || !provided) return false;
if (expected.length !== provided.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ provided.charCodeAt(i);
return diff === 0;
}
  • Step 4: Run the test to verify it passes

Run: cd control && node --test Expected: PASS (3 tests).

  • Step 5: Write the D1 schema

Create control/schema.sql:

-- Command queue: append-only per pairing code; seq monotonic per code.
CREATE TABLE IF NOT EXISTS commands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL,
seq INTEGER NOT NULL,
payload TEXT NOT NULL, -- JSON command, as sent by the panel
ts INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_commands_code_seq ON commands(code, seq);
-- Latest status blob per code (upserted by the game ~1Hz).
CREATE TABLE IF NOT EXISTS status (
code TEXT PRIMARY KEY,
blob TEXT NOT NULL,
ts INTEGER NOT NULL
);
-- Latest capabilities manifest per code (upserted once on enable).
CREATE TABLE IF NOT EXISTS caps (
code TEXT PRIMARY KEY,
blob TEXT NOT NULL,
ts INTEGER NOT NULL
);
  • Step 6: Write the panel shell module

Create control/src/panel.html.js (a shell — the builder/editor/readout sections are added in Tasks 5–6):

// Returns the control-panel HTML. Built up across tasks. Shell = pairing input,
// clear/pause buttons, and the command-send plumbing every later section reuses.
export function panelHtml() {
return `<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>BH Control</title>
<style>
:root{color-scheme:dark}
body{margin:0;background:#04060a;color:#cfe;font:15px system-ui;padding:12px}
h2{color:#5ff;margin:.4em 0 .2em}
input,button,select{font:inherit;background:#0b1422;color:#cfe;border:1px solid #1f3a5a;border-radius:8px;padding:8px}
button{cursor:pointer}
button:active{background:#13314f}
.row{display:flex;flex-wrap:wrap;gap:8px;margin:6px 0}
#status{font-family:ui-monospace,monospace;color:#7fd;white-space:pre-wrap}
</style></head><body>
<h2>Bullet Heaven · Control</h2>
<div class="row">Pairing code: <input id="code" maxlength="6" placeholder="AB12" style="width:6em;text-transform:uppercase"></div>
<div class="row">
<button onclick="send({kind:'pause_spawns',on:true})">Pause spawns</button>
<button onclick="send({kind:'pause_spawns',on:false})">Resume spawns</button>
<button onclick="send({kind:'clear'})">Clear arena</button>
</div>
<h2>Readout</h2><div id="status">(no status yet)</div>
<script>
const KEY = new URLSearchParams(location.search).get('key') || '';
const codeEl = document.getElementById('code');
function code(){ return (codeEl.value||'').trim().toUpperCase(); }
async function send(cmd){
if(!code()) return alert('Enter the pairing code shown in-game');
await fetch('/cmd',{method:'POST',headers:{'Content-Type':'text/plain'},
body:JSON.stringify({code:code(),cmd})});
}
async function pollStatus(){
if(code()){
try{
const r = await fetch('/status?code='+encodeURIComponent(code()));
if(r.ok){ const s = await r.json(); document.getElementById('status').textContent = JSON.stringify(s,null,1); }
}catch(_){}
}
setTimeout(pollStatus, 1000);
}
pollStatus();
window.send = send;
</script></body></html>`;
}
  • Step 7: Implement the worker fetch handler

Create control/src/worker.js:

import { nextSeq, commandsAfter, keyOk } from './queue.js';
import { panelHtml } from './panel.html.js';
const CORS = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*', 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS' };
const j = (obj, status = 200) => new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json', ...CORS } });
export default {
async fetch(req, env) {
const url = new URL(req.url);
if (req.method === 'OPTIONS') return new Response(null, { headers: CORS });
// Panel page — fail-closed behind CONTROL_KEY.
if (url.pathname === '/' && req.method === 'GET') {
if (!keyOk(env.CONTROL_KEY, url.searchParams.get('key'))) {
return new Response('unauthorized', { status: 401 });
}
const CSP = "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'";
return new Response(panelHtml(), { headers: { 'Content-Type': 'text/html', 'Content-Security-Policy': CSP } });
}
if (url.pathname === '/cmd' && req.method === 'POST') {
const { code, cmd } = JSON.parse(await req.text());
if (!code || !cmd) return j({ ok: false }, 400);
const rows = (await env.DB.prepare('SELECT seq FROM commands WHERE code=?').bind(code).all()).results || [];
const seq = nextSeq(rows);
await env.DB.prepare('INSERT INTO commands(code,seq,payload,ts) VALUES(?,?,?,?)')
.bind(code, seq, JSON.stringify(cmd), Date.now()).run();
return j({ ok: true, seq });
}
if (url.pathname === '/poll' && req.method === 'GET') {
const code = url.searchParams.get('code');
const after = parseInt(url.searchParams.get('after') || '0', 10);
if (!code) return j([], 400);
const rows = (await env.DB.prepare('SELECT seq,payload FROM commands WHERE code=? AND seq>?').bind(code, after).all()).results || [];
// Best-effort prune of drained rows keeps the table small.
if (rows.length) await env.DB.prepare('DELETE FROM commands WHERE code=? AND seq<=?').bind(code, after).run();
return j(commandsAfter(rows, after).map(r => ({ seq: r.seq, cmd: JSON.parse(r.payload) })));
}
if (url.pathname === '/status') {
if (req.method === 'POST') {
const { code, blob } = JSON.parse(await req.text());
await env.DB.prepare('INSERT INTO status(code,blob,ts) VALUES(?,?,?) ON CONFLICT(code) DO UPDATE SET blob=excluded.blob, ts=excluded.ts')
.bind(code, JSON.stringify(blob), Date.now()).run();
return j({ ok: true });
}
const row = await env.DB.prepare('SELECT blob FROM status WHERE code=?').bind(url.searchParams.get('code')).first();
return j(row ? JSON.parse(row.blob) : {});
}
if (url.pathname === '/caps') {
if (req.method === 'POST') {
const { code, blob } = JSON.parse(await req.text());
await env.DB.prepare('INSERT INTO caps(code,blob,ts) VALUES(?,?,?) ON CONFLICT(code) DO UPDATE SET blob=excluded.blob, ts=excluded.ts')
.bind(code, JSON.stringify(blob), Date.now()).run();
return j({ ok: true });
}
const row = await env.DB.prepare('SELECT blob FROM caps WHERE code=?').bind(url.searchParams.get('code')).first();
return j(row ? JSON.parse(row.blob) : {});
}
return new Response('not found', { status: 404 });
}
};
  • Step 8: Write the wrangler config

Create control/wrangler.toml (fill database_id after Step 9):

name = "bullet-heaven-control"
main = "src/worker.js"
compatibility_date = "2024-09-01"
workers_dev = true
[[d1_databases]]
binding = "DB"
database_name = "bullet-heaven-control"
database_id = "PASTE_AFTER_CREATE"
  • Step 9: Create the D1 DB, apply schema, set the key, deploy
Terminal window
cd control
source ~/.secrets
export CLOUDFLARE_API_TOKEN="$CF_LUMARA_DEPLOY_TOKEN" CLOUDFLARE_ACCOUNT_ID="$CF_ACCOUNT_ID"
npx --yes wrangler d1 create bullet-heaven-control # paste the returned database_id into wrangler.toml
npx wrangler d1 execute bullet-heaven-control --remote --file=schema.sql
# Generate + store the panel key (add to ~/.secrets as BULLET_HEAVEN_CONTROL_KEY first)
echo "$BULLET_HEAVEN_CONTROL_KEY" | npx wrangler secret put CONTROL_KEY
npx wrangler deploy

Expected: deploy prints the bullet-heaven-control.<handle>.workers.dev URL.

  • Step 10: Smoke-test the live endpoints
Terminal window
source ~/.secrets
B="https://bullet-heaven-control.chris-allen-06f.workers.dev"
curl -s -o /dev/null -w "panel no-key: %{http_code}\n" "$B/" # expect 401
curl -s -o /dev/null -w "panel keyed: %{http_code}\n" "$B/?key=$BULLET_HEAVEN_CONTROL_KEY" # expect 200
curl -s -X POST "$B/cmd" -H 'Content-Type: text/plain' --data '{"code":"TEST","cmd":{"kind":"clear"}}'
curl -s "$B/poll?code=TEST&after=0" # expect [{"seq":1,"cmd":{"kind":"clear"}}]
curl -s "$B/poll?code=TEST&after=1" # expect [] (and the row is now pruned)
  • Step 11: Write the README and commit

Create control/README.md documenting endpoints, the CONTROL_KEY secret, pairing, and the deploy command. Then:

Terminal window
cd /Users/chris/Claude/bullet-heaven
git add control/
git commit -m "feat(control): bullet-heaven-control relay worker + D1 + fail-closed panel shell"

Task 2: /sim dev seams — suppress spawns, invuln, clear arena

Section titled “Task 2: /sim dev seams — suppress spawns, invuln, clear arena”

Files:

  • Modify: sim/sim.gd (add dev_suppress_spawns, edit _spawn_enemies top, edit is_invulnerable, add dev_clear_enemies), sim/player_state.gd (add dev_invuln)
  • Test: tests/test_dev_seams.gd

Interfaces:

  • Consumes: Sim.new(seed, content), sim.enemies (EnemyPool), sim.player (PlayerState), sim.tick(InputState), SimContentFixture.db().
  • Produces: Sim.dev_suppress_spawns: bool, Sim.dev_clear_enemies() -> void, PlayerState.dev_invuln: bool, Sim.is_invulnerable() honouring dev_invuln.

Invoke the bh-dev-chunk skill before starting.

  • Step 1: Write the failing test

Create tests/test_dev_seams.gd:

extends GutTest
func _sim() -> Sim:
return Sim.new(1234, SimContentFixture.db())
func test_suppress_spawns_halts_the_normal_trickle() -> void:
var sim := _sim()
sim.dev_suppress_spawns = true
for i in range(600): # 10s — normally spawns a swarm
sim.tick(InputState.new(Vector2.ZERO))
assert_eq(sim.enemies.count, 0, "no enemies should spawn while suppressed")
func test_dev_clear_enemies_empties_the_pool() -> void:
var sim := _sim()
for i in range(600):
sim.tick(InputState.new(Vector2(1, 0)))
assert_gt(sim.enemies.count, 0, "precondition: some enemies spawned")
sim.dev_clear_enemies()
assert_eq(sim.enemies.count, 0, "clear empties the enemy pool")
func test_dev_invuln_blocks_player_damage() -> void:
var sim := _sim()
sim.player.dev_invuln = true
assert_true(sim.is_invulnerable(), "dev_invuln flips the single damage guard")
var hp_before := sim.player.hp
for i in range(900): # 15s of standing still in the swarm
sim.tick(InputState.new(Vector2.ZERO))
assert_eq(sim.player.hp, hp_before, "invulnerable player takes no damage")
  • Step 2: Run it to verify it fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_dev_seams.gd -gexit Expected: FAIL — Invalid set ... 'dev_suppress_spawns' (member doesn’t exist yet).

  • Step 3: Add the PlayerState.dev_invuln field

In sim/player_state.gd, after the iframe_timer line (line ~25):

var dev_invuln: bool = false # dev-tool: makes is_invulnerable() true (default off → baseline byte-identical)
  • Step 4: Add the Sim dev flag + edit is_invulnerable + add dev_clear_enemies

In sim/sim.gd, add near the top-level run-state vars (after the boss/story members):

# ── Dev/playtest seams (default off → determinism baseline byte-identical) ──
var dev_suppress_spawns: bool = false

Edit is_invulnerable() (currently return player.iframe_timer > 0.0):

func is_invulnerable() -> bool:
return player.iframe_timer > 0.0 or player.dev_invuln

Add a method (place it near _spawn_enemies):

# Dev-tool: remove every enemy this tick (swap-remove descending so indices stay valid).
func dev_clear_enemies() -> void:
for i in range(enemies.count - 1, -1, -1):
enemies.remove_at(i)
  • Step 5: Honour dev_suppress_spawns at the top of _spawn_enemies

In sim/sim.gd, _spawn_enemies(dt), add as the FIRST line of the function body (before the _any_boss_alive() check):

if dev_suppress_spawns:
return
  • Step 6: Run the dev-seam test to verify it passes

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_dev_seams.gd -gexit Expected: PASS (3 tests).

  • Step 7: Re-import + boot-check + test count + determinism
Terminal window
godot --headless --import 2>&1 | grep -i "script error" && echo "IMPORT ERRORS" || echo "import clean"
godot --headless --path . --quit-after 120 2>&1 | grep -i "script error" && echo "BOOT ERRORS" || echo "boot clean"
bash scripts/check-test-count.sh
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism.gd -gexit
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit

Expected: import/boot clean; test-count guard passes (one MORE script than before); determinism + checksum tests PASS (baseline 1432233777/2300319179 unchanged).

  • Step 8: Commit
Terminal window
git add sim/sim.gd sim/player_state.gd tests/test_dev_seams.gd
git commit -m "feat(sim): dev seams — dev_suppress_spawns, dev_invuln, dev_clear_enemies (default off, baseline unchanged)"

Task 3: ControlClient + arming + dispatcher (preset/boss spawn, placement, time-scale, status, caps)

Section titled “Task 3: ControlClient + arming + dispatcher (preset/boss spawn, placement, time-scale, status, caps)”

Files:

  • Create: net/control_client.gd
  • Modify: main.gd (create/persist ControlClient, apply_dev_command, time-scale in _physics_process, build/publish caps, HUD pairing-code, F6 arm)

Interfaces:

  • Consumes: Sim.spawn_story_enemy(name, pos, power), Sim._spawn_boss/_spawn_boss2/_spawn_funzo/_spawn_graviton/_spawn_eye, sim.dev_suppress_spawns, sim.dev_clear_enemies(), sim.player.dev_invuln, Sim_Const.BUILD.
  • Produces: ControlClient.enable(main), ControlClient.pairing_code, main.apply_dev_command(cmd: Dictionary), main._dev_timescale: float.

Note: ControlClient is a NETWORK/Node tool (like telemetry.gd) — its polling is render-side, hard to unit-test headlessly. Per the project’s testing discipline (telemetry has no unit test; it’s verified live), Task 3’s verification is the live smoke test in Step 8, plus the boot-check. The dispatch LOGIC that is testable (placement math) gets a unit test in Step 1.

Invoke the bh-dev-chunk skill before starting.

  • Step 1: Write the failing test for placement math

Add to tests/test_dev_seams.gd:

func test_ring_placement_spreads_around_a_center() -> void:
# Placement is render-side, but the math is pure: N points evenly on a ring.
var pts := ControlPlacement.ring(Vector2(100, 50), 240.0, 4)
assert_eq(pts.size(), 4)
for p in pts:
assert_almost_eq(p.distance_to(Vector2(100, 50)), 240.0, 0.5)
  • Step 2: Run it to verify it fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_dev_seams.gd -gexit Expected: FAIL — ControlPlacement not found.

  • Step 3: Create the placement helper + ControlClient

Create net/control_client.gd:

class_name ControlClient
extends Node
# Render-side remote-control poller (NOT /sim). Mirrors net/telemetry.gd but
# INBOUND: GETs a command queue from the bullet-heaven-control worker, drains it
# into main.apply_dev_command(), and POSTs status + capabilities back. OFF until
# enable() is called; never polls in the public web demo or ordinary play.
const ENDPOINT := "https://bullet-heaven-control.chris-allen-06f.workers.dev"
const POLL_INTERVAL := 1.0
var pairing_code: String = ""
var _main: Node = null
var _poll_http: HTTPRequest
var _status_http: HTTPRequest
var _cursor: int = 0
var _accum: float = 0.0
var _enabled: bool = false
func enable(main: Node) -> void:
_main = main
_enabled = true
pairing_code = "%04X" % (randi() % 0x10000) # global RNG — render-side, determinism-neutral
_poll_http = HTTPRequest.new(); add_child(_poll_http)
_poll_http.request_completed.connect(_on_poll)
_status_http = HTTPRequest.new(); add_child(_status_http)
# Publish capabilities once so the panel can populate its controls.
if _main.has_method("build_caps"):
_post("/caps", { "code": pairing_code, "blob": _main.build_caps() })
func _process(dt: float) -> void:
if not _enabled:
return
_accum += dt
if _accum < POLL_INTERVAL:
return
_accum = 0.0
_poll_http.request("%s/poll?code=%s&after=%d" % [ENDPOINT, pairing_code, _cursor])
if _main.has_method("build_status"):
_post("/status", { "code": pairing_code, "blob": _main.build_status() })
func _on_poll(_r: int, code: int, _h: PackedStringArray, body: PackedByteArray) -> void:
if code != 200:
return
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if not (parsed is Array):
return
for item in parsed:
if item is Dictionary and item.has("cmd"):
_cursor = maxi(_cursor, int(item.get("seq", _cursor)))
if _main.has_method("apply_dev_command"):
_main.apply_dev_command(item["cmd"])
func _post(path: String, body: Dictionary) -> void:
if _status_http == null:
return
_status_http.request(ENDPOINT + path, ["Content-Type: text/plain"], HTTPClient.METHOD_POST, JSON.stringify(body))
# Pure placement math (render-side helper class; unit-testable).
class ControlPlacement:
static func ring(center: Vector2, radius: float, n: int) -> Array[Vector2]:
var out: Array[Vector2] = []
for k in range(maxi(n, 1)):
var a := TAU * float(k) / float(maxi(n, 1))
out.append(center + Vector2(cos(a), sin(a)) * radius)
return out
  • Step 4: Run the placement test to verify it passes

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_dev_seams.gd -gexit Expected: PASS. (After adding net/ class, run godot --headless --import if ControlPlacement/ControlClient aren’t found — the stale-class-cache trap.)

  • Step 5: Wire main.gd — create/persist ControlClient + arm on F6

In main.gd _ready() (alongside gameplay_telemetry/quality_manager, the persistent senders):

control_client = ControlClient.new() # persistent; armed on demand, off by default
add_child(control_client)

Add the member near the other tool members (~line 47):

var control_client: ControlClient
var _dev_timescale: float = 1.0

In _new_run(), exclude it from the queue_free sweep (alongside gameplay_telemetry/quality_manager):

if c == gameplay_telemetry or c == quality_manager or c == control_client:
continue

In main’s key handler (the same _input/_unhandled_input that has F2/F4 — search quality_manager.cycle_override), add F6:

if event is InputEventKey and event.pressed and event.keycode == KEY_F6:
if control_client.pairing_code == "":
control_client.enable(self)
  • Step 6: Implement the dispatcher + time-scale + status/caps builders in main.gd
# Apply a remote dev command from the control panel. Render-side; routes to the
# guarded sim dev seams. Spawn placement uses the GLOBAL rng (randf), never
# sim.rng, so it can't desync the deterministic spawn stream.
func apply_dev_command(cmd: Dictionary) -> void:
if sim == null:
return
match String(cmd.get("kind", "")):
"clear":
sim.dev_clear_enemies()
"pause_spawns":
sim.dev_suppress_spawns = bool(cmd.get("on", true))
"invuln":
sim.player.dev_invuln = bool(cmd.get("on", true))
"timescale":
_dev_timescale = clampf(float(cmd.get("value", 1.0)), 0.05, 8.0)
"spawn_preset":
var name := String(cmd.get("type", "swarmer"))
var count := int(cmd.get("count", 1))
for p in _dev_spawn_positions(cmd, count):
sim.spawn_story_enemy(name, p, 1.0)
"spawn_boss":
_dev_spawn_boss(String(cmd.get("boss", "warden")))
"spawn_custom":
pass # implemented in Task 4
"player_stat":
pass # implemented in Task 5
func _dev_spawn_boss(which: String) -> void:
var at := sim.player.pos + Vector2(randf() - 0.5, randf() - 0.5).normalized() * 640.0
match which:
"warden": sim._spawn_boss()
"boss2": sim._spawn_boss2(at)
"funzo": sim._spawn_funzo(at)
"graviton": sim._spawn_graviton(at)
"eye": sim._spawn_eye(at)
func _dev_spawn_positions(cmd: Dictionary, count: int) -> Array[Vector2]:
var where := String(cmd.get("placement", "ring"))
var c := sim.player.pos
match where:
"ring":
return ControlClient.ControlPlacement.ring(c, 360.0, count)
"ahead":
var aim: Vector2 = sim.player.dash_dir
if aim.length() < 0.01: aim = Vector2.RIGHT
var out: Array[Vector2] = []
for k in range(count):
out.append(c + aim.normalized() * 420.0 + Vector2(randf() - 0.5, randf() - 0.5) * 80.0)
return out
_: # random
var out2: Array[Vector2] = []
for k in range(count):
out2.append(c + Vector2(randf() - 0.5, randf() - 0.5).normalized() * randf_range(260.0, 520.0))
return out2
# Status blob the panel reads (~1Hz). alive-per-type built from EnemyPool.type_name.
func build_status() -> Dictionary:
var alive := {}
for i in range(sim.enemies.count):
var n := EnemyPool.type_name(sim.enemies.type_id[i])
alive[n] = int(alive.get(n, 0)) + 1
return {
"code": control_client.pairing_code,
"fps": Engine.get_frames_per_second(),
"run_time": sim.run_time,
"hp": sim.player.hp, "hp_max": sim.player.max_hp,
"invuln": sim.player.dev_invuln, "paused": sim.dev_suppress_spawns,
"timescale": _dev_timescale, "alive": alive,
}
# Capabilities manifest the panel uses to populate its controls (published once).
func build_caps() -> Dictionary:
return {
"build": Sim_Const.BUILD,
"presets": ["swarmer","tank","shooter","splitter","elite","spider","skirmisher",
"brute","rusher","zapper","scatterer","bomber","orbiter","lancer","ghost","accumulator"],
"bosses": ["warden","boss2","funzo","graviton","eye"],
"shapes": [], # filled in Task 4
"behaviors": [], # filled in Task 4
"attacks": [], # filled in Task 4
"player_stats": [],# filled in Task 5
}
  • Step 7: Apply the render-side time-scale in _physics_process

In main.gd _physics_process, replace the single sim.tick(input) call with a time-scaled accumulator (keep all the post-tick consume/levelup/game_over code AFTER the loop, unchanged — they read the latest sim state):

# Render-side time-scale (dev tool). Default 1.0 → exactly one tick/frame.
_ts_accum += _dev_timescale
var steps := int(floor(_ts_accum))
_ts_accum -= float(steps)
for _s in range(steps):
if sim.game_over: break
sim.tick(input)

Add the member near _dev_timescale:

var _ts_accum: float = 0.0

(If _dev_timescale is always 1.0, steps is exactly 1 every frame — behaviour-identical to before.)

  • Step 8: Re-import, boot-check, live smoke test
Terminal window
godot --headless --import 2>&1 | grep -i "script error" && echo "IMPORT ERRORS" || echo "import clean"
godot --headless --path . --quit-after 120 2>&1 | grep -i "script error" && echo "BOOT ERRORS" || echo "boot clean"
bash scripts/check-test-count.sh
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit

Then a LIVE test: open the project in the editor, F5, start SURVIVAL, press F6 (note the REMOTE ▸ XXXX code is wired in Step 9), open https://bullet-heaven-control.<handle>.workers.dev/?key=$KEY, enter the code, click “Spawn” (via a temporary preset button or curl):

Terminal window
source ~/.secrets; B="https://bullet-heaven-control.chris-allen-06f.workers.dev"
curl -s -X POST "$B/cmd" -H 'Content-Type: text/plain' --data '{"code":"XXXX","cmd":{"kind":"spawn_preset","type":"tank","count":5,"placement":"ring"}}'
curl -s "$B/status?code=XXXX" # expect alive.tank >= 5 within ~1s

Expected: 5 tanks ring the player in-game; status reflects them.

  • Step 9: Add the HUD pairing-code readout

In main.gd where the HUD/build number is drawn (search the F2 overlay or hud), show REMOTE ▸ <code> when control_client.pairing_code != "". (Render-only; place beside the build number.)

  • Step 10: Commit
Terminal window
git add net/control_client.gd main.gd tests/test_dev_seams.gd
git commit -m "feat(control): ControlClient poller + dispatcher (preset/boss spawn, placement, time-scale, status, caps); F6 arm"

Task 4: Custom-enemy decoupling — TYPE_CUSTOM, shape_id/attack_id columns, render + firing dispatch, dev_spawn_custom

Section titled “Task 4: Custom-enemy decoupling — TYPE_CUSTOM, shape_id/attack_id columns, render + firing dispatch, dev_spawn_custom”

Files:

  • Modify: sim/enemy_pool.gd (TYPE_CUSTOM, shape_id/attack_id columns), sim/sim.gd (attack-id consts, dev_spawn_custom, _update_custom_attacks, extend lancer/orbiter guards), render/archetype_renderer.gd (shape_for reads shape_id), main.gd (spawn_custom dispatch + caps shapes/behaviors/attacks)
  • Test: tests/test_custom_enemy.gd

Interfaces:

  • Consumes: EnemyPool.add(...), EnemyPool.remove_at(), enemies.shape_id, enemies.attack_id, ArchetypeRenderer.shape_for.
  • Produces: EnemyPool.TYPE_CUSTOM = 22, enemies.shape_id: PackedInt32Array, enemies.attack_id: PackedInt32Array, Sim.dev_spawn_custom(spec: Dictionary, pos: Vector2) -> int, attack-id constants ATTACK_NONE/BOLT/FAN/BOMB/BEAM/SHARDS.

Invoke the bh-dev-chunk skill before starting.

  • Step 1: Write the failing test

Create tests/test_custom_enemy.gd:

extends GutTest
func _sim() -> Sim:
return Sim.new(1234, SimContentFixture.db())
func test_custom_columns_swap_remove_in_lockstep() -> void:
var pool := EnemyPool.new(64)
var a := pool.add(Vector2.ZERO, Vector2.ZERO, 14.0, 10.0)
pool.shape_id[a] = 4
pool.attack_id[a] = 2
var b := pool.add(Vector2(99, 0), Vector2.ZERO, 14.0, 10.0)
pool.shape_id[b] = 9
pool.attack_id[b] = 1
pool.remove_at(a) # b swaps into slot a
assert_eq(pool.shape_id[a], 9, "shape_id swapped with the moved enemy")
assert_eq(pool.attack_id[a], 1, "attack_id swapped with the moved enemy")
func test_dev_spawn_custom_sets_identity_columns() -> void:
var sim := _sim()
var spec := { "body": 5, "behavior": EnemyPool.BEHAVIOR_GHOST, "weapon": 2,
"hp": 120.0, "speed": 180.0, "radius": 22.0, "contact": 12.0, "armor": 4.0, "element": 1 }
var i := sim.dev_spawn_custom(spec, Vector2(300, 0))
assert_eq(sim.enemies.type_id[i], EnemyPool.TYPE_CUSTOM)
assert_eq(sim.enemies.shape_id[i], 5)
assert_eq(sim.enemies.attack_id[i], 2)
assert_eq(sim.enemies.behavior[i], EnemyPool.BEHAVIOR_GHOST)
assert_almost_eq(sim.enemies.radius[i], 22.0, 0.01)
assert_eq(sim.enemies.base_element[i], 1)
func test_custom_renderer_uses_shape_id() -> void:
var r := ArchetypeRenderer.new()
assert_eq(r.shape_for(EnemyPool.TYPE_CUSTOM, 7), 7, "custom enemies render by their shape_id override")
assert_eq(r.shape_for(EnemyPool.TYPE_TANK, 7), ArchetypeRenderer.SHAPE_HEXAGON, "normal enemies ignore the override")
func test_custom_bolt_weapon_fires_at_the_player() -> void:
var sim := _sim()
var spec := { "body": 2, "behavior": EnemyPool.BEHAVIOR_WALK, "weapon": 1, # ATTACK_BOLT
"hp": 50.0, "speed": 0.0, "radius": 16.0, "contact": 0.0, "armor": 0.0, "element": -1 }
sim.dev_spawn_custom(spec, sim.player.pos + Vector2(200, 0))
var before := sim.enemy_proj.count
for i in range(180): # 3s — long enough for a default fire interval
sim.tick(InputState.new(Vector2.ZERO))
assert_gt(sim.enemy_proj.count + sim.kills_by_type_total(), before, "a custom bolt enemy emits enemy projectiles")

(If kills_by_type_total doesn’t exist, assert only sim.enemy_proj.count > 0 at some tick by sampling inside the loop — adjust to the real accessor during implementation.)

  • Step 2: Run it to verify it fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_custom_enemy.gd -gexit Expected: FAIL — TYPE_CUSTOM / shape_id not found.

  • Step 3: Add TYPE_CUSTOM + the two columns to EnemyPool

In sim/enemy_pool.gd:

const TYPE_CUSTOM := 22 # dev-built enemy: identity from per-enemy shape_id/attack_id, not a fixed type

Add the column declarations (next to grow_t):

var shape_id: PackedInt32Array # render silhouette override (-1 = use _TYPE_SHAPE[type])
var attack_id: PackedInt32Array # ranged-attack override for TYPE_CUSTOM (-1 = none)

In _init, after grow_t.resize(cap):

shape_id.resize(cap)
attack_id.resize(cap)

In add, inside the if i != -1: block (after grow_t[i] = 0.0):

shape_id[i] = -1
attack_id[i] = -1

In remove_at, inside the if i != last: block (after grow_t[i] = grow_t[last]):

shape_id[i] = shape_id[last]
attack_id[i] = attack_id[last]
  • Step 4: Add the attack-id constants + dev_spawn_custom to Sim

In sim/sim.gd, near the dev-seam vars:

# Custom-enemy ranged-attack vocabulary (attack_id column on TYPE_CUSTOM enemies).
const ATTACK_NONE := 0
const ATTACK_BOLT := 1
const ATTACK_FAN := 2
const ATTACK_BOMB := 3
const ATTACK_BEAM := 4
const ATTACK_SHARDS := 5
# Default fire tuning for custom weapons (custom enemies have no bible dict).
const CUSTOM_FIRE_INTERVAL := 1.6
const CUSTOM_PROJ_SPEED := 340.0
const CUSTOM_PROJ_DAMAGE := 8.0
const CUSTOM_FAN_PELLETS := 5
const CUSTOM_FAN_SPREAD := 0.6
const CUSTOM_BOMB_DELAY := 1.2
const CUSTOM_BOMB_RADIUS := 90.0
const CUSTOM_BOMB_DAMAGE := 18.0

Add the spawn method:

# Dev-tool: spawn a fully custom enemy. spec keys: body(shape_id), behavior,
# weapon(attack_id), hp, speed, radius, contact, armor, element. Never spawned by
# the baseline run → determinism-neutral.
func dev_spawn_custom(spec: Dictionary, pos: Vector2) -> int:
var i := enemies.add(
pos, Vector2.ZERO,
float(spec.get("radius", 16.0)), float(spec.get("hp", 50.0)),
float(spec.get("armor", 0.0)), float(spec.get("speed", 120.0)),
float(spec.get("contact", 12.0)), 1.0,
EnemyPool.TYPE_CUSTOM, int(spec.get("element", -1)),
int(spec.get("behavior", EnemyPool.BEHAVIOR_WALK)), 0.0)
if i != -1:
enemies.shape_id[i] = int(spec.get("body", 0))
enemies.attack_id[i] = int(spec.get("weapon", ATTACK_NONE))
return i
  • Step 5: Make the renderer consult shape_id for custom enemies

In render/archetype_renderer.gd, add/replace the shape lookup with:

func shape_for(type_id: int, shape_override: int = -1) -> int:
if type_id == EnemyPool.TYPE_CUSTOM and shape_override >= 0:
return clampi(shape_override, 0, SHAPE_COUNT - 1)
if type_id >= 0 and type_id < _TYPE_SHAPE.size():
return _TYPE_SHAPE[type_id]
return SHAPE_CIRCLE

In the sync() partitioning loop where it currently reads _TYPE_SHAPE[type_id] (or shape_for(type_id)), pass the override:

var shape := shape_for(pool.type_id[i], pool.shape_id[i])
  • Step 6: Add the custom-attack firing pass + call it in tick

In sim/sim.gd, add a pass that fires custom enemies by attack_id (timer-fire weapons: bolt/fan/bomb), keyed by entity_id like the other ranged passes:

func _update_custom_attacks(dt: float) -> void:
var next_timers: Dictionary = {}
for i in range(enemies.count):
if enemies.type_id[i] != EnemyPool.TYPE_CUSTOM:
continue
var aid := enemies.attack_id[i]
if aid <= ATTACK_NONE or aid == ATTACK_BEAM or aid == ATTACK_SHARDS:
continue # none / beam / shards handled by the lancer/orbiter passes (Step 8)
var eid: int = enemies.entity_id[i]
var elapsed: float = float(_custom_fire_timers.get(eid, 0.0)) + dt
if elapsed >= CUSTOM_FIRE_INTERVAL:
elapsed = 0.0
var to := player.pos - enemies.pos[i]
var d := to.length()
match aid:
ATTACK_BOLT:
if d > 0.001:
enemy_proj.add(enemies.pos[i], (to / d) * CUSTOM_PROJ_SPEED,
SHOOTER_PROJ_RADIUS, SHOOTER_PROJ_LIFETIME, CUSTOM_PROJ_DAMAGE)
ATTACK_FAN:
if d > 0.001:
var base_a := (to / d).angle()
for k in range(CUSTOM_FAN_PELLETS):
var t: float = 0.0 if CUSTOM_FAN_PELLETS == 1 else (float(k) / float(CUSTOM_FAN_PELLETS - 1) - 0.5)
var a := base_a + t * CUSTOM_FAN_SPREAD
enemy_proj.add(enemies.pos[i], Vector2(cos(a), sin(a)) * CUSTOM_PROJ_SPEED,
SHOOTER_PROJ_RADIUS, SHOOTER_PROJ_LIFETIME, CUSTOM_PROJ_DAMAGE)
ATTACK_BOMB:
bombs.append({ "pos": player.pos, "delay": CUSTOM_BOMB_DELAY, "radius": CUSTOM_BOMB_RADIUS,
"damage": CUSTOM_BOMB_DAMAGE, "max_delay": CUSTOM_BOMB_DELAY })
next_timers[eid] = elapsed
_custom_fire_timers = next_timers

Add the timer dict near _ranged_fire_timers:

var _custom_fire_timers: Dictionary

Initialise it where _ranged_fire_timers = {} is set:

_custom_fire_timers = {}

Call _update_custom_attacks(dt) in tick() right after the existing _update_ranged(dt) call (find it; it’s in the hash-querying weapon/enemy phase).

  • Step 7: Run the custom-enemy test to verify it passes (bolt/fan/bomb + render)

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_custom_enemy.gd -gexit Expected: PASS for the column/spawn/render/bolt tests. (godot --headless --import first if TYPE_CUSTOM isn’t resolved.)

  • Step 8: Extend the lancer + orbiter passes to cover custom beam/shards

In sim/sim.gd, in _update_lancer (and its telegraph/fire helpers) where the guard is if enemies.type_id[i] != EnemyPool.TYPE_LANCER: continue, broaden to:

var tid := enemies.type_id[i]
if tid != EnemyPool.TYPE_LANCER and not (tid == EnemyPool.TYPE_CUSTOM and enemies.attack_id[i] == ATTACK_BEAM):
continue

Do the same in the orbiter passes for ATTACK_SHARDS:

var tid := enemies.type_id[i]
if tid != EnemyPool.TYPE_ORBITER and not (tid == EnemyPool.TYPE_CUSTOM and enemies.attack_id[i] == ATTACK_SHARDS):
continue

Where those passes read _enemy_types[TYPE_LANCER]/_enemy_types[TYPE_ORBITER] for params, custom enemies must use the same dict (they have no own entry) — read the lancer/orbiter bible dict regardless of which matched (it’s a per-pattern default), e.g. keep var e: Dictionary = _enemy_types[EnemyPool.TYPE_LANCER] for the beam pattern.

  • Step 9: Add a custom beam/shards test + verify

Add to tests/test_custom_enemy.gd:

func test_custom_beam_enemy_registers_in_the_lancer_pass() -> void:
var sim := _sim()
var spec := { "body": 13, "behavior": EnemyPool.BEHAVIOR_WALK, "weapon": Sim.ATTACK_BEAM,
"hp": 80.0, "speed": 60.0, "radius": 18.0, "contact": 8.0, "armor": 0.0, "element": -1 }
var i := sim.dev_spawn_custom(spec, sim.player.pos + Vector2(0, 240))
# It should tick through the lancer state machine without error and stay alive a while.
for f in range(120):
sim.tick(InputState.new(Vector2.ZERO))
assert_true(sim.enemies.count >= 1, "custom beam enemy persists and ticks through the lancer pass")

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_custom_enemy.gd -gexit Expected: PASS.

  • Step 10: Wire spawn_custom dispatch + caps vocabulary in main.gd

Replace the "spawn_custom": pass arm:

"spawn_custom":
var spec: Dictionary = cmd.get("spec", {})
var count := int(cmd.get("count", 1))
for p in _dev_spawn_positions(cmd, count):
sim.dev_spawn_custom(spec, p)

Fill the caps shapes/behaviors/attacks in build_caps():

"shapes": [
{"id":0,"name":"triangle"},{"id":1,"name":"hexagon"},{"id":2,"name":"diamond"},
{"id":3,"name":"chevron"},{"id":4,"name":"star"},{"id":5,"name":"asterisk"},
{"id":7,"name":"cross"},{"id":8,"name":"square"},{"id":9,"name":"lightning"},
{"id":10,"name":"scatter"},{"id":11,"name":"bomb"},{"id":12,"name":"ring"},
{"id":13,"name":"lance"},{"id":14,"name":"wisp"},{"id":15,"name":"starburst"},{"id":16,"name":"missile"},
],
"behaviors": [
{"id":EnemyPool.BEHAVIOR_WALK,"name":"walk"},{"id":EnemyPool.BEHAVIOR_DASH,"name":"dash"},
{"id":EnemyPool.BEHAVIOR_SKIRMISH,"name":"skirmish"},{"id":EnemyPool.BEHAVIOR_RUSH,"name":"rush"},
{"id":EnemyPool.BEHAVIOR_GHOST,"name":"ghost"},
],
"attacks": [
{"id":Sim.ATTACK_NONE,"name":"none"},{"id":Sim.ATTACK_BOLT,"name":"bolt"},
{"id":Sim.ATTACK_FAN,"name":"pellet-fan"},{"id":Sim.ATTACK_BOMB,"name":"bomb"},
{"id":Sim.ATTACK_BEAM,"name":"beam"},{"id":Sim.ATTACK_SHARDS,"name":"orbit-shards"},
],

(Note: SHAPE id 6 = CIRCLE is reserved for bosses — omit it from the builder body list above.)

  • Step 11: Re-import, boot-check, test count, determinism
Terminal window
godot --headless --import 2>&1 | grep -i "script error" && echo "IMPORT ERRORS" || echo "import clean"
godot --headless --path . --quit-after 120 2>&1 | grep -i "script error" && echo "BOOT ERRORS" || echo "boot clean"
bash scripts/check-test-count.sh
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism.gd -gexit
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit

Expected: clean; baseline 1432233777/2300319179 UNCHANGED (TYPE_CUSTOM never spawns in the baseline; the lancer/orbiter guard edits add a branch that’s false for all existing enemies).

  • Step 12: Commit
Terminal window
git add sim/enemy_pool.gd sim/sim.gd render/archetype_renderer.gd main.gd tests/test_custom_enemy.gd
git commit -m "feat(control): custom enemies — TYPE_CUSTOM + shape_id/attack_id columns, render + firing dispatch, dev_spawn_custom"

Task 5: Player stat editor — caps player_stats + player_stat command + apply

Section titled “Task 5: Player stat editor — caps player_stats + player_stat command + apply”

Files:

  • Modify: sim/sim.gd (add dev_set_player_stat(field, value)), main.gd (player_stat dispatch + caps player_stats)
  • Test: tests/test_dev_seams.gd (add cases)

Interfaces:

  • Consumes: PlayerState fields, StatEffects.TABLE.
  • Produces: Sim.dev_set_player_stat(field: String, value: float) -> void.

Invoke the bh-dev-chunk skill before starting.

  • Step 1: Write the failing test

Add to tests/test_dev_seams.gd:

func test_dev_set_player_stat_writes_fields() -> void:
var sim := Sim.new(1234, SimContentFixture.db())
sim.dev_set_player_stat("max_hp", 500.0)
assert_almost_eq(sim.player.max_hp, 500.0, 0.01)
sim.dev_set_player_stat("speed", 400.0)
assert_almost_eq(sim.player.speed, 400.0, 0.01)
sim.dev_set_player_stat("hp", 999.0) # heal-to-value
assert_almost_eq(sim.player.hp, 999.0, 0.01)
func test_dev_set_player_stat_ignores_unknown_field() -> void:
var sim := Sim.new(1234, SimContentFixture.db())
sim.dev_set_player_stat("not_a_field", 5.0) # silent no-op, no crash
assert_true(true)
  • Step 2: Run it to verify it fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_dev_seams.gd -gexit Expected: FAIL — dev_set_player_stat not found.

  • Step 3: Implement dev_set_player_stat (allow-listed)

In sim/sim.gd:

# Dev-tool: directly set a player stat. Allow-listed to real PlayerState fields
# (no arbitrary set()). Silent no-op on an unknown field (never crash a tool).
const DEV_PLAYER_FIELDS := ["max_hp", "hp", "speed", "pickup_radius", "armor",
"damage_mult", "fire_rate_mult", "dash_cooldown_mult", "decoy_power_mult", "decoy_life_mult"]
func dev_set_player_stat(field: String, value: float) -> void:
if field in DEV_PLAYER_FIELDS:
player.set(field, value)
  • Step 4: Run the test to verify it passes

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_dev_seams.gd -gexit Expected: PASS.

  • Step 5: Wire the player_stat dispatch + caps in main.gd

Replace the "player_stat": pass arm:

"player_stat":
sim.dev_set_player_stat(String(cmd.get("field", "")), float(cmd.get("value", 0.0)))

Fill build_caps() player_stats:

"player_stats": [
{"field":"max_hp","label":"Max HP","min":1,"max":2000,"step":10},
{"field":"hp","label":"Heal to","min":1,"max":2000,"step":10},
{"field":"speed","label":"Move speed","min":40,"max":900,"step":10},
{"field":"fire_rate_mult","label":"Fire rate ×","min":0.2,"max":8,"step":0.1},
{"field":"damage_mult","label":"Damage ×","min":0.2,"max":20,"step":0.1},
{"field":"pickup_radius","label":"Pickup radius","min":20,"max":600,"step":10},
{"field":"armor","label":"Armor","min":0,"max":12,"step":1},
{"field":"dash_cooldown_mult","label":"Dash CD ×","min":0.1,"max":3,"step":0.1},
],
  • Step 6: Re-import, boot-check, test count, determinism, commit
Terminal window
godot --headless --import 2>&1 | grep -i "script error" && echo "IMPORT ERRORS" || echo "import clean"
godot --headless --path . --quit-after 120 2>&1 | grep -i "script error" && echo "BOOT ERRORS" || echo "boot clean"
bash scripts/check-test-count.sh
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit
git add sim/sim.gd main.gd tests/test_dev_seams.gd
git commit -m "feat(control): player stat editor — dev_set_player_stat + caps player_stats + dispatch"

Task 6: Full panel UI (builder + player editor + readout) + on-device verification

Section titled “Task 6: Full panel UI (builder + player editor + readout) + on-device verification”

Files:

  • Modify: control/src/panel.html.js (add spawn grid, count/placement, enemy builder, player editor; consume /caps)

Interfaces:

  • Consumes: GET /caps?code, GET /status?code, POST /cmd (all from Task 1); command kinds from Tasks 3–5.

  • Step 1: Replace panelHtml() with the full, caps-driven panel

Rewrite control/src/panel.html.js so that on entering a pairing code it fetches /caps, then builds: a spawn grid (one button per presets + per bosses), count chips + placement radio, an enemy builder (body/movement/weapon <select>s from caps.shapes/behaviors/attacks + range sliders for hp/speed/radius/contact/armor + an element number input + “Spawn custom”/“Save preset”), the isolate + survivability rows (Task 1 shell + an invuln checkbox + time-scale chips), and a player editor (one slider per caps.player_stats, sending {kind:'player_stat',field,value} on input). Reuse the Task-1 send()/code()/pollStatus() plumbing. Key points:

// On code entry, load caps once and render the builder:
async function loadCaps(){
const r = await fetch('/caps?code='+encodeURIComponent(code()));
const caps = await r.json();
renderSpawnGrid(caps.presets, caps.bosses);
renderBuilder(caps.shapes, caps.behaviors, caps.attacks);
renderPlayerEditor(caps.player_stats);
}
// Spawn buttons:
// preset → send({kind:'spawn_preset', type, count: curCount, placement: curPlace})
// boss → send({kind:'spawn_boss', boss})
// Builder "Spawn custom":
// send({kind:'spawn_custom', count: curCount, placement: curPlace,
// spec:{ body:+bodySel.value, behavior:+moveSel.value, weapon:+weapSel.value,
// hp:+hp.value, speed:+spd.value, radius:+rad.value, contact:+con.value,
// armor:+arm.value, element:+elem.value }})
// Survivability:
// invuln checkbox → send({kind:'invuln', on: chk.checked})
// time chip → send({kind:'timescale', value: v})
// Player slider input (debounced) → send({kind:'player_stat', field, value:+slider.value})

Keep all values interpolated into the DOM via textContent/.value (never innerHTML with server data) — the readout strip already uses textContent.

  • Step 2: Redeploy the worker
Terminal window
cd control && source ~/.secrets
export CLOUDFLARE_API_TOKEN="$CF_LUMARA_DEPLOY_TOKEN" CLOUDFLARE_ACCOUNT_ID="$CF_ACCOUNT_ID"
npx wrangler deploy
  • Step 3: End-to-end verification (Mac editor first)

Open the project, F5, start SURVIVAL, press F6, open https://bullet-heaven-control.<handle>.workers.dev/?key=$KEY, enter the on-screen code. Verify in order:

  1. Spawn grid: each enemy button spawns it; each boss button spawns the boss.
  2. Count + placement: “25 / ring” rings 25; “ahead” puts them in front.
  3. Isolate: “Pause spawns” then “Clear arena” → only what you spawn remains.
  4. Survivability: invuln on → player survives a swarm; time-scale 0.25× → slow-mo; 4× → fast-forward.
  5. Enemy builder: “wisp + ghost + pellet-fan, high HP” spawns and behaves; “Save preset” re-spawns it.
  6. Player editor: Max HP 500, Damage × 5, Move speed 600 take effect live.
  7. Readout: alive-per-type + fps + hp update ~1Hz.
  • Step 4: On-device verification (Apple TV)

Sync the gameplay changes to ~/Claude/bullet-heaven-tvos/ and deploy via the bh-deploy skill (BUILD bump). On the ATV, enter SURVIVAL, arm remote control via the start-menu affordance (Step 5 below), and drive a session from a phone browser. Confirm spawns/clear/builder/player-edits land within ~1s.

  • Step 5: Add the start-menu “Remote Control” affordance (tvOS reach)

In ui/start_menu.gd, add a small secondary control (a fourth card or a footer button) that emits a new signal remote_requested; in main.gd connect it to control_client.enable(self) so the ATV (no keyboard) can arm it. Keep F6 as the desktop shortcut.

  • Step 6: Commit
Terminal window
cd /Users/chris/Claude/bullet-heaven
git add control/src/panel.html.js ui/start_menu.gd main.gd
git commit -m "feat(control): full caps-driven panel (spawn grid, enemy builder, player editor, readout) + start-menu arm"

Spec coverage:

  • Spawn any enemy/boss → Task 3 (spawn_preset/spawn_boss) + Task 6 grid. ✓
  • Custom enemy builder (body × movement × weapon × stats × element) → Task 4 (decoupling) + Task 6 (builder UI). ✓
  • Player stat editor → Task 5 + Task 6. ✓
  • Isolate (pause + clear) → Task 2 (dev_suppress_spawns/dev_clear_enemies) + Task 1/6 UI. ✓
  • Survivability (invuln + time-scale) → Task 2 (dev_invuln) + Task 3 (time-scale) + Task 6 UI. ✓
  • Readout back → Task 3 (build_status) + Task 1/6 panel. ✓
  • Relay worker + D1 + fail-closed auth + pairing → Task 1 + Task 3 (pairing code). ✓
  • Capabilities manifest → Task 3 (shapes/behaviors/attacks/presets/bosses) + Task 5 (player_stats) + Task 6 (consume). ✓
  • Determinism baseline unchanged → re-verified in Tasks 2, 4, 5. ✓
  • Testing strategy (sim seams headless, worker node test, panel manual) → Tasks 1–6 cover all three. ✓

Type consistency: dev_suppress_spawns, dev_clear_enemies(), dev_invuln, dev_spawn_custom(), dev_set_player_stat(), apply_dev_command(), build_status()/build_caps(), ControlClient.enable()/pairing_code, EnemyPool.TYPE_CUSTOM/shape_id/attack_id, ATTACK_*, ArchetypeRenderer.shape_for(type_id, override) — names used identically across tasks. Command kind strings (clear/pause_spawns/invuln/timescale/spawn_preset/spawn_boss/spawn_custom/player_stat) match between dispatcher (Tasks 3–5) and panel (Task 6).

Placeholder scan: Task 3 and 4 contain pass arms that are explicitly filled in later tasks (4 and 5 respectively) — these are intentional staged stubs, each with a forward reference, not unspecified work. The database_id in wrangler.toml is filled by the d1 create command in the same task. No “TBD”/“handle edge cases”/“add validation”-style gaps remain.

Known accessor risks to confirm during implementation (not blockers): EnemyPool.type_name(id) (used in build_status) and the exact names of the lancer/orbiter guard lines + the _update_ranged call site in tick() should be confirmed against the live source when editing (Task 4 Step 6/8). The custom-bolt test (Task 4 Step 1) may need its assertion adjusted to the real enemy_proj/kills accessor.