PURPOSE
Enemy spawn director (the GameMaster singleton). Owns all enemy spawning: kill-cap scaling, ramp/quality curves, trickle accumulator, periodic wave bursts, velocity-biased directional spawning, elite-pack composition, racer lane groups, and the spawn queue with per-spawn stagger. Replaces the legacy bridge-side spawn timer — bridge.ts calls GameMaster.tick() once per frame and the spawner decides what, when, and where.
OWNS
GameMastersingleton (state object literal):totalEnemiesSpawned,activeEnemyCount,spawnQueue,_ramp,_targetOnScreen,_trickleAccum,_waveTimer,_waveCount,_telegraphFired,_flankTimer,_retreatTimer,_eliteTimer,_posSamples,_posSampleTimer,_lastQueueSpawnTime.- Configured-curve state set by
reset():_quantityCurve,_qualityCurve,_curveRandomization,_enemySetId,_biomeId,_forceTypeId,_levelData,_zoneSpawnRateMult,_recentDamage,_recentDamageTimer. - Enemy object recycle bin
_enemyRecycleBin(capped atMAX_RECYCLE_BIN = 256) and the helpersreleaseEnemy()(returns dead enemies to the bin after clearing leak-risk fields) andgetEnemyBinSize()(telemetry probe). - Spawn SFX throttle (
_lastSpawnSfxTime,SPAWN_SFX_COOLDOWN_MS = 300). - Kill-cap table
KILL_CAP_TABLE(7 entries, 0 kills → cap 120 through 2000 kills → cap 600) andgetKillCap()interpolator. - Per-run difficulty ramp constants (
PER_RUN_RAMP_KILLS_PER_STEP, spawn/HP ramp start + per-step) andcomputePerRunRamp(). - Basics-only-window gate (
BASICS_ONLY_TIER_SECONDS = 60,isBasicsOnlyPhase()). - Power curve
internalRamp()((t/RAMP_DURATION)^RAMP_EXPONENT,RAMP_DURATION = 210,RAMP_EXPONENT = 1.8) andevalCurve()for tent-pole curve interpolation with optionalrandomizejitter. - Wave config (
WAVE_MIN_TIME = 25,WAVE_MIN_PROGRESS = 0,WAVE_BASE_INTERVAL = 15,WAVE_MIN_INTERVAL = 8,WAVE_INTERVAL_DECAY = 3). - Flank config (
FLANK_INTERVAL,FLANK_COUNT) — values declared but flank maneuvers are disabled intick. - Position-history sampling (
POS_SAMPLE_INTERVAL = 0.5,POS_SAMPLE_COUNT = 10). - Boss-spawner gating helpers:
hasAliveBossEnemy()(file-private) andsyncBossSpawnerDisabled()(exported). SpawnQueueEntryinterface (typeId,x,y, optionalspawnTimeanddelaySpawn).- Re-exports
tickBossSpawnProfileandresetBossSpawnProfilefrom./boss-spawn-profile. - Test-compat exports
spawnEnemy(world, ship, typeId, x, y),spawnHorde(world, ship, count, x, y)(spawnscharger_commonin a 50px ring), andupdateSpawner(world, ship, game, dt)(runsprocessQueue+updateCount).
READS FROM
../core/types—WorldState,ShipState,GameStatetypes.../core/state— thegamesingleton (imported asgameState);spawnEnemyreads HP/damage/speed multipliers and timers off it (worldKnobs.enemyHpMult,enemySpeedMult,enemyDamageMult,missionTimerMax,missionTimer,missionElapsed,enemyDifficultyLevel,level,stats.totalKills,_hardMode,_currentLevel).../core/clock—Clock.now()(queue spawn timestamps, SFX throttle) andClock.uniqueNow()(flock-id seed)../behaviors—rollPersonality(typeId)(merged into the spawned enemy template),resolveArchetype(behavior, typeId), andEnemyBehaviors.get(behavior)(cached onenemy._beh).../../data/enemies—ENEMY_TYPE_MAP(per-type stats),HULL_SHAPE_MAP,getSpawnPool/getSpawnPoolForSet,pickEliteForProgress/pickEliteForSet,SET_SWARM_ARCHETYPE, and theEnemySetIdtype.../../data/planet-config—PLANETS[world.planetId].spawnGraceSecondsfor the early-trickle grace window (Void = 0).../world/generation—WorldGenerator(import only; not invoked from this file).../core/frame-cache—getAliveEnemyCount()forupdateCount().../audio/micro-sfx—MicroSfx.play('enemy_spawn')on each successful spawn (subject to the SFX throttle).../world/wave-telegraph—fireWaveTelegraph,WAVE_TELEGRAPH_LEAD,BIG_WAVE_THRESHOLDfor the pre-wave warning.../affixes/roll—rollEliteAffixes(rarity, rng, undefined, archetype)for elite leaders../lanes—RacerLanetype (consumed viaworld._racerLanes)../zone-spawn-adapter—querySpawnZone,pickFromPool,computeDirectorZoneOutputsfor level-zone routing.../world/chunk-manager—LevelDatatype.WorldState:world.enemies(read for boss-alive check, alive-count snapshot, push on spawn),world.planetId,world._racerLanes(cast throughany),world._hubsIlluminated(cast throughany).ShipState:ship.x,ship.y,ship.vx,ship.vy,ship.angle,ship.hp,ship.hpMax.GameState:progress,heat,overtime,time,level,enemyDifficultyLevel,missionElapsed,missionTimer,missionTimerMax,_currentLevel,_hardMode,_bossSpawnerDisabled,bossRoom,stats.totalKills,worldKnobs.enemyCountMult/enemyHpMult/enemySpeedMult/enemyDamageMult,runDef.context.facilities.eliteRarityCap.
PUSHES TO
world.enemies.push(enemy)for every spawn (inspawnEnemy).GameMaster.spawnQueue.push(...)viaqueueSpawn(typeId, x, y, delay)(entries carryClock.now() + delay * 1000asspawnTime)._enemyRecycleBinpush inreleaseEnemy, pop inspawnEnemy(reused object reset viaObject.assignand explicit zero-outs).GameMaster._posSamplesshifted/pushed as a 10-slot rolling history (sampled everyPOS_SAMPLE_INTERVALseconds).game._bossSpawnerDisabledset bysyncBossSpawnerDisabledwhenever the alive-boss-enemy state changes (called fromtickend and at the tail ofspawnEnemy/updateCount).MicroSfx.play('enemy_spawn')(cooldown-gated).fireWaveTelegraph(projectedWaveSize)from inside the wave block when_waveTimerfalls into the telegraph lead window and the projected wave size clearsBIG_WAVE_THRESHOLD.totalEnemiesSpawned++andactiveEnemyCount++on every successfulspawnEnemy.- Mutates per-enemy fields after creation (e.g.
_packLeaderRef,_rarityOverride,_spawnT,_racerLane,_racerWaypointIdx,angle).
DOES NOT
- Does not update enemy AI, movement, weapon firing, or death cleanup — those live in
behaviors.ts,bridge.ts, and adjacent enemy modules.spawnEnemyonly writes initial state and caches the behavior handler on_beh. - Does not despawn or prune live enemies. The retreat system was removed; the flank-maneuver block is also disabled (constants remain but
tickno longer reads them). On-screen enemies stay alive until killed elsewhere. - Does not drive bosses directly. Boss spawn-profile logic is re-exported from
./boss-spawn-profile; this file only gates the regular spawner when a boss enemy is alive viasyncBossSpawnerDisabled. - Does not perform enemy rendering, atlas selection, or HP-bar drawing.
- Does not roll affixes for non-elites. Followers inherit only
_rarityOverride; pack leaders are the only enemies withaffixespopulated (viarollEliteAffixes). Bosses skip world-spawner affix rolling entirely. - Does not enforce a hard activity cap beyond
getKillCap+ per-frame target math; force-fill above 50% deficit is the only place it actively backfills, and even that is bounded byeffectiveTarget - aliveand an 8-enemy cap. - Does not free recycled-bin slots — the bin only grows up to
MAX_RECYCLE_BIN; further releases are dropped on the floor.
Signals
- Spawn whoosh
MicroSfx.play('enemy_spawn')— throttled to one per 300 ms across all enemies, even on burst spawns. fireWaveTelegraph(projectedWaveSize)— screen-tint, edge-vignette, and audio sting fired 1.5 s (=WAVE_TELEGRAPH_LEAD) before a wave whose projected size meetsBIG_WAVE_THRESHOLD. Re-armed via_telegraphFired = falseafter the wave actually spawns.syncBossSpawnerDisabledtogglesgame._bossSpawnerDisabled— read by other systems to suppress regular spawns while a boss enemy is alive; intentionally never unsets the flag whilegame.bossRoomis true with no boss alive yet (bridge.ts pins it manually in that window).
Entry points
GameMaster.tick(world, ship, game, dt)— main per-frame driver. Order inside the tick: bucket-decay recent damage → recompute_ramp(curve orinternalRamp) and_targetOnScreenfrom kill-cap × multipliers × density shaping × global mult × ease × zone mult → onboarding clamp for level 1 → push player-position sample → early-trickle branch (first 60 s afterplanetGrace, guarantees ≥1 alive, queues with 0.1 s stagger) → post-60 s trickle (_spawnDirectional(...,1)) → wave block (telegraph fire-check, then size =min(16, floor(4 + ramp×12×countMult)), spawned if deficit > 0) → elite-pack timer (gated by_eliteProgressGateand!isBasicsOnlyPhase) → min-population force-fill at <50% target (up to 8 at once) →processQueue→updateCount.GameMaster.spawnEnemy(world, ship, typeId, x, y, arrivalAngle?)— single-entity spawn. EnforcesMIN_SPAWN_DIST = 450px (pushes outward if requested point is too close). Computes HP via the chaindef.hp × hpMult × 2.573 × earlyGameEase × peakHpMultwherehpMult = enemyHpMult × hpIntraLevel × (0.6 + tierEaseProgress × 0.4) × levelStatMult × perRunHpRamp; speed viadef.speed × enemySpeedMult × 1.2; damage viaenemyDamageMult × dmgIntraLevel × dmgReduction × levelStatMult. Pulls from_enemyRecycleBin(or{}) andObject.assigns the spawn template (see Pattern notes). Zero-outs expando fields, caches_beh = EnemyBehaviors.get(behavior), sets initialangletoward ship, pushes toworld.enemies, increments counters, callssyncBossSpawnerDisabled, fires throttled SFX, returns the enemy (ornulliftypeIdis falsy).GameMaster.queueSpawn(typeId, x, y, delay = 0)— appends tospawnQueuewithspawnTime = Clock.now() + delay × 1000.GameMaster.processQueue(world, ship, dt)— at most one queued spawn per frame, gated by a 100 ms global rate limit (_lastQueueSpawnTime). Walks the queue back-to-front, spawns the first ready entry, and does an O(1) swap-and-pop removal.GameMaster.updateCount(world)— refreshesactiveEnemyCountfromgetAliveEnemyCount()and re-syncs the boss-spawner flag.GameMaster._spawnDirectional(world, ship, game, count)— batch spawner. Computes movement angle from oldest→current_posSamples(>10 px displacement; falls back toship.angle), distance =350 + min(750, currentSpeed × 1.5)px, packs of base 2–4 scaled by0.25 + min(1.25, timeMins/5 × 1.25); 70 % chance the pack angle is within ±45° of movement, 30 % random. Behavior overrides: chargers and shooters always solo, orbs and gunners spawn flocks of 4–14 (sharedflockId), racers redirect to_spawnRacerGroup. Per-member jitter 30–60 px from pack center, queued with 0.1 s stagger.GameMaster._spawnRacerGroup(world, ship, typeId)— picks a randomworld._racerLaneslane and direct-spawns 3–5 racers staggered along the waypoints, assigning_racerLane,_racerWaypointIdx, and angle toward the next waypoint.GameMaster._spawnElitePack(world, ship, game)— short-circuits when_forceTypeIdis set. Picks(leaderId, followerArchetype, eliteRarity)frompickEliteForSet/pickEliteForProgressclamped byrunDef.context.facilities.eliteRarityCap. Direct-spawns leader at 400–600 px, marks_isPackLeader, sets_rarityOverride, scales radius (1.17× or 1.35× legendary), HP × 3.5, XP × 3.5,damageMult × 1.6,orbitRadius × {1.3, 1.5, 1.8}by rarity, disables aggro, then callsrollEliteAffixes(eliteRarity, Math.random, undefined, leaderArchetype)whereleaderArchetype = typeId.split('_')[0](undefined for boss-roster types without the_raritysuffix). Spawns 2–6${followerArchetype}_commonbodyguards ringed around the leader, each with_packLeaderRef, inherited_rarityOverride, staggered_spawnT = 0.3 + i × 0.1.GameMaster._pickTypeForZone(x, y, progress)— when_levelDatais set, queries the zone at the spawn point and picks from the zone-specific pool viapickFromPool; otherwise falls back topickTypeForProgress.GameMaster.pickTypeForProgress(progress)— dev_forceTypeIdshort-circuit first. Otherwise selects pool viagetSpawnPoolForSet(_enemySetId, progress)when an enemy set is configured, elsegetSpawnPool(progress). During the basics-only window (isBasicsOnlyPhase), filters the pool to types starting withSET_SWARM_ARCHETYPE[_enemySetId] + '_'; falls back to the unfiltered pool if the filter empties it. Returns a uniform random pick.GameMaster.reset(spawnConfig?, biomeId?, enemySetId?, levelData?)— wipes all runtime state, installs curves (quantityCurve,qualityCurve,curveRandomization) and zone/biome/enemy-set context, sets timers to their initial values (_waveTimer = WAVE_BASE_INTERVAL,_eliteTimer = 25,_flankTimer = 8).GameMaster.getRamp(),GameMaster.getTargetCount()— read-only accessors for HUD/debug.GameMaster.getDifficultyMult(elapsed)andGameMaster.getEnemyCap(totalKills)— legacy-compat passthroughs tointernalRampandgetKillCap.syncBossSpawnerDisabled(world, game)— file-level export used at the tail ofspawnEnemyandupdateCountand intended as the public sync hook for other systems.getEnemyBinSize()— recycle-bin size probe for the per-framediagSetPoolStatstelemetry.releaseEnemy(enemy)— public hook for kill cleanup. Drops the enemy on the floor if the bin is already atMAX_RECYCLE_BIN; otherwise nulls refs (_pendingXpGame,_packLeaderRef,_enemyProxy,_hitscanLines,_sniperBeam,_typeDef,_racerLane,_pathWP), clears boss/affix/ability state, wipes_statusessoenemy-status.tsre-lazy-allocates on next apply, and resets behavior lazy-init fields (_angVel,_prevAngle,_aoeCooldown,_fishDir,_gunnerMarchTimer,_fieldPhase,_fieldTargetX,_fieldTargetY) back toundefinedso behavior=== undefinedinit guards re-fire.- Test-compat exports
spawnEnemy(...),spawnHorde(...)(charger ring),updateSpawner(...)(queue + count). - Re-exports
tickBossSpawnProfile,resetBossSpawnProfilefrom./boss-spawn-profile.
Pattern notes
_spawnTinitialization. Every spawned enemy gets_spawnT = 0.3plus a fixed_spawnDur = 0.3in theObject.assigntemplate — a 0.3 s opacity fade-in driven elsewhere. There is no spawn invulnerability; enemies are targetable from frame 1. Elite pack followers override the value after spawn to_spawnT = _spawnDur = 0.3 + i × 0.1so they phase in one-by-one._spawnPopT = 0is a separate 0.12 s squash-stretch counter triggered when_spawnThits zero.- Pool recycling.
spawnEnemypops from_enemyRecycleBinwhen available (pop()is O(1)) and falls back to{}for fresh allocations. Reused objects go throughObject.assign(enemy, {...})with the full spawn template, plus a separate block of explicit zero-outs for expando fields the template does not list (e.g._pendingXpCount,_explodeDebrisNow,_deathVfxT,_chargerPhase,_orbPhase,_mortarRingActive). The template predeclares behavior hot fields (_pathAge,_strafeTimer,_jitterAngle,_pulseTimer, gunner/field/sniper FSM state, etc.) so the V8 hidden class is stable across all recycled instances.releaseEnemymirrors this discipline on the death path — every ref-holding field is nulled to prevent leaks; lazy-init behavior fields are set back toundefined(notnull) because behaviors check=== undefinedfor first-time init. The bin caps at 256 to bound memory across long runs; surplus releases are silently dropped. - Affix rolling. Only elite pack leaders get affixes. Inside
_spawnElitePack, after leader stat buffs,leader.affixes = rollEliteAffixes(eliteRarity, Math.random, undefined, leaderArchetype)is called withleaderArchetype = leader.typeId.split('_')[0](undefined for boss-roster types likecaimanthat lack the_raritysuffix, in which case the roller falls back to a uniform distribution). The third argument (currentlyundefined) lets the roller skip excluded affixes; it is reserved for boss-affix exclusion. Rarity bands: rare → 0–1, epic → 1–2, legendary → 2–3 (interpreted byrollEliteAffixes). Followers stay vanilla (affixes: []from the template) so packs read as “deadly leader plus grunts.” TherarityCapfromrunDef.context.facilities.eliteRarityCaponly clamps the maximum rarity; it does not prevent elites from spawning entirely. - Boss anchor recycling.
releaseEnemyexplicitly clears_isBossAnchor,gateGroupId, andphaseIndexbecause the bosses-as-enemies path lets former Junkrat anchors land in the recycle bin; without the clears, a recycled anchor inherited into a normal trash mob would corrupt the next encounter’sgatedaffix orrespawn_asphase logic. - Queue rate limit and stagger.
processQueueenforces a single global 100 ms rate limit (_lastQueueSpawnTime) so even simultaneously queued packs stream in one-per-frame. The per-pack stagger (j × 0.1seconds in_spawnDirectional,i × 0.1in early trickle) is layered on top via the queue entry’sspawnTime. The queue walks back-to-front and does swap-and-pop removal to keep it O(1). - Density math separation.
_ramp(from the configuredquantityCurveorinternalRamp) and the time/kill-basedcombinedRampare separate signals._rampfeedsgetKillCap-style scaling and wave-size projection;combinedRampshapes the trickle base count viabaseLow * swellMult * densityShape. Both compound into the final_targetOnScreen. - HP and count ramp compounding. Per-run ramps (
perRunSpawnRamp,perRunHpRamp) start low (10 % / 55 %) and climb in 50-kill steps. They multiply the worldKnobs / intra-level / peak chains so all the other scaling compounds normally on top. Hard-mode levels (_hardMode) pin both per-run ramps and the tier-start HP ease at 1.0, so hard tiers play as “peak” the entire 4 minutes. - Boss-room gate semantics.
syncBossSpawnerDisabledonly toggles_bossSpawnerDisabledwhen a boss enemy is actually alive or when the game is not in a boss room. The bridge keeps the flag pinned manually during the “closing-room declared but boss not yet spawned” window — this helper intentionally early-returns to avoid clobbering that manual hold. - Min-spawn-distance push.
spawnEnemyenforcesMIN_SPAWN_DIST = 450regardless of caller. If the requested point is closer than that, it is pushed outward along the existing angle (or a random angle if exactly on top of the ship), so individualspawnEnemycalls cannot land an enemy in the player’s lap. - Forced-type dev mode. Setting
_forceTypeIdshort-circuits bothpickTypeForProgress(returns the forced id) and_spawnElitePack(early-return). Used by the weapon-gauntlet dev scenario to lock spawns to a single archetype.