Skip to content

Status Effects and Damage-over-Time with Timers

Poison, burn, slow, stun, shields, and temporary buffs are all the same beginner problem: some state starts now, changes gameplay for a while, and must end cleanly even if the actor dies, leaves the level, or gets the effect again.

The one-minute version

  • A status effect is runtime state, not just a particle, tag, or damage type class.
  • Put first-project status logic in one owner, usually a StatusEffectComponent or a HealthComponent extension, instead of spreading poison/burn/stun graphs across every hazard and weapon.
  • Use timers for periodic or delayed work: poison ticks, burn ticks, stun expiration, shield timeout, regeneration, and cooldown-like buffs.
  • Store timer handles for effects that must stop, refresh, pause, or clear on death and EndPlay.
  • Decide the stack rule before writing the graph: ignore duplicate, refresh duration, add stacks, replace weaker effect, or run separate instances.
  • In multiplayer, the server owns gameplay effects and health changes. Clients show local icons, sounds, materials, and damage numbers from replicated effect/health state.

Where status effects should live

For one poison volume, it is tempting to put all poison state in the volume:

PoisonCloud BeginOverlap player
-> Set Timer ApplyDamage every 1.0

That breaks down when fire arrows, lava, enemies, pickups, armor, UI icons, death cleanup, save/load, and multiplayer all need to agree on whether an actor is poisoned, burning, slowed, or immune.

Use one receiver-side owner:

flowchart LR
    Source[Cloud / projectile / item] --> Request[Apply or Add Status Effect]
    Request --> Effects[StatusEffectComponent]
    Effects --> Health[HealthComponent / damage]
    Effects --> Movement[Movement/input/ability gates]
    Effects --> UI[OnStatusChanged -> widgets/icons]
    Effects --> Cleanup[Timers cleared on end/death/EndPlay]

The source asks for an effect. The target decides whether it accepts, stacks, refreshes, resists, replicates, displays, and eventually removes that effect.

Effect records

A simple Blueprint struct keeps effect state readable:

FActiveStatusEffect
    EffectId              // "Poison", "Burn", "Slow"
    SourceActor           // optional, may become invalid
    InstigatorController  // optional credit/ownership
    Stacks
    DurationRemaining
    TickInterval
    TickDamage
    MovementMultiplier
    bBlocksInput
    TimerHandle           // runtime only; do not save this

For a very small project, you can start simpler:

ActiveEffects: Map Name -> FActiveStatusEffect
OnStatusEffectsChanged()

Use a Name, enum, or Gameplay Tag as the effect ID. The ID is the stable piece that UI, save data, immunity rules, and debug prints can talk about.

What other systems get back

An effect owner should expose a readable summary instead of forcing widgets, weapons, and movement code to inspect timer handles:

HasStatusEffect(EffectId) -> Boolean
GetStatusStacks(EffectId) -> Integer
GetRemainingDuration(EffectId) -> real
OnStatusEffectsChanged(ActiveEffectSummaries)

Timer handles are for the component to clear or replace callbacks. They are not the public status state, not useful save data, and not something a widget should use to decide whether poison is active.

Stack and refresh rules

Pick one rule per effect type:

Rule Use when Example
Ignore duplicate The effect is either on or off. Stun cannot be stacked by rapid hits.
Refresh duration Reapplying restarts the countdown. Poison cloud keeps poison active while you stand in it.
Add stacks up to a cap More applications make it stronger. Burn stacks up to 3, each stack adds damage.
Replace weaker Only the strongest version matters. Slow 60% replaces Slow 20%.
Separate instances Each source is tracked independently. Multiple traps each own their own debuff.

Do not leave this implicit. "It kind of stacks because the timer ran twice" is where most first status-effect bugs come from.

Timer shapes

Timers are callbacks in the world's timer manager. They are not threads and they are not saved game state.

Two common shapes are enough for a beginner project.

One timer per effect instance

Use this when effects are few and easy to inspect:

AddPoison
-> find ActiveEffects["Poison"]
-> if absent: add effect and Set Timer by Event PoisonTick looping every 1.0
-> if present: refresh duration or add stacks
-> store PoisonTimerHandle in the active effect

PoisonTick
-> Apply Damage or HealthComponent ApplyDamageToHealth
-> reduce duration
-> if duration <= 0: RemovePoison

This is clear in Blueprint. It can become noisy if dozens of effects each own their own timers.

One component timer for all effects

Use this when several effects can be active:

StatusEffectComponent BeginPlay or first effect added
-> Set Timer by Event StatusTick every 0.25 or 1.0, looping

StatusTick
-> For Each ActiveEffects
-> reduce remaining time
-> if effect's tick accumulator reached TickInterval:
       apply tick result
-> remove expired effects
-> if no effects remain: clear component timer

This keeps one timer handle and one debug point. The tradeoff is that your effect struct needs remaining time and tick accumulator values.

Damage-over-time

Damage-over-time (DoT) is just periodic damage with clear ownership.

Common server-side poison shape:

Poison source asks target to add Poison
-> target StatusEffectComponent accepts Poison
-> server starts/refreshes effect
-> every 1.0 second:
       Apply Damage to owning actor
       or call HealthComponent ApplyDamageToHealth with DamageType Poison
-> HealthComponent broadcasts health changes/death
-> StatusEffectComponent removes Poison when expired or target dies

Use the built-in damage pipeline when the target should see normal damage events, damage types, instigator/causer credit, armor, and generic hit reactions. Use a direct HealthComponent function when this is purely internal health math and you have intentionally skipped Unreal's damage event path.

Whichever path you choose, make it one path. Do not let poison subtract health directly while bullets use the HealthComponent; that splits clamping, death, UI, and replication.

Slow, stun, and timed buffs

Not every status effect changes health.

Effect What it should touch
Slow Movement speed multiplier or movement component settings, restored on removal.
Stun Input/ability/fire gates, AI behavior state, attack windows, maybe movement stop.
Shield Damage acceptance rule in HealthComponent, plus UI/presentation.
Invulnerability Damage rejection rule, often with a timer and visible feedback.
Regeneration Timed positive health delta through the same health write path.
Damage boost Weapon/ability damage modifier owned by the actor or ability owner.

Always store the old or base value you need to restore. If slow sets Max Walk Speed to 200, removal should restore the correct base speed, not a guessed constant that breaks sprint, crouch, equipment, or later buffs.

For stun, avoid making every input action check five effect booleans. Give the character a single query:

CanPerformGameplayAction(ActionName)
-> returns false if bIsDead, bStunned, menu state, cooldown, or authority rules block it

The effect component can feed that query without owning every action graph.

UI icons and feedback

UI should display effect state. It should not be the authority for effect state.

Beginner-friendly shape:

StatusEffectComponent changes ActiveEffects
-> OnStatusEffectsChanged(ActiveEffects summary)
-> HUD/status widget rebuilds icons
-> widget starts local icon animation or cooldown display

The widget may animate a radial countdown locally, but the effect owner still decides when the effect actually expires. When replicated state arrives late or changes, the widget corrects itself from the real remaining duration/value.

For damage numbers, hit flashes, poison screen tint, or burn material effects, use local presentation driven by OnHealthChanged, OnStatusEffectsChanged, RepNotify, or a server-approved cosmetic event. Do not replicate widget instances.

Death, EndPlay, and cleanup

Effects must end when their owner can no longer receive them.

Clean these paths:

StatusEffectComponent RemoveEffect
-> clear that effect's timer if it owns one
-> restore modified movement/input/visual state
-> remove from ActiveEffects
-> broadcast OnStatusEffectsChanged

Owner BeginDeath
-> StatusEffectComponent ClearAllEffects(Reason = Death)

StatusEffectComponent EndPlay
-> clear all stored timer handles
-> unbind external listeners if any

Timers bound to a destroyed object should not keep calling into that object, but explicit cleanup is still worth writing because it documents why the effect ended and prevents your own maps/sets/widgets from staying wrong.

Save and load

Timer handles are runtime IDs. Do not save them.

If an effect should survive quitting or level travel, save plain values:

SavedEffect
    EffectId
    Stacks
    RemainingDuration
    OptionalSourceId

On load:

Load saved active effects
-> rebuild ActiveEffects
-> restart runtime timers from RemainingDuration / TickInterval
-> refresh UI once

Many first games deliberately do not save temporary combat effects. That is a valid design. Write it down as a rule instead of accidentally preserving some effects and losing others.

Multiplayer authority

Gameplay effects should be server-owned when they affect health, movement, input, score, or combat.

Client enters poison cloud
-> overlap may happen locally for prediction/presentation
-> server validates overlap or damage source
-> server adds Poison to target
-> replicated ActiveEffects summary / health reaches clients
-> clients update icons and local effects

Replicate the durable facts:

  • which effect IDs are active;
  • stacks;
  • remaining duration if the UI needs a countdown;
  • health/death changes caused by the effect;
  • movement/input state if other clients need to see it.

Do not replicate every timer tick if a replicated health value or effect state will describe the result. Do not trust a client-side poison timer to subtract real health.

Failure modes

Symptom Likely cause
Poison damages forever Timer handle was not cleared on expiration, EndOverlap, death, or EndPlay.
Poison never ticks Timer was set with Time <= 0, overwritten by the same handle, or set on the wrong actor instance.
Stun never ends Duration path paused/cleared incorrectly or no removal function restores state.
Slow stacks break movement Removal restores a guessed speed instead of recalculating from base speed and active modifiers.
UI icon stays after effect ended Widget owns its own icon state and missed OnStatusEffectsChanged or initial refresh.
Effect applies twice per tick Duplicate application started multiple timers for the same effect without a stack rule.
Damage-over-time kills twice Health/death logic lacks a single bIsDead gate.
Client sees poison but health does not change Client ran local effect gameplay; server never accepted the effect.
Save/load loses effects Saved data did not include effect IDs and remaining duration, or tried to save timer handles.

Debug with prints that include actor name, authority, effect ID, stack count, timer handle status, remaining duration, and old/new health.

The pattern everyone actually uses

Poison:

PoisonCloud overlap or poison hit
-> target implements BPI_StatusReceiver or has StatusEffectComponent
-> AddStatusEffect(Poison, Duration = 5.0, Tick = 1.0, Damage = 4)

StatusEffectComponent AddStatusEffect
-> if Poison exists: refresh duration or add stack according to rule
-> else add active effect and start timer / component tick timer
-> broadcast OnStatusEffectsChanged

Poison tick
-> server applies poison damage through HealthComponent or Apply Damage
-> if owner died: ClearAllEffects
-> if duration expired: RemoveStatusEffect(Poison)

Slow:

AddStatusEffect(Slow50, Duration = 3.0)
-> add active effect
-> RecalculateMovementModifiers
-> CharacterMovement Max Walk Speed = BaseWalkSpeed * strongest slow multiplier
-> start/remove timer

Remove Slow50
-> RecalculateMovementModifiers
-> restore speed from base value and remaining effects

Stun:

AddStatusEffect(Stun, Duration = 1.5)
-> set bStunned true through action-gate owner
-> stop current attack/movement if your design requires it
-> OnStatusEffectsChanged

Remove Stun
-> set bStunned false if no other stun remains
-> OnStatusEffectsChanged

Lookalikes - which one do I want?

Tool Use when Watch out
StatusEffectComponent Several effects need common apply, refresh, tick, remove, UI, and cleanup rules. It should not own every action graph in the character.
HealthComponent The effect only changes health or damage acceptance in a small project. It can become crowded if movement/input/buffs grow.
Set Timer by Event An effect needs delayed or repeated callbacks. Store handles and clear them. Timers do not replicate or save.
Delay One simple latent wait in one graph path is enough. No handle, poor for refresh/clear/stacking.
Damage Type Class A damage event needs a category such as poison or fire. It is not the active status effect by itself.
Gameplay Tags You need stable labels such as Status.Poisoned. Tags classify state; they do not tick damage or restore movement.
Gameplay Ability System The game needs a full attribute/effect/ability framework. Large upfront system for a first Blueprint project.

Rule of thumb: an effect starts from a source, is owned by the target, changes gameplay through one owner, and always has a removal path.

Going deeper