PURPOSE

AI Director ported from 07c-director.js. Maintains a per-frame pressure model from telemetry (player HP, enemy density, projectile density, kill streak), smoothly ramps a heat value (1-10), maps heat to one of four personalities (GUARDIAN, CONDUCTOR, PREDATOR, DEMON), and surfaces enemy behavior knobs (fire-rate multiplier, aggro-range multiplier, force-retreat flag) plus a Mood vector consumed by audio/VFX/observability. Inspired by Left 4 Dead’s AI Director. This file does not own enemy spawn scheduling, wave authoring, or density caps — those live elsewhere; the Director only computes the pressure signals and personality knobs that other systems read.

OWNS

  • Director singleton: live personality (GUARDIAN/CONDUCTOR/PREDATOR/DEMON), heat (1-10), pressure (0-1), pressureBreakdown (survival/density/projectile/momentum), enemy-behavior knobs (enemyFireRateMult, enemyAggroRangeMult, forceRetreat, suppressFiring), and unused-here cap fields (enemyCapBase, enemyCapCurrent).
  • Mood singleton (exported as both a TypeScript interface and a runtime object): ten smoothed scalars — tension, chaos, dread, triumph, calm, urgency, heatFeel, deathProximity, combatDensity, rewardMoment. Initialized to zero; urgency, heatFeel, and rewardMoment are declared but never written by this module.
  • Knobs object: tuning struct (forceRetreat, suppressFiring, enemyFireRateMult, enemyAggroRangeMult, worldEnemyHpMult, worldEnemyCountMult) exported alongside the Director for consumers; defaults are identity.
  • MOOD_RATE constant (2.5) controlling lerp speed for mood updates.
  • updateMood, update, and reset methods on the Director singleton.
  • Compatibility exports for tests: makeDirectorState() returning a fresh Mood and updateDirector(game, ship, world, dt) forwarding to Director.update.

READS FROM

  • GameState from ../core/types — uses game.killStreak for triumph and momentum components.
  • ShipStateship.hp, ship.hpMax (HP percent), ship.x, ship.y (closest-enemy distance).
  • WorldStateworld.enemies[] (iterated for closest-enemy distance, gated by alive), world.enemyBullets.length (projectile density).
  • getAliveEnemyCount() from ../core/frame-cache — pre-cached alive-enemy count used for chaos and density terms.

PUSHES TO

The Director mutates only its own singleton state and the module-level Mood object. Consumers pull from these:

  • engine/enemies/index.ts re-exports Director, Mood, and Knobs.
  • engine/bridge.ts calls Director.update(game, ship, world, dt) once per simulation tick, gated by !runDef.sandbox (sandbox runs skip the director entirely).
  • Enemy AI / behavior code reads Director.enemyFireRateMult, Director.enemyAggroRangeMult, Director.forceRetreat, and Director.suppressFiring to adjust per-enemy behavior.
  • Audio, VFX, telemetry, and HUD/observability layers read the Mood scalars to drive presentation.
  • Test scaffolding (services/assembleRunService.ts, testing/dev-scenarios.ts, playground screens) reach the Director surface through the re-export from engine/enemies.

DOES NOT

  • Does not spawn enemies, schedule waves, author wave compositions, or place spawns in the world. Spawn scheduling lives in engine/enemies/spawner.ts and the GameMaster.tick pipeline.
  • Does not enforce the enemy cap. The enemyCapBase and enemyCapCurrent fields are present on the singleton but not consumed inside this file; alive-count gating happens outside this module.
  • Does not track density itself — it consumes getAliveEnemyCount() from the frame cache and world.enemyBullets.length from the world snapshot.
  • Does not write to Mood.urgency, Mood.heatFeel, or Mood.rewardMoment (declared on the interface and initialized to zero; intended for future or external writers).
  • Does not consume or mutate world.worldKnobs. The Knobs object exported here is a separate tuning struct; per-run worldKnobs are handled by run config / bridge.
  • Does not run while runDef.sandbox is true.
  • Does not persist state across runs — reset() clears all Director fields and zeros the Mood vector.
  • Does not own boss spawning, boss pressure profiles, or sealed-arena gating.

Signals

Pressure components (each clamped 0-1, weighted into overall pressure):

  • survival(1 - hp/hpMax) * 2, clamped. Weight 0.3.
  • densityaliveEnemyCount / 25, clamped. Weight 0.3.
  • projectileenemyBullets.length / 30, clamped. Weight 0.2.
  • momentum1 - killStreak / 15, clamped (low streak ⇒ high pressure). Weight 0.2.

Heat: target 1 + pressure * 9, lerped each frame by (target - heat) * 0.1 * dt, clamped to [1, 10].

Personality bands and resulting knob settings:

Heat rangePersonalityforceRetreatsuppressFiringenemyFireRateMultenemyAggroRangeMult
< 3GUARDIANfalsefalse0.70.8
[3, 6)CONDUCTORfalsefalse1.01.0
[6, 9)PREDATORtruefalse1.31.2
>= 9DEMONtruefalse1.61.3

Mood targets (per frame, computed in updateMood, then lerped toward via R = MOOD_RATE * dt):

  • tTension = clamp(pressure).
  • tChaos = clamp(aliveEnemyCount / 18).
  • tDread = clamp((1 - hpPct) * 0.6 + pressure * 0.4).
  • tTriumph = clamp((killStreak / 8) * hpPct).
  • tCalm = clamp(1 - pressure - tChaos * 0.3).
  • tDeath = clamp(1 - hpPct)Mood.deathProximity.
  • tDensity = 1 - closestEnemyDist / 350 when within 350 units, else 0 ⇒ Mood.combatDensity.

Per-mood lerp factors: tension/chaos/triumph/combatDensity use min(1, R); dread uses min(1, R * 0.6); calm uses min(1, R * 0.5); deathProximity uses min(1, R * 0.8).

Entry points

  • Director.update(game, ship, world, dt) — recompute pressure breakdown, integrate heat, set personality + knobs, then call updateMood. Invoked from engine/bridge.ts per simulation tick when not in sandbox.
  • Director.updateMood(game, ship, world, dt) — public on the singleton but normally called by update; recomputes Mood targets and lerps.
  • Director.reset() — restore defaults at run start (heat back to 5, personality back to CONDUCTOR, all pressure and Mood scalars zeroed, fire-rate and aggro knobs back to 1.0).
  • updateDirector(game, ship, world, dt) — compatibility shim forwarding to Director.update for tests.
  • makeDirectorState() — compatibility shim returning a fresh zeroed Mood for tests.

Pattern notes

  • Module-singleton state. Director, Mood, and Knobs are exported as plain object singletons; there is no factory for per-run instances inside production code (the test makeDirectorState builds a Mood only). Reset semantics live on Director.reset() rather than re-instantiation.
  • Smoothed lerps with per-signal rates. Mood deltas are scaled by MOOD_RATE * dt and additionally by per-mood multipliers (* 0.6, * 0.5, * 0.8), each clamped to min(1, ...) so large dt jumps do not overshoot.
  • Pressure is a weighted average. Components sum-of-weights is 1.0 (0.3 + 0.3 + 0.2 + 0.2); the dictionary expression in code uses + rather than averaging.
  • Heat ramp is frame-rate dependent in form (* 0.1 * dt) — coefficient is small enough that this reads as smoothing rather than a true exponential lerp.
  • TypeScript pattern: the identifier Mood is exported both as an interface and as a const of that interface, so Mood can be used as a type and as a runtime singleton.
  • Sandbox gate lives at the call site (bridge.ts), not inside Director.update. The Director itself has no awareness of sandbox mode.
  • Knobs.worldEnemyHpMult and Knobs.worldEnemyCountMult are tuning hooks declared here but written/read by other systems (worldKnobs on GameState is a separate path).
  • The 350-unit threshold for combatDensity and the < 3 / < 6 / < 9 personality bands are inline literals; the file uses MOOD_RATE as the only extracted constant.