Telemetry Sampler

The telemetry sampler is the forensic flight recorder that snapshots performance and game state on a per-tick cadence and ships those samples to the telemetry_samples Supabase table. It is the live-run counterpart to the per-run TelemetryCollector — instead of holding everything until run-end, the sampler streams compact rows continuously so Supabase queries can reconstruct what was happening when an issue occurred.

Trigger modes

The sampler feeds one fat table (telemetry_samples) through three trigger paths:

  • HeartbeatSampler.tick(game, ship, camera, world) is called every loop frame from the bridge, and an internal rate-limiter only emits a heartbeat row once per HEARTBEAT_INTERVAL_S (5 s) when a session is set. One row carries the full forensic snapshot.
  • EventSampler.event(name, game, ship, camera, world, extra?) writes one row per call, tagged kind = 'event:<name>'. Used at meaningful transitions (level_start, boss_spawn, boss_death, reward, player_death, recover_from_stuck, etc.).
  • CrashSampler.crash(reason, game, ship, camera, world, extra?) flushes the pre-crash ring buffer as individual kind = 'ring' rows and then writes the crash row itself (kind = 'crash:<reason>').

Other entrypoints on the Sampler singleton bind identity and feed state: setSession(id), setPlayer(id), setRun(id), tickFps(rawDt), plus flush() and reset() for unload paths and tests.

Cadence and stride

The sampler does not expose a TELEMETRY_SAMPLE_EVERY_N_FRAMES knob — sample stride is time-based, not frame-counted, with these constants in engine/telemetry/sampler.ts:

  • HEARTBEAT_INTERVAL_S = 5 — seconds between heartbeat rows (~48 rows in a 4-minute run). Overridable at runtime via window.__samplerHeartbeatSec.
  • RING_BUFFER_SEC = 60 — pre-crash window captured at 1 Hz in memory, flushed only on crash.
  • MAX_RING_FLUSH_ROWS = 60 — hard cap on ring rows emitted per crash so a crash loop cannot burn quota.
  • MAX_CRASH_ROWS_PER_SESSION = 5 — after this, further crash rows are dropped because the underlying bug repeats every frame.
  • FLUSH_INTERVAL_MS = 2000 — sender batches enqueued rows and POSTs to PostgREST every 2 s in a single request per URL group.
  • FPS_WINDOW_MAX = 120 — rolling FPS window (~2 s of frames at 60 fps) for fpsAvg / fpsMin computations.

tick() is internally rate-limited, so the bridge can call it every frame without bloating the table; only the 1 Hz ring push and the 5 s heartbeat actually emit.

What a heartbeat captures

_heartbeatSnapshot() builds the full payload used by heartbeat, event, and crash rows. The relevant axes:

  • Run contextphase, level, levelKind, hardMode, isChallenge, planetId.
  • Timingtime, missionElapsed, missionTimer, dt, rawDt, timeDilation, overtime.
  • Ship — position (x, y), velocity (vx, vy), hp / hpMax, shield / shieldMax, heat, invulnerable, invulnTimer, alive, maxSpeed, angle, weaponCount, weaponSlotsMax.
  • Camerax, y, zoom.
  • World countsenemies, playerBullets, enemyBullets, particles, dmgNumbers, pickups, gems, events, weaponBoxes, artifactBoxes, destructibles, terrain. Arrays are summarized by length, never serialized verbatim.
  • BossarenaActive, defId, encounterTime, sealedActive, pendingBarDamage, bossLevelCleared.
  • RewardsqueueLength, currentRewardActive, postRewardLevelAdvance, weaponChestFreeze.
  • StatstotalKills, eliteKills, damageDealt, damageTaken, killStreak, bestStreak.
  • PerffpsAvg, fpsMin, longestFrameMs, plus heap (used/total/limit from performance.memory when available).
  • Canvas guardsguardHits, guardReported from CanvasGuardStats.
  • Viewportw, h, dpr.

Ring snapshots are a compact ~150-byte subset (phase, level, ship pos/vel/hp/shield, camera, enemy/bullet/particle counts, boss state, time-dilation, fps) because the ring is captured every second and we don’t want 60 KB per crash.

FPS feed

Sampler.tickFps(rawDt) is called once per RAF tick from the bridge. It pushes 1 / rawDt into _fpsWindow (trimmed to 120 samples), tracks _longestFrameMs, and resets the longest-frame tracker every 30 s so it surfaces recent spikes instead of a single anomaly hours old.

Send pipeline

Rows are enqueued via a local _enqueue() that batches into _queue and POSTs to ${SUPABASE_URL}/rest/v1/telemetry_samples every 2 s (with apikey + Authorization: Bearer <anon> headers). The visibility-change and pagehide listeners trigger a keepalive flush so the dump lands even when the page is dying. On crash, Sampler.crash() force-flushes immediately. Failed posts are dropped — the sampler is non-critical instrumentation; it never localStorage-queues, never throws, and every public method is wrapped in try/catch.

Live tuning

For testing, window.__sampler exposes read-only stats (queueLen, ringLen, fpsAvg, fpsMin, longestFrameMs, crashRows) and a flush() method.

Relationship to the collector

The sampler (engine/telemetry/sampler.ts) ships continuous per-tick rows to telemetry_samples for forensic and monitoring use. The collector (engine/telemetry/collector.ts) accumulates a rich per-run payload (FPS histogram, kill log, damage chain, pickups, heat events, rewards, director phases, weapon stats, snapshots, render-perf timeline, spike log, artifact log + stats) that is flushed once at run-end (or via finalizePartial() on unload, or periodically via the 10 s heartbeat callback). They are independent pipelines feeding different tables and consumers.