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
TelemetryCollectorclass and the exportedtelemetrysingleton instance.RunTelemetrypayload shape returned byfinalize()/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_INTERVALfor ship-state + render-perf snapshots. - Burst capture: every
BURST_INTERVAL(30s) push the nextBURST_FRAMES(30) consecutive render-perf frames at full detail, taggedburst:true. - Heartbeat: fires registered callback with partial payload every
HEARTBEAT_INTERVAL(10s). _activerun-in-progress flag and_lastStatscache (sofinalizePartial()can build a payload without a liveGameState).- 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 privatet()relative-time helper.
READS FROM
GameState/ShipState/WorldStatefrom../core/types(consumed intick()andfinalize()).diagGetPerfSnapshot(),diagDrainSpikes(),diagResetSpikes()and typesRenderPerfSnapshot,SpikeSnapshotfrom../core/render-diag.game.stats.{totalKills, eliteKills, killsByType, damageDealt, damageTaken, distance, coinsCollected, maxLevel}andgame.level.ship.{x, y, hp, shield, heat, invulnerable}data sampled into snapshots.dtandfpspassed 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)) withfinalizePartial()result everyHEARTBEAT_INTERVALseconds. Sender uses this to push partial run data to Supabase mid-run. - Calls
diagResetSpikes()at run start anddiagDrainSpikes()each tick to move spike snapshots from render-diag intospikeLog(capped at 30 per run by render-diag).
DOES NOT
- Does not allocate on the hot path beyond
Array.pushof 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 —
finalScoreis set tokillsTotalwith 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 anyhullId,levelId,seed.
Signals
recordKill(enemyType, weaponId, x, y)— append tokillLog.recordDamageChain(entry)— overwrites the_pendingDmgChainslot; called unconditionally fromdamage.tson every damage attempt (including invuln-gated ones). Flush is sample-gated intick().recordPickup(type, x, y)— append topickupLog.recordHeatEvent('stall' | 'burn' | 'recover', val)— incrementsheatStalls/heatBurnsand logs the event.recordReward(level, choices, chosenIdx)— append torewardLog.recordDirectorPhase(phase, intensity)— append todirectorLog.recordWeaponFire(weaponId)— increment shots inweaponDmgmap.recordWeaponHit(weaponId, damage)— increment hits and accumulate dmg.recordDeath(causeOfDeath)— setcauseOfDeathstring for final payload.recordArtifactEvent(id, ev, tier, val?, enemies?)— append toartifactLog; later aggregated in_buildArtifactStats().onHeartbeat(cb)— register a callback that receivesRunTelemetrypartials everyHEARTBEAT_INTERVAL.
Entry points
telemetry— singletonTelemetryCollectorexported for app-wide use; reset per run viabegin().begin(hullId, levelId, seed)— resets all state, marks_active = true, recordsstartedAtISO.tick(dt, fps, game, ship)— call every frame; advancesstartTime, 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 liveGameState.finalizePartial()— builds a payload from cached_lastStats, used for page-unload / abandoned runs where no clean game-over exists;causeOfDeathdefaults to'abandoned'.activegetter — true betweenbegin()andfinalize().- Re-exports
type DamageChainEntrysodamage.tscan build records without redeclaring the schema.
Pattern notes
- Singleton instance pattern: one collector per app, mutated in place across runs.
- Sample gating: the
_pendingDmgChainslot is overwritten unconditionally (two property writes, no alloc on overwrite);tick()flushes it todamageChainLogonly when≥DMG_CHAIN_SAMPLE_INTERVALseconds 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:truetag 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.
spikeLogis timestamped here usingthis.t()even though spikes are detected bydiagEndFrame()in render-diag, so all timestamps in the payload share the run-relative clock.- FPS percentiles are computed from a sorted copy of
frameTimesat finalize time; because lower-fps values represent worse performance,fpsP95is read from the 5th percentile andfpsP99from the 1st percentile. _lastStatsis a plain object updated each tick via cheap field copies (no alloc), sofinalizePartial()can produce a payload from apagehidehandler without touchingGameState.- 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. DamageChainEntrycarries 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()routese.valtohealed(event_heal),blocked(bubble_block,fortress_absorb), ordmg(default) based on event type;pickonly seedspickedAton first occurrence;offeredis meta and excluded from triggers.