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:
Triggers on a condition (player proximity, threshold cross, death). Fires after gates resolve.
Late
40–20
summoner (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:
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.
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';
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:
Add the id to WORLD_ELITE_AFFIX_DEFS in data/affixes.ts (this also feeds the
exported WORLD_ELITE_AFFIX_IDS).
Add the id to WORLD_ELITE_AFFIX_POOL in engine/affixes/roll.ts — same order.
Decide if any archetype should bias toward it. Add an entry to ARCHETYPE_AFFIX_BIAS:
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
BOSS_AFFIX_DEFS (arena + anchors) vs WORLD_ELITE_AFFIX_DEFS (open world).
Priority band — gate (100–80), reactive (75–50), or late (40–20).
Up to 4 hooks: onSpawn, onUpdate, filterIncomingDamage, onDeath.
Implementation in runtime.ts / palette.ts; use the late-bound adapters for
damage and prop spawn.
Tunables in palette.ts, re-exported from data/affixes.ts. Palette tint for
world-roaming affixes.
World-roaming: add to WORLD_ELITE_AFFIX_POOL + optional ARCHETYPE_AFFIX_BIAS.
Boss-only: reference by id from data/bosses.ts.
Write gameplay/affixes/<name>.md.
Verify in the dev playground affix-roll-pool browser.