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

DirectorFileOutputDrives
Zone directorzone-spawn-adapter.tscomputeDirectorZoneOutputsspawnRateMultiplier (0.3–3.0), eliteChance (0–0.3)The per-zone spawn rate; elite roll chance
Mood directordirector.tsDirector.updatepressure (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)
  • playerHpFracship.hp / ship.hpMax
  • hubsIlluminated — count of lit hubs this run
  • playerZone — the zone the player is currently standing in
  • recentDamage — 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 timeMultiplierPhase
0–30 s0.5 → 1.0Warm-up
30–120 s1.0 → 1.2Steady
120–180 s1.2 → 1.5Pressure
180–210 s1.5 → 2.0Ramp
210–240 s2.0 → 3.0Final 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.3 AND recentDamage > 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:

ComponentFormulaWeight
survival(1 − hpFrac) × 2, clamped 0–10.3
densityvisibleEnemyCount / 25, clamped 0–10.3
projectileenemyBullets / 30, clamped 0–10.2
momentum1 − killStreak / 15, clamped 0–10.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:

HeatPersonalityFire rateAggro rangeForce retreat
< 3GUARDIAN0.7×0.8×no
3–6CONDUCTOR1.0×1.0×no
6–9PREDATOR1.3×1.2×yes
≥ 9DEMON1.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 — _bossSpawnerDisabled short-circuits the regular gate entirely.

Source

  • src/starship-survivors/engine/enemies/director.tsDirector, Mood, pressureBreakdown, personality buckets
  • src/starship-survivors/engine/enemies/zone-spawn-adapter.tscomputeDirectorZoneOutputs, DirectorZoneInputs, DirectorZoneOutputs, shouldSpawn
  • src/starship-survivors/engine/enemies/spawner.tsGameMaster.tick (zone director call site, _zoneSpawnRateMult, KILL_CAP_TABLE)
  • src/starship-survivors/engine/bridge.ts — frame-level Director.update call, sandbox short-circuit