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

  • GameMaster singleton (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 at MAX_RECYCLE_BIN = 256) and the helpers releaseEnemy() (returns dead enemies to the bin after clearing leak-risk fields) and getEnemyBinSize() (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) and getKillCap() interpolator.
  • Per-run difficulty ramp constants (PER_RUN_RAMP_KILLS_PER_STEP, spawn/HP ramp start + per-step) and computePerRunRamp().
  • 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) and evalCurve() for tent-pole curve interpolation with optional randomize jitter.
  • 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 in tick.
  • Position-history sampling (POS_SAMPLE_INTERVAL = 0.5, POS_SAMPLE_COUNT = 10).
  • Boss-spawner gating helpers: hasAliveBossEnemy() (file-private) and syncBossSpawnerDisabled() (exported).
  • SpawnQueueEntry interface (typeId, x, y, optional spawnTime and delaySpawn).
  • Re-exports tickBossSpawnProfile and resetBossSpawnProfile from ./boss-spawn-profile.
  • Test-compat exports spawnEnemy(world, ship, typeId, x, y), spawnHorde(world, ship, count, x, y) (spawns charger_common in a 50px ring), and updateSpawner(world, ship, game, dt) (runs processQueue + updateCount).

READS FROM

  • ../core/typesWorldState, ShipState, GameState types.
  • ../core/state — the game singleton (imported as gameState); spawnEnemy reads HP/damage/speed multipliers and timers off it (worldKnobs.enemyHpMult, enemySpeedMult, enemyDamageMult, missionTimerMax, missionTimer, missionElapsed, enemyDifficultyLevel, level, stats.totalKills, _hardMode, _currentLevel).
  • ../core/clockClock.now() (queue spawn timestamps, SFX throttle) and Clock.uniqueNow() (flock-id seed).
  • ./behaviorsrollPersonality(typeId) (merged into the spawned enemy template), resolveArchetype(behavior, typeId), and EnemyBehaviors.get(behavior) (cached on enemy._beh).
  • ../../data/enemiesENEMY_TYPE_MAP (per-type stats), HULL_SHAPE_MAP, getSpawnPool / getSpawnPoolForSet, pickEliteForProgress / pickEliteForSet, SET_SWARM_ARCHETYPE, and the EnemySetId type.
  • ../../data/planet-configPLANETS[world.planetId].spawnGraceSeconds for the early-trickle grace window (Void = 0).
  • ../world/generationWorldGenerator (import only; not invoked from this file).
  • ../core/frame-cachegetAliveEnemyCount() for updateCount().
  • ../audio/micro-sfxMicroSfx.play('enemy_spawn') on each successful spawn (subject to the SFX throttle).
  • ../world/wave-telegraphfireWaveTelegraph, WAVE_TELEGRAPH_LEAD, BIG_WAVE_THRESHOLD for the pre-wave warning.
  • ../affixes/rollrollEliteAffixes(rarity, rng, undefined, archetype) for elite leaders.
  • ./lanesRacerLane type (consumed via world._racerLanes).
  • ./zone-spawn-adapterquerySpawnZone, pickFromPool, computeDirectorZoneOutputs for level-zone routing.
  • ../world/chunk-managerLevelData type.
  • WorldState: world.enemies (read for boss-alive check, alive-count snapshot, push on spawn), world.planetId, world._racerLanes (cast through any), world._hubsIlluminated (cast through any).
  • 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 (in spawnEnemy).
  • GameMaster.spawnQueue.push(...) via queueSpawn(typeId, x, y, delay) (entries carry Clock.now() + delay * 1000 as spawnTime).
  • _enemyRecycleBin push in releaseEnemy, pop in spawnEnemy (reused object reset via Object.assign and explicit zero-outs).
  • GameMaster._posSamples shifted/pushed as a 10-slot rolling history (sampled every POS_SAMPLE_INTERVAL seconds).
  • game._bossSpawnerDisabled set by syncBossSpawnerDisabled whenever the alive-boss-enemy state changes (called from tick end and at the tail of spawnEnemy / updateCount).
  • MicroSfx.play('enemy_spawn') (cooldown-gated).
  • fireWaveTelegraph(projectedWaveSize) from inside the wave block when _waveTimer falls into the telegraph lead window and the projected wave size clears BIG_WAVE_THRESHOLD.
  • totalEnemiesSpawned++ and activeEnemyCount++ on every successful spawnEnemy.
  • 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. spawnEnemy only 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 tick no 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 via syncBossSpawnerDisabled.
  • 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 with affixes populated (via rollEliteAffixes). 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 by effectiveTarget - alive and 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 meets BIG_WAVE_THRESHOLD. Re-armed via _telegraphFired = false after the wave actually spawns.
  • syncBossSpawnerDisabled toggles game._bossSpawnerDisabled — read by other systems to suppress regular spawns while a boss enemy is alive; intentionally never unsets the flag while game.bossRoom is 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 or internalRamp) and _targetOnScreen from 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 after planetGrace, 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 _eliteProgressGate and !isBasicsOnlyPhase) → min-population force-fill at <50% target (up to 8 at once) → processQueueupdateCount.
  • GameMaster.spawnEnemy(world, ship, typeId, x, y, arrivalAngle?) — single-entity spawn. Enforces MIN_SPAWN_DIST = 450 px (pushes outward if requested point is too close). Computes HP via the chain def.hp × hpMult × 2.573 × earlyGameEase × peakHpMult where hpMult = enemyHpMult × hpIntraLevel × (0.6 + tierEaseProgress × 0.4) × levelStatMult × perRunHpRamp; speed via def.speed × enemySpeedMult × 1.2; damage via enemyDamageMult × dmgIntraLevel × dmgReduction × levelStatMult. Pulls from _enemyRecycleBin (or {}) and Object.assigns the spawn template (see Pattern notes). Zero-outs expando fields, caches _beh = EnemyBehaviors.get(behavior), sets initial angle toward ship, pushes to world.enemies, increments counters, calls syncBossSpawnerDisabled, fires throttled SFX, returns the enemy (or null if typeId is falsy).
  • GameMaster.queueSpawn(typeId, x, y, delay = 0) — appends to spawnQueue with spawnTime = 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) — refreshes activeEnemyCount from getAliveEnemyCount() 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 to ship.angle), distance = 350 + min(750, currentSpeed × 1.5) px, packs of base 2–4 scaled by 0.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 (shared flockId), 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 random world._racerLanes lane 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 _forceTypeId is set. Picks (leaderId, followerArchetype, eliteRarity) from pickEliteForSet / pickEliteForProgress clamped by runDef.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 calls rollEliteAffixes(eliteRarity, Math.random, undefined, leaderArchetype) where leaderArchetype = typeId.split('_')[0] (undefined for boss-roster types without the _rarity suffix). Spawns 2–6 ${followerArchetype}_common bodyguards ringed around the leader, each with _packLeaderRef, inherited _rarityOverride, staggered _spawnT = 0.3 + i × 0.1.
  • GameMaster._pickTypeForZone(x, y, progress) — when _levelData is set, queries the zone at the spawn point and picks from the zone-specific pool via pickFromPool; otherwise falls back to pickTypeForProgress.
  • GameMaster.pickTypeForProgress(progress) — dev _forceTypeId short-circuit first. Otherwise selects pool via getSpawnPoolForSet(_enemySetId, progress) when an enemy set is configured, else getSpawnPool(progress). During the basics-only window (isBasicsOnlyPhase), filters the pool to types starting with SET_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) and GameMaster.getEnemyCap(totalKills) — legacy-compat passthroughs to internalRamp and getKillCap.
  • syncBossSpawnerDisabled(world, game) — file-level export used at the tail of spawnEnemy and updateCount and intended as the public sync hook for other systems.
  • getEnemyBinSize() — recycle-bin size probe for the per-frame diagSetPoolStats telemetry.
  • releaseEnemy(enemy) — public hook for kill cleanup. Drops the enemy on the floor if the bin is already at MAX_RECYCLE_BIN; otherwise nulls refs (_pendingXpGame, _packLeaderRef, _enemyProxy, _hitscanLines, _sniperBeam, _typeDef, _racerLane, _pathWP), clears boss/affix/ability state, wipes _statuses so enemy-status.ts re-lazy-allocates on next apply, and resets behavior lazy-init fields (_angVel, _prevAngle, _aoeCooldown, _fishDir, _gunnerMarchTimer, _fieldPhase, _fieldTargetX, _fieldTargetY) back to undefined so behavior === undefined init guards re-fire.
  • Test-compat exports spawnEnemy(...), spawnHorde(...) (charger ring), updateSpawner(...) (queue + count).
  • Re-exports tickBossSpawnProfile, resetBossSpawnProfile from ./boss-spawn-profile.

Pattern notes

  • _spawnT initialization. Every spawned enemy gets _spawnT = 0.3 plus a fixed _spawnDur = 0.3 in the Object.assign template — 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.1 so they phase in one-by-one. _spawnPopT = 0 is a separate 0.12 s squash-stretch counter triggered when _spawnT hits zero.
  • Pool recycling. spawnEnemy pops from _enemyRecycleBin when available (pop() is O(1)) and falls back to {} for fresh allocations. Reused objects go through Object.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. releaseEnemy mirrors this discipline on the death path — every ref-holding field is nulled to prevent leaks; lazy-init behavior fields are set back to undefined (not null) because behaviors check === undefined for 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 with leaderArchetype = leader.typeId.split('_')[0] (undefined for boss-roster types like caiman that lack the _rarity suffix, in which case the roller falls back to a uniform distribution). The third argument (currently undefined) 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 by rollEliteAffixes). Followers stay vanilla (affixes: [] from the template) so packs read as “deadly leader plus grunts.” The rarityCap from runDef.context.facilities.eliteRarityCap only clamps the maximum rarity; it does not prevent elites from spawning entirely.
  • Boss anchor recycling. releaseEnemy explicitly clears _isBossAnchor, gateGroupId, and phaseIndex because 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’s gated affix or respawn_as phase logic.
  • Queue rate limit and stagger. processQueue enforces a single global 100 ms rate limit (_lastQueueSpawnTime) so even simultaneously queued packs stream in one-per-frame. The per-pack stagger (j × 0.1 seconds in _spawnDirectional, i × 0.1 in early trickle) is layered on top via the queue entry’s spawnTime. The queue walks back-to-front and does swap-and-pop removal to keep it O(1).
  • Density math separation. _ramp (from the configured quantityCurve or internalRamp) and the time/kill-based combinedRamp are separate signals. _ramp feeds getKillCap-style scaling and wave-size projection; combinedRamp shapes the trickle base count via baseLow * 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. syncBossSpawnerDisabled only toggles _bossSpawnerDisabled when 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. spawnEnemy enforces MIN_SPAWN_DIST = 450 regardless 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 individual spawnEnemy calls cannot land an enemy in the player’s lap.
  • Forced-type dev mode. Setting _forceTypeId short-circuits both pickTypeForProgress (returns the forced id) and _spawnElitePack (early-return). Used by the weapon-gauntlet dev scenario to lock spawns to a single archetype.