Skip to content

Replay auto-upload (Dev builds) design

Approved by Chris 2026-10-14 (auth option (a): no key, hard caps).

Dev builds record every human run as an input replay (DevFlags.record_replay, main._save_replay_if_recording). On tvOS the only writable place is Library/Caches, which the OS may purge under storage pressure, and a copy only reaches the Mac when someone remembers to pull it with devicectl. Toby’s first recorded runs were lost (a save bug, fixed 065d4f78), and relying on “tell Claude someone played” is the discipline-based fix this repo avoids. Replays must leave the device on their own.

  • Telemetry Worker telemetry/src/worker.js: open ingest routes (CORS *, no client key), D1 DB, KV CONFIG, daily cron with purgeOld over RETENTION_TABLES (D1 only). /events caps at 512 KB and 20 events. No R2 binding anywhere.
  • Client: net/event_queue.gd posts text with HTTPRequest; muted covers playback, capture and showcase runs; the player’s Share diagnostics toggle gates sending. Nothing compresses a body or uses request_raw.
  • Channel: BuildChannel.current() == "dev" on the tvOS-Dev / iOS-Dev / Android-Dev presets, BUT the root Web preset also carries custom_features="dev", so channel alone is not a safe gate.
  • Replay files: replay-latest.json (overwritten each run) + replay-<unix>-<seed>.json (one per run), saved to user:// or, on tvOS, OS.get_cache_dir(). ~1.5 MB of JSON per 5 min of play.

Client: net/replay_uploader.gd (new, render-side Node)

Section titled “Client: net/replay_uploader.gd (new, render-side Node)”
  • Gate: BuildChannel.current() == "dev" AND not OS.has_feature("web") AND not muted (playback, capture, showcase) AND Share diagnostics on AND EventQueue.live_platform() (never headless/editor, so tests inject the transport).
  • The files on disk are the queue. No new queue: the uploader scans the replay dirs for replay-<unix>-<seed>.json (never replay-latest.json), uploads one at a time, oldest first, and deletes a file only after the Worker answers 200 with {"ok":true,...}. A failure leaves the file for the next pass.
  • When: a pass runs at boot (after the prewarm) and right after each replay save. Backoff on failure mirrors the event queue (10 s doubling to 300 s); a pass never runs while a human run is in progress, so uploads never compete with gameplay frames.
  • Body: file bytes.compress(FileAccess.COMPRESSION_GZIP) sent with HTTPRequest.request_raw, Content-Type: application/gzip, query build, install (the telemetry install id), unix, seed.
  • replay-latest.json stays on device untouched (local convenience).

Worker: POST /replay on the telemetry Worker

Section titled “Worker: POST /replay on the telemetry Worker”
  • New R2 binding REPLAYS (bucket bh-replays).
  • Validates before storing: gzip body ≤ 2 MB (else 413); decompresses with DecompressionStream("gzip"), decompressed ≤ 16 MB; parses JSON and requires the replay shape (seed integer, ticks array, fresh_save bool) and seed == the query seed. Anything else → 400 {"error":...} (the client then deletes it, matching the event queue’s “the Worker’s own error drops it” rule, so one bad file cannot block the queue forever).
  • Daily cap: at most 200 stored objects per UTC day (counter in KV CONFIG), 429 over it (client retries later). This plus the shape check is the abuse bound in place of a key.
  • Stores the ORIGINAL gzip bytes at replays/<build>/<install>/<unix>-<seed>.json.gz, with custom metadata (build, install, unix, seed, ticks count). Idempotent: an existing key returns 200 (a retried upload after a lost response must not duplicate or fail).
  • Retention: an R2 lifecycle rule deletes objects after 365 days (the privacy page’s 12-month promise). Set once by hand with wrangler, documented in docs/architecture/dev-tools.md § Telemetry v2.

Lists bh-replays via wrangler and downloads every object not already present into gitignored replays/ (same <build>/<install>/ layout, gunzipped). Prints the new files with tick count and duration. Existing hand-pulled copies in .superpowers/replays/ move there.

Add a “development builds only” line for gameplay input replays to ~/Claude/bullet-heaven-site/privacy/ (deploy via that repo’s scripts/deploy-site.sh) and marketing/appstore/compliance.md, following the LAN co-op diagnostics precedent. Store/TestFlight builds never upload replays (they never record them: DevFlags are all-false there).

  • GUT: uploader gate truth table (dev/web/muted/diagnostics); queue selection (oldest first, skips replay-latest.json); delete-only-on-200; keep on network error and 429; drop on 400; gzip round trip of a real recorder file. Injectable transport, scratch dirs (_replay_dirs style), and a test of the DEFAULT dir list (the 2026-10-14 bug was a test seam hiding the default branch).
  • Worker (node tests, telemetry/test/): valid upload stored with metadata; idempotent re-upload; 413/400 on oversize, non-gzip, bad shape, seed mismatch; 429 at the daily cap.
  • Device: a Dev ATV install, an idle autostart death, the object appears in R2, the on-device archive file is gone, replays-pull.sh fetches it and it matches.

Out-of-band steps (Chris’s OK at the time)

Section titled “Out-of-band steps (Chris’s OK at the time)”

wrangler r2 bucket create bh-replays + lifecycle rule, telemetry/deploy.sh (also clears the pending console/telemetry redeploys), privacy site deploy, Dev install.

No D1 table or dashboard for replays (R2 listing is enough), no upload of tutorial-replay.json yet, no store/TestFlight path.