PURPOSE

Boss encounter spawn and teardown. spawnBoss reads a BossDef from data/bosses, materializes its roster into world.enemies with scaled HP / damage / affixes / abilities, applies the def’s terrain pattern, sets up VFX layers, and arms the boss spawn profile. onBossEncounterEnd reverses the lot on win, player death, or level exit — culls anchor bodies and arena-tagged bullets, clears encounter state, fires the win signal, runs the supply-pod cascade payoff, and drops the legacy portal where applicable.

OWNS

  • spawnBoss(defId, game, world, kind) — full roster spawn pipeline.
  • onBossEncounterEnd(reason, game, world) — single teardown funnel for the three exit reasons.
  • getSharedBossVfxKit() — exposes the module-singleton VFX layer kit so out-of-module hooks (notably the respawn_as affix runtime invoking def.onPhaseChanged) rebind layers against the same kit instance the boss’s setupVfx used.
  • Module-level sharedKit — the singleton VFX layer kit instance, created at module load via createVfxLayerKit() and kept for the lifetime of the module.
  • Per-encounter HP and damage multiplier derivation: kind-tier base mult (BOSS_KIND_HP_MULT_BOSS / _MINI, BOSS_KIND_DAMAGE_MULT_BOSS / _MINI) multiplied by a linear depth ramp (1.0× at the run’s first level → 1.5× at the final level, computed against RUN_LEVEL_SEQUENCE or CHALLENGE_LEVEL_SEQUENCE).
  • Per-body roster stamping: isBoss, sharesHealthWithBoss, untargetable, displayName (forced to def.displayName on the isBoss anchor so the HUD bar’s fallback path surfaces the def name), barColor, affixes (cloned affixState per id), abilities (fresh createAbilityInstance per id), gateGroupId (from affixState.gateGroup.gateGroupId if present, stamped directly so the gated filter doesn’t have to walk sibling affix arrays), hp / hpMax, damageMult (multiplied into any prior value), behavior (mapped from aiId).
  • Win-path payoff: extra boss_kill signal fire, supply-pod cascade(s) at the resolved death anchor (1× for mini-tier, 2× for boss-tier with ~80px jitter on the second ring so they don’t visually stack), telemetry recordDirectorPhase('boss_kill:supply_pod_cascade_<kind>', totalSpawned), optional per-def rewardCascadeTypes composition override.
  • Win-VFX anchor priority resolution: last-sharer death snapshot (_lastBossDeathX/Y set by damage.ts) → any still-alive isBoss enemy → arena center.
  • Teardown: cull pass over world.enemies (kills any isBoss / sharesHealthWithBoss / _isBossAnchor), conditional removal of _cullOutsideArena-tagged enemy bullets (only on win and level_exit, not player_death), terrain-pattern cull, VFX layer clear, background-loop stop.
  • Reset of encounter-pointer state on GameState: bossArena, bossSpawnProfile, bossEncounterTime, _activeBossDefId, _pendingBarDamage, _lastBossDeathX/Y snapshot.
  • Run-sequence win handling: sets _bossLevelCleared = true on mini_boss / boss levels (no portal — the level is the fight) versus legacy portal materialization at the death anchor on other level kinds.
  • Helpers: resolveRosterPositions (translates BossSpawnPosition literals into {x, y} arrays), mapAiIdToBehavior (translates roster aiId strings to runtime behavior strings).

READS FROM

  • BOSS_DEFS registry (../../data/bosses) — looked up by defId; missing key throws.
  • BossDef shape — roster, arena.terrain, spawnProfileId, id, displayName, barColor, setupVfx, backgroundLoop, reward.xp / .currency, rewardCascadeTypes.
  • bossDefKind(def) — selects mini vs boss cascade multiplier on the win path.
  • BossRosterEntry fields — enemyTypeId, position, count, isBoss, sharesHealthWithBoss, untargetable, displayName, barColor, affixIds, affixState, abilityIds, aiId, hp.
  • BOSS_KIND_HP_MULT_BOSS / _MINI, BOSS_KIND_DAMAGE_MULT_BOSS / _MINI, RUN_LEVEL_SEQUENCE, CHALLENGE_LEVEL_SEQUENCE from data/level-progression.
  • GameState.bossRoom — must be set by the caller before spawnBoss; missing throws.
  • GameState._currentLevel, runDef.context.isChallenge, _levelKind, time — drive depth scaling, run vs challenge length, run-sequence win path, and portal spawnTime.
  • GameState._lastBossDeathX/Y — snapshot set by damage.ts when the bar hits zero, consumed for win-VFX anchor priority.
  • GameState._activeBossDefId, bossArena, portal — read during teardown to identify the active def, anchor priority fallback, and skip portal materialization when one already exists.
  • WorldState.enemies — scanned for fallback win-anchor (any alive isBoss) and walked during cull.
  • WorldState.enemyBullets — walked for _cullOutsideArena-tagged removal on non-player_death exits.
  • ship singleton from ../core/state — passed to GameMaster.spawnEnemy as the spawn reference.
  • createBossArena(room) from ./arena — wraps the room into the BossArena helper.
  • arena.cx / arena.cy / cardinalPoints(0.42) / ringPoints(count, 0.6) / randomPoint() — position resolution.
  • applyTerrainPattern(pattern, arena, world) — applied early (before any roster spawn) so ability spawns can route around pillars.

PUSHES TO

  • world.enemies — appended via GameMaster.spawnEnemy for each resolved roster position; each spawned enemy is then stamped with the per-body fields listed in OWNS. Teardown sets alive = false and hp = 0 on every matching anchor body.
  • world.enemyBullets — spliced for any bullet with _cullOutsideArena === true on win / level_exit teardown.
  • GameState.bossArena — set to the newly created arena on spawn; nulled on teardown.
  • GameState.bossSpawnProfile — set to def.spawnProfileId ?? 'none' on spawn; nulled on teardown.
  • GameState.bossEncounterTime — zeroed on spawn; zeroed again on teardown.
  • GameState._activeBossDefId — set to def.id on spawn; nulled on teardown.
  • GameState._pendingBarDamage — zeroed on teardown.
  • GameState._lastBossDeathX/Y — set to undefined on teardown so stale snapshots don’t leak into the next encounter.
  • GameState._bossLevelCleared — set to true on win when _levelKind is mini_boss or boss; signals bridge.ts to advance the run or finish on the final boss.
  • GameState.portal — materialized as a closingmaterialized chamber at the resolved death anchor on legacy win paths (non-mini_boss / non-boss levels) when bossRoom is non-null and portal is null.
  • Sig.fire('boss_kill', 0, 0, def.reward.xp, def.reward.currency, def.id) on every win.
  • getPropPool().triggerSupplyPodCascade(x, y, customTypes) — fired once for mini, twice with jitter for boss-tier, on every win.
  • telemetry.recordDirectorPhase('boss_kill:supply_pod_cascade_<kind>', totalSpawned) on every win.
  • def.setupVfx(spawnedHosts, world, arena, sharedKit) on spawn when defined — receives the singleton kit so re-binding hooks share it.
  • startBossBackgroundLoop(def.id, def.backgroundLoop) on spawn when def.backgroundLoop is defined; stopBossBackgroundLoop() on teardown.
  • clearBossVfxLayers() on teardown.
  • cullTerrainPattern(world) on teardown.
  • resetBossSpawnProfile() on both spawn and teardown.

DOES NOT

  • Does not decide when to start an encounter, which def to use, or build the BossRoom — caller must set game.bossRoom (closing-circle or sealed-rect) before invoking spawnBoss; missing room throws.
  • Does not run the boss AI, ability cooldowns, affix update loop, projectile flight, damage application, or HP-bar rendering — those live in enemies / abilities / affixes / combat / HUD.
  • Does not compute per-shot damage, knockback, or hit reactions — only stamps a damageMult expando at spawn time.
  • Does not select or fire weapons for the roster — abilities and AI behaviors drive that downstream.
  • Does not render the boss bar, portal VFX, supply-pod props, or background-loop frames — only triggers their spawn or lifecycle hooks.
  • Does not define boss content (rosters, affixes, abilities, terrain patterns, rewards, scaling constants) — those live in data/bosses and data/level-progression.
  • Does not silently fall back on unknown defId or unknown aiId — both throw, matching the crash-loud-on-bad-data stance for internal config.
  • Does not catch unknown BossSpawnPosition variants silently — the never-typed exhaustiveness guard throws.
  • Does not cull boss-fired bullets on player_death teardown — they’re left to expire naturally per the spec.
  • Does not persist any encounter state across runs — every encounter-pointer field is cleared on teardown.
  • Does not materialize a new portal on mini_boss / boss level kinds — those levels signal _bossLevelCleared and let bridge.ts advance the run.
  • Does not materialize a portal when game.portal is already non-null (legacy path guard).
  • Does not own the _lastBossDeathX/Y snapshot — damage.ts writes it when the bar hits zero; this module only reads and then clears it.
  • Does not deduplicate boss_killdamage.ts already fires it on the final sharer’s death and this module fires again on win; listeners must be idempotent.

Signals

  • OutboundSig.fire('boss_kill', 0, 0, def.reward.xp, def.reward.currency, def.id) on every win. This is a second fire routed through encounter end so reward systems with a single contract see the encounter close exactly once; damage.ts already fires boss_kill on the final sharer’s death. Listeners are required to be idempotent.
  • Inbound — none watched directly. The encounter relies on the _lastBossDeathX/Y snapshot that damage.ts writes when the boss bar reaches zero, but consumes it via GameState rather than a signal.
  • Indirect downstreamGameMaster.spawnEnemy is the canonical spawn path for every roster body, so normal enemy-spawn signals fire through that for each one. Anchor bodies set alive = false directly in the teardown loop and do not route through enemy_kill.

Entry points

  • spawnBoss(defId, game, world, kind = 'mini') — full encounter spawn. Throws on unknown defId or missing bossRoom. kind selects the stat tier (mini uses base def stats; boss doubles HP and per-body damage via BOSS_KIND_*_BOSS mults). The depth multiplier (1.0× → 1.5× linear across the run sequence) is applied on top of the kind base.
  • onBossEncounterEnd(reason, game, world) — single teardown funnel branched by reason:
    • 'win': fires boss_kill, runs the supply-pod cascade(s) at the resolved death anchor, records telemetry, sets _bossLevelCleared on run-sequence boss levels, materializes the legacy portal otherwise, clears encounter state.
    • 'player_death': skips the win signal and cascade, leaves _cullOutsideArena bullets to expire naturally, clears encounter state.
    • 'level_exit': silent teardown (player walked through the portal); culls roster, removes arena bullets, clears state, no win payoff.
  • getSharedBossVfxKit() — returns the module-singleton sharedKit. Used by the respawn_as affix runtime when invoking def.onPhaseChanged so the rebind targets the same VFX layer kit that def.setupVfx originally received.

Pattern notes

  • Lifecycle contract — caller sets game.bossRoom first, then calls spawnBoss. Encounter state lives on GameState (bossArena, bossSpawnProfile, bossEncounterTime, _activeBossDefId) and is fully owned by this module from spawn through teardown. Every exit reason clears the same fields so subsequent encounters never inherit stale state.
  • HP and damage scaling — two multipliers stack: the kind base (mini = 1×, boss = 2× for both HP and per-body damage) and the depth ramp (linear 1.0× → 1.5× across the run’s level count, using CHALLENGE_LEVEL_SEQUENCE.length when runDef.context.isChallenge is true, else RUN_LEVEL_SEQUENCE.length). HP is written directly to hp and hpMax; damage is multiplied into any prior damageMult expando so charger / melee / projectile damage paths all pick it up.
  • isBoss anchor displayName override — even when a roster entry has its own displayName (e.g. “CITRINE”, “MARCO-A”), the isBoss-flagged entry has its name forced to def.displayName. The HUD bar’s fallback path looks up the isBoss anchor’s displayName when the game._activeBossDefId lookup misses, so this match keeps both surfacing routes consistent.
  • Gate group surfacegateGroupId is read from entry.affixState?.gateGroup?.gateGroupId (a synthetic key on the roster entry) and stamped directly on the enemy. The gated filter on isBoss hosts (e.g. Pierre) then checks each sibling’s gateGroupId directly without walking sibling affix arrays. Carriers (Marcos) don’t need a gated affix — only the identifying field.
  • Win-anchor priority chainlastBossX/Y resolves through arena center → any still-alive isBoss enemy → _lastBossDeathX/Y snapshot from damage.ts. The snapshot has the highest priority because it correctly handles asymmetric rosters where the final death is a non-isBoss sharer (e.g. Prism Cluster’s Topaz). The mid-fall-through to still-alive isBoss handles player_death and level_exit teardowns where bodies are still up.
  • Double boss_kill firedamage.ts fires boss_kill on the last sharer’s death; this module fires it again on win. The contract is that listeners must be idempotent so a system with a single-fire entry point still sees the encounter close exactly once at the right moment.
  • Cascade composition — supply-pod cascade reuses the prop-pool primitive already used by the broken-pod path. Mini-tier fires 1×, boss-tier fires 2× with the second cascade offset by (rand-0.5)*80 on both axes so the rings don’t visually stack. def.rewardCascadeTypes overrides the default cascade composition list (SUPPLY_POD_CASCADE_TYPES) per boss when present.
  • Bullet cull policy_cullOutsideArena-tagged enemy bullets are removed on win and level_exit so the field clears immediately, but left on player_death so they persist for their natural lifetime (spec §6).
  • Position resolutionresolveRosterPositions handles 'center', 'cardinal' (arena cardinalPoints(0.42), wraps modulo when count exceeds 4), 'ring' (arena ringPoints(count, 0.6)), 'random' (per-spawn arena.randomPoint()), and object-literal offsets (arena-relative, added to arena.cx / arena.cy). Object-literal positions are arena-RELATIVE — a data file writing {x: -180, y: 0} means “180 units left of arena center” — so boss data never needs the world position of the arena.
  • Exhaustiveness guardBossSpawnPosition is exhausted with a never-typed fallthrough; adding a new variant without handling it is a compile-time error backed by a runtime throw.
  • aiId mappingmapAiIdToBehavior translates roster aiId strings to the existing enemy behavior strings the runtime dispatcher recognizes (kite_playerkite, hold_center / formation_lockstatic, orbit_centerorbit, patrol_random / disperse_to_fillpatrol). Unknown ids throw rather than silent-fallback so data-table bugs surface immediately.
  • VFX kit singletonsharedKit is a single instance created at module load and reused for every encounter. Both def.setupVfx (during spawnBoss) and out-of-module hooks (via getSharedBossVfxKit()) bind layers against the same kit so phase-change rebinds replace rather than duplicate.
  • Run-sequence vs legacy win path — on mini_boss / boss level kinds the level is the boss fight, so the win path sets _bossLevelCleared = true and returns without materializing a portal — bridge.ts then advances the run or finishes it on the final boss. On any other level kind (dev/playground scenarios), the legacy path materializes a portal at the death anchor as long as bossRoom exists and portal is still null.
  • Profile reset bookendsresetBossSpawnProfile() is called both during spawnBoss (after arming the profile) and during onBossEncounterEnd so fire-count bookkeeping inside the spawn-profile module is clean across the boundary.