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).
Global Constraints
Section titled “Global Constraints”/simpurity: every file undersim/extends RefCountedand 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 throughSim.rng. Current baseline (seed 1234, 600 ticks), pinned intests/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) andupgrade_rng(upgrade rolls). Dev placement uses a RENDER-side RNG (randf), neversim.rng— drawing fromsim.rngwould 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 forSCRIPT ERROR) → test-count guard (scripts/check-test-count.sh) → determinism → commit. Invoke thebh-dev-chunkskill at the start of each game-side task. - CF deploy: wrangler from the worker dir with
CLOUDFLARE_API_TOKEN="$CF_LUMARA_DEPLOY_TOKEN"ANDCLOUDFLARE_ACCOUNT_ID="$CF_ACCOUNT_ID"(9106 = missing account id).text/plainrequest 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 thebh-deployskill) so the ATV build can be controlled — but deployment is out of scope for these tasks (done once the tool is verified).
File Structure
Section titled “File Structure”New — relay worker (control/):
control/wrangler.toml— worker config + D1 binding (mirrortelemetry/wrangler.toml).control/schema.sql—commands/status/capstables (idempotent).control/src/queue.js— pure logic:nextSeq,commandsAfter,keyOk— node-tested.control/src/worker.js— fetch handler wiringqueue.jsto D1; serves the panel atGET /.control/src/panel.html.js— exports the panel HTML string (built up across Tasks 1, 5, 6).control/test/queue.test.mjs—node:testforqueue.js.control/README.md— ops notes (deploy, key, pairing).
New — game side:
net/control_client.gd—ControlClient 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.gd—dev_invuln: bool = false.sim/enemy_pool.gd—TYPE_CUSTOM = 22;shape_id/attack_idcolumns (resize/add/remove lockstep).render/archetype_renderer.gd—shape_forconsultsshape_idfor custom enemies.main.gd— create/persistControlClient;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
cd controlsource ~/.secretsexport 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.tomlnpx 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_KEYnpx wrangler deployExpected: deploy prints the bullet-heaven-control.<handle>.workers.dev URL.
- Step 10: Smoke-test the live endpoints
source ~/.secretsB="https://bullet-heaven-control.chris-allen-06f.workers.dev"curl -s -o /dev/null -w "panel no-key: %{http_code}\n" "$B/" # expect 401curl -s -o /dev/null -w "panel keyed: %{http_code}\n" "$B/?key=$BULLET_HEAVEN_CONTROL_KEY" # expect 200curl -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:
cd /Users/chris/Claude/bullet-heavengit 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(adddev_suppress_spawns, edit_spawn_enemiestop, editis_invulnerable, adddev_clear_enemies),sim/player_state.gd(adddev_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()honouringdev_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_invulnfield
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
Simdev flag + editis_invulnerable+ adddev_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 = falseEdit is_invulnerable() (currently return player.iframe_timer > 0.0):
func is_invulnerable() -> bool: return player.iframe_timer > 0.0 or player.dev_invulnAdd 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_spawnsat 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
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.shgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism.gd -gexitgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexitExpected: import/boot clean; test-count guard passes (one MORE script than before); determinism + checksum tests PASS (baseline 1432233777/2300319179 unchanged).
- Step 8: Commit
git add sim/sim.gd sim/player_state.gd tests/test_dev_seams.gdgit 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/persistControlClient,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 ControlClientextends 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 = nullvar _poll_http: HTTPRequestvar _status_http: HTTPRequestvar _cursor: int = 0var _accum: float = 0.0var _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: ControlClientvar _dev_timescale: float = 1.0In _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: continueIn 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
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.shgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexitThen 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):
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 ~1sExpected: 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
git add net/control_client.gd main.gd tests/test_dev_seams.gdgit 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_idcolumns),sim/sim.gd(attack-id consts,dev_spawn_custom,_update_custom_attacks, extend lancer/orbiter guards),render/archetype_renderer.gd(shape_forreadsshape_id),main.gd(spawn_customdispatch + 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 constantsATTACK_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 toEnemyPool
In sim/enemy_pool.gd:
const TYPE_CUSTOM := 22 # dev-built enemy: identity from per-enemy shape_id/attack_id, not a fixed typeAdd 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] = -1In 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_customtoSim
In sim/sim.gd, near the dev-seam vars:
# Custom-enemy ranged-attack vocabulary (attack_id column on TYPE_CUSTOM enemies).const ATTACK_NONE := 0const ATTACK_BOLT := 1const ATTACK_FAN := 2const ATTACK_BOMB := 3const ATTACK_BEAM := 4const ATTACK_SHARDS := 5# Default fire tuning for custom weapons (custom enemies have no bible dict).const CUSTOM_FIRE_INTERVAL := 1.6const CUSTOM_PROJ_SPEED := 340.0const CUSTOM_PROJ_DAMAGE := 8.0const CUSTOM_FAN_PELLETS := 5const CUSTOM_FAN_SPREAD := 0.6const CUSTOM_BOMB_DELAY := 1.2const CUSTOM_BOMB_RADIUS := 90.0const CUSTOM_BOMB_DAMAGE := 18.0Add 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_idfor 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_CIRCLEIn 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_timersAdd the timer dict near _ranged_fire_timers:
var _custom_fire_timers: DictionaryInitialise 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): continueDo 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): continueWhere 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_customdispatch + caps vocabulary inmain.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
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.shgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism.gd -gexitgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexitExpected: 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
git add sim/enemy_pool.gd sim/sim.gd render/archetype_renderer.gd main.gd tests/test_custom_enemy.gdgit 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(adddev_set_player_stat(field, value)),main.gd(player_statdispatch + capsplayer_stats) - Test:
tests/test_dev_seams.gd(add cases)
Interfaces:
- Consumes:
PlayerStatefields,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_statdispatch + caps inmain.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
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.shgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexitgit add sim/sim.gd main.gd tests/test_dev_seams.gdgit 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
cd control && source ~/.secretsexport 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:
- Spawn grid: each enemy button spawns it; each boss button spawns the boss.
- Count + placement: “25 / ring” rings 25; “ahead” puts them in front.
- Isolate: “Pause spawns” then “Clear arena” → only what you spawn remains.
- Survivability: invuln on → player survives a swarm; time-scale 0.25× → slow-mo; 4× → fast-forward.
- Enemy builder: “wisp + ghost + pellet-fan, high HP” spawns and behaves; “Save preset” re-spawns it.
- Player editor: Max HP 500, Damage × 5, Move speed 600 take effect live.
- 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
cd /Users/chris/Claude/bullet-heavengit add control/src/panel.html.js ui/start_menu.gd main.gdgit commit -m "feat(control): full caps-driven panel (spawn grid, enemy builder, player editor, readout) + start-menu arm"Self-Review
Section titled “Self-Review”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.