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.
burning — value 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:
- If no entry exists for this type, create one with
stacks: 1, timer: duration, value. - If an entry exists:
timer = max(existing.timer, duration)— duration refreshes to the longer of the two (never shortened by a shorter re-apply).valueis overwritten with the new value (no averaging, no max).stacksincrements by 1 if belowmaxStacks, 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:
stacksis forced to 0timeris forced to 0- For
stunned, the legacye._stunTimeris 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 × valuedamage multiplier, read every hit viagetShredMult(e)indamage.ts. - Burning:
stacks × value × dtHP loss, computed each frame intickStatuses.
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_statusaction — targets one enemy bysnap.uid1(the signal’s primary uid). Readsstatus,duration,value,maxStacksparams.apply_status_aoeaction — applies to all live enemies withinradiusof 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 ontimer > 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 frombridge.ts.clearStatuses(e)— wipes the record on death/despawn.
Files
src/starship-survivors/engine/effects/types.ts—EnemyStatusType,EnemyStatusEntryinterface.src/starship-survivors/engine/effects/enemy-status.ts— full status implementation (apply, tick, read, clear).src/starship-survivors/engine/effects/actions.ts—apply_enemy_status/apply_status_aoeaction handlers.src/starship-survivors/engine/combat/damage.ts—getShredMultconsumer (line 302).