Status Effect System
Status Effect System
Status: Designed Author: user + game-designer Last Updated: 2026-03-28 System Index: #6 — MVP, Feature Layer
Overview
The Status Effect System manages buffs and debuffs on players and enemies — applying, stacking, ticking, and clearing them. Status effects are data-driven Godot Resources (.tres), following the same pattern as cards and enemies. Effects modify how other systems calculate damage, block, and healing through multipliers and flat bonuses. The system does not calculate damage itself — it provides modifiers that the Card Effect Resolver and Player Resources query during their calculations.
Player Fantasy
"I can stack advantages and exploit enemy weaknesses." Applying Vulnerable then hitting with a big attack feels powerful. Getting debuffed creates urgency — "I need to kill that enemy before Poison kills me." In co-op, one player debuffing while another deals damage is the core coordination loop.
Detailed Design
Core Rules
- Status effects are identified by
StringName(e.g.,"strength","vulnerable","poison"). - Every status has a stack count (integer ≥ 1). Applying a status that already exists adds to the stack count.
- Statuses have a duration type: PERMANENT (lasts until removed), TIMED (decreases by 1 each turn), or TICKED (deals effect then decreases).
- Statuses can exist on both players and enemies. The same status ID behaves identically on either.
- Status data is read-only resources. Runtime state (who has what, stack counts) is tracked per-entity by the Combat System.
- All status mutations are server-authoritative.
StatusData Resource Schema
| Field | Type | Description |
|---|---|---|
id | StringName | Unique identifier (e.g., "vulnerable") |
display_name | String | Player-facing name |
description | String | Tooltip text (supports {stacks} token) |
duration_type | DurationType enum | PERMANENT, TIMED, TICKED |
modifier_type | StatusModifierType enum | DAMAGE_DEALT_MULT, DAMAGE_TAKEN_MULT, FLAT_DAMAGE, TICK_DAMAGE, BLOCK_SHARE, NONE |
modifier_value | float | The modifier's magnitude (e.g., 1.5 for Vulnerable) |
stacks_affect | StackBehavior enum | INTENSITY (stacks change magnitude), DURATION (stacks change duration), NONE |
icon_id | StringName | Reference to status icon asset |
is_debuff | bool | True if negative (for UI coloring and future cleanse mechanics) |
Enums
- DurationType:
PERMANENT, TIMED, TICKED - StatusModifierType:
DAMAGE_DEALT_MULT, DAMAGE_TAKEN_MULT, FLAT_DAMAGE, TICK_DAMAGE, BLOCK_SHARE, NONE - StackBehavior:
INTENSITY, DURATION, NONE
V1 Status Effects
| Status | Duration | Modifier | Stack Behavior | Effect Description |
|---|---|---|---|---|
| Strength | PERMANENT | FLAT_DAMAGE, +1 per stack | INTENSITY | +X damage on all ATTACK actions. Stacks = damage bonus. |
| Vulnerable | TIMED | DAMAGE_TAKEN_MULT, 1.5 | DURATION | Take 50% more damage. Stacks = turns remaining. |
| Weak | TIMED | DAMAGE_DEALT_MULT, 0.75 | DURATION | Deal 25% less damage. Stacks = turns remaining. |
| Poison | TICKED | TICK_DAMAGE, 1 per stack | INTENSITY | Lose X HP at start of turn, then stacks decrease by 1. |
| Linked | TIMED | BLOCK_SHARE, 0.5 | DURATION | When this entity blocks, linked partner gains 50% of that block. Stacks = turns remaining. |
Application Rules
apply_status(target, status_id, stacks):
if target already has status_id:
if status.stacks_affect == INTENSITY:
target.statuses[status_id].stacks += stacks
elif status.stacks_affect == DURATION:
target.statuses[status_id].stacks += stacks # extends duration
else:
target.statuses[status_id] = { stacks: stacks }
Tick Rules (start of turn)
for status in entity.statuses:
if status.duration_type == TICKED:
# Apply tick effect
if status.modifier_type == TICK_DAMAGE:
deal_damage(entity, status.stacks) # Poison: stacks = damage
status.stacks -= 1
elif status.duration_type == TIMED:
status.stacks -= 1
# PERMANENT: no tick, no decrease
if status.stacks <= 0:
remove_status(entity, status.id)
Tick timing: Player statuses tick at the start of the player phase. Enemy statuses tick at the start of the enemy phase.
Query Interface
Other systems query the Status Effect System to get modifiers:
get_damage_dealt_modifier(entity) -> float:
modifier = 1.0
if entity has "weak": modifier *= 0.75
flat_bonus = 0
if entity has "strength": flat_bonus += strength.stacks
return { multiplier: modifier, flat: flat_bonus }
get_damage_taken_modifier(entity) -> float:
modifier = 1.0
if entity has "vulnerable": modifier *= 1.5
return { multiplier: modifier, flat: 0 }
States and Transitions
Per-entity status tracking is a simple dictionary:
entity.statuses = {
"vulnerable": { stacks: 2 },
"strength": { stacks: 3 }
}
No state machine — statuses are added, modified, and removed from the dictionary.
Interactions with Other Systems
| System | Direction | Interface |
|---|---|---|
| Player Resources | Queried by | Player Resources calls get_damage_taken_modifier() during damage resolution |
| Card Effect Resolver | Called by, queried by | Resolver calls apply_status() for APPLY_STATUS effects. Queries get_damage_dealt_modifier() for damage cards. |
| Enemy Data & AI | Bidirectional | Enemies can have statuses. Enemy BUFF/DEBUFF actions call apply_status(). |
| Combat System | Called by | Combat triggers status ticks at phase boundaries |
| Combat UI | Reads | UI displays status icons with stack counts on player/enemy portraits |
| Co-op Mechanics | Reads/writes | Linked status is applied by Assist cards, queried during block resolution |
Formulas
Damage Modification Pipeline
Full pipeline (owned by Card Effect Resolver, using Status Effect System modifiers):
1. base_value = card_effect.value + effect_modifiers (from EffectModifier resources)
2. flat_bonus = attacker.strength_stacks (if ATTACK action)
3. pre_mult_value = base_value + flat_bonus
4. attacker_mult = get_damage_dealt_modifier(attacker).multiplier # Weak: 0.75
5. after_attacker = floor(pre_mult_value * attacker_mult)
6. target_mult = get_damage_taken_modifier(target).multiplier # Vulnerable: 1.5
7. final_damage = floor(after_attacker * target_mult)
Order matters: Weak applies before Vulnerable. This is the Slay the Spire convention.
Example Calculations
8 damage, attacker has 2 Strength, target is Vulnerable:
base = 8, flat = 2, pre_mult = 10
attacker_mult = 1.0 (no Weak), after_attacker = 10
target_mult = 1.5, final = floor(10 * 1.5) = 15
6 damage, attacker is Weak, target is Vulnerable:
base = 6, flat = 0, pre_mult = 6
attacker_mult = 0.75, after_attacker = floor(6 * 0.75) = 4
target_mult = 1.5, final = floor(4 * 1.5) = 6
Poison 5 ticks on a player:
damage = stacks = 5, applied as unblockable HP loss
stacks becomes 4 after tick
Linked Block Sharing
When player A gains X block and has Linked status:
partner = linked_partner(A)
shared_block = floor(X * 0.5)
partner.block += shared_block
Edge Cases
| Case | Resolution |
|---|---|
| Apply 0 stacks of a status | No-op. Don't add a status with 0 stacks. |
| Status stacks overflow (e.g., 999 Strength) | No hard cap in V1. If this becomes a problem, add a per-status max. |
| Poison kills a player | Valid. Poison damage at start of phase can kill. Dead before they play cards. |
| Poison on an enemy | Valid. Players could have future Poison cards. Ticks at start of enemy phase. |
| Vulnerable + Weak on same entity | Both apply. Weak reduces outgoing damage, Vulnerable increases incoming. They affect different calculations. |
| Applying Linked to a player in a 1-player game | No partner to link to. Status is applied but has no effect (no partner to share block with). |
| Linked: both partners gain block simultaneously | Each triggers share for the other. To prevent infinite loop: block sharing does NOT trigger further sharing. Shared block is "passive" — it doesn't count as "gaining block" for Linked purposes. |
| Status applied to dead entity | Rejected. Dead entities cannot receive statuses. |
| All statuses cleared (future cleanse mechanic) | Remove all entries from entity's status dictionary. Signal UI to update. |
| Status with TIMED duration at exactly 1 stack | Next tick reduces to 0, status is removed. Effectively "lasts 1 turn." |
Dependencies
Upstream
| System | Dependency Type | Interface |
|---|---|---|
| Player Resources | Hard | Status modifiers affect damage calculations in Player Resources |
Downstream
| System | Dependency Type | Interface |
|---|---|---|
| Card Effect Resolver | Hard | Resolver queries modifiers and applies statuses |
| Combat System | Hard | Combat triggers ticks at phase boundaries |
| Enemy Data & AI | Hard | Enemy actions can apply statuses |
| Co-op Mechanics | Hard | Linked status is a co-op mechanic |
| Combat UI | Hard | Displays status icons and stack counts |
Contracts
- Status effects are modifiers, not executors. They don't deal damage directly — they modify calculations done by other systems.
- Exception: Poison TICK_DAMAGE is executed by the Status Effect System during tick, bypassing normal damage resolution (no Block, no Vulnerable modifier on Poison damage).
- Status data resources are read-only. Runtime stacks are tracked separately per entity.
Tuning Knobs
| Knob | Default | Safe Range | Affects |
|---|---|---|---|
| Vulnerable multiplier | 1.5 | 1.25-2.0 | Burst damage potential, debuff card value |
| Weak multiplier | 0.75 | 0.5-0.9 | Defensive debuff power |
| Poison tick rate | 1 per stack, -1 stack per turn | Fixed for V1 | Poison lethality, DOT pressure |
| Linked share ratio | 0.5 | 0.25-1.0 | Co-op block efficiency |
| Strength per stack | +1 damage | +1 to +2 | Scaling power, enemy threat from buff actions |
Interaction warning: Vulnerable (1.5x) stacks multiplicatively with Strength (+flat). A 6-damage attack with 3 Strength = 9, with Vulnerable = floor(9 * 1.5) = 13. Small Strength values have outsized impact when combined with Vulnerable.
Acceptance Criteria
| # | Criterion | Verification |
|---|---|---|
| 1 | All V1 status effects load without errors | Unit test: load each .tres, assert valid fields |
| 2 | Applying existing status adds stacks correctly | Unit test: apply Vulnerable 2, then Vulnerable 1 → stacks = 3 |
| 3 | TIMED statuses decrease and remove at 0 | Unit test: Vulnerable 2, tick, assert 1, tick, assert removed |
| 4 | Poison deals damage then decreases | Unit test: Poison 5, tick → 5 damage dealt, stacks = 4 |
| 5 | Weak reduces damage by 25% (floored) | Unit test: 7 damage, Weak → floor(5.25) = 5 |
| 6 | Vulnerable increases damage by 50% (floored) | Unit test: 7 damage, Vulnerable → floor(10.5) = 10 |
| 7 | Strength adds flat damage to attacks | Unit test: 6 base + 3 Strength → 9 damage |
| 8 | Linked shares 50% block to partner (floored) | Unit test: player gains 7 block, partner gains floor(3.5) = 3 |
| 9 | Linked block sharing doesn't cause infinite loop | Unit test: both players Linked, player A gains 10 block → A has 10, B gains 5, no further sharing |
| 10 | Poison bypasses Block | Unit test: player has 20 block, Poison 5 ticks → HP reduced by 5, Block unchanged |