Enemy Status Stacks

Lightweight per-enemy status effects (stunned, shredded, burning) are stored as a lazy Record<string, EnemyStatusEntry> on the enemy object (e._statuses). The system lives in src/starship-survivors/engine/effects/enemy-status.ts; the type is defined in src/starship-survivors/engine/effects/types.ts.

EnemyStatusEntry shape

export type EnemyStatusType = 'stunned' | 'shredded' | 'burning';
 
export interface EnemyStatusEntry {
  /** Current stack count (shredded stacks, burning stacks). */
  stacks: number;
  /** Seconds remaining before status expires. */
  timer: number;
  /** Effect magnitude per stack (DPS for burning, +%dmg for shredded). */
  value: number;
}

Three fields, all numeric. There is no sourceArtifactId, no source weapon tag, and no per-stack provenance stored on the entry — the system is intentionally minimal. Once a status lands, it is anonymous: the engine cannot tell you which artifact, weapon, or mod put a given burning stack on the enemy. Damage attribution for telemetry routes through game.stats.damageDealt only.

Storage is keyed by status type string, so an enemy carries at most one entry per type. The _statuses record is allocated lazily on first applyStatus call (cheaper than a Map on mobile) and cleared on death via clearStatuses(e).

The three status types

stunned — refresh-only, no stacking

Stuns set stacks = 1 and refresh timer to the max of existing and incoming duration. They do not stack and value is unused. A legacy compatibility field e._stunTimer is mirrored so older AI behaviors that check the flag directly continue to work. Stunned enemies skip their AI tick entirely (no movement, no firing).

shredded+value multiplicative damage taken per stack

Each shredded stack multiplies all incoming damage. The multiplier is computed in getShredMult(e):

return 1 + stacks * entry.value;

So 3 stacks of shredded with value = 0.15 yields a 1 + 3 × 0.15 = 1.45× damage multiplier. Applied before the affix filter chain in damage.ts (line 302), so shred amplification is visible to filter chains and shield absorption math.

The cap is set by the maxStacks argument passed at apply time by the calling action (apply_enemy_status / apply_status_aoe), not by the data type itself. Callers choose their own stack ceiling.

burningvalue DPS per stack, ticked each frame

In tickStatuses each frame, while timer > 0:

const burnDmg = entry.value * entry.stacks * dt;
e.hp -= burnDmg;
if (game.stats) game.stats.damageDealt += burnDmg;

Burning damage bypasses both the shred multiplier and the affix filter chain — it deducts directly from enemy.hp. Each stack contributes its full value as DPS, so 3 stacks of value = 8 is 24 DPS total. The header comment caps burning at 3 stacks by convention, but again the actual ceiling comes from the maxStacks argument at the call site.

How stacks apply: additive on top of refresh

applyStatus(e, type, duration, value, maxStacks) has a single rule for re-application:

  1. If no entry exists for this type, create one with stacks: 1, timer: duration, value.
  2. If an entry exists:
    • timer = max(existing.timer, duration) — duration refreshes to the longer of the two (never shortened by a shorter re-apply).
    • value is overwritten with the new value (no averaging, no max).
    • stacks increments by 1 if below maxStacks, otherwise stays at the cap.

This means stacks are additive but capped, durations are refresh-to-max, and per-stack value is whatever the most recent application passed in. Two applications with different value parameters at the same status type will leave the enemy with the second caller’s value applied to every stack — including the older ones. This is intentional, but worth knowing when designing mixed-source effects.

Expiration

tickStatuses runs once per frame (called from the enemy update loop in engine/bridge.ts) and decrements every status’s timer by dt. When timer reaches zero or below:

  • stacks is forced to 0
  • timer is forced to 0
  • For stunned, the legacy e._stunTimer is also cleared

The entry itself is not deleted from the record — it just becomes inert (stacks = 0, timer = 0). hasStatus and getStacks both gate on timer > 0, so a zeroed entry reads as absent. The next applyStatus of that type will pass the existing branch and bump stacks back to 1, refreshing duration. There is no full cleanup until the enemy dies (clearStatuses).

How “value × stacks” is summed

For both stack-bearing types, the effective magnitude is stacks × value computed at read time, not stored:

  • Shred: 1 + stacks × value damage multiplier, read every hit via getShredMult(e) in damage.ts.
  • Burning: stacks × value × dt HP loss, computed each frame in tickStatuses.

There is no separate “total magnitude” cached on the entry. Because value is overwritten on each re-apply, the sum is always evaluated against the most recent caller’s value, not a per-stack history. If you need provenance-aware stacking (different stacks remembering different source magnitudes), it doesn’t exist today — you would need to split status types or store an array of per-stack values on the entry.

Catchall third type

The type union is 'stunned' | 'shredded' | 'burning' — there is no fourth catchall slot in the type system. However, applyStatus itself takes type: string (not the union), so callers can technically write arbitrary status keys into _statuses. None of those keys would tick (burning is hardcoded), apply shred math (shred is hardcoded), or short-circuit AI (stun is hardcoded), so unknown types are inert storage — present for stack/timer bookkeeping but with no engine consumer. Treat the union as canonical and don’t introduce new keys without wiring a consumer.

Callers

  • apply_enemy_status action — targets one enemy by snap.uid1 (the signal’s primary uid). Reads status, duration, value, maxStacks params.
  • apply_status_aoe action — applies to all live enemies within radius of either the ship (center: 'ship', default) or the signal point (center: 'signal').

Both live in engine/effects/actions.ts (lines ~379–417) and are the only paths that write to _statuses. There is no direct-from-weapon application — every status comes through the effect engine’s action dispatch.

Read API

  • hasStatus(e, type) — boolean, gated on timer > 0.
  • getStacks(e, type) — integer stack count, returns 0 if expired.
  • getShredMult(e) — damage multiplier (≥ 1).
  • tickStatuses(enemies, dt, game, ship, world) — per-frame tick, called from bridge.ts.
  • clearStatuses(e) — wipes the record on death/despawn.

Files

  • src/starship-survivors/engine/effects/types.tsEnemyStatusType, EnemyStatusEntry interface.
  • src/starship-survivors/engine/effects/enemy-status.ts — full status implementation (apply, tick, read, clear).
  • src/starship-survivors/engine/effects/actions.tsapply_enemy_status / apply_status_aoe action handlers.
  • src/starship-survivors/engine/combat/damage.tsgetShredMult consumer (line 302).