Director / Pressure System
The dynamic spawn director is the global dial that decides whether the next second feels calm or chaotic. It reads the player’s current state — zone, recent kills, HP, projectile load, run timer — and emits a single multiplier on top of every zone’s base spawn rate. Per-zone rates (Enemy Spawn Zones) set the texture; the director sets the volume. The kill cap acts as the hard ceiling no matter what the director wants.
There are actually two cooperating directors living in engine/enemies/. They share the “director” name and feed the same spawner, but operate on different inputs and outputs.
The two directors
| Director | File | Output | Drives |
|---|---|---|---|
| Zone director | zone-spawn-adapter.ts → computeDirectorZoneOutputs | spawnRateMultiplier (0.3–3.0), eliteChance (0–0.3) | The per-zone spawn rate; elite roll chance |
| Mood director | director.ts → Director.update | pressure (0–1), heat (1–10), personality (GUARDIAN / CONDUCTOR / PREDATOR / DEMON) | Enemy fire-rate mult, aggro-range mult, force-retreat, mood readouts for telemetry |
The spawner reads spawnRateMultiplier from the zone director (cached in GameMaster._zoneSpawnRateMult). Enemy behavior code reads Director.personality, enemyFireRateMult, and enemyAggroRangeMult from the mood director. Both run every frame from bridge.ts unless the run is in sandbox mode (runDef.sandbox short-circuits Director.update).
Zone director — the rate dial
computeDirectorZoneOutputs(inputs) is a pure function. It takes:
runTimer— seconds elapsed in the current run (0–240 nominal)playerHpFrac—ship.hp / ship.hpMaxhubsIlluminated— count of lit hubs this runplayerZone— the zone the player is currently standing inrecentDamage— damage taken in the last 5 seconds
…and returns spawnRateMultiplier and eliteChance.
Base rate curve
The headline number is the base rate, a piecewise lerp keyed off t = runTimer / 240:
| Run time | Multiplier | Phase |
|---|---|---|
| 0–30 s | 0.5 → 1.0 | Warm-up |
| 30–120 s | 1.0 → 1.2 | Steady |
| 120–180 s | 1.2 → 1.5 | Pressure |
| 180–210 s | 1.5 → 2.0 | Ramp |
| 210–240 s | 2.0 → 3.0 | Final push |
eliteChance follows a parallel curve: 0% for the first minute, climbing to 30% in the final stretch.
Modifiers stacked on the base
The base rate is then bent by player state:
- Pressure relief. If
playerHpFrac < 0.3ANDrecentDamage > 20, the multiplier is clamped down to 0.5 — a quiet patch so the player can recover. Triggers only when both conditions hold; one of them alone doesn’t change anything. - Illumination compensation. Every hub the player lights makes remaining dark territory harder by
1 + illuminatedFrac × 0.5. Lighting all ~20 hubs = up to +50% rate. The idea: as the player claims territory, the rest of the map fights back.
The final value is clamped to [0.3, 3.0] — the director can never fully shut spawning off and can never go above 3× zone base.
How the spawner consumes it
Every frame in GameMaster.tick, the spawner queries the player’s zone, calls computeDirectorZoneOutputs, and stores the multiplier in _zoneSpawnRateMult. Anywhere the spawner gates a spawn, shouldSpawn(baseRate, directorMult, dt, roll) rolls roll < baseRate × directorMult × dt. The director can’t bypass zones; it scales them.
Mood director — pressure and personality
Director.update runs the same frame and feeds a different signal chain. Instead of “how fast do enemies appear,” it answers “how aggressive do they act once they’re here.” Its outputs go to enemy AI, not the spawner.
Pressure model
pressure is the weighted mean of four normalized inputs:
| Component | Formula | Weight |
|---|---|---|
survival | (1 − hpFrac) × 2, clamped 0–1 | 0.3 |
density | visibleEnemyCount / 25, clamped 0–1 | 0.3 |
projectile | enemyBullets / 30, clamped 0–1 | 0.2 |
momentum | 1 − killStreak / 15, clamped 0–1 | 0.2 |
High HP and a long kill streak push pressure down. Crowded screens and incoming bullets push it up. pressure lives in [0, 1] and feeds tension and dread in the mood lerp.
Heat and personality
heat is a smoothed lerp toward 1 + pressure × 9 (range 1–10). Personality is a hard-bucketed switch on heat:
| Heat | Personality | Fire rate | Aggro range | Force retreat |
|---|---|---|---|---|
| < 3 | GUARDIAN | 0.7× | 0.8× | no |
| 3–6 | CONDUCTOR | 1.0× | 1.0× | no |
| 6–9 | PREDATOR | 1.3× | 1.2× | yes |
| ≥ 9 | DEMON | 1.6× | 1.3× | yes |
Default personality at run start is CONDUCTOR (heat = 5). GUARDIAN appears when the player is steamrolling; DEMON appears in late-run hell. forceRetreat makes the spawner pull back excess enemies when count > 1.5× target.
Mood readouts
The director also tracks ten smoothed mood scalars (tension, chaos, dread, triumph, calm, urgency, heatFeel, deathProximity, combatDensity, rewardMoment). They lerp toward target values at MOOD_RATE = 2.5 per second. These are read by audio, music, and telemetry — they don’t drive spawning.
Pulses and quiet-patch correction
The zone director’s curve only goes up over time — the only down-pulse is pressure relief at low HP. The mood director’s pressure breakdown is what reacts to “the player is bored / steamrolling”: low density + high momentum (long kill streak) collapse pressure to near zero, which drops heat into GUARDIAN. GUARDIAN dampens enemy fire and aggro, not spawn count. Spawn count keeps climbing on the zone curve regardless.
The practical effect: quiet patches happen because the player kills the on-screen pool faster than the curve refills it. The director itself doesn’t insert lulls — that’s an emergent property of the kill-cap throttle.
Kill-cap throttle
The hard ceiling: KILL_CAP_TABLE (spawner.ts) maps total kills → max on-screen enemies (e.g., 9 at 0 kills, 600 at 2000 kills). Even at director 3.0× × zone 0.8/s = 2.4/s sustained, the spawner refuses to spawn past the kill cap. Overtime adds +50% to the cap (hard ceiling 900). The kill cap also scales with enemy difficulty level (levelCountMult = 1 + min(2.0, 0.04 × edl)).
Sandbox mode
In sandbox runs the mood director is skipped entirely (if (!runDef.sandbox) Director.update(...)). The zone director still runs because it’s a pure function the spawner calls inline. Mission elapsed time is also frozen, so the run-timer curve doesn’t progress — sandbox sessions sit at whatever director state they were at when frozen.
Telemetry
Director.pressureBreakdown is broken out into its four components so dashboards can show which factor dominated a moment. telemetry.recordDirectorPhase(label, value) is called from many systems (boss kills, event completion, affix procs, wave telegraphs, prop breaks) to log narrative beats with director context.
What the director does NOT do
- It doesn’t pick what to spawn — that’s the zone pool plus the basics-only window gate.
- It doesn’t pick where to spawn — that’s the spawn-arc/velocity-bias logic in
GameMaster.tick. - It doesn’t drive the spawn arrival curves (
combinedRamp,densityShape,_quantityCurve). Those are independent shaping curves the spawner applies before the zone director’s multiplier. - It doesn’t override boss-room or closing-room flow —
_bossSpawnerDisabledshort-circuits the regular gate entirely.
Source
src/starship-survivors/engine/enemies/director.ts—Director,Mood,pressureBreakdown, personality bucketssrc/starship-survivors/engine/enemies/zone-spawn-adapter.ts—computeDirectorZoneOutputs,DirectorZoneInputs,DirectorZoneOutputs,shouldSpawnsrc/starship-survivors/engine/enemies/spawner.ts—GameMaster.tick(zone director call site,_zoneSpawnRateMult,KILL_CAP_TABLE)src/starship-survivors/engine/bridge.ts— frame-levelDirector.updatecall, sandbox short-circuit