Enemy Spawning
Enemy spawning splits into three phases: planning (pre-compute every placement before the run), slot generation (turn planned placements into world positions per chunk), and dispatch (clone models + assemble ECS entities). Each phase lives in its own subsystem with tight contracts between them.
Pipeline
RunDirector.buildRunPlan()
↓
planEnemies(seed, ENEMY_BALANCE, pacingManager, chaseSections)
↓ EnemyRunPlan (placements + hazards + path-monster markers)
↓
ChunkPlanner.planRun — applies chunkTypeRequirements / exclusions
↓
On chunk spawn:
EnemySlotGenerator.generateEnemySlots(geometry, placements, rng)
↓ EnemySlot[]
↓
EnemySpawnDispatch.dispatchEnemySpawn(slot) → ECS entity
Planning: planEnemies
src/apps/enemies/server/utils/enemy-planner.ts deterministically lays out every enemy placement for the entire run before any chunk spawns. It consumes ENEMY_BALANCE (from apps/enemies/server/balance/enemy-balance.ts) and produces an EnemyRunPlan containing:
placements: Map<marker, EnemyPlacement[]>pathMonsterMarkers: number[](sweep cadence)chunkTypeRequirements/chunkTypeExclusions(consumed byChunkPlanner)pressureTimeline—{ gameplayPressure, desiredPressure }per marker, for debug visualization
Pressure Model
Each marker has a desired pressure derived from pacing phase (PHASE_BASE_PRESSURE["Calm"|"Tense"|"Challenge"]) scaled by trail progression. Placed enemies add their pressure to a gameplay pressure accumulator that decays each marker (PRESSURE_DECAY_RATE). The planner fills the gap greedily, preferring candidates whose pressure fits gap * MAX_OVERSHOOT.
All tuning lives in apps/chunks/server/balance/pacing.ts — change numbers there, not in the planner.
Phase 8 Feedback
When the Phase 8 flags are on, director.getFeedbackPressureMultiplier(marker) scales the desired pressure before the gap is computed. See Director Feedback Loops.
Plan Eligibility: Policy Framework
Candidate filtering uses the shared policy framework in src/shared/policy/. The enemies subsystem composes five policies under apps/enemies/server/policy/:
| Policy | Rejects when |
|---|---|
MinTrailPolicy | marker < cfg.minTrail |
MaxTrailPolicy | marker > cfg.maxTrail |
IncompatiblePolicy | an incompatible enemy type is already active |
MaxPerRunPolicy | totalPlaced >= cfg.maxPerRun |
CooldownPolicy | marker - lastPlacedAt < cfg.cooldownMarkers |
Composed as:
export const PlanEligibility = composeAnd([
MinTrailPolicy, MaxTrailPolicy, IncompatiblePolicy,
MaxPerRunPolicy, CooldownPolicy,
]);
// enemy-planner.ts
const candidates = configs.filter((cfg) =>
PlanEligibility.evaluate(cfg, { marker, activeTypes, typeStats }) === true,
);
Adding a new rule: drop a file in apps/enemies/server/policy/, export a Policy<EnemyBalanceEntry, PlanContext>, and add one line to eligibility-composite.ts. See src/shared/policy/README.md for framework conventions.
Slot Generation: EnemySlotGenerator
src/apps/enemies/server/utils/enemy-slot-generator.ts converts a planned placement into a concrete EnemySlot { spawnCFrame, enemyType } for a specific chunk. It picks a random path node, offsets perpendicular to the path, clamps to baseplate bounds, then runs the candidate through:
SlotPlacementcomposite (hard reject) —SlotSpacingPolicy+SlotClearancePolicy:- Spacing: candidate must sit ≥
MIN_SPACINGfrom every previously placed slot. - Clearance:
hasClearance()against the baked foliage voxel grid must pass for the enemy'sREQUIRED_CLEARANCE[type].
- Spacing: candidate must sit ≥
- Cover score (soft) —
coverScore()returns a 0-1 fit against the enemy'sCOVER_PREFERENCE[type]; used only to rank surviving candidates.
pickBestCandidate helper
Slot generation and item placement share the same sample-reject-score-pick helper:
import { pickBestCandidate } from "shared/spawn/candidate-picker";
const best = pickBestCandidate<SlotCandidate>({
attempts: MAX_REROLLS + 1,
generate: () => { /* propose candidate */ },
hardReject: (cand) => SlotPlacement.evaluate({ worldPos: cand.worldPos }, slotCtx) === false,
score: (cand) => /* lower is better */,
});
Special-case: LampHead uses generateLampHeadSlot with a visibility-optimized edge placement (samples candidate edges, raycasts to spawn-map targets).
Tuning lives in apps/enemies/server/balance/slot-placement.ts (OFFSET_RANGES, REQUIRED_CLEARANCE, COVER_PREFERENCE, MIN_SPACING, …).
Dispatch: EnemySpawnDispatch
src/apps/enemies/server/services/enemy-spawn-dispatch.ts owns the per-type spawn dispatch. It handles special cases (FlowerTrap petals, LampHead beam rig, Ghost delayed activation) and normal paths (factory in enemy-spawn.ts). Called by ChunkLifecycleManager when a chunk finishes spawning.
Key Files
| File | Purpose |
|---|---|
enemies/server/utils/enemy-planner.ts | Pre-plans every placement (pure, deterministic). |
enemies/server/utils/enemy-slot-generator.ts | Chunk geometry → concrete slots. |
enemies/server/policy/ | 5 plan policies + 2 slot policies + composites. |
enemies/server/balance/enemy-balance.ts | Per-type pressure, cooldown, minTrail, etc. |
enemies/server/balance/slot-placement.ts | Placement tuning numbers. |
enemies/server/services/enemy-spawn-dispatch.ts | Model cloning + ECS entity assembly. |
enemies/server/services/enemy-tick-coordinator.ts | Runs all 16 ECS behavior systems + network sync. |
enemies/server/services/enemy-quirkymal-coordinator.ts | Lost Quirkymal / Mimic lifecycle. |
enemies/server/services/enemy-damage-handler.ts | Player-to-enemy damage resolution. |
enemies/server/services/enemy-path-monster-coordinator.ts | PathMonster scheduling. |
enemies/server/services/enemy-devour-coordinator.ts | Devour idle-spawn + teleport-on-unload. |
enemies/server/services/enemy-forest-chaser-coordinator.ts | Off-path detection via Liang-Barsky LoS. |
shared/spawn/candidate-picker.ts | Shared sample-reject-score-pick helper. |