Skip to content

Replay auto-upload (Dev builds) — implementation plan

Replay Auto-Upload (Dev builds) Implementation Plan

Section titled “Replay Auto-Upload (Dev builds) 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. In this repo, consult skill bh-sdd-concurrent before Task 1, dispatch bh-implementer (no model param) into the plan worktree, review with bh-reviewer, merge with bh-merge-reviewed-branch.

Goal: Dev builds upload every archived input replay (replay-<unix>-<seed>.json) to R2 through the telemetry Worker on their own, and a Mac script pulls them into replays/, so human replays stop depending on someone remembering to devicectl them off the device. Unblocks #114 (bot fidelity score).

Architecture: A persistent ReplayUploader node in main treats the replay files on disk as the queue: one gzip upload at a time, oldest first, deleted only on the Worker’s {"ok":true} (or its own 400/413 verdict). The telemetry Worker gets POST /replay, which validates size, gzip, JSON shape and seed, enforces a 200/day cap in KV, and stores the original gzip bytes in R2 bucket bh-replays. scripts/replays-pull.sh lists the bucket (Cloudflare REST API) and downloads new objects with wrangler.

Tech Stack: Godot 4.7 typed GDScript + GUT; Cloudflare Worker (plain JS, node --test with hand mocks); R2; KV; Node 22 for the Mac script.

Spec: docs/superpowers/specs/2026-10-14-replay-auto-upload-design.md (approved 2026-10-14, auth option (a): no key, shape check + caps).

  • Gate: BuildChannel.current() == "dev" AND not OS.has_feature("web") AND not muted (playback, capture, showcase) AND Share diagnostics on AND EventQueue.live_platform(). The root Web preset carries custom_features="dev", so channel alone is never enough.
  • Never upload replay-latest.json (or tutorial-replay*.json); it stays on the device untouched.
  • Delete a local file ONLY after HTTP 200 with {"ok":true,...}, or a 400/413 (the Worker’s own verdict). Network error, 429, 5xx, or a 200 without ok:true keep it.
  • Backoff mirrors the event queue: EventQueueCore.BACKOFF_MIN_S (10 s) doubling to EventQueueCore.BACKOFF_MAX_S (300 s).
  • No pass while a human run is in progress.
  • Body: bytes.compress(FileAccess.COMPRESSION_GZIP) via HTTPRequest.request_raw, header Content-Type: application/gzip, query build, install, unix, seed.
  • Worker limits: gzip body ≤ 2 MB (else 413); decompressed ≤ 16 MB (else 413); shape seed integer, ticks array, fresh_save bool, seed == query seed (else 400); 200 stored objects per UTC day (else 429).
  • R2 key replays/<build>/<install>/<unix>-<seed>.json.gz, custom metadata build/install/unix/seed/ticks, original gzip bytes, contentType: application/gzip and NO contentEncoding (so wrangler r2 object get returns the gzip bytes unchanged). Existing key → 200.
  • Retention: R2 lifecycle rule, objects deleted after 365 days.
  • Out-of-band steps each need Chris’s OK at the time: bucket create + lifecycle rule, telemetry/deploy.sh, privacy site deploy, Dev install.
  • Never pull a whole GUT run into context: scripts/check-test-count.sh (set GUT_LOG=<own path> when another worktree runs gates).
  • Not doing: no D1 table or dashboard for replays, no tutorial-replay.json upload, no store/TestFlight path.

Deviations from the spec (decided while planning)

Section titled “Deviations from the spec (decided while planning)”
  1. 413 also drops the file on the client. The spec lists 400 as the drop verdict; a 413 can never succeed on retry either, so keeping it would wedge the queue forever.
  2. The pull script lists the bucket with the Cloudflare REST API (GET /accounts/{id}/r2/buckets/bh-replays/objects), because wrangler 4.152 has no r2 object list (only get/put/delete). Downloads still use wrangler r2 object get.
  3. The Worker checks the daily cap before decompressing (cheaper under abuse). A bad file arriving while capped gets 429, is retried, and gets its 400 the next day.
  4. The boot pass waits for the first death when record_replay autostarts a human run (the run is human from tick 0, so the “no pass during a human run” rule holds it). The pending flag survives, so it runs right after that death.
  5. Caps raised to 8 MB gzip / 64 MB raw (Chris, 2026-10-22). Review found 20-min analog runs over the old 2 MB gzip cap (6.5 MB raw / 2.42 MB gzip; the client deletes on 413, so they would be lost). The Worker validates by streaming — head parse plus ],[ row count, never materializing the decompressed body — to stay under the Worker’s 128 MB memory.
File Task Responsibility
telemetry/src/worker.js (modify) 1 POST /replay route, handleReplay, gunzipCapped, exported limits
telemetry/wrangler.toml (modify) 1 [[r2_buckets]] binding REPLAYS → bh-replays
telemetry/test/replay.test.mjs (create) 1 Worker tests with in-memory R2 + KV mocks
net/replay_uploader.gd (create) 2 ReplayUploader node: gate, queue scan, gzip body, verdict, backoff
tests/test_replay_uploader.gd (create) 2 Uploader unit tests, injected transport, scratch dirs
main.gd (modify) 3 Create/keep the uploader, wire dirs/busy/muted, kick at boot and after a save, diagnostics toggle
tests/test_replay_upload_wiring.gd (create) 3 main wiring, incl. the DEFAULT dir list
scripts/replays-pull.mjs, scripts/replays-pull.sh (create) 4 List R2, download new objects, gunzip into replays/
scripts/test/replays-pull.test.mjs (create) 4 Pure helper tests
.gitignore (modify) 4 replays/
docs/architecture/dev-tools.md (modify) 1, 4 Replay auto-upload subsection in § Telemetry v2
marketing/appstore/compliance.md (modify) 5 Dev-only replay line
~/Claude/bullet-heaven-site/privacy/index.html (modify, separate repo) 5 Dev-only replay line

Tasks 1, 2, 4, 5 are independent. Task 3 needs Task 2. Task 6 (rollout) needs all of them merged to main.

Plan worktree: .claude/worktrees/replay-upload, branch replay-upload off origin/main. Task sub-worktrees per bh-sdd-concurrent.


Files:

  • Modify: telemetry/src/worker.js (limits next to EVENTS_MAX at :55-56; handler near handleEvents at :198; route in the fetch if-chain at :347+, right after the /events line)
  • Modify: telemetry/wrangler.toml (after the [[kv_namespaces]] block)
  • Create: telemetry/test/replay.test.mjs
  • Modify: docs/architecture/dev-tools.md (new ### Replay auto-upload (Dev builds) inside § Telemetry v2 at :350, placed just before ### Crash symbolication at :542)

Interfaces:

  • Produces: POST /replay?build=<1-6 digits>&install=<32 lowercase hex>&unix=<1-11 digits>&seed=<-?1-20 digits>, body = gzip bytes. Responses: 200 {"ok":true,"key":...} (stored), 200 {"ok":true,"dup":true,"key":...} (already stored), 400 {"error":...}, 413 {"error":...}, 429 {"error":"daily cap"}. Exports REPLAY_MAX_GZ, REPLAY_MAX_RAW, REPLAY_DAILY_CAP. Task 2 relies on exactly these codes.

  • Step 1: Check nothing enumerates the CONFIG KV (the daily counter lives there as replay_count:<YYYY-MM-DD>).

Run: rg -n "CONFIG\.list|\.list\(" telemetry/src scripts/publish-config.mjs Expected: no match. If something lists CONFIG keys, make it skip the replay_count: prefix in this task.

  • Step 2: Write the failing tests telemetry/test/replay.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";
import { gzipSync } from "node:zlib";
import worker, { REPLAY_MAX_GZ, REPLAY_MAX_RAW, REPLAY_DAILY_CAP } from "../src/worker.js";
const INST = "0123456789abcdef0123456789abcdef";
const KEY = `replays/340/${INST}/1792006650-42.json.gz`;
const r2 = () => {
const m = new Map();
return { m, head: async (k) => (m.has(k) ? { key: k } : null), put: async (k, v, o) => { m.set(k, { bytes: new Uint8Array(v), o }); } };
};
const kv = (init = {}) => {
const m = new Map(Object.entries(init));
return { m, get: async (k) => m.get(k) ?? null, put: async (k, v) => { m.set(k, v); } };
};
const envOf = (kvInit) => ({ REPLAYS: r2(), CONFIG: kv(kvInit) });
const replay = (o = {}) => JSON.stringify({ seed: 42, ticks: [[0, 0, 0, 0, 0, 0], [1, 0, 0, 0, 0, 0]], fresh_save: true, ...o });
const post = (env, body, q = {}) => {
const p = new URLSearchParams({ build: "340", install: INST, unix: "1792006650", seed: "42", ...q });
return worker.fetch(new Request(`https://x/replay?${p}`, { method: "POST", body }), env);
};
const today = () => `replay_count:${new Date().toISOString().slice(0, 10)}`;
test("a valid upload stores the original gzip bytes with metadata and counts the day", async () => {
const env = envOf();
const gz = gzipSync(replay());
const res = await post(env, gz);
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), { ok: true, key: KEY });
const obj = env.REPLAYS.m.get(KEY);
assert.deepEqual(Buffer.from(obj.bytes), gz);
assert.deepEqual(obj.o.customMetadata, { build: "340", install: INST, unix: "1792006650", seed: "42", ticks: "2" });
assert.equal(obj.o.httpMetadata.contentType, "application/gzip");
assert.equal(obj.o.httpMetadata.contentEncoding, undefined, "wrangler get must return the gzip bytes as-is");
assert.equal(env.CONFIG.m.get(today()), "1");
});
test("a retried upload of the same key is 200 dup, stored once, not counted twice", async () => {
const env = envOf();
await post(env, gzipSync(replay()));
const res = await post(env, gzipSync(replay()));
assert.equal(res.status, 200);
assert.equal((await res.json()).dup, true);
assert.equal(env.REPLAYS.m.size, 1);
assert.equal(env.CONFIG.m.get(today()), "1");
});
test("an existing key answers 200 even at the daily cap", async () => {
const env = envOf();
await post(env, gzipSync(replay()));
env.CONFIG.m.set(today(), String(REPLAY_DAILY_CAP));
assert.equal((await post(env, gzipSync(replay()))).status, 200);
});
test("a gzip body over REPLAY_MAX_GZ is 413", async () => {
const env = envOf();
assert.equal((await post(env, new Uint8Array(REPLAY_MAX_GZ + 1))).status, 413);
assert.equal(env.REPLAYS.m.size, 0);
});
test("a body that decompresses past REPLAY_MAX_RAW is 413", async () => {
const env = envOf();
assert.equal((await post(env, gzipSync(Buffer.alloc(REPLAY_MAX_RAW + 1, 0x20)))).status, 413);
assert.equal(env.REPLAYS.m.size, 0);
});
for (const [name, body] of [
["not gzip", Buffer.from("hello")],
["gzip but not JSON", gzipSync("nope")],
["no ticks", gzipSync(JSON.stringify({ seed: 42, fresh_save: true }))],
["fresh_save not bool", gzipSync(replay({ fresh_save: "yes" }))],
["seed not an integer", gzipSync(replay({ seed: 42.5 }))],
["seed mismatch", gzipSync(replay({ seed: 43 }))],
]) {
test(`400 and nothing stored: ${name}`, async () => {
const env = envOf();
const res = await post(env, body);
assert.equal(res.status, 400);
assert.ok((await res.json()).error);
assert.equal(env.REPLAYS.m.size, 0);
});
}
for (const [k, v] of [["build", "x"], ["install", "ABC"], ["unix", ""], ["seed", "1.5"]]) {
test(`400 on a bad ${k} query value`, async () => {
assert.equal((await post(envOf(), gzipSync(replay()), { [k]: v })).status, 400);
});
}
test("a negative seed is accepted and keyed as-is", async () => {
const env = envOf();
assert.equal((await post(env, gzipSync(replay({ seed: -7 })), { seed: "-7" })).status, 200);
assert.ok(env.REPLAYS.m.has(`replays/340/${INST}/1792006650--7.json.gz`));
});
test("429 at the daily cap, nothing stored", async () => {
const env = envOf({ [today()]: String(REPLAY_DAILY_CAP) });
assert.equal((await post(env, gzipSync(replay()))).status, 429);
assert.equal(env.REPLAYS.m.size, 0);
});
  • Step 3: Run to verify it fails

Run: cd telemetry && node --test test/replay.test.mjs 2>&1 | tail -15 Expected: FAIL (REPLAY_MAX_GZ is not exported).

  • Step 4: Implement in telemetry/src/worker.js

Next to EVENTS_MAX_BYTES:

// POST /replay (Dev builds' archived input replays, R2 bh-replays). No client key: the shape check
// plus the daily cap are the abuse bound (spec 2026-10-14, option (a)).
export const REPLAY_MAX_GZ = 2 * 1024 * 1024;
export const REPLAY_MAX_RAW = 16 * 1024 * 1024;
export const REPLAY_DAILY_CAP = 200;
const REPLAY_QUERY = { build: /^\d{1,6}$/, install: /^[0-9a-f]{32}$/, unix: /^\d{1,11}$/, seed: /^-?\d{1,20}$/ };

Near handleEvents:

// Streams the gunzip so a small bomb is cut off at `max` instead of being inflated whole.
async function gunzipCapped(bytes, max) {
const reader = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")).getReader();
const parts = [];
let n = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
n += value.byteLength;
if (n > max) { await reader.cancel(); return null; }
parts.push(value);
}
const out = new Uint8Array(n);
let o = 0;
for (const p of parts) { out.set(p, o); o += p.byteLength; }
return out;
}
async function handleReplay(req, env, url) {
const q = {};
for (const [k, re] of Object.entries(REPLAY_QUERY)) {
const v = url.searchParams.get(k) ?? "";
if (!re.test(v)) return json({ error: `bad ${k}` }, 400);
q[k] = v;
}
const gz = new Uint8Array(await req.arrayBuffer());
if (gz.byteLength > REPLAY_MAX_GZ) return json({ error: "too large" }, 413);
const key = `replays/${q.build}/${q.install}/${q.unix}-${q.seed}.json.gz`;
// A retry after a lost response must neither duplicate nor fail.
if (await env.REPLAYS.head(key)) return json({ ok: true, dup: true, key });
const day = `replay_count:${new Date().toISOString().slice(0, 10)}`;
const count = parseInt((await env.CONFIG.get(day)) ?? "0", 10) || 0;
if (count >= REPLAY_DAILY_CAP) return json({ error: "daily cap" }, 429);
let raw;
try { raw = await gunzipCapped(gz, REPLAY_MAX_RAW); } catch { return json({ error: "not gzip" }, 400); }
if (raw === null) return json({ error: "decompressed too large" }, 413);
let r;
try { r = JSON.parse(new TextDecoder().decode(raw)); } catch { return json({ error: "not json" }, 400); }
if (!r || typeof r !== "object" || !Number.isInteger(r.seed) || !Array.isArray(r.ticks) || typeof r.fresh_save !== "boolean")
return json({ error: "not a replay" }, 400);
if (String(r.seed) !== q.seed) return json({ error: "seed mismatch" }, 400);
await env.REPLAYS.put(key, gz, {
httpMetadata: { contentType: "application/gzip" },
customMetadata: { build: q.build, install: q.install, unix: q.unix, seed: q.seed, ticks: String(r.ticks.length) },
});
await env.CONFIG.put(day, String(count + 1), { expirationTtl: 2 * 86400 });
return json({ ok: true, key });
}

In the fetch if-chain, right after the /events line:

if (req.method === "POST" && url.pathname === "/replay") return handleReplay(req, env, url);

(Check url is already defined at that point in fetch; it is used by the /config line at :362.)

telemetry/wrangler.toml, after [[kv_namespaces]]:

# Dev builds' input replays (POST /replay). Lifecycle rule: delete after 365 days (set by hand, see
# docs/architecture/dev-tools.md § Replay auto-upload).
[[r2_buckets]]
binding = "REPLAYS"
bucket_name = "bh-replays"
  • Step 5: Run to verify it passes, plus the rest of the telemetry suite

Run: cd telemetry && node --test test/*.test.mjs 2>&1 | tail -8 Expected: all pass, 0 fail.

  • Step 6: Docs. In docs/architecture/dev-tools.md, insert before ### Crash symbolication:
### Replay auto-upload (Dev builds)
Dev builds upload each archived input replay (`replay-<unix>-<seed>.json`, never `replay-latest.json`)
to `POST /replay` on the telemetry Worker (spec `docs/superpowers/specs/2026-10-14-replay-auto-upload-design.md`).
- **Client** `net/replay_uploader.gd` (`ReplayUploader`, persistent child of main): gate = Dev channel, not web,
not muted, Share diagnostics on, live platform. The files on disk are the queue: one at a time, oldest first,
gzip body via `request_raw`; deleted on `200 {"ok":true}` or a 400/413, kept on network error/429/5xx with the
event queue's 10 s → 300 s backoff. A pass is kicked at boot and after every replay save, and never runs while
a human run is in progress.
- **Worker:** gzip ≤ 2 MB, decompressed ≤ 16 MB (413), shape `seed` int / `ticks` array / `fresh_save` bool and
seed == query seed (400), 200 objects per UTC day (429, KV `CONFIG` key `replay_count:<YYYY-MM-DD>`, 2-day TTL).
Stores the original gzip at `replays/<build>/<install>/<unix>-<seed>.json.gz` in R2 `bh-replays` (binding
`REPLAYS`), custom metadata build/install/unix/seed/ticks. An existing key answers 200 `dup`.
- **Retention:** R2 lifecycle rule `expire-365` deletes objects after 365 days (the privacy page's 12 months),
set once by hand: `cd telemetry && npx wrangler r2 bucket lifecycle add bh-replays expire-365 --expire-days 365`.
R2 is not in `RETENTION_TABLES` (D1 only).
  • Step 7: Commit
Terminal window
git add telemetry/src/worker.js telemetry/wrangler.toml telemetry/test/replay.test.mjs docs/architecture/dev-tools.md
git commit -m "Telemetry: POST /replay stores Dev input replays in R2 (size, gzip, shape, seed and daily-cap checks)"

Files:

  • Create: net/replay_uploader.gd
  • Test: tests/test_replay_uploader.gd

Interfaces:

  • Consumes: Task 1’s response codes (200 ok:true stored/dup; 400/413 drop; 429/other retry). EventQueue.live_platform() (static, net/event_queue.gd:23), EventQueueCore.BACKOFF_MIN_S/BACKOFF_MAX_S (net/event_queue_core.gd:17-18), BuildChannel.current(), Sim_Const.BUILD.

  • Produces (Task 3 uses these exact names):

    • class_name ReplayUploader extends Node
    • func setup(id: String, share_on: bool) -> void, func set_enabled(on: bool, id: String) -> void, func kick() -> void, func pump(dt: float) -> void
    • var dirs_fn: Callable (() -> Array[String]), var busy_fn: Callable (() -> bool), var muted_fn: Callable (() -> bool), var transport: Callable ((url: String, headers: PackedStringArray, body: PackedByteArray) -> void)
    • var share: bool, var install_id: String, var backoff_s: float, var _pending: bool
    • func on_response(ok_transport: bool, code: int, body: String) -> void
    • statics gate_open(channel, web, muted, share_on, live) -> bool, parse_archive(file_name) -> Dictionary, queue_in(dirs: Array[String]) -> Array[String], body_for(path) -> PackedByteArray, url_for(path, build, install) -> String, verdict(ok_transport, code, body) -> int
  • Step 1: Write the failing tests tests/test_replay_uploader.gd (scaffold conventions: skill bh-write-test)

extends GutTest
const INST := "0123456789abcdef0123456789abcdef"
var _dir := ""
var _sent: Array[Dictionary] = []
func before_each() -> void:
_dir = OS.get_user_data_dir().path_join("gut-replay-up-%d" % Time.get_ticks_usec())
DirAccess.make_dir_recursive_absolute(_dir)
_sent.clear()
func after_each() -> void:
for f: String in DirAccess.get_files_at(_dir):
DirAccess.remove_absolute(_dir.path_join(f))
DirAccess.remove_absolute(_dir)
func _write(file_name: String, text: String = '{"fresh_save":false,"seed":1,"ticks":[]}') -> String:
var p := _dir.path_join(file_name)
var f := FileAccess.open(p, FileAccess.WRITE)
f.store_string(text)
f.close()
return p
func _u() -> ReplayUploader:
var u := ReplayUploader.new()
add_child_autofree(u)
u.set_process(false)
u.setup(INST, true)
u.live_override = 1
u.channel_override = "dev"
var dirs: Array[String] = [_dir]
u.dirs_fn = func() -> Array[String]: return dirs
u.transport = func(url: String, h: PackedStringArray, b: PackedByteArray) -> void:
_sent.append({"url": url, "h": h, "b": b})
return u
func test_gate_truth_table() -> void:
assert_true(ReplayUploader.gate_open("dev", false, false, true, true))
assert_false(ReplayUploader.gate_open("testflight", false, false, true, true), "not dev")
assert_false(ReplayUploader.gate_open("appstore", false, false, true, true), "store")
assert_false(ReplayUploader.gate_open("dev", true, false, true, true), "web carries the dev feature")
assert_false(ReplayUploader.gate_open("dev", false, true, true, true), "muted")
assert_false(ReplayUploader.gate_open("dev", false, false, false, true), "diagnostics off")
assert_false(ReplayUploader.gate_open("dev", false, false, true, false), "headless/editor")
func test_real_gate_is_closed_headless() -> void:
var u := _u()
u.live_override = -1
u.channel_override = ""
_write("replay-10-1.json")
u.kick()
u.pump(0.0)
assert_eq(_sent.size(), 0, "live_platform() is false in GUT")
func test_no_install_id_sends_nothing() -> void:
var u := _u()
u.setup("", true)
_write("replay-10-1.json")
u.kick()
u.pump(0.0)
assert_eq(_sent.size(), 0)
func test_parse_archive() -> void:
assert_eq(ReplayUploader.parse_archive("replay-1792006650-3876285654.json"), {"unix": 1792006650, "seed": 3876285654})
assert_eq(ReplayUploader.parse_archive("replay-5--7.json"), {"unix": 5, "seed": -7})
assert_eq(ReplayUploader.parse_archive("replay-latest.json"), {})
assert_eq(ReplayUploader.parse_archive("tutorial-replay-5-7.json"), {})
assert_eq(ReplayUploader.parse_archive("replay-5-7.json.tmp"), {})
func test_queue_is_oldest_first_and_skips_non_archives() -> void:
_write("replay-30-1.json")
_write("replay-10-2.json")
_write("replay-latest.json")
_write("tutorial-replay-5-3.json")
_write("replay-20-3.json")
var dirs: Array[String] = [_dir, _dir.path_join("missing")]
var q := ReplayUploader.queue_in(dirs)
assert_eq(q, [_dir.path_join("replay-10-2.json"), _dir.path_join("replay-20-3.json"), _dir.path_join("replay-30-1.json")] as Array[String])
func test_one_upload_at_a_time_with_gzip_and_query() -> void:
var u := _u()
_write("replay-10-1.json")
_write("replay-20-2.json")
u.kick()
u.pump(0.0)
u.pump(0.0)
assert_eq(_sent.size(), 1, "second pump waits for the in-flight answer")
var url: String = _sent[0].url
assert_true(url.begins_with(ReplayUploader.ENDPOINT + "?"))
assert_string_contains(url, "build=%d" % Sim_Const.BUILD)
assert_string_contains(url, "install=" + INST)
assert_string_contains(url, "unix=10")
assert_string_contains(url, "seed=1")
assert_true("Content-Type: application/gzip" in (_sent[0].h as PackedStringArray))
func test_ok_deletes_and_moves_to_the_next_file() -> void:
var u := _u()
var first := _write("replay-10-1.json")
_write("replay-20-2.json")
u.kick()
u.pump(0.0)
u.on_response(true, 200, '{"ok":true,"key":"k"}')
assert_false(FileAccess.file_exists(first))
u.pump(0.0)
assert_eq(_sent.size(), 2)
assert_string_contains(_sent[1].url, "unix=20")
func test_dup_counts_as_stored() -> void:
var u := _u()
var p := _write("replay-10-1.json")
u.kick()
u.pump(0.0)
u.on_response(true, 200, '{"ok":true,"dup":true,"key":"k"}')
assert_false(FileAccess.file_exists(p))
func test_queue_empty_clears_pending() -> void:
var u := _u()
u.kick()
u.pump(0.0)
assert_false(u._pending)
func test_400_and_413_drop_the_file() -> void:
for code: int in [400, 413]:
var u := _u()
var p := _write("replay-10-%d.json" % code)
u.kick()
u.pump(0.0)
u.on_response(true, code, '{"error":"x"}')
assert_false(FileAccess.file_exists(p), "code %d drops" % code)
assert_eq(u.backoff_s, 0.0)
func test_network_error_429_5xx_and_200_without_ok_keep_the_file_and_back_off() -> void:
for c: Array in [[false, 0, ""], [true, 429, '{"error":"daily cap"}'], [true, 500, ""], [true, 200, '{"ok":false}'], [true, 200, "garbage"]]:
var u := _u()
var p := _write("replay-10-1.json")
u.kick()
u.pump(0.0)
u.on_response(c[0], c[1], c[2])
assert_true(FileAccess.file_exists(p), "kept on %s" % [c])
assert_eq(u.backoff_s, EventQueueCore.BACKOFF_MIN_S)
func test_backoff_doubles_to_the_cap_and_holds_the_queue() -> void:
var u := _u()
_write("replay-10-1.json")
u.kick()
u.pump(0.0)
u.on_response(false, 0, "")
u.pump(EventQueueCore.BACKOFF_MIN_S - 1.0)
assert_eq(_sent.size(), 1, "still waiting")
u.pump(1.0) # wait drains
u.pump(0.0)
assert_eq(_sent.size(), 2)
u.on_response(false, 0, "")
assert_eq(u.backoff_s, EventQueueCore.BACKOFF_MIN_S * 2.0)
func test_backoff_never_exceeds_the_cap() -> void:
var u := _u()
_write("replay-10-1.json")
u.backoff_s = EventQueueCore.BACKOFF_MAX_S * 0.75
u.kick()
u.pump(0.0)
u.on_response(false, 0, "")
assert_eq(u.backoff_s, EventQueueCore.BACKOFF_MAX_S)
func test_busy_holds_the_pass_until_the_run_ends() -> void:
var u := _u()
var busy := [true]
u.busy_fn = func() -> bool: return busy[0]
_write("replay-10-1.json")
u.kick()
u.pump(0.0)
assert_eq(_sent.size(), 0, "human run in progress")
busy[0] = false
u.pump(0.0)
assert_eq(_sent.size(), 1)
func test_muted_sends_nothing() -> void:
var u := _u()
u.muted_fn = func() -> bool: return true
_write("replay-10-1.json")
u.kick()
u.pump(0.0)
assert_eq(_sent.size(), 0)
func test_set_enabled_off_closes_the_gate() -> void:
var u := _u()
u.set_enabled(false, "")
_write("replay-10-1.json")
u.kick()
u.pump(0.0)
assert_eq(_sent.size(), 0)
func test_gzip_round_trip_of_a_real_recorder_file() -> void:
var rec := ReplayRecorder.new(77)
rec.fresh_save = true
var p := _dir.path_join("replay-10-77.json")
assert_true(rec.save(p))
var body := ReplayUploader.body_for(p)
assert_eq(body[0], 0x1f, "gzip magic")
assert_eq(body[1], 0x8b, "gzip magic")
assert_eq(body.decompress_dynamic(-1, FileAccess.COMPRESSION_GZIP), FileAccess.get_file_as_bytes(p))
var parsed: Variant = JSON.parse_string(body.decompress_dynamic(-1, FileAccess.COMPRESSION_GZIP).get_string_from_utf8())
assert_eq(int(parsed["seed"]), 77, "the Worker's seed check will see the query seed")
  • Step 2: Run to verify it fails

Run: godot --headless --path . --import >/dev/null 2>&1; git checkout -- project.godot audio/bus_layout.tres; GUT_LOG=$TMPDIR/bh-gut-replay-up.log scripts/check-test-count.sh Expected: FAIL; grep -n "replay_uploader" $TMPDIR/bh-gut-replay-up.log | head shows the missing ReplayUploader class.

  • Step 3: Implement net/replay_uploader.gd
class_name ReplayUploader extends Node
## Dev builds: ships archived input replays (replay-<unix>-<seed>.json) to the telemetry Worker's
## POST /replay. The files on disk ARE the queue: one upload at a time, oldest first; a file is deleted
## only on the Worker's {"ok":true}, or on its own 400/413 verdict so one bad file can't wedge the queue.
## Spec: docs/superpowers/specs/2026-10-14-replay-auto-upload-design.md
const ENDPOINT := "https://bullet-heaven-telemetry.chris-allen-06f.workers.dev/replay"
const HTTP_TIMEOUT_S := 60.0
const ARCHIVE_PATTERN := "^replay-(\\d+)-(-?\\d+)\\.json$"
enum Verdict { STORED, DROPPED, RETRY }
var install_id: String = ""
var share: bool = false
var dirs_fn: Callable # () -> Array[String]
var busy_fn: Callable # () -> bool: a human run is in progress
var muted_fn: Callable # () -> bool: playback / capture / showcase
var transport: Callable # tests: (url, headers, body) -> void; invalid = HTTPRequest
var live_override: int = -1 # tests: 1/0 force EventQueue.live_platform(); -1 = real
var channel_override: String = "" # tests: replaces BuildChannel.current()
var backoff_s: float = 0.0
var _wait_s: float = 0.0
var _pending: bool = false
var _inflight: String = ""
var _http: HTTPRequest = null
func setup(id: String, share_on: bool) -> void:
install_id = id
share = share_on
func set_enabled(on: bool, id: String) -> void:
setup(id, on)
if not on and _http != null and _inflight != "":
_http.cancel_request()
_inflight = ""
func kick() -> void:
_pending = true
static func gate_open(channel: String, web: bool, muted: bool, share_on: bool, live: bool) -> bool:
return channel == "dev" and not web and not muted and share_on and live
func is_open() -> bool:
var ch := channel_override if channel_override != "" else BuildChannel.current()
var live := EventQueue.live_platform() if live_override < 0 else live_override == 1
var muted := muted_fn.is_valid() and bool(muted_fn.call())
return install_id != "" and gate_open(ch, OS.has_feature("web"), muted, share, live)
static func parse_archive(file_name: String) -> Dictionary:
var m := RegEx.create_from_string(ARCHIVE_PATTERN).search(file_name)
if m == null:
return {}
return {"unix": int(m.get_string(1)), "seed": int(m.get_string(2))}
static func queue_in(dirs: Array[String]) -> Array[String]:
var found: Array[Dictionary] = []
for d: String in dirs:
if not DirAccess.dir_exists_absolute(d):
continue
for f: String in DirAccess.get_files_at(d):
var p := parse_archive(f)
if not p.is_empty():
found.append({"path": d.path_join(f), "unix": p["unix"], "name": f})
found.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a["unix"] < b["unix"] or (a["unix"] == b["unix"] and a["name"] < b["name"]))
var out: Array[String] = []
for e: Dictionary in found:
out.append(e["path"])
return out
static func body_for(path: String) -> PackedByteArray:
var raw := FileAccess.get_file_as_bytes(path)
return raw.compress(FileAccess.COMPRESSION_GZIP) if raw.size() > 0 else PackedByteArray()
static func url_for(path: String, build: int, install: String) -> String:
var p := parse_archive(path.get_file())
return "%s?build=%d&install=%s&unix=%d&seed=%d" % [ENDPOINT, build, install, p["unix"], p["seed"]]
static func verdict(ok_transport: bool, code: int, body: String) -> int:
if not ok_transport:
return Verdict.RETRY
if code == 200:
var j: Variant = JSON.parse_string(body)
return Verdict.STORED if j is Dictionary and (j as Dictionary).get("ok", false) == true else Verdict.RETRY
if code == 400 or code == 413:
return Verdict.DROPPED
return Verdict.RETRY
func _process(dt: float) -> void:
pump(dt)
func pump(dt: float) -> void:
if _inflight != "":
return
if _wait_s > 0.0:
_wait_s = maxf(0.0, _wait_s - dt)
return
if not _pending or not is_open():
return
if busy_fn.is_valid() and bool(busy_fn.call()):
return
var dirs: Array[String] = []
if dirs_fn.is_valid():
dirs.assign(dirs_fn.call())
var q := queue_in(dirs)
if q.is_empty():
_pending = false
return
var body := body_for(q[0])
if body.is_empty():
push_warning("ReplayUploader: dropping empty/unreadable %s" % q[0])
DirAccess.remove_absolute(q[0])
return
_inflight = q[0]
var url := url_for(_inflight, Sim_Const.BUILD, install_id)
var headers := PackedStringArray(["Content-Type: application/gzip"])
if transport.is_valid():
transport.call(url, headers, body)
return
if _http == null:
_http = HTTPRequest.new()
_http.timeout = HTTP_TIMEOUT_S
add_child(_http)
_http.request_completed.connect(_on_http_done)
if _http.request_raw(url, headers, HTTPClient.METHOD_POST, body) != OK:
on_response(false, 0, "")
func _on_http_done(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
on_response(result == HTTPRequest.RESULT_SUCCESS, code, body.get_string_from_utf8())
func on_response(ok_transport: bool, code: int, body: String) -> void:
var path := _inflight
_inflight = ""
if path == "":
return
if verdict(ok_transport, code, body) == Verdict.RETRY:
backoff_s = EventQueueCore.BACKOFF_MIN_S if backoff_s <= 0.0 else minf(backoff_s * 2.0, EventQueueCore.BACKOFF_MAX_S)
_wait_s = backoff_s
return
DirAccess.remove_absolute(path)
backoff_s = 0.0
  • Step 4: Run to verify it passes

Run: godot --headless --path . --import >/dev/null 2>&1; git checkout -- project.godot audio/bus_layout.tres; GUT_LOG=$TMPDIR/bh-gut-replay-up.log scripts/check-test-count.sh Expected: PASS, script count up by 1. On failure, grep the log for test_replay_uploader, never print the whole log. Warnings-as-errors traps: annotate Dictionary.get() results as Variant, type every loop variable.

  • Step 5: Commit
Terminal window
git add net/replay_uploader.gd tests/test_replay_uploader.gd
git commit -m "ReplayUploader: Dev replays leave the device on their own (files are the queue, delete only on ok)"

Files:

  • Modify: main.gd — var decl next to var event_queue (:121); creation after event_queue.auto_fn (:496); keep-list in _sweep_run_children (:1139-1146); _save_replay_if_recording (:2901-2911); _finish_boot_prewarm (:758-765); _on_diagnostics_toggled (:3151-3156)
  • Test: tests/test_replay_upload_wiring.gd

Interfaces:

  • Consumes: Task 2’s ReplayUploader API (exact names in Task 2’s Produces block). Existing main.replay_dirs_for(override: Array[String]) -> Array[String] (static, :2908), _replay_dirs, _auto, sim.game_over, event_queue.muted.

  • Produces: main.replay_uploader: ReplayUploader (persistent, never swept).

  • Step 1: Write the failing tests tests/test_replay_upload_wiring.gd

Copy _boot_main() and the scratch-dir/DevFlags.load_for(true, {"record_replay": true}) setup verbatim from tests/test_replay_autostart.gd (the helper that boots main, and the kill pattern m.sim.player.hp = 0.0 + m._physics_process(1.0 / 60.0)), including its after_each DevFlags reset. Then:

func test_uploader_exists_and_survives_a_new_run() -> void:
var m := _boot_main()
assert_not_null(m.replay_uploader)
var u: ReplayUploader = m.replay_uploader
m._new_run()
assert_true(is_instance_valid(u) and u.get_parent() == m, "persistent, never swept")
# The 2026-10-14 bug was a test seam hiding the default branch: check the production default too.
func test_default_dirs_are_main_replay_dirs() -> void:
var m := _boot_main()
m._replay_dirs = [] as Array[String]
var dirs: Array[String] = []
dirs.assign(m.replay_uploader.dirs_fn.call())
assert_eq(dirs, ["user://", OS.get_cache_dir()] as Array[String])
func test_override_dirs_reach_the_uploader() -> void:
var m := _boot_main()
var scratch: Array[String] = ["user://gut-x"]
m._replay_dirs = scratch
var dirs: Array[String] = []
dirs.assign(m.replay_uploader.dirs_fn.call())
assert_eq(dirs, scratch)
func test_busy_during_a_human_run_and_free_after_death_with_a_kick() -> void:
var m := _boot_main()
m._replay_dirs = _scratch_dirs # the scratch list the copied setup builds
m._new_run(true)
m.replay_uploader._pending = false
assert_true(m.replay_uploader.busy_fn.call(), "human run in progress")
m.sim.player.hp = 0.0
m._physics_process(1.0 / 60.0)
assert_false(m.replay_uploader.busy_fn.call(), "dead run is not busy")
assert_true(m.replay_uploader._pending, "a replay save kicks a pass")
func test_boot_prewarm_finish_kicks_a_pass() -> void:
var m := _boot_main()
m.replay_uploader._pending = false
m._finish_boot_prewarm()
assert_true(m.replay_uploader._pending)
func test_muted_follows_the_event_queue() -> void:
var m := _boot_main()
m.event_queue.muted = true
assert_true(m.replay_uploader.muted_fn.call())
m.event_queue.muted = false
assert_false(m.replay_uploader.muted_fn.call())
func test_diagnostics_toggle_reaches_the_uploader() -> void:
var m := _boot_main()
m._on_diagnostics_toggled(false)
assert_false(m.replay_uploader.share)
m._on_diagnostics_toggled(true)
assert_true(m.replay_uploader.share)
assert_eq(m.replay_uploader.install_id, m.meta.install_id)

(Use the scratch-dir variable name test_replay_autostart.gd actually uses in place of _scratch_dirs. If _finish_boot_prewarm() starts a run because sim == null, that is fine for this assert.)

  • Step 2: Run to verify it fails

Run: GUT_LOG=$TMPDIR/bh-gut-replay-wire.log scripts/check-test-count.sh Expected: FAIL in test_replay_upload_wiring.gd (replay_uploader is not a member of main).

  • Step 3: Implement in main.gd

Next to var event_queue (:121):

var replay_uploader: ReplayUploader # Dev: archived input replays -> telemetry /replay (persistent)

After event_queue.auto_fn = ... (:496):

replay_uploader = ReplayUploader.new() # persistent (never swept)
add_child(replay_uploader)
replay_uploader.setup(meta.install_id, meta.share_diagnostics)
replay_uploader.dirs_fn = func() -> Array[String]: return replay_dirs_for(_replay_dirs)
# Uploads never compete with a human run's frames; attract, menu and results are fair game.
replay_uploader.busy_fn = func() -> bool: return sim != null and not sim.game_over and not _auto
replay_uploader.muted_fn = func() -> bool: return event_queue.muted

In _sweep_run_children, add to the first condition line: or c == event_queue or c == replay_uploader or ....

In _save_replay_if_recording, the success branch becomes:

if rec.save(d.path_join(file_name)):
rec.save(d.path_join(archive))
if replay_uploader != null:
replay_uploader.kick()
return

In _finish_boot_prewarm, before boot_prewarm_finished.emit():

if replay_uploader != null:
replay_uploader.kick() # archives left by earlier launches (waits out a record_replay human run)

In _on_diagnostics_toggled, after event_queue.set_enabled(on, id):

replay_uploader.set_enabled(on, id)
  • Step 4: Run to verify it passes, plus the determinism and replay suites

Run: godot --headless --path . --import >/dev/null 2>&1; git checkout -- project.godot audio/bus_layout.tres; GUT_LOG=$TMPDIR/bh-gut-replay-wire.log scripts/check-test-count.sh Expected: PASS (whole suite: the change is render-side only, determinism pins must not move). Then scripts/gate.sh 2>&1 | tail -15 → green.

  • Step 5: Commit
Terminal window
git add main.gd tests/test_replay_upload_wiring.gd
git commit -m "main: persistent ReplayUploader, kicked at boot and after each replay save, gated with diagnostics"

Files:

  • Create: scripts/replays-pull.mjs, scripts/replays-pull.sh (chmod +x)
  • Test: scripts/test/replays-pull.test.mjs (the gate’s scripts/test/*.test.mjs glob picks it up)
  • Modify: .gitignore, docs/architecture/dev-tools.md (Task 1’s subsection), docs/research/2026-10-14-toby-replay-vs-autopilot.md:162-163

Interfaces:

  • Consumes: Task 1’s key layout replays/<build>/<install>/<unix>-<seed>.json.gz.

  • Produces: local layout replays/<build>/<install>/<unix>-<seed>.json (gunzipped); hand-pulled copies in replays/hand-pulled/.

  • Step 1: Write the failing tests scripts/test/replays-pull.test.mjs

import { test } from "node:test";
import assert from "node:assert/strict";
import { join } from "node:path";
import { localPath, toFetch, summary } from "../replays-pull.mjs";
const I = "0123456789abcdef0123456789abcdef";
test("localPath mirrors the R2 layout, gunzipped name", () => {
assert.equal(localPath("/r", `replays/340/${I}/1792006650-42.json.gz`), join("/r", "340", I, "1792006650-42.json"));
assert.equal(localPath("/r", `replays/340/${I}/5--7.json.gz`), join("/r", "340", I, "5--7.json"));
assert.equal(localPath("/r", "replays/x/y.json.gz"), null);
assert.equal(localPath("/r", "other/340/a.json.gz"), null);
});
test("toFetch skips keys already on disk and unknown keys", () => {
const have = new Set([join("/r", "340", I, "1-1.json")]);
const keys = [`replays/340/${I}/1-1.json.gz`, `replays/340/${I}/2-2.json.gz`, "junk"];
assert.deepEqual(toFetch("/r", keys, (p) => have.has(p)), [[`replays/340/${I}/2-2.json.gz`, join("/r", "340", I, "2-2.json")]]);
});
test("summary reports ticks and seconds at 60 Hz", () => {
assert.deepEqual(summary(JSON.stringify({ seed: 1, ticks: new Array(600).fill([0]), fresh_save: true })), { ticks: 600, seconds: 10 });
});
  • Step 2: Run to verify it fails

Run: node --test scripts/test/replays-pull.test.mjs 2>&1 | tail -5 Expected: FAIL (cannot find ../replays-pull.mjs).

  • Step 3: Implement scripts/replays-pull.mjs
// Pulls Dev input replays from R2 bh-replays into replays/<build>/<install>/<unix>-<seed>.json.
// Lists via the Cloudflare REST API (wrangler has no `r2 object list`), downloads via wrangler.
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { gunzipSync } from "node:zlib";
import { fileURLToPath } from "node:url";
export const BUCKET = "bh-replays";
const KEY_RE = /^replays\/(\d+)\/([0-9a-f]{32})\/(\d+--?\d+)\.json\.gz$/;
export function localPath(root, key) {
const m = KEY_RE.exec(key);
return m ? join(root, m[1], m[2], `${m[3]}.json`) : null;
}
export function toFetch(root, keys, exists = existsSync) {
return keys.map((k) => [k, localPath(root, k)]).filter(([, p]) => p !== null && !exists(p));
}
export function summary(text) {
const r = JSON.parse(text);
const ticks = Array.isArray(r.ticks) ? r.ticks.length : 0;
return { ticks, seconds: Math.round(ticks / 60) };
}
async function listKeys(account, token) {
const keys = [];
let cursor = "";
do {
const u = new URL(`https://api.cloudflare.com/client/v4/accounts/${account}/r2/buckets/${BUCKET}/objects`);
u.searchParams.set("prefix", "replays/");
u.searchParams.set("per_page", "1000");
if (cursor) u.searchParams.set("cursor", cursor);
const res = await fetch(u, { headers: { Authorization: `Bearer ${token}` } });
const j = await res.json();
if (!j.success) throw new Error(`R2 list failed (${res.status}): ${JSON.stringify(j.errors)}`);
for (const o of j.result) keys.push(o.key);
cursor = j.result_info?.is_truncated ? j.result_info.cursor : "";
} while (cursor);
return keys;
}
async function main() {
const repo = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const root = join(repo, "replays");
const account = process.env.CLOUDFLARE_ACCOUNT_ID;
const token = process.env.CF_R2_TOKEN;
if (!account || !token) throw new Error("CLOUDFLARE_ACCOUNT_ID and CF_R2_TOKEN must be set (use scripts/replays-pull.sh)");
const todo = toFetch(root, await listKeys(account, token));
if (todo.length === 0) { console.log("replays-pull: nothing new"); return; }
const tmp = join(tmpdir(), `bh-replay-${process.pid}.json.gz`);
for (const [key, dest] of todo) {
execFileSync("npx", ["wrangler", "r2", "object", "get", `${BUCKET}/${key}`, "--remote", "--file", tmp],
{ cwd: join(repo, "telemetry"), stdio: ["ignore", "ignore", "inherit"] });
const text = gunzipSync(readFileSync(tmp)).toString("utf8");
mkdirSync(dirname(dest), { recursive: true });
writeFileSync(dest, text);
const s = summary(text);
console.log(`${dest.slice(repo.length + 1)} ${s.ticks} ticks ${Math.floor(s.seconds / 60)}m${String(s.seconds % 60).padStart(2, "0")}s`);
}
rmSync(tmp, { force: true });
console.log(`replays-pull: ${todo.length} new`);
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((e) => { console.error(e.message); process.exit(1); });
}

scripts/replays-pull.sh:

#!/bin/sh
# Pull new Dev input replays from R2 into replays/ (gitignored). Needs an R2-read token.
set -eu
cd "$(dirname "$0")/.."
. "$HOME/.secrets"
CLOUDFLARE_ACCOUNT_ID=06f5622d4192dcefefbe0da75d766bfc CF_R2_TOKEN="${CF_LUMARA_DEPLOY_TOKEN:?not set in ~/.secrets}" \
exec node scripts/replays-pull.mjs "$@"

(Which token can read R2 is only provable once the bucket exists: Task 6 Step 5. If CF_LUMARA_DEPLOY_TOKEN gets a 403 there, stop and ask Chris; token changes go through cloudflare-ops. Never echo the token.)

.gitignore: add a line replays/ (with a comment # Dev input replays pulled from R2 (scripts/replays-pull.sh)).

  • Step 4: Run to verify it passes

Run: chmod +x scripts/replays-pull.sh && node --test scripts/test/replays-pull.test.mjs 2>&1 | tail -5 Expected: PASS.

  • Step 5: Move the hand-pulled copies — in the MAIN checkout /Users/chris/Claude/bullet-heaven, not the worktree (both dirs are gitignored, so the files exist only there; 4 files on 2026-10-22)
Terminal window
cd /Users/chris/Claude/bullet-heaven && mkdir -p replays/hand-pulled && mv -n .superpowers/replays/*.json replays/hand-pulled/ && ls replays/hand-pulled

Expected: eve-2026-10-14-replay-1792006650-3876285654.json, toby-2026-10-14-1837537114.json, toby-2026-10-14-1837537114.run881.json, toby-2026-10-14-1837537114.withpicks.json. mv -n never overwrites; leave .superpowers/replays/ itself in place. Do this step only after the branch with the .gitignore line is merged (else replays/ shows as untracked in main).

  • Step 6: Docs. In docs/research/2026-10-14-toby-replay-vs-autopilot.md:162-163 replace .superpowers/replays/ with replays/hand-pulled/. Append to Task 1’s ### Replay auto-upload (Dev builds) subsection in docs/architecture/dev-tools.md:
- **Mac:** `scripts/replays-pull.sh` lists `bh-replays` (Cloudflare REST API; wrangler has no object list) and
downloads every object not already in gitignored `replays/` (same `<build>/<install>/` layout, gunzipped), printing
tick count and duration. Hand-pulled pre-upload copies live in `replays/hand-pulled/`. Compare against a bot run
with `tools/replay_compare` (`--replay=replays/...`).
  • Step 7: Commit
Terminal window
git add scripts/replays-pull.mjs scripts/replays-pull.sh scripts/test/replays-pull.test.mjs .gitignore docs/architecture/dev-tools.md docs/research/2026-10-14-toby-replay-vs-autopilot.md
git commit -m "replays-pull.sh: fetch Dev replays from R2 into gitignored replays/"

Files:

  • Modify: marketing/appstore/compliance.md (the “Multiplayer (LAN co-op, dev builds only today)” block at :68-75)
  • Modify: ~/Claude/bullet-heaven-site/privacy/index.html (after the dev-builds sentence at :88). Separate repo: read its CLAUDE.md first, commit there, do NOT deploy (Task 6).

Copy rule: player-facing copy, so no em dashes and plain English.

  • Step 1: compliance.md. After the LAN co-op dev-only block, add:
**Gameplay input replays (internal development builds only):** Dev builds record each run's controller
inputs (stick and button state per frame, the run's seed and settings) and upload the file to our telemetry
service, keyed by the anonymous install id, when Share diagnostics is on. Kept 12 months, then deleted. App
Store and TestFlight builds never record or upload replays, so the App Store privacy label is unchanged.
  • Step 2: privacy/index.html. After the :88 paragraph (“Our internal development builds also have co-op over a local network…”), add a paragraph in the same markup style as its neighbours:
<p>Our internal development builds also record gameplay input replays (which way the stick and buttons were pressed each frame, plus the run's seed and settings) and send them to us with the anonymous install id when Share diagnostics is on, so we can study how people play. We keep them for 12 months. The App Store and TestFlight versions of the game never record or send replays.</p>

Do not touch the data-privacy-covers attributes (read live by scripts/publish-config.mjs).

  • Step 3: Check and commit both

Run: rg -n "—" marketing/appstore/compliance.md | tail -3 (no new em dash in the added lines) and open file:///Users/chris/Claude/bullet-heaven-site/privacy/index.html locally (or siteview_capture it) to check it renders.

Terminal window
git add marketing/appstore/compliance.md && git commit -m "compliance: Dev-only gameplay input replay upload"
cd ~/Claude/bullet-heaven-site && git add privacy/index.html && git commit -m "Privacy: Dev-build gameplay input replays" && git push

(Pushing the site repo does not deploy it; check that repo’s CLAUDE.md confirms that before pushing. If a push auto-deploys, hold the push for Task 6 Step 4.)


Task 6: Rollout (main session, each step needs Chris’s OK at the time)

Section titled “Task 6: Rollout (main session, each step needs Chris’s OK at the time)”

Precondition: Tasks 1-5 reviewed (bh-reviewer) and merged to main via bh-merge-reviewed-branch, pushed. Ask Chris separately for each numbered OK below; a “yes” to one is not a yes to the next.

  • Step 1 (OK #1): create the bucket + lifecycle rule
Terminal window
cd /Users/chris/Claude/bullet-heaven/telemetry && npx wrangler r2 bucket create bh-replays
npx wrangler r2 bucket lifecycle add --help # confirm flag names on 4.152 before running the next line
npx wrangler r2 bucket lifecycle add bh-replays expire-365 --expire-days 365
npx wrangler r2 bucket lifecycle list bh-replays

Expected: the rule shows 365-day expiry. If the account is ambiguous, wrangler.toml’s pinned account_id already applies inside telemetry/.

  • Step 2 (OK #2): deploy the telemetry Worker (also clears any pending telemetry redeploy)
Terminal window
cd /Users/chris/Claude/bullet-heaven && telemetry/deploy.sh --check-only && telemetry/deploy.sh

Smoke, stores nothing (bad body → 400):

Terminal window
curl -s -X POST 'https://bullet-heaven-telemetry.chris-allen-06f.workers.dev/replay?build=0&install=00000000000000000000000000000000&unix=0&seed=1' --data-binary 'x'

Expected: {"error":"not gzip"}. A 500 or REPLAYS undefined means the binding didn’t deploy: stop and tell Chris (deploy failures: never improvise another route).

  • Step 3 (OK #3): privacy site deploy via ~/Claude/bullet-heaven-site/scripts/deploy-site.sh only. Then confirm the live privacy page contains “gameplay input replays”.

  • Step 4 (OK #4): Dev install on the Apple TV. Read docs/reference/builds-and-releases.md first (BUILD bump, one merger at a time), then skill bh-deploy. Before installing, check no human is playing (console sessions human_playing): an install kills a live run.

  • Step 5: device proof (no extra OK needed once the install is approved)

    1. Let the record_replay autostart human run die idle (no input).
    2. Within ~10 s of the results screen, the object exists: scripts/replays-pull.sh prints one new replays/<build>/<install>/<unix>-<seed>.json. A 403 on the list = the token lacks R2 read: stop, ask Chris (cloudflare-ops).
    3. On-device archive gone, replay-latest.json kept: xcrun devicectl device info files --device <atv-id> --domain-type appDataContainer --domain-identifier digital.lumara.bulletheaven.dev --subdirectory Library/Caches lists replay-latest.json and no replay-<unix>-<seed>.json.
    4. Content matches: copy replay-latest.json off the device (devicectl device copy from) and cmp it with the pulled file (same run, same bytes).
  • Step 6: close out. Tick the related RELEASE/Issue item, comment on #114 that human replays now arrive on their own, and update memory (bullet-heaven-learning-autopilot, bullet-heaven-agent-evaluation-ideas): drop the “pull new ATV replays by hand” note.


  • Spec coverage: client gate/queue/when/body → Tasks 2-3; Worker validate/cap/store/idempotent/retention → Task 1 (+ lifecycle in Task 6); Mac pull + move hand-pulled copies → Task 4; privacy → Task 5; GUT, Worker and device tests → Tasks 1-3, 6; out-of-band steps → Task 6. Deviations listed above.
  • Names consistent across tasks: ReplayUploader, kick, pump, on_response, dirs_fn/busy_fn/muted_fn/transport, setup/set_enabled, REPLAY_MAX_GZ/REPLAY_MAX_RAW/REPLAY_DAILY_CAP, key replays/<build>/<install>/<unix>-<seed>.json.gz.