Damage Event Trace
Every resolved player-damage hit gets sampled at ≤1 Hz into telemetry_runs.damage_chain_log. Records stage-by-stage outcomes (invuln, flatDR, threshDR, final dmg, knockback). Helps diagnose unexplained deaths post-hoc.
What gets traced
damagePlayer() in engine/combat/damage.ts is the single source of truth for player damage. Every call walks a fixed seven-stage chain, and the per-stage outcomes are captured into a DamageChainEntry and handed to telemetry.recordDamageChain(). The collector’s tick flushes at most one entry per second to damageChainLog → telemetry_runs.damage_chain_log. When the sample gate drops a record there is no allocation past the staging slot — every hit pays only an object overwrite.
The seven stages
The chain is documented inline at the top of damagePlayer with [trace] markers pointing at each stage:
- A. Invuln gate — early-return if
ship.invulnerable. Knockback and damage both skipped. Star Power, shield-break grace, level transitions, and several artifacts flip this flag. Blocked attempts are still recorded (the gate path is itself diagnostic information). - B. Flat DR multiplier —
_flatDR = max(0, 1 - damageReduction/100). Themax()meansdr ≥ 100silently zeros all damage with no visible feedback — this is the path that produced the v5.162.x “invuln after Star Power” bug (knocked-around but -0 damage). - C. Threshold DR —
_threshDRramps from 1.0 (above 40 percent HP) down to 0.76 (at 5 percent HP). A low-HP survival buffer. - D. Final damage —
dmg = amount × _flatDR × _threshDR. Can be 0. - E. Knockback — applied unconditionally.
kbForce = 5 + dmg × 0.19, clamped to 15. Evendmg = 0still produces a 5-unit shove (the “knocked around but no damage” symptom). - F. Shield vs hull route — if
shield > 0then shield absorbs (capped at shield). Else hull takes thehpDamageMult-scaled portion. Shield never bleeds through to HP. - G. Shield-break grace — if this hit dropped shield to 0, set 0.3 s of invuln to prevent the same attack from chunking HP in the same frame.
Recorded fields
Each DamageChainEntry captures, per hit:
source— short label passed by the caller ('shooter_bullet','contact','mortar', etc.) so we can correlate “no damage” reports to specific enemy archetypes. Defaults to'unknown'.amtIn— the rawamountargument before any multipliers.invuln— true on the gate-A early-return path.exclusiveState,invulnTimer— ship state at the time of the hit, captured so we can tell which system held the invuln.dr,flatDR— the damage-reduction percentage and the resulting 0-1 multiplier.hpFrac,threshDR— HP fraction before the hit and the resulting threshold multiplier.dmgFinal—amount × flatDR × threshDR.kbForce— the knockback applied (0 on the gate path).routedTo—'shield' | 'hull' | 'noop'.absorbed,hullDmg— actual shield-absorbed and hull-taken portions.hpBefore,shieldBefore,hpAfter,shieldAfter— pre/post snapshots.shieldBroken— true if this hit dropped shield to 0 and triggered the grace window.
The pre-state (_traceHpBefore, _traceShieldBefore) is captured before the invuln gate so blocked attempts still report the ship’s HP/shield at the moment the hit was rejected.
Sample rate
The collector enforces ≤1 Hz: at most one entry per second is flushed to damageChainLog. The staging slot inside recordDamageChain is overwritten on every hit, so during a busy frame only the latest staged entry survives the next flush. This keeps the row volume in telemetry_runs.damage_chain_log bounded regardless of how many bullets land per second, while still giving us a representative sample of stage outcomes across the run.
What it diagnoses
The trace exists specifically for post-hoc death analysis where the player’s report (“I died and don’t know why” / “it hit me but did nothing”) doesn’t map cleanly to in-game feedback. The two canonical patterns it catches:
- Silent zero-damage hits. Stage B’s
max(0, …)clamp meansdamageReduction ≥ 100producesdmgFinal = 0with no on-screen feedback. The trace showsflatDR = 0,dmgFinal = 0,kbForce = 5,routedTo: 'noop'(or'hull'withhullDmg = 0) — exactly matching the “knocked around but no damage” symptom. - Unexplained chunks. A hit that drained more HP than the player expected shows up as
routedTo: 'hull'withdmgFinalreflecting the full multiplier chain — letting us check whetherthreshDRwas lower than expected, whetherdamageReductionwas missing, or whether a specificsourcearchetype was deal-through-shield in a way that surprised the player.
Reference
- Implementation:
engine/combat/damage.ts—damagePlayer(). Search for[trace]markers to jump to each stage. - Full architecture doc:
docs/architecture/damage-chain.md. - Storage:
telemetry_runs.damage_chain_log(Supabase).