Status Effect Propagation Architecture — How Chain Lightning, Contagion, and Freeze-Shatter Work in Games
If your elemental combo system defines "what happens" but not "how it spreads," combat stays flat. This guide covers the architecture layer that determines how status effects travel through space — and how to keep them from causing infinite loops or frame spikes.
Who This Helps
- Developers building elemental combo systems for roguelikes
- Architects who need a propagation model that scales to hundreds of entities
- Designers who want position-play (grouping enemies, environmental interaction) to matter
The Problem
When status effects only apply to a single target, combat becomes a 1:1 exchange. When effects propagate through space, three things happen:
- Positioning becomes strategic. Players group enemies to chain effects.
- Environment interacts. Oil + fire, water + electricity, ice + physical impact.
- Build synergies express spatially. "+50% conduction," "+2 pierce," "+3 chain" become visually felt.
But a badly designed propagation system creates its own disasters:
- Infinite loops: A → B → A → B until the game freezes
- Frame spikes: BFS scanning hundreds of entities every frame
- Unpredictability: Non-deterministic order makes the system impossible to learn
- Visual chaos: 10+ hop chains become unreadable
Core Ideas
Propagation = Source → Medium → Target
Source (ignition point) Medium (propagation vector) Target (recipient)
───────────────────── ────────────────────── ────────────────
Lightning strike location → air/water/chain → enemies in radius
Poison cloud → air diffusion → entities inside cloud
Ice shard → physics collision → enemies in fragment path
Propagation Graph Is a DAG
Model all propagation as a directed acyclic graph. Each entity ignites only once per chain. Cycles are structurally impossible:
[Player] ──lightning──→ [Enemy_A] ──chain──→ [Enemy_B] ──chain──→ [Enemy_C]
│ │
└──→ [Enemy_D] ←─────────────────────┘
(B and C both target D, but D only takes damage once)
Hop-Based Falloff
Each propagation hop reduces effect magnitude, naturally preventing infinite spread:
damage_n = damage_0 × falloff^(n-1)
duration_n = duration_0 × falloff^(n-1)
Example (falloff = 0.6):
Hop 1: 100 damage, 3s
Hop 2: 60 damage, 1.8s
Hop 3: 36 damage, 1.1s
Hop 4: 21 damage, 0.6s ← practical termination threshold
How to Apply It
1. Define Propagation Types
| Type | Behavior | Range | Targets | Falloff | VFX |
|---|---|---|---|---|---|
| Chain Lightning | Ignites → nearest → repeat | Mid (200–400px) | 1–3 per hop | 0.5–0.7 | Zigzag line between entities |
| Contagion | Status replicates after delay | Short (80–150px) | All in radius | Duration-based | Pulsing circle expanding |
| Freeze-Shatter | Frozen enemy destroyed → fragments fire as projectiles | Fragment flight (100–300px) | Enemies in path | Fixed per fragment | Ice crystal physics |
| Oil + Fire | Oil → ignition → fire spreads along oil surface | Oil patch connections | All in fire zone | None (duration only) | Tile-based fire spread |
2. Implement the Propagation Queue
interface PropagationDef {
id: string;
type: 'chain' | 'area' | 'projectile' | 'environmental';
maxHops: number;
maxTargetsPerHop: number;
range: number;
falloff: number;
minDamageMultiplier: number;
targetPriority: 'nearest' | 'random' | 'lowest_hp' | 'highest_hp';
canHitSameTarget: boolean;
canHitSource: boolean;
requiredStatus?: string; // e.g. "wet" required for lightning to propagate
blockedByStatus?: string[]; // e.g. "frozen" blocks lightning propagation
consumesStatus: boolean;
}
3. Run the Propagation Loop
function propagate(def, sourceEntity, spatialIndex, effectBus) {
const queue = new PropagationQueue();
const visited = new Set<string>();
visited.add(sourceEntity.id);
queue.enqueue({
defId: def.id, sourceId: sourceEntity.id,
currentHop: 0,
remainingDamage: sourceEntity.getStatusDamage(def.id),
remainingDuration: sourceEntity.getStatusDuration(def.id),
visitedTargets: visited,
originPosition: sourceEntity.position
});
while (!queue.isEmpty) {
if (queue.totalPending > MAX_PROPAGATION_QUEUE) break; // frame budget guard
const events = queue.processNextLayer();
for (const event of events) {
if (event.currentHop >= def.maxHops) continue;
const damage = event.remainingDamage * Math.pow(def.falloff, event.currentHop);
if (damage < def.minDamageMultiplier * event.remainingDamage) continue;
const candidates = spatialIndex.queryRadius(
event.originPosition, def.range,
c => isEligibleTarget(c, def, event, visited)
);
const targets = selectTargets(candidates, def, event);
for (const target of targets) {
effectBus.emit('status:apply', { target: target.id, statusId: def.id, damage });
effectBus.emit('vfx:chain', { from: event.originPosition, to: target.position });
visited.add(target.id);
if (event.currentHop + 1 < def.maxHops) {
queue.enqueue({ ...event, currentHop: event.currentHop + 1, originPosition: target.position });
}
}
}
}
}
4. Guard Against Failure Modes
- Infinite loops:
visitedset +maxHopshard limit - Frame spikes: Cap propagation events per frame (e.g. 32), defer the rest
- Visual chaos: Fade VFX opacity by
0.7^hop, limit visible connections to top 5 - Non-determinism: Seed-based shuffle + synchronous queue processing at frame start
Common Mistakes
Path of Exile's Chain/Pierce Loop
PoE's "Chain" gem initially created infinite loops when combined with "Pierce." The fix: "cannot hit the same target consecutively" rule. Lesson: Always enforce a no-revisit constraint on the same chain.
Frame Budget Ignorance
Processing 100 simultaneous infections in one frame turns 16ms into 80ms. Lesson: Budget propagation per frame. Spread remaining work across frames.
Unreadable 10-Hop Chains
Players cannot cognitively track more than about 5 chained events. Lesson: Use opacity fade, connection-line limits, and minimap indicators for distant propagation.
Checklist
- [ ] Propagation is modeled as a DAG (no cycles possible)
- [ ] Each entity can only ignite once per propagation chain
- [ ] Falloff reduces damage and duration per hop toward a termination threshold
- [ ] Frame budget cap is enforced on simultaneous propagation events
- [ ] VFX opacity fades with hop count
- [ ] Spatial index (grid or quadtree) is used for radius queries
- [ ] Target eligibility checks include status conditions, team filters, and immunity
- [ ] New propagation types can be added data-driven (JSON/table) without code changes
References
- Noita — physics-based material propagation (https://noitagame.com/)
- Wizard of Legend — element combo chain system
- Enter the Gungeon — water/electricity and oil/fire environmental interaction
- Risk of Rain 2 — Ukulele chain lightning item
- Path of Exile — Chain/Pierce gem interaction and cycle prevention
- Hades II — magic propagation bounce mechanics
- Chrono Trigger — tech combo element propagation
- Nystrom, Robert. Game Programming Patterns. 2014. (BFS / event queue patterns)