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
Directorsingleton: 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).Moodsingleton (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, andrewardMomentare declared but never written by this module.Knobsobject: tuning struct (forceRetreat,suppressFiring,enemyFireRateMult,enemyAggroRangeMult,worldEnemyHpMult,worldEnemyCountMult) exported alongside the Director for consumers; defaults are identity.MOOD_RATEconstant (2.5) controlling lerp speed for mood updates.updateMood,update, andresetmethods on the Director singleton.- Compatibility exports for tests:
makeDirectorState()returning a fresh Mood andupdateDirector(game, ship, world, dt)forwarding toDirector.update.
READS FROM
GameStatefrom../core/types— usesgame.killStreakfor triumph and momentum components.ShipState—ship.hp,ship.hpMax(HP percent),ship.x,ship.y(closest-enemy distance).WorldState—world.enemies[](iterated for closest-enemy distance, gated byalive),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.tsre-exportsDirector,Mood, andKnobs.engine/bridge.tscallsDirector.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, andDirector.suppressFiringto adjust per-enemy behavior. - Audio, VFX, telemetry, and HUD/observability layers read the
Moodscalars to drive presentation. - Test scaffolding (
services/assembleRunService.ts,testing/dev-scenarios.ts, playground screens) reach the Director surface through the re-export fromengine/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.tsand theGameMaster.tickpipeline. - Does not enforce the enemy cap. The
enemyCapBaseandenemyCapCurrentfields 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 andworld.enemyBullets.lengthfrom the world snapshot. - Does not write to
Mood.urgency,Mood.heatFeel, orMood.rewardMoment(declared on the interface and initialized to zero; intended for future or external writers). - Does not consume or mutate
world.worldKnobs. TheKnobsobject exported here is a separate tuning struct; per-runworldKnobsare handled by run config / bridge. - Does not run while
runDef.sandboxis 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. Weight0.3.density—aliveEnemyCount / 25, clamped. Weight0.3.projectile—enemyBullets.length / 30, clamped. Weight0.2.momentum—1 - killStreak / 15, clamped (low streak ⇒ high pressure). Weight0.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 range | Personality | forceRetreat | suppressFiring | enemyFireRateMult | enemyAggroRangeMult |
|---|---|---|---|---|---|
< 3 | GUARDIAN | false | false | 0.7 | 0.8 |
[3, 6) | CONDUCTOR | false | false | 1.0 | 1.0 |
[6, 9) | PREDATOR | true | false | 1.3 | 1.2 |
>= 9 | DEMON | true | false | 1.6 | 1.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 / 350when 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 callupdateMood. Invoked fromengine/bridge.tsper simulation tick when not in sandbox.Director.updateMood(game, ship, world, dt)— public on the singleton but normally called byupdate; recomputes Mood targets and lerps.Director.reset()— restore defaults at run start (heat back to5, personality back toCONDUCTOR, all pressure and Mood scalars zeroed, fire-rate and aggro knobs back to1.0).updateDirector(game, ship, world, dt)— compatibility shim forwarding toDirector.updatefor tests.makeDirectorState()— compatibility shim returning a fresh zeroedMoodfor tests.
Pattern notes
- Module-singleton state.
Director,Mood, andKnobsare exported as plain object singletons; there is no factory for per-run instances inside production code (the testmakeDirectorStatebuilds a Mood only). Reset semantics live onDirector.reset()rather than re-instantiation. - Smoothed lerps with per-signal rates. Mood deltas are scaled by
MOOD_RATE * dtand additionally by per-mood multipliers (* 0.6,* 0.5,* 0.8), each clamped tomin(1, ...)so largedtjumps 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
Moodis exported both as aninterfaceand as aconstof that interface, soMoodcan be used as a type and as a runtime singleton. - Sandbox gate lives at the call site (
bridge.ts), not insideDirector.update. The Director itself has no awareness of sandbox mode. Knobs.worldEnemyHpMultandKnobs.worldEnemyCountMultare tuning hooks declared here but written/read by other systems (worldKnobsonGameStateis a separate path).- The 350-unit threshold for
combatDensityand the< 3 / < 6 / < 9personality bands are inline literals; the file usesMOOD_RATEas the only extracted constant.