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_epic → charger).
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.
| Rarity | Affixes rolled |
|---|---|
common | 0 |
uncommon | 0 |
rare | 1 affix, gated by a 70% chance (RARE_AFFIX_DROP_RATE) — otherwise 0 |
epic | 1 or 2 (50/50) — always at least 1 |
legendary | 2 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 weightBIAS_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.
| Archetype | Biased affix | Thematic intent |
|---|---|---|
charger, brute | hardened | slow-melee tanks turtle up |
racer | phasing | fast chasers blink |
mortar, bombardier | volatile | explosive ranged pop |
sniper, lurker | reflective_burst | charged-beam punishers reflect |
gunner | burning_aura | burst-fire ranged ignites |
wisp | summoner | tiny swarmers summon more |
field | regenerating | static-zone enemies heal |
orb | gravity_well | basic 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[].