Damage Defense Chain

Incoming damage to the player runs through a fixed pipeline of gates and reducers before it lands on shield or hull. Every hit takes the same path; only the routing at the end differs. The full chain lives in damagePlayer() in engine/combat/damage.ts and the stages are marked inline with [trace] STAGE A..G comments. Each resolved hit is sampled into the telemetry damage-chain log so post-hoc balance work can audit it.

Stage order

The pipeline is deterministic and runs top-to-bottom on every call to damagePlayer(ship, amount, …).

  1. Stage A — Invuln gate. If ship.invulnerable is set, the function returns immediately. No damage, no knockback, no VFX. Star Power, the 0.3s shield-break grace window, level transitions, and several artifacts all flip this flag. The blocked attempt is still recorded into the damage-chain trace for diagnostics.
  2. Stage B — Flat damage reduction (DR). flatDR = max(0, 1 - damageReduction/100). damageReduction is stored as a whole-number percentage on the ship (e.g. 3 = 3%). The max(0, …) floor means any DR value at or above 100 silently zeroes all damage — which has historically produced a “knocked around but no damage” bug, since knockback (Stage E) still fires.
  3. Stage C — Threshold DR (low-HP survival buffer). Below 40% HP, an additional multiplier ramps in linearly: 0% reduction at 40% HP, scaling to 24% reduction at 5% HP and below. Above 40% HP the multiplier is exactly 1 (no effect). Constants: _HP_DR_HI = 0.40, _HP_DR_LO = 0.05, _HP_DR_MAX = 0.24.
  4. Stage D — Final damage. dmg = amount × flatDR × threshDR. This is the number that downstream stages route. It can be zero.
  5. Stage E — Knockback. Always applies, even when dmg = 0. kbForce = clamp(5 + dmg × 0.19, max=15), pushed in the direction opposite hitAngle. Body collisions and orb detonations apply their own specialized knockback before calling damagePlayer(); this is the universal baseline shove on top.
  6. Stage F — Shield-vs-hull route. If ship.shield > 0, the shield absorbs (capped at current shield value) and the hit ends there — shield damage never bleeds into HP, even if dmg exceeds remaining shield. If ship.shield <= 0, the hull takes the hit (optionally scaled by an hpDamageMult parameter that lets specific damage sources, like shooter bullets, hit hull softer than shield).
  7. Stage G — Shield-break grace. When a hit drops shield from positive to zero in the same call, ship.invulnerable is set with ship.invulnTimer = CFG.INVULN (and the shatter VFX, screen invert, and shockwave pulse all fire). This stops the same attack from chunking HP in the same frame. After the grace expires, hull takes hits while the shield regenerates in the background.

Shield: not a buffer, a layer

The shield is binary in routing: while shield > 0, every point of incoming damage hits the shield, and overflow past current shield is discarded. There is no spillover into HP. This is the intentional break from the more common “shield as buffer” model — the code comment calls it out explicitly: “Shield NEVER bleeds through to HP. This makes shield feel like a real defensive layer, not a partial buffer.”

Practical implications:

  • A 200-damage hit against a ship with 15 shield deals 15 to the shield and 0 to hull, then triggers the Stage G grace window.
  • A small chip hit (say 3 damage) against 15 shield deals 3 to the shield, leaves 12 shield, and never touches hull.
  • Shield damage does not break the kill streak; hull damage does.

There is no concept of overshield in the live ship state: ship.shieldMax is the hard cap and ship.shield cannot exceed it. The shieldHitIntensity visual scales absorbed / shieldMax × 4 (capped at 1.5), so big hits relative to max-shield produce louder VFX, but the storage itself never exceeds max.

Shield regen delay

Any damage call — whether routed to shield or hull — resets ship.shieldRegenTimer to ship.shieldRegenDelay (default 4s, see makeShip() in engine/core/state.ts). The shield only starts regenerating after a full 4-second window without taking damage. Once regen begins, it fills 0→100% over shieldRegenFillTime (default 2s).

When the shield breaks (drops to 0 from a positive value), ship._shieldRecovering is flagged and ship._shieldBackgroundRegen starts at 0. During recovery the hull takes hits directly. The shield is only restored when background regen completes uninterrupted — taking damage cancels in-progress regen and the 4s timer restarts.

”True” damage and DR bypass

There is no explicit damage_type: 'true' enum that bypasses the shield. The closest mechanism is the hpDamageMult parameter on damagePlayer() — but that path only activates when shield is already empty; it scales the hull portion, not a shield-bypass. If a damage source must hit HP through an intact shield, the current engine has no direct route — design that scenario instead via shield-break setups, Stage A invuln-toggling, or by depleting shield first.

DR ≥ 100% does silently zero damage (Stage B floor), and Stage A invuln zeros it explicitly — both produce knockback without HP/shield change.

Death and grace artifacts

If ship.hp <= 0 after Stage F’s hull route, ship.alive is set false on the same frame. The revive path (Death Defiance) is handled separately in ReviveSystem.check() — not inside damagePlayer().

Why the trace

Every resolved call ends with telemetry.recordDamageChain({...}), capturing pre/post HP and shield, the route taken, the absorbed amount, hull damage, knockback force, and whether the shield broke. The collector samples at ≤1 Hz into telemetry_runs.damage_chain_log. Use this when a player reports “I got hit but took no damage” or “shield should have absorbed that” — the trace will show which stage zeroed the hit (usually Stage A invuln or Stage B with DR ≥ 100).

  • engine/combat/damage.tsdamagePlayer() is the single source of truth.
  • engine/core/state.tsmakeShip() defines the default shield/HP/DR/regen knobs.
  • engine/core/config.tsCFG.INVULN is the shield-break grace duration.
  • Wiki: gameplay/concepts/death-defiance-timing.md — revive token routing past hp <= 0.