Effect Priority Bands

What this is

The priority field on EffectDef (engine/effects/types.ts:128-129) and on AffixDef (engine/affixes/index.ts:13-20) is a single integer that controls dispatch order whenever multiple effects or affixes are reacting to the same gameplay moment. Higher priority fires first. The default for EffectDef.priority is 50, which the inline comment names the “effects band”. The number is consumed by two ordering mechanisms:

  • Affix dispatchengine/affixes/index.ts → sortedDefs() sorts a host’s affix instances b.priority - a.priority (descending) before each of the four lifecycle dispatchers walks the chain (onSpawn, onUpdate, onDeath, runDamageFilterChain).
  • Signal listenersengine/core/signals.ts → Sig.on(name, fn, priority) keeps the listener list sorted descending. The header comment names three signal bands: Core logic: 100, Effects: 50, Audio/VFX: 0.

Both systems use the same convention (higher = earlier) and the same default (50). The signal-bus bands and the affix priority numbers are not the same axis — a signal listener at priority 100 fires before a listener at 50; an affix priority 100 sorts ahead of an affix at 50 on the same host. The shared numeric scale is convention, not coupling.

The three bands (from data/affixes.ts)

The world-elite affix catalog clusters priority values into three named bands. The inline comments on phasing, summoner, hardened, and gravity_well (lines 117-148) call these out explicitly. Mapping the full 15-affix catalog (BOSS_AFFIX_DEFS + WORLD_ELITE_AFFIX_DEFS) onto the bands:

BandRangeAffixes in this bandHook kind
Gate100 – 80shielded (100), shielded_respawn (100), respawn_as (95), gated (90), periodic_invuln (85), reflective (80)filterIncomingDamage that can return 0
Reactive75 – 50phasing (75), reflective_burst (70), burning_aura (60), volatile (60), gravity_well (55), regenerating (50)onUpdate, onDeath, or filters that pass damage through
Late40 – 20summoner (40), hardened (25), armored (20)Post-processing — multipliers, threshold triggers, cleanup

EffectDef instances follow the same convention but are not catalogued by band in the data tables — every artifact / passive / mod effect ships with priority unset, defaulting to 50 (mid-reactive). The bands are a design contract on where to put a new effect or affix when you author one, not a runtime enum.

Gate band (100 – 80)

Gate-band hooks decide whether anything else gets to run. They are the filterIncomingDamage hooks that can return 0 and short-circuit the rest of the chain in runDamageFilterChain (engine/affixes/index.ts:84-95).

The chain short-circuits on strict === 0, so the canonical gate pattern is:

filterIncomingDamage(host, dmg, ...): number {
  if (gateClosed(host)) return 0;
  return dmg; // pass-through when gate is open
}

shielded and shielded_respawn (priority 100) return 0 while any anchor is alive. gated (90) returns 0 while a sibling sharing gateGroupId is alive. periodic_invuln (85) returns 0 during the active invuln window. Putting these at the top of the chain means: when the gate is closed, no downstream affix runs — armored’s multiplier, hardened’s ramp, and any reactive side effects (e.g. reflective_burst’s AoE) are all skipped.

For effects (artifact / passive / mod), the gate band is rarely needed — effects don’t run on the enemy-damage path. The signal-bus equivalent is the core-logic band at priority 100, reserved for engine systems that must observe a signal before any gameplay reaction.

Reactive band (75 – 50)

Reactive hooks run after gates resolve, when the host actually takes damage or ticks. They cover three shapes:

  • Reactive filters that don’t return 0phasing (75) and reflective_burst (70) implement filterIncomingDamage but both pass damage through. phasing returns 0 only during its active phase window, so most frames it sits in the reactive band even though it can occasionally gate. reflective_burst fires a side-effect AoE on the player when the incoming hit exceeds threshold and always returns input unchanged.
  • onUpdate ticksburning_aura (60), regenerating (50), gravity_well (55). These run every frame on the host; placing them mid-band keeps them off both the gate critical path and the late post-processing.
  • onDeath triggersvolatile (60). Explosion on death; doesn’t compete with damage filters.

This is the default home for artifact / passive / mod effects: EffectDef.priority defaults to 50, which sits inside this band. Effects triggered by onHit, enemy_kill, damage_taken, and similar signals should stay at 50 unless there’s a reason to reorder. The phasing affix’s comment captures the only common reason to deviate within the band: “Priority 75 — fires after reflective_burst (70) so phasing’s invuln short-circuits the burst chain when both are present.” If two reactive effects on the same signal must run in a specific order, bump one up inside the band; don’t escape the band entirely.

Late band (40 – 20)

Late-band hooks run after gates and reactive logic have already had their say. The catalog comments make the reasoning explicit:

  • summoner (40) — “fires AFTER damage filters; the host has to actually drop below the HP threshold for the trigger to land.” Spawning minions before damage applies would mean the threshold check sees pre-damage HP and never fires.
  • hardened (25) — “fires close to last so other affixes (phasing, reflective_burst) get their say first.” Hardened’s ramp multiplier is the final scaling step; everything reactive should already have evaluated.
  • armored (20) — flat 0.5× multiplier at the very bottom. Whatever damage survives the gate and reactive bands gets the armored reduction last.

The late band is for post-processing: multipliers that should apply after threshold checks, cleanup signals, threshold-driven spawns, and any side effect whose correctness depends on observing the post-reaction state. For signal-bus listeners, the closest equivalent is the audio/VFX band at priority 0 — the listeners that fire only to feed back to the player after gameplay state has resolved.

Why this exists

The runDamageFilterChain short-circuit is the single most consequential reason priority bands matter in practice. A host with shielded (100) + armored (20) takes zero damage while shielded; armored never runs. A host with periodic_invuln (85) + hardened (25) takes zero damage inside the invuln window. Without the gate band sitting on top, an armored-style filter would still apply its 0.5× to a “zero” damage value — which works numerically, but masks the design intent and creates an extra runtime branch on every hit. Bands make the policy readable: filters that can deny damage live at the top; passive multipliers live at the bottom.

The same logic applies to onUpdate and signal listeners even though those chains don’t short-circuit. Putting the shielded anchor-health check ahead of the regenerating tick on the same host means a single frame’s anchor-respawn decision is visible to regenerating before it heals. Putting effect listeners in the mid-band (50) keeps them after engine core logic (100) and before audio/VFX feedback (0).

The default of 50 is load-bearing: a content author shipping a new artifact, passive, or mod effect almost never needs to think about priority. They land in the reactive band automatically. Only when authoring a new affix (or an effect that explicitly must gate or post-process) do bands become an authoring concern, and data/affixes.ts is the only file that currently calls bands out by name.