PURPOSE

Zero-allocation-path data capture during gameplay. Collects rich per-frame and per-event data, then flushes a single JSON payload at run end. Pre-allocated arrays with push (no per-frame alloc), compact event objects, FPS histogram via bucket counting, 5-second snapshot sampling for ship state timeline. All timestamps relative to run start, in seconds rounded to 2 decimals.

OWNS

  • TelemetryCollector class and the exported telemetry singleton instance.
  • RunTelemetry payload shape returned by finalize() / finalizePartial().
  • Exported types: ArtifactEvent, ArtifactStat, DamageChainEntry (re-exported so damage.ts can build records without redeclaring schema; schema authority stays here).
  • FPS histogram buckets (0-15, 15-30, 30-45, 45-60, 60+).
  • Sample-gated damage-chain log: at most 1 record per DMG_CHAIN_SAMPLE_INTERVAL (1.0s), always the most recent.
  • 5-second SNAPSHOT_INTERVAL for ship-state + render-perf snapshots.
  • Burst capture: every BURST_INTERVAL (30s) push the next BURST_FRAMES (30) consecutive render-perf frames at full detail, tagged burst:true.
  • Heartbeat: fires registered callback with partial payload every HEARTBEAT_INTERVAL (10s).
  • _active run-in-progress flag and _lastStats cache (so finalizePartial() can build a payload without a live GameState).
  • Per-weapon stats map (weaponDmg: shots, hits, dmg).
  • Per-artifact aggregation in _buildArtifactStats() — sums damage/healed/blocked/enemiesHit, tracks highest tier seen and first pick time.
  • Heat-state edge detection (overheat enter, time-overheated accumulator, stall/burn counters).
  • Local round2() helper and private t() relative-time helper.

READS FROM

  • GameState / ShipState / WorldState from ../core/types (consumed in tick() and finalize()).
  • diagGetPerfSnapshot(), diagDrainSpikes(), diagResetSpikes() and types RenderPerfSnapshot, SpikeSnapshot from ../core/render-diag.
  • game.stats.{totalKills, eliteKills, killsByType, damageDealt, damageTaken, distance, coinsCollected, maxLevel} and game.level.
  • ship.{x, y, hp, shield, heat, invulnerable} data sampled into snapshots.
  • dt and fps passed in from the frame loop.

PUSHES TO

  • Nothing directly — pure data sink. The payload returned by finalize() / finalizePartial() is consumed by the sender layer.
  • Invokes a single registered heartbeat callback (onHeartbeat(cb)) with finalizePartial() result every HEARTBEAT_INTERVAL seconds. Sender uses this to push partial run data to Supabase mid-run.
  • Calls diagResetSpikes() at run start and diagDrainSpikes() each tick to move spike snapshots from render-diag into spikeLog (capped at 30 per run by render-diag).

DOES NOT

  • Does not allocate on the hot path beyond Array.push of small event objects.
  • Does not write to disk, network, or any global state.
  • Does not depend on rendering, audio, input, or any UI module.
  • Does not score the run — finalScore is set to killsTotal with a TODO for real scoring.
  • Does not store every frame time as a separate timeline; FPS is bucketed plus a percentile array.
  • Does not buffer multiple damage-chain records per second; the pending slot is overwritten unconditionally and flushed at the 1s gate (quiet seconds produce no record).
  • Does not validate config inputs — begin() accepts any hullId, levelId, seed.

Signals

  • recordKill(enemyType, weaponId, x, y) — append to killLog.
  • recordDamageChain(entry) — overwrites the _pendingDmgChain slot; called unconditionally from damage.ts on every damage attempt (including invuln-gated ones). Flush is sample-gated in tick().
  • recordPickup(type, x, y) — append to pickupLog.
  • recordHeatEvent('stall' | 'burn' | 'recover', val) — increments heatStalls / heatBurns and logs the event.
  • recordReward(level, choices, chosenIdx) — append to rewardLog.
  • recordDirectorPhase(phase, intensity) — append to directorLog.
  • recordWeaponFire(weaponId) — increment shots in weaponDmg map.
  • recordWeaponHit(weaponId, damage) — increment hits and accumulate dmg.
  • recordDeath(causeOfDeath) — set causeOfDeath string for final payload.
  • recordArtifactEvent(id, ev, tier, val?, enemies?) — append to artifactLog; later aggregated in _buildArtifactStats().
  • onHeartbeat(cb) — register a callback that receives RunTelemetry partials every HEARTBEAT_INTERVAL.

Entry points

  • telemetry — singleton TelemetryCollector exported for app-wide use; reset per run via begin().
  • begin(hullId, levelId, seed) — resets all state, marks _active = true, records startedAtISO.
  • tick(dt, fps, game, ship) — call every frame; advances startTime, updates FPS stats, drains render-diag spikes, samples snapshots and render-perf at 5s, arms/consumes bursts, fires heartbeat, flushes the pending damage-chain entry.
  • finalize(game) — sets _active = false, builds the full payload from live GameState.
  • finalizePartial() — builds a payload from cached _lastStats, used for page-unload / abandoned runs where no clean game-over exists; causeOfDeath defaults to 'abandoned'.
  • active getter — true between begin() and finalize().
  • Re-exports type DamageChainEntry so damage.ts can build records without redeclaring the schema.

Pattern notes

  • Singleton instance pattern: one collector per app, mutated in place across runs.
  • Sample gating: the _pendingDmgChain slot is overwritten unconditionally (two property writes, no alloc on overwrite); tick() flushes it to damageChainLog only when ≥DMG_CHAIN_SAMPLE_INTERVAL seconds have elapsed since the last flush. Net result: at most 1 record per second, always the most recent damage event in that window; a quiet second produces no record.
  • Burst sampling sits alongside steady-state 5s sampling: steady cadence covers the average, bursts reconstruct fine-grained sequences around spikes; burst:true tag lets the analytics layer separate them.
  • Render-perf timeline is appended at the same 5s interval as ship-state snapshots so the two timelines align by index.
  • spikeLog is timestamped here using this.t() even though spikes are detected by diagEndFrame() in render-diag, so all timestamps in the payload share the run-relative clock.
  • FPS percentiles are computed from a sorted copy of frameTimes at finalize time; because lower-fps values represent worse performance, fpsP95 is read from the 5th percentile and fpsP99 from the 1st percentile.
  • _lastStats is a plain object updated each tick via cheap field copies (no alloc), so finalizePartial() can produce a payload from a pagehide handler without touching GameState.
  • All event timestamps are stamped at signal time (recordX / recordDamageChain), not at flush/finalize time.
  • All floats run through round2() before landing in the payload to keep JSON compact.
  • DamageChainEntry carries the full damage pipeline state (invuln gate, flatDR, threshDR, dmgFinal, kbForce, routing, shield-break) so the post-hoc reader can diagnose “no damage” / “wrong damage” bugs from telemetry alone; the field comment in the source enumerates the read-pattern for each known failure mode.
  • _buildArtifactStats() routes e.val to healed (event_heal), blocked (bubble_block, fortress_absorb), or dmg (default) based on event type; pick only seeds pickedAt on first occurrence; offered is meta and excluded from triggers.