Elite Affix Rolling

When an elite pack spawns, the leader (and only the leader) rolls a set of affixes from a curated world-roaming pool. The roll is a pure data pass — count is decided by the leader’s rarity tier, identity is drawn from the pool with a mild archetype bias.

Entry point: rollEliteAffixes(rarity, rng, pool, archetype) in engine/affixes/roll.ts. Called from _spawnElitePack in engine/enemies/spawner.ts immediately after the leader is created, using the leader’s runtime rarity and the archetype prefix extracted from typeId (e.g. charger_epiccharger).

Rarity bands

The leader’s EnemyRarity drives the affix count. Numbers below are the target count; the caller clamps to pool size if the pool ever shrinks below the target.

RarityAffixes rolled
common0
uncommon0
rare1 affix, gated by a 70% chance (RARE_AFFIX_DROP_RATE) — otherwise 0
epic1 or 2 (50/50) — always at least 1
legendary2 or 3 (50/50) — always at least 2

common and uncommon returning 0 is intentional: those tiers are the basic-enemy bands, not elites. The elite spawn path only invokes the roll for rare/epic/legendary leaders in practice, but the function defines the full table for safety.

The pool

WORLD_ELITE_AFFIX_POOL is the world-roaming subset only — boss-arena affixes are excluded so the roll has no BossArena dependency. Eight entries as of this revision:

burning_aura, volatile, regenerating, reflective_burst, phasing, summoner, hardened, gravity_well.

The pool lives in roll.ts rather than being imported from data/affixes to break the circular module-init chain runtime → spawner → roll → data/affixes → runtime. data/affixes.ts re-exports its own WORLD_ELITE_AFFIX_IDS for downstream consumers, and a test asserts the two lists are equal — duplication is enforced at build time, not by trust.

Sampling

Always without replacement — a single leader never carries the same affix twice. Two sampling modes:

  • Uniform (sampleUnique): Fisher-Yates partial shuffle on a working copy of the pool. Used when no archetype bias applies.
  • Weighted (sampleWeighted): inverse-CDF rejection-style sampling. Each remaining pool entry contributes weight BIAS_WEIGHT (3.0) if it matches the archetype’s biased affix, otherwise weight 1.0. Pick → swap-and-pop → repeat. O(n²) on pool size, fine for the 8-entry pool.

The roll picks weighted sampling when archetype is passed and ARCHETYPE_AFFIX_BIAS[archetype] exists in the pool. Otherwise it falls back to uniform — this preserves the original behavior for callers (and tests) that don’t pass an archetype.

Archetype bias

Each archetype names one thematic affix. When that archetype’s elite leader rolls, the named affix gets BIAS_WEIGHT = 3.0 (≈3× as likely as a non-themed pool entry when both compete in the draw). The bias is mild — not a guaranteed match. A charger legendary still has a real chance to roll three unrelated affixes.

ArchetypeBiased affixThematic intent
charger, brutehardenedslow-melee tanks turtle up
racerphasingfast chasers blink
mortar, bombardiervolatileexplosive ranged pop
sniper, lurkerreflective_burstcharged-beam punishers reflect
gunnerburning_auraburst-fire ranged ignites
wispsummonertiny swarmers summon more
fieldregeneratingstatic-zone enemies heal
orbgravity_wellbasic orbital pulls

Archetypes not in the map (e.g. boss-roster types like caiman whose typeId doesn’t carry the _<rarity> suffix) fall through to uniform sampling.

Leader-only

Affixes attach to leader.affixes only. Followers spawned around the elite leader stay vanilla so the pack reads as “deadly leader + grunts” rather than “every body has wrinkles.”

Determinism

The rng: Rng parameter is a () => number returning uniform [0,1). Defaults to Math.random; tests inject a seeded RNG for deterministic assertions. The function is otherwise pure — same inputs produce the same AffixInstance[].