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). The max() means dr ≥ 100 silently 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_threshDR ramps from 1.0 (above 40 percent HP) down to 0.76 (at 5 percent HP). A low-HP survival buffer.
  • D. Final damagedmg = amount × _flatDR × _threshDR. Can be 0.
  • E. Knockback — applied unconditionally. kbForce = 5 + dmg × 0.19, clamped to 15. Even dmg = 0 still produces a 5-unit shove (the “knocked around but no damage” symptom).
  • F. Shield vs hull route — if shield > 0 then shield absorbs (capped at shield). Else hull takes the hpDamageMult-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 raw amount argument 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.
  • dmgFinalamount × 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 means damageReduction ≥ 100 produces dmgFinal = 0 with no on-screen feedback. The trace shows flatDR = 0, dmgFinal = 0, kbForce = 5, routedTo: 'noop' (or 'hull' with hullDmg = 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' with dmgFinal reflecting the full multiplier chain — letting us check whether threshDR was lower than expected, whether damageReduction was missing, or whether a specific source archetype was deal-through-shield in a way that surprised the player.

Reference

  • Implementation: engine/combat/damage.tsdamagePlayer(). Search for [trace] markers to jump to each stage.
  • Full architecture doc: docs/architecture/damage-chain.md.
  • Storage: telemetry_runs.damage_chain_log (Supabase).