Skip to main content

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 by ChunkPlanner)
  • 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/:

PolicyRejects when
MinTrailPolicymarker < cfg.minTrail
MaxTrailPolicymarker > cfg.maxTrail
IncompatiblePolicyan incompatible enemy type is already active
MaxPerRunPolicytotalPlaced >= cfg.maxPerRun
CooldownPolicymarker - 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:

  • SlotPlacement composite (hard reject) — SlotSpacingPolicy + SlotClearancePolicy:
    • Spacing: candidate must sit ≥ MIN_SPACING from every previously placed slot.
    • Clearance: hasClearance() against the baked foliage voxel grid must pass for the enemy's REQUIRED_CLEARANCE[type].
  • Cover score (soft) — coverScore() returns a 0-1 fit against the enemy's COVER_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

FilePurpose
enemies/server/utils/enemy-planner.tsPre-plans every placement (pure, deterministic).
enemies/server/utils/enemy-slot-generator.tsChunk geometry → concrete slots.
enemies/server/policy/5 plan policies + 2 slot policies + composites.
enemies/server/balance/enemy-balance.tsPer-type pressure, cooldown, minTrail, etc.
enemies/server/balance/slot-placement.tsPlacement tuning numbers.
enemies/server/services/enemy-spawn-dispatch.tsModel cloning + ECS entity assembly.
enemies/server/services/enemy-tick-coordinator.tsRuns all 16 ECS behavior systems + network sync.
enemies/server/services/enemy-quirkymal-coordinator.tsLost Quirkymal / Mimic lifecycle.
enemies/server/services/enemy-damage-handler.tsPlayer-to-enemy damage resolution.
enemies/server/services/enemy-path-monster-coordinator.tsPathMonster scheduling.
enemies/server/services/enemy-devour-coordinator.tsDevour idle-spawn + teleport-on-unload.
enemies/server/services/enemy-forest-chaser-coordinator.tsOff-path detection via Liang-Barsky LoS.
shared/spawn/candidate-picker.tsShared sample-reject-score-pick helper.