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:
- Heartbeat —
Sampler.tick(game, ship, camera, world)is called every loop frame from the bridge, and an internal rate-limiter only emits a heartbeat row once perHEARTBEAT_INTERVAL_S(5 s) when a session is set. One row carries the full forensic snapshot. - Event —
Sampler.event(name, game, ship, camera, world, extra?)writes one row per call, taggedkind = 'event:<name>'. Used at meaningful transitions (level_start, boss_spawn, boss_death, reward, player_death, recover_from_stuck, etc.). - Crash —
Sampler.crash(reason, game, ship, camera, world, extra?)flushes the pre-crash ring buffer as individualkind = '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 viawindow.__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) forfpsAvg/fpsMincomputations.
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 context —
phase,level,levelKind,hardMode,isChallenge,planetId. - Timing —
time,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. - Camera —
x,y,zoom. - World counts —
enemies,playerBullets,enemyBullets,particles,dmgNumbers,pickups,gems,events,weaponBoxes,artifactBoxes,destructibles,terrain. Arrays are summarized by length, never serialized verbatim. - Boss —
arenaActive,defId,encounterTime,sealedActive,pendingBarDamage,bossLevelCleared. - Rewards —
queueLength,currentRewardActive,postRewardLevelAdvance,weaponChestFreeze. - Stats —
totalKills,eliteKills,damageDealt,damageTaken,killStreak,bestStreak. - Perf —
fpsAvg,fpsMin,longestFrameMs, plusheap(used/total/limit fromperformance.memorywhen available). - Canvas guards —
guardHits,guardReportedfromCanvasGuardStats. - Viewport —
w,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.