Dash-Encounter Telemetry Implementation Plan
Dash-Encounter Telemetry Implementation Plan
Section titled “Dash-Encounter Telemetry 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: Capture what a human player actually does during a charger/spider dash encounter (telegraph + lunge), so real movement/dodge behavior can inform how Ace’s AI is tuned — a first pass, explicitly expandable later per Chris’s own framing.
Architecture: A new client-side per-tick tracker (net/dash_encounter_telemetry.gd) detects
dash-enemy encounters near the human pilot, samples relative position sparsely, and POSTs a
compact summary to a new server route/table on encounter resolution. No /sim changes — reads
only already-existing queryable sim.enemies.* fields.
Tech Stack: Godot 4.6.3, typed GDScript, GUT 9.6.0 (client); Cloudflare Workers + D1 (server,
telemetry/).
Global Constraints
Section titled “Global Constraints”- No
/simchanges. - Human sessions only — gated on
main.gd’s existing_autoflag (not _auto). AI/attract-mode encounters are not tracked here; Ace’s full state is already available locally via soak tooling. - Single-pilot (P1/
sim.player) only for this first pass — co-op dual-pilot tracking is an explicit non-goal, not silently dropped. - Enemy identity across ticks MUST use
sim.enemies.entity_id[i](a stable per-spawn counter,sim/enemy_pool.gd:225-226), NEVER the raw pool array indexi—EntityPooluses swap-remove (sim/enemy_pool.gd:358:entity_id[i] = entity_id[last]on removal), so a raw index can be silently reassigned to a DIFFERENT enemy the instant the tracked one dies mid-encounter. Track byentity_id, and treat “entity_id no longer found in the pool” as the enemy having died/despawned — end that encounter with whatever samples were already collected. - Outcome is a pure GEOMETRIC contact check (pilot/enemy hitbox overlap during the tracked enemy’s lunge), not an HP/damage check — a documented first-pass simplification (see the design spec’s Non-goals), not something to silently “improve” mid-implementation.
- Telemetry must never affect gameplay or crash on failure — matches every existing
net/file’s own stated convention (_enabledgate, fire-and-forget, errors ignored).
Task 1: Server-side — new encounters table + /encounter route
Section titled “Task 1: Server-side — new encounters table + /encounter route”Files:
- Modify:
telemetry/schema.sql - Modify:
telemetry/src/worker.js
Interfaces:
-
Produces:
POST /encounteraccepting the JSON body shape below; inserts one row into the newencounterstable. -
Step 1: Add the
encounterstable
Read the current telemetry/schema.sql in full first to match its exact formatting/comment
style. Append:
-- Per-encounter dash-avoidance telemetry (2026-07-17): captures a human player's relative-- position around a single charger/spider telegraph+lunge cycle, so real dodge behavior can-- inform AI tuning. First pass (Chris: "lets try this first but it may be worth expanding the-- payload later") -- outcome is a pure geometric contact check, not HP/damage-based.CREATE TABLE IF NOT EXISTS encounters ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL, session TEXT, build INTEGER, is_auto INTEGER, enemy_type TEXT, chain INTEGER, outcome TEXT, player_dashed INTEGER, closest_approach_px REAL, duration_s REAL, samples_json TEXT);CREATE INDEX IF NOT EXISTS idx_encounters_build ON encounters(build);CREATE INDEX IF NOT EXISTS idx_encounters_enemy_type ON encounters(enemy_type);- Step 2: Add the
/encounterroute toworker.js
Read the current telemetry/src/worker.js in full first (it’s ~180 lines) to confirm the exact
current shape of the /gameplay handler block, and the num/intt/str helper functions
already defined at the top of the file. Add a new route block, following the SAME
parse-then-insert-then-respond shape as /gameplay, placed immediately after the existing
/gameplay block and before the dashboard/viewer routes:
if (req.method === "POST" && url.pathname === "/encounter") { let s; try { s = JSON.parse(await req.text()); } catch { return new Response("bad json", { status: 400, headers: CORS }); } try { await env.DB.prepare( `INSERT INTO encounters (ts,session,build,is_auto,enemy_type,chain,outcome,player_dashed,closest_approach_px,duration_s,samples_json) VALUES (?,?,?,?,?,?,?,?,?,?,?)` ).bind( Date.now(), str(s.session, 40), intt(s.build), s.is_auto ? 1 : 0, str(s.enemy_type, 16), s.chain ? 1 : 0, str(s.outcome, 12), s.player_dashed ? 1 : 0, num(s.closest_approach_px), num(s.duration_s), str(JSON.stringify(s.samples || []), 4000) ).run(); } catch (_e) { return new Response("db error", { status: 500, headers: CORS }); } return new Response("ok", { headers: CORS }); }Also update the file’s top-of-file route comment block (currently lines 1-4, listing
POST /sample, GET / or /dash, GET /stats.json) to add:
// POST /encounter — ingest one dash-encounter summary (text/plain JSON body)
- Step 3: Apply the schema migration and smoke-test the route
This project’s telemetry worker has no automated test suite (verify this is still true by
checking for any telemetry/*test* or telemetry/*.test.js files before assuming — if one
exists, follow its existing pattern instead of skipping straight to a manual smoke test).
Apply the schema change to the live D1 database:
cd telemetry && npx wrangler d1 execute bullet-heaven-telemetry --remote --command "$(cat schema.sql | tail -15)"(Only run the NEW CREATE TABLE/CREATE INDEX statements just added, not the whole file, to
avoid re-running old migrations — extract just the new SQL block from Step 1.)
Deploy the updated worker:
cd telemetry && npx wrangler deploySmoke-test with a real POST:
curl -s -X POST https://bullet-heaven-telemetry.chris-allen-06f.workers.dev/encounter \ -H "Content-Type: text/plain" \ -d '{"session":"smoketest","build":175,"is_auto":false,"enemy_type":"charger","chain":false,"outcome":"clear","player_dashed":true,"closest_approach_px":42.5,"duration_s":2.1,"samples":[{"t":0.0,"dx":150.0,"dy":0.0,"phase":"charge"}]}'Expected: ok response. Then verify the row landed:
cd telemetry && npx wrangler d1 execute bullet-heaven-telemetry --remote --command "SELECT * FROM encounters WHERE session='smoketest'"Expected: one row with the smoke-test values. Delete it afterward so it doesn’t pollute real data:
cd telemetry && npx wrangler d1 execute bullet-heaven-telemetry --remote --command "DELETE FROM encounters WHERE session='smoketest'"- Step 4: Commit
git add telemetry/schema.sql telemetry/src/worker.jsgit commit -m "feat(telemetry): add /encounter route + encounters table for dash-avoidance capture"Task 2: Client-side dash-encounter tracker
Section titled “Task 2: Client-side dash-encounter tracker”Files:
- Create:
net/dash_encounter_telemetry.gd - Test:
tests/test_dash_encounter_telemetry.gd
Interfaces:
-
Produces:
class_name DashEncounterTelemetry extends Nodewithsetup() -> void(mirrorsGameplayTelemetry.setup()’s enable/device-id pattern) andupdate(sim: Sim, pilot: PlayerState) -> void(called once per real sim tick — NOT a_process()override, since this needs tick-precise sampling ofsim.enemiesstate, andmain.gd’s render frame rate doesn’t map 1:1 to sim ticks under its dev time-scale tool). -
Step 1: Write the failing tests
Create tests/test_dash_encounter_telemetry.gd:
extends GutTest
# DashEncounterTelemetry tracks charger/spider encounters near the human pilot for later# balance analysis. Pure detection/capture logic tested here WITHOUT network calls -- `_enabled`# stays false under GUT (matches every other net/ file's own editor/headless detection), so# `update()` must still run its detection/capture bookkeeping (readable via test-only accessors)# even when telemetry sending itself is disabled.
func test_starts_tracking_when_a_dash_enemy_begins_charging_in_range() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO var ei := sim.enemies.add(Vector2(150, 0), Vector2.ZERO, 14.0, 100.0) sim.enemies.behavior[ei] = EnemyPool.BEHAVIOR_DASH sim.enemies.dash_phase[ei] = EnemyPool.DASH_CHARGE sim.hash.rebuild(sim.enemies) var tracker := DashEncounterTelemetry.new() tracker.update(sim, sim.player) assert_eq(tracker.active_encounter_count(), 1, "a charging dash enemy in range starts a tracked encounter")
func test_ignores_a_dash_enemy_out_of_detection_range() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO var ei := sim.enemies.add(Vector2(1000, 0), Vector2.ZERO, 14.0, 100.0) sim.enemies.behavior[ei] = EnemyPool.BEHAVIOR_DASH sim.enemies.dash_phase[ei] = EnemyPool.DASH_CHARGE sim.hash.rebuild(sim.enemies) var tracker := DashEncounterTelemetry.new() tracker.update(sim, sim.player) assert_eq(tracker.active_encounter_count(), 0, "a dash enemy far outside detection range is not tracked")
func test_tracks_by_entity_id_not_pool_index() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO var e1 := sim.enemies.add(Vector2(150, 0), Vector2.ZERO, 14.0, 100.0) sim.enemies.behavior[e1] = EnemyPool.BEHAVIOR_DASH sim.enemies.dash_phase[e1] = EnemyPool.DASH_CHARGE sim.hash.rebuild(sim.enemies) var tracker := DashEncounterTelemetry.new() tracker.update(sim, sim.player) assert_eq(tracker.active_encounter_count(), 1) # Kill e1 (swap-remove) and immediately spawn a NEW enemy that lands at the SAME pool index -- # a naive index-keyed tracker would wrongly think its original encounter is still live. var original_entity_id: int = sim.enemies.entity_id[e1] sim.enemies.remove_at(e1) var e2 := sim.enemies.add(Vector2(150, 0), Vector2.ZERO, 14.0, 100.0) # very likely reuses e1's freed slot sim.enemies.behavior[e2] = EnemyPool.BEHAVIOR_DASH sim.enemies.dash_phase[e2] = EnemyPool.DASH_CHARGE sim.hash.rebuild(sim.enemies) assert_ne(sim.enemies.entity_id[e2], original_entity_id, "sanity: the new enemy must have a DIFFERENT entity_id even if it reused the same pool slot") tracker.update(sim, sim.player) # The original encounter should have ended (enemy gone) and a NEW, separate encounter started # for e2 -- never silently continuing the old encounter under e2's identity. assert_eq(tracker.active_encounter_count(), 1, "e1's encounter ended on removal; e2 starts a fresh one")
func test_outcome_is_contact_when_hitboxes_overlap_during_lunge() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO var ei := sim.enemies.add(Vector2(150, 0), Vector2.ZERO, 14.0, 100.0) sim.enemies.behavior[ei] = EnemyPool.BEHAVIOR_DASH sim.enemies.dash_phase[ei] = EnemyPool.DASH_CHARGE sim.hash.rebuild(sim.enemies) var tracker := DashEncounterTelemetry.new() tracker.update(sim, sim.player) # Fire the lunge, landing directly on the pilot (hitbox overlap: pilot radius + enemy radius = 14+14, well over 0 distance). sim.enemies.dash_phase[ei] = EnemyPool.DASH_LUNGE sim.enemies.pos[ei] = Vector2(5, 0) # inside contact range of the pilot at origin sim.enemies.vel[ei] = Vector2(-500, 0) sim.hash.rebuild(sim.enemies) tracker.update(sim, sim.player) var mid_resolved := tracker.take_resolved_encounters() assert_eq(mid_resolved.size(), 0, "still mid-lunge, not yet resolved") # Now the enemy leaves range (lunge complete, moved off) -- the encounter resolves, and the # contact recorded above must actually surface in the final outcome, not just be tracked and # then dropped. sim.enemies.pos[ei] = Vector2(5000, 5000) sim.hash.rebuild(sim.enemies) tracker.update(sim, sim.player) var resolved := tracker.take_resolved_encounters() assert_eq(resolved.size(), 1, "the encounter must resolve once the enemy leaves range") assert_eq(String(resolved[0].outcome), "contact", "a hitbox overlap during lunge must surface as outcome=='contact' in the final resolved record")
func test_resolves_as_clear_when_lunge_ends_without_contact() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO var ei := sim.enemies.add(Vector2(150, 0), Vector2.ZERO, 14.0, 100.0) sim.enemies.behavior[ei] = EnemyPool.BEHAVIOR_DASH sim.enemies.dash_phase[ei] = EnemyPool.DASH_CHARGE sim.hash.rebuild(sim.enemies) var tracker := DashEncounterTelemetry.new() tracker.update(sim, sim.player) sim.enemies.dash_phase[ei] = EnemyPool.DASH_LUNGE sim.enemies.pos[ei] = Vector2(500, 500) # lunging somewhere well away from the pilot sim.enemies.vel[ei] = Vector2(500, 500) sim.hash.rebuild(sim.enemies) tracker.update(sim, sim.player) # Enemy leaves detection range entirely (encounter ends because it's no longer found nearby). sim.enemies.pos[ei] = Vector2(5000, 5000) sim.hash.rebuild(sim.enemies) tracker.update(sim, sim.player) var resolved := tracker.take_resolved_encounters() assert_eq(resolved.size(), 1, "the encounter must resolve once the enemy leaves range") assert_eq(String(resolved[0].outcome), "clear", "no contact occurred -> clear")
func test_samples_are_capped_at_40() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO var ei := sim.enemies.add(Vector2(150, 0), Vector2.ZERO, 14.0, 100.0) sim.enemies.behavior[ei] = EnemyPool.BEHAVIOR_DASH sim.enemies.dash_phase[ei] = EnemyPool.DASH_CHARGE sim.hash.rebuild(sim.enemies) var tracker := DashEncounterTelemetry.new() # Call update() far more than 40 times with the enemy still charging -- samples must not grow unbounded. for i in range(300): tracker.update(sim, sim.player) assert_true(tracker.sample_count_for_test(sim.enemies.entity_id[ei]) <= 40, "samples are hard-capped at 40 per encounter")
func _content() -> ContentDB: return SimContentFixture.db()- Step 2: Run tests to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_dash_encounter_telemetry -gexit
Expected: FAIL — DashEncounterTelemetry class doesn’t exist yet. (If SimContentFixture.db()
isn’t the right helper name for this test file’s needs, check an existing ai/ test file for
the real pattern in use — every prior test file this session used _content() -> SimContentFixture.db() or an equivalent already-established local helper.)
- Step 3: Write the implementation
Create net/dash_encounter_telemetry.gd:
class_name DashEncounterTelemetryextends Node
# Client-side dash-encounter capture (2026-07-17): tracks a human pilot's relative position# around each charger/spider telegraph+lunge cycle, so real dodge behavior can inform how Ace's# AI is tuned. First pass (Chris: "lets try this first but it may be worth expanding the payload# later"). Human sessions ONLY (main.gd gates calls to update() on `not _auto`) -- Ace's full# state is already available for free via local soak tooling, no need to route AI runs through# this network path. Single-pilot (P1) only for this first pass.## Enemies are tracked by `sim.enemies.entity_id[i]` (a stable per-spawn counter), NEVER the raw# pool array index -- EntityPool uses swap-remove on death, so an index can be silently reassigned# to a completely different enemy mid-encounter (sim/enemy_pool.gd:358).
const ENDPOINT := "https://bullet-heaven-telemetry.chris-allen-06f.workers.dev/encounter"const DETECT_RADIUS: float = 300.0 # matches ai/auto_player.gd's THREAT_RADIUS -- an already-tuned distance, not a new arbitrary oneconst SAMPLE_INTERVAL_S: float = 0.1const MAX_SAMPLES: int = 40
var _http: HTTPRequestvar _session: String = ""var _device: String = ""var _enabled: bool = false
# entity_id -> Dictionary{enemy_type, chain, start_time, last_sample_time, samples, closest_approach,# player_dashed, outcome, last_dash_cd, resolved_at_lunge_seen}var _active: Dictionary = {}var _resolved: Array[Dictionary] = []
func setup() -> void: _enabled = not OS.has_feature("editor") and DisplayServer.get_name() != "headless" if not _enabled: return _session = "%08x%08x" % [randi(), randi()] _device = TelemetryDevice.id() _http = HTTPRequest.new() add_child(_http)
func update(sim: Sim, pilot: PlayerState) -> void: var seen_ids: Dictionary = {} # entity_id -> true, for this tick's in-range dash enemies var near := sim.hash.query_circle(pilot.pos, DETECT_RADIUS, sim.enemies) for i in near: if sim.enemies.behavior[i] != EnemyPool.BEHAVIOR_DASH: continue var eid: int = sim.enemies.entity_id[i] var is_charging := sim.enemies.dash_phase[i] == EnemyPool.DASH_CHARGE var is_lunging := sim.enemies.dash_phase[i] == EnemyPool.DASH_LUNGE if not is_charging and not is_lunging: continue seen_ids[eid] = true if not _active.has(eid): _active[eid] = { "enemy_type": EnemyPool.type_name(sim.enemies.type_id[i]), "chain": false, "start_time": sim.run_time, "last_sample_time": -1.0, "samples": [], "closest_approach": INF, "player_dashed": false, "contact": false, "start_dash_cd": pilot.dash_cd, } var enc: Dictionary = _active[eid] var dist := pilot.pos.distance_to(sim.enemies.pos[i]) enc.closest_approach = minf(enc.closest_approach, dist) if pilot.dash_cd > enc.start_dash_cd + 0.01: enc.player_dashed = true # dash_cd rose during this encounter -> dash was used (same transition logic main.gd already uses for dash fx) if is_lunging and dist <= pilot.radius + sim.enemies.radius[i]: enc.contact = true var t: float = sim.run_time - float(enc.start_time) if t - float(enc.last_sample_time) >= SAMPLE_INTERVAL_S and enc.samples.size() < MAX_SAMPLES: enc.samples.append({ "t": snappedf(t, 0.01), "dx": snappedf(pilot.pos.x - sim.enemies.pos[i].x, 0.1), "dy": snappedf(pilot.pos.y - sim.enemies.pos[i].y, 0.1), "phase": "lunge" if is_lunging else "charge", }) enc.last_sample_time = t
# Anything active but not seen this tick has left range or died -- resolve it now. for eid in _active.keys(): if not seen_ids.has(eid): _resolve(eid)
func _resolve(eid: int) -> void: var enc: Dictionary = _active[eid] _active.erase(eid) var out := { "enemy_type": enc.enemy_type, "chain": enc.chain, "outcome": "contact" if enc.contact else "clear", "player_dashed": enc.player_dashed, "closest_approach_px": enc.closest_approach if enc.closest_approach < INF else 0.0, "duration_s": enc.last_sample_time if enc.last_sample_time >= 0.0 else 0.0, "samples": enc.samples, } _resolved.append(out) _send(out)
func _send(enc: Dictionary) -> void: if not _enabled or _http == null: return var body := { "session": _session, "build": Sim_Const.BUILD, "is_auto": false, "enemy_type": enc.enemy_type, "chain": enc.chain, "outcome": enc.outcome, "player_dashed": enc.player_dashed, "closest_approach_px": enc.closest_approach_px, "duration_s": enc.duration_s, "samples": enc.samples, } _http.request(ENDPOINT, ["Content-Type: text/plain"], HTTPClient.METHOD_POST, JSON.stringify(body))
# ── Test seams (also used internally by any future dashboard hook) ─────────────────────────func active_encounter_count() -> int: return _active.size()
func take_resolved_encounters() -> Array[Dictionary]: var r := _resolved _resolved = [] return r
func sample_count_for_test(entity_id: int) -> int: if not _active.has(entity_id): return 0 return _active[entity_id].samples.size()- Step 4: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_dash_encounter_telemetry -gexit
Expected: PASS — all 6 tests green. If test_tracks_by_entity_id_not_pool_index fails because
sim.enemies.add() after a remove_at() doesn’t deterministically reuse the freed slot, that’s
fine — the test’s actual assertion (a fresh encounter starts for the new enemy, not a
silently-continued one) still holds regardless of which slot it lands in; only the test’s own
inline comment about “very likely reuses” may not hold on every run, which doesn’t affect
correctness of the assertion itself.
- Step 5: Commit
git add net/dash_encounter_telemetry.gd tests/test_dash_encounter_telemetry.gdgit commit -m "feat(telemetry): DashEncounterTelemetry — client-side dash-avoidance capture"Task 3: Wire into main.gd
Section titled “Task 3: Wire into main.gd”Files:
- Modify:
main.gd
Interfaces:
-
Consumes:
DashEncounterTelemetry.setup(),DashEncounterTelemetry.update(sim, pilot)(Task 2). -
Step 1: Read the current file around the existing telemetry wiring
Read main.gd around lines 88-92 (var declarations), 260-270 (setup), and 1095-1125 (the tick
loop) — confirm these line numbers still match (this file has been touched by other work today;
re-read before editing rather than trusting the numbers below blindly).
- Step 2: Declare and initialize the tracker
Near the existing var gameplay_telemetry: GameplayTelemetry declaration, add:
var dash_encounter_telemetry: DashEncounterTelemetryNear the existing gameplay_telemetry = GameplayTelemetry.new(); gameplay_telemetry.setup(); add_child(gameplay_telemetry) block, add the same pattern:
dash_encounter_telemetry = DashEncounterTelemetry.new() # persistent across runs (per-encounter reporter) dash_encounter_telemetry.setup() add_child(dash_encounter_telemetry)Add dash_encounter_telemetry to BOTH existing persistent-node exclusion lists that already
include gameplay_telemetry (the two if c == gameplay_telemetry or c == quality_manager or c == control_client ... lines) — these are the lists of nodes that survive a scene
teardown/restart between runs; missing this would destroy/recreate the tracker every run and
break its per-encounter state.
- Step 3: Hook the per-tick update call
Inside the existing tick loop:
for _s in range(steps): if sim.game_over: break if _replay_rec: _replay_rec.record(input, input2) sim.tick_single(input, input2)add the new call, gated on not _auto (human sessions only):
for _s in range(steps): if sim.game_over: break if _replay_rec: _replay_rec.record(input, input2) sim.tick_single(input, input2) if not _auto: dash_encounter_telemetry.update(sim, sim.player)- Step 4: Verify with a boot smoke test
Run: godot --headless --path . --quit-after 120 2>&1 | grep "SCRIPT ERROR"
Expected: no output. (This won’t exercise update()’s logic meaningfully headless since no real
gameplay runs — it only confirms the new node wires up and boots without error. Task 2’s unit
tests are the real logic coverage.)
- Step 5: Run the full suite
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: 0 unexpected failures, same 4 pre-existing risky/pending, count matches
scripts/check-test-count.sh’s expectation (241 existing scripts + 1 new = 242).
- Step 6: Commit
git add main.gdgit commit -m "feat(telemetry): wire DashEncounterTelemetry into main.gd's tick loop (human sessions only)"Task 4: Verification, deploy, and push
Section titled “Task 4: Verification, deploy, and push”Files: none (verification only).
- Step 1: Full suite + count guard
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Run: bash scripts/check-test-count.sh
Expected: both clean.
- Step 2: Determinism baseline
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_determinism_survival -gexit
Expected: PASS unchanged (this plan never touches /sim).
- Step 3: Confirm the worker is deployed and live
If Task 1’s Step 3 already deployed the worker, re-confirm it’s still live:
curl -s https://bullet-heaven-telemetry.chris-allen-06f.workers.dev/encounter -X OPTIONS -o /dev/null -w "%{http_code}\n"Expected: 200 (CORS preflight succeeds, confirming the route exists on the live worker).
- Step 4: Push
git push origin main- Step 5: Report to Chris
This plan does NOT include a BUILD bump or device deploy — it only wires new tracking into the
existing tick loop, invisible until Chris/Toby next play a real device build. Tell Chris this
explicitly: the capture code is live in the repo but won’t actually start collecting data until
the NEXT real device deploy (a separate bh-deploy action, not implied by this plan).