How to design a new affix

An affix is a behavior primitive bolted onto an enemy host. The host can be a boss (arena required, anchor enemies supported) or a roving world elite (no arena, no anchors). Affixes are pure data in data/affixes.ts; the lifecycle bodies live in engine/affixes/runtime.ts, the visuals/tunables in engine/affixes/palette.ts, and dispatch + registry in engine/affixes/index.ts. Auto-roll for elite packs lives in engine/affixes/roll.ts.

Follow the steps below in order. Skipping any of them is how affixes ship half-wired.

Step 1 — Pick the pool: boss vs world-elite

Decide which of the two manifest arrays in data/affixes.ts the new affix belongs in:

  • BOSS_AFFIX_DEFS — assumes a live BossArena and may spawn anchor enemies on arena ring points (arena.ringPoints(N, frac)). Use this when the affix needs requireArena(game) to not crash, references _isBossAnchor, or hooks into respawn_as-style phase plumbing. Boss affixes are excluded from the world elite roll pool — bosses hand-pick their affix list from data/bosses.ts.
  • WORLD_ELITE_AFFIX_DEFS — runs on rare-tier-plus elite pack leaders in the open world. No arena, no anchors, no boss-bar plumbing. The host is a regular enemy with full HP, position, and death lifecycle. Most new affixes go here.

If you can describe the affix without saying the word “arena” or “anchor,” it belongs in WORLD_ELITE_AFFIX_DEFS.

Step 2 — Pick a priority band

Affix hooks are dispatched in descending priority order per host. A filterIncomingDamage hook returning 0 short-circuits the chain — no further damage filters run. So priority controls who gets to nullify a hit first. See the priority bands concept page for the rules of thumb, but the three bands in use are:

BandRangeUsed byRule
Gate100–80shielded, shielded_respawn, respawn_as, gated, periodic_invuln, reflective, phasing (75)Hard invuln or phase swap. Runs first so it can return 0 and end the chain.
Reactive75–50reflective_burst (70), burning_aura (60), volatile (60), gravity_well (55), regenerating (50)Triggers on a condition (player proximity, threshold cross, death). Fires after gates resolve.
Late40–20summoner (40), hardened (25), armored (20)Multipliers and one-shot triggers that need other affixes to settle first. Summoner reads post-filter HP; hardened multiplies the surviving damage.

When two affixes both want to react to the same event, the higher number runs first. The lockstep comments in data/affixes.ts (/** Priority 75 — fires after… */) are the canonical record — preserve that style on any new entry.

Step 3 — Add up to 4 lifecycle hooks

The AffixDef contract in engine/affixes/index.ts exposes exactly four optional hooks:

interface AffixDef {
  id: string;
  priority: number;
  onSpawn?(host, game, world): void;
  onUpdate?(host, dt, game, world): void;
  filterIncomingDamage?(host, dmg, game, world): number;
  onDeath?(host, game, world): void;
}

Pick the minimum set the affix needs. The implementation goes in runtime.ts (or palette.ts for pure visual data). Export the hook bag as <id>Hooks:

export const myAffixHooks = {
  onUpdate(host, dt, game, world) { /* … */ },
  filterIncomingDamage(host, dmg) { /* return surviving dmg */ },
};

Patterns to follow when writing the hook bodies:

  • State lives on AffixInstance.state as a plain Record<string, unknown>. Read it through numParam(state, 'key', fallback) / strParam(state, 'key', fallback) — these crash loudly on mistyped slot values (no silent fallback for bad data).
  • Use getInstance(host, defId) to fetch the per-host slot. It throws if the dispatcher fed the wrong host into the hook (a bug, not a recoverable branch).
  • Use hostHasAffix(host, defId) for the non-throwing co-presence check when writing affix × affix interactions (e.g. phasing + hardened, volatile + reflective_burst).
  • Damage and prop spawning are late-bound adapters. Call damagePlayerViaAdapter(...) and spawnPropAtViaAdapter(...) — never import combat/damage.ts or world/props.ts directly. Doing so closes the import cycle and crashes module load. The adapters are wired from bridge.ts at engine boot.
  • Crash-on-missing-arena when the hook needs it: const arena = requireArena(game);. This is correct for boss affixes only.
  • Emit particles through Particles.add(...) using the palette tint reserved in step 4. Keep per-frame emission gated (e.g. Math.random() < dt * 3 for ~3/sec).
  • Telemetry: call telemetry.recordDirectorPhase('affix_proc:<id>', value) on proc events. Avoid per-frame telemetry (sample at ~1/sec with Math.random() < dt).

Step 4 — Register tuning constants

All numeric defaults live in engine/affixes/palette.ts (visuals + tunables) or, for boss-flavored constants that predate the palette split, at the top of runtime.ts. The rule is: data/affixes.ts is the manifest, not the tuning store — it imports the defs and re-exports the tunables so downstream consumers (dev playground, tests, the gameplay wiki) have a single source of truth.

When adding a new affix:

  1. Declare the defaults in palette.ts:
    export const MY_AFFIX_DEFAULT_RADIUS = 200;
    export const MY_AFFIX_DEFAULT_DPS = 6;
  2. Add a palette tint if it’s world-roaming (boss affixes are deliberately absent from AFFIX_VFX_PALETTE so they don’t trigger the elite halo):
    export const AFFIX_VFX_PALETTE = {
      // …
      my_affix: { r: 255, g: 110, b: 30 },
    } as const;
  3. Re-export the constants from data/affixes.ts so the manifest stays the public face:
    export {
      MY_AFFIX_DEFAULT_RADIUS,
      MY_AFFIX_DEFAULT_DPS,
    } from '../engine/affixes/palette';
  4. Read the defaults via numParam(inst.state, 'radius', MY_AFFIX_DEFAULT_RADIUS) — the data table can override per-host without changing the constant.

Every number in the hook body must trace back to a named constant. No magic values.

Step 5 — Auto-roll inclusion

World-roaming affixes get rolled onto elite-pack leaders by engine/affixes/roll.rollEliteAffixes(). The roll pool is a local constant WORLD_ELITE_AFFIX_POOL in roll.ts, deliberately kept separate from data/affixes.ts to avoid a circular import — a test asserts the two arrays are equal at build time.

If your affix is world-roaming:

  1. Add the id to WORLD_ELITE_AFFIX_DEFS in data/affixes.ts (this also feeds the exported WORLD_ELITE_AFFIX_IDS).
  2. Add the id to WORLD_ELITE_AFFIX_POOL in engine/affixes/roll.ts — same order.
  3. Decide if any archetype should bias toward it. Add an entry to ARCHETYPE_AFFIX_BIAS:
    export const ARCHETYPE_AFFIX_BIAS: Record<string, string> = {
      // …
      my_archetype: 'my_affix',
    };
    Biased archetypes get a BIAS_WEIGHT (3.0) multiplier in weighted sampling — ~3× more likely, not guaranteed.

If your affix is boss-only, skip the roll pool entirely. Instead, reference it by id from the boss def in data/bosses.ts — the boss spawn path constructs AffixInstance[] from the def’s affixIds list with a literal state object.

Step 6 — Write the per-affix gameplay page

Every shipped affix has a dedicated page under gameplay/affixes/<name>.md. Use the existing pages as templates (e.g. phasing.md, summoner.md). Cover:

  • One-line summary (what the player sees).
  • Pool (boss vs world-elite) + priority band + reason.
  • Default tunables (link back to the constants in palette.ts).
  • State shape (what’s stored on AffixInstance.state).
  • Affix × affix interactions — list every hostHasAffix(...) branch in the hook body, the visual cue, and the counterplay.
  • Affix × prop crossover (the death-drop, if any) — palette match + thematic reason.
  • Telemetry events emitted.

This page is also the canonical place for stat tables. Per the wiki rule, prose definitions live on exactly one page (here); numeric tables may also appear on the roll-up affix index without violating the duplication rule.

Step 7 — Test in the dev playground

The dev playground ships an affix-roll-pool browser that draws N rolls for a given rarity + archetype and shows the resulting affix mix. Use it to sanity-check:

  • The new id appears in the rolled mix at the expected rate.
  • Biased archetypes hit the bias-pair at ~3× the unbiased rate.
  • The pool’s target × rarity distribution still hits the published numbers (rare 70% chance of 1, epic 1–2, legendary 2–3).
  • The host’s halo color matches the new palette tint.
  • The death drop appears (when an onDeath prop drop is wired).

Then spawn an elite manually via the dev console and verify the four lifecycle hooks fire in priority order — the affix dispatcher logs are sorted descending and the per-frame trace should show the new affix slotted at the priority you picked in step 2.

Recap

  1. BOSS_AFFIX_DEFS (arena + anchors) vs WORLD_ELITE_AFFIX_DEFS (open world).
  2. Priority band — gate (100–80), reactive (75–50), or late (40–20).
  3. Up to 4 hooks: onSpawn, onUpdate, filterIncomingDamage, onDeath. Implementation in runtime.ts / palette.ts; use the late-bound adapters for damage and prop spawn.
  4. Tunables in palette.ts, re-exported from data/affixes.ts. Palette tint for world-roaming affixes.
  5. World-roaming: add to WORLD_ELITE_AFFIX_POOL + optional ARCHETYPE_AFFIX_BIAS. Boss-only: reference by id from data/bosses.ts.
  6. Write gameplay/affixes/<name>.md.
  7. Verify in the dev playground affix-roll-pool browser.