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

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:

But a badly designed propagation system creates its own disasters:

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

TypeBehaviorRangeTargetsFalloffVFX
Chain LightningIgnites → nearest → repeatMid (200–400px)1–3 per hop0.5–0.7Zigzag line between entities
ContagionStatus replicates after delayShort (80–150px)All in radiusDuration-basedPulsing circle expanding
Freeze-ShatterFrozen enemy destroyed → fragments fire as projectilesFragment flight (100–300px)Enemies in pathFixed per fragmentIce crystal physics
Oil + FireOil → ignition → fire spreads along oil surfaceOil patch connectionsAll in fire zoneNone (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

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

References

  1. Noita — physics-based material propagation (https://noitagame.com/)
  2. Wizard of Legend — element combo chain system
  3. Enter the Gungeon — water/electricity and oil/fire environmental interaction
  4. Risk of Rain 2 — Ukulele chain lightning item
  5. Path of Exile — Chain/Pierce gem interaction and cycle prevention
  6. Hades II — magic propagation bounce mechanics
  7. Chrono Trigger — tech combo element propagation
  8. Nystrom, Robert. Game Programming Patterns. 2014. (BFS / event queue patterns)