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 therespawn_asaffix runtime invokingdef.onPhaseChanged) rebind layers against the same kit instance the boss’ssetupVfxused.- Module-level
sharedKit— the singleton VFX layer kit instance, created at module load viacreateVfxLayerKit()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 againstRUN_LEVEL_SEQUENCEorCHALLENGE_LEVEL_SEQUENCE). - Per-body roster stamping:
isBoss,sharesHealthWithBoss,untargetable,displayName(forced todef.displayNameon theisBossanchor so the HUD bar’s fallback path surfaces the def name),barColor,affixes(clonedaffixStateper id),abilities(freshcreateAbilityInstanceper id),gateGroupId(fromaffixState.gateGroup.gateGroupIdif present, stamped directly so thegatedfilter doesn’t have to walk sibling affix arrays),hp/hpMax,damageMult(multiplied into any prior value),behavior(mapped fromaiId). - Win-path payoff: extra
boss_killsignal 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), telemetryrecordDirectorPhase('boss_kill:supply_pod_cascade_<kind>', totalSpawned), optional per-defrewardCascadeTypescomposition override. - Win-VFX anchor priority resolution: last-sharer death snapshot (
_lastBossDeathX/Yset bydamage.ts) → any still-aliveisBossenemy → arena center. - Teardown: cull pass over
world.enemies(kills anyisBoss/sharesHealthWithBoss/_isBossAnchor), conditional removal of_cullOutsideArena-tagged enemy bullets (only onwinandlevel_exit, notplayer_death), terrain-pattern cull, VFX layer clear, background-loop stop. - Reset of encounter-pointer state on
GameState:bossArena,bossSpawnProfile,bossEncounterTime,_activeBossDefId,_pendingBarDamage,_lastBossDeathX/Ysnapshot. - Run-sequence win handling: sets
_bossLevelCleared = trueonmini_boss/bosslevels (no portal — the level is the fight) versus legacy portal materialization at the death anchor on other level kinds. - Helpers:
resolveRosterPositions(translatesBossSpawnPositionliterals into{x, y}arrays),mapAiIdToBehavior(translates rosteraiIdstrings to runtimebehaviorstrings).
READS FROM
BOSS_DEFSregistry (../../data/bosses) — looked up bydefId; missing key throws.BossDefshape —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.BossRosterEntryfields —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_SEQUENCEfromdata/level-progression.GameState.bossRoom— must be set by the caller beforespawnBoss; missing throws.GameState._currentLevel,runDef.context.isChallenge,_levelKind,time— drive depth scaling, run vs challenge length, run-sequence win path, and portalspawnTime.GameState._lastBossDeathX/Y— snapshot set bydamage.tswhen 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 aliveisBoss) and walked during cull.WorldState.enemyBullets— walked for_cullOutsideArena-tagged removal on non-player_deathexits.shipsingleton from../core/state— passed toGameMaster.spawnEnemyas the spawn reference.createBossArena(room)from./arena— wraps the room into theBossArenahelper.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 viaGameMaster.spawnEnemyfor each resolved roster position; each spawned enemy is then stamped with the per-body fields listed in OWNS. Teardown setsalive = falseandhp = 0on every matching anchor body.world.enemyBullets— spliced for any bullet with_cullOutsideArena === trueonwin/level_exitteardown.GameState.bossArena— set to the newly created arena on spawn; nulled on teardown.GameState.bossSpawnProfile— set todef.spawnProfileId ?? 'none'on spawn; nulled on teardown.GameState.bossEncounterTime— zeroed on spawn; zeroed again on teardown.GameState._activeBossDefId— set todef.idon spawn; nulled on teardown.GameState._pendingBarDamage— zeroed on teardown.GameState._lastBossDeathX/Y— set toundefinedon teardown so stale snapshots don’t leak into the next encounter.GameState._bossLevelCleared— set totrueon win when_levelKindismini_bossorboss; signalsbridge.tsto advance the run or finish on the final boss.GameState.portal— materialized as aclosing→materializedchamber at the resolved death anchor on legacy win paths (non-mini_boss/ non-bosslevels) whenbossRoomis non-null andportalis null.Sig.fire('boss_kill', 0, 0, def.reward.xp, def.reward.currency, def.id)on everywin.getPropPool().triggerSupplyPodCascade(x, y, customTypes)— fired once for mini, twice with jitter for boss-tier, on everywin.telemetry.recordDirectorPhase('boss_kill:supply_pod_cascade_<kind>', totalSpawned)on everywin.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 whendef.backgroundLoopis 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 setgame.bossRoom(closing-circle or sealed-rect) before invokingspawnBoss; 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
damageMultexpando 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/bossesanddata/level-progression. - Does not silently fall back on unknown
defIdor unknownaiId— both throw, matching the crash-loud-on-bad-data stance for internal config. - Does not catch unknown
BossSpawnPositionvariants silently — thenever-typed exhaustiveness guard throws. - Does not cull boss-fired bullets on
player_deathteardown — 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/bosslevel kinds — those levels signal_bossLevelClearedand letbridge.tsadvance the run. - Does not materialize a portal when
game.portalis already non-null (legacy path guard). - Does not own the
_lastBossDeathX/Ysnapshot —damage.tswrites it when the bar hits zero; this module only reads and then clears it. - Does not deduplicate
boss_kill—damage.tsalready fires it on the final sharer’s death and this module fires again onwin; listeners must be idempotent.
Signals
- Outbound —
Sig.fire('boss_kill', 0, 0, def.reward.xp, def.reward.currency, def.id)on everywin. This is a second fire routed through encounter end so reward systems with a single contract see the encounter close exactly once;damage.tsalready firesboss_killon the final sharer’s death. Listeners are required to be idempotent. - Inbound — none watched directly. The encounter relies on the
_lastBossDeathX/Ysnapshot thatdamage.tswrites when the boss bar reaches zero, but consumes it viaGameStaterather than a signal. - Indirect downstream —
GameMaster.spawnEnemyis the canonical spawn path for every roster body, so normal enemy-spawn signals fire through that for each one. Anchor bodies setalive = falsedirectly in the teardown loop and do not route throughenemy_kill.
Entry points
spawnBoss(defId, game, world, kind = 'mini')— full encounter spawn. Throws on unknowndefIdor missingbossRoom.kindselects the stat tier (mini uses base def stats; boss doubles HP and per-body damage viaBOSS_KIND_*_BOSSmults). 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 byreason:'win': firesboss_kill, runs the supply-pod cascade(s) at the resolved death anchor, records telemetry, sets_bossLevelClearedon run-sequence boss levels, materializes the legacy portal otherwise, clears encounter state.'player_death': skips the win signal and cascade, leaves_cullOutsideArenabullets 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-singletonsharedKit. Used by therespawn_asaffix runtime when invokingdef.onPhaseChangedso the rebind targets the same VFX layer kit thatdef.setupVfxoriginally received.
Pattern notes
- Lifecycle contract — caller sets
game.bossRoomfirst, then callsspawnBoss. Encounter state lives onGameState(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.lengthwhenrunDef.context.isChallengeis true, elseRUN_LEVEL_SEQUENCE.length). HP is written directly tohpandhpMax; damage is multiplied into any priordamageMultexpando so charger / melee / projectile damage paths all pick it up. isBossanchor displayName override — even when a roster entry has its owndisplayName(e.g. “CITRINE”, “MARCO-A”), theisBoss-flagged entry has its name forced todef.displayName. The HUD bar’s fallback path looks up theisBossanchor’sdisplayNamewhen thegame._activeBossDefIdlookup misses, so this match keeps both surfacing routes consistent.- Gate group surface —
gateGroupIdis read fromentry.affixState?.gateGroup?.gateGroupId(a synthetic key on the roster entry) and stamped directly on the enemy. Thegatedfilter onisBosshosts (e.g. Pierre) then checks each sibling’sgateGroupIddirectly without walking sibling affix arrays. Carriers (Marcos) don’t need agatedaffix — only the identifying field. - Win-anchor priority chain —
lastBossX/Yresolves through arena center → any still-aliveisBossenemy →_lastBossDeathX/Ysnapshot fromdamage.ts. The snapshot has the highest priority because it correctly handles asymmetric rosters where the final death is a non-isBosssharer (e.g. Prism Cluster’s Topaz). The mid-fall-through to still-aliveisBosshandlesplayer_deathandlevel_exitteardowns where bodies are still up. - Double
boss_killfire —damage.tsfiresboss_killon the last sharer’s death; this module fires it again onwin. 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)*80on both axes so the rings don’t visually stack.def.rewardCascadeTypesoverrides the default cascade composition list (SUPPLY_POD_CASCADE_TYPES) per boss when present. - Bullet cull policy —
_cullOutsideArena-tagged enemy bullets are removed onwinandlevel_exitso the field clears immediately, but left onplayer_deathso they persist for their natural lifetime (spec §6). - Position resolution —
resolveRosterPositionshandles'center','cardinal'(arenacardinalPoints(0.42), wraps modulo when count exceeds 4),'ring'(arenaringPoints(count, 0.6)),'random'(per-spawnarena.randomPoint()), and object-literal offsets (arena-relative, added toarena.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 guard —
BossSpawnPositionis exhausted with anever-typed fallthrough; adding a new variant without handling it is a compile-time error backed by a runtime throw. aiIdmapping —mapAiIdToBehaviortranslates rosteraiIdstrings to the existing enemybehaviorstrings the runtime dispatcher recognizes (kite_player→kite,hold_center/formation_lock→static,orbit_center→orbit,patrol_random/disperse_to_fill→patrol). Unknown ids throw rather than silent-fallback so data-table bugs surface immediately.- VFX kit singleton —
sharedKitis a single instance created at module load and reused for every encounter. Bothdef.setupVfx(duringspawnBoss) and out-of-module hooks (viagetSharedBossVfxKit()) bind layers against the same kit so phase-change rebinds replace rather than duplicate. - Run-sequence vs legacy win path — on
mini_boss/bosslevel kinds the level is the boss fight, so the win path sets_bossLevelCleared = trueand returns without materializing a portal —bridge.tsthen 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 asbossRoomexists andportalis still null. - Profile reset bookends —
resetBossSpawnProfile()is called both duringspawnBoss(after arming the profile) and duringonBossEncounterEndso fire-count bookkeeping inside the spawn-profile module is clean across the boundary.