Skip to content

How to Build: Enemy Spawning

A world without enemies is just a walking simulator. Once the Wave Function Collapse (WFC) pipeline generates a level, we need to populate it with things to fight — clusters of foes along the road, tougher camps off the beaten path, and a boss guarding the exit. This guide covers how to read zone data from the WFC output and turn it into living, breathing (and respawning) enemy entities.

Three encounter types

The spawning system produces three distinct patterns, each with its own gameplay purpose:

Road clusters are groups of 2-6 enemies scattered along the main path with gaps between them. They're the bread and butter of progression — the player walks into a cluster, fights through it, regens, and moves on to the next. Cluster size and enemy composition scale with level depth.

Off-road camps sit at points of interest placed by the WFC pipeline. These are optional, harder encounters (4-8 enemies biased toward tougher types) that reward exploration with better gold drops. Think of them as the "risk versus reward" choice.

The boss is a single scaled-up enemy in the final chunk. It doesn't respawn. Killing it completes the level and unlocks the next checkpoint.

The traits you'll need

Enemy entities are built from several Koota traits working together.

typescript
type IsEnemy = {}           // tag trait — marks an entity as an enemy
type IsDead = {}            // tag trait — added on death, removed on respawn
type IsLevelBoss = {}       // tag trait — prevents respawn, triggers level complete

type EnemyData = {
  enemyId: string           // profile ID from shared game data
  tier: number
  goldDrop: number          // pre-rolled on spawn
}

type EnemyStats = {
  attack: number
  strength: number
  defence: number
  attackSpeed: number       // ticks between attacks
  attackRange: number       // hex tile range
}

type RespawnTimer = {
  ticksRemaining: number
  respawnTicks: number      // total ticks to wait (e.g. 40 = ~10 seconds)
}

type SpawnPosition = {
  x: number
  z: number
}

Notice that max HP is not duplicated on EnemyStats — it lives on the Health trait from the health system. The respawn system reads Health.max to restore HP when the timer expires.

The spawn action

The core spawnEnemy action is the factory function that creates a single enemy entity.

typescript
function spawnEnemy(
  world: World,
  profileId: string,
  position: THREE.Vector3,
  scaleFactor: number,
  options?: { isBoss?: boolean; goldMultiplier?: number }
): Entity

It pulls the enemy profile from shared game data, scales stats by the scaleFactor, pre-rolls a gold drop amount from the profile's min/max range (multiplied by goldMultiplier if present), and spawns the entity with all the traits listed above. If isBoss is true, the IsLevelBoss tag is added.

The key insight here is that gold is rolled at spawn time, not at death time. This keeps death handling simple — just read the pre-computed value and add it to the player's wallet.

Populating road clusters

Road cluster placement reads the road zone hexes from the WFC output and distributes encounter points along the path.

typescript
function spawnRoadEncounters(
  world: World,
  roadPath: HexCoord[],
  tierConfig: TierConfig
): void

The algorithm selects cluster center points at intervals along the road, then for each center, picks a random cluster size within the tier's range and selects enemy types from the tier's pool. Individual enemies get a small random hex offset from the center so they don't stack on the same tile.

Placing off-road camps

Off-road camps use the Point of Interest (POI) positions that the WFC pipeline already placed.

typescript
function spawnOffRoadCamps(
  world: World,
  pois: HexCoord[],
  tierConfig: TierConfig
): void

Each POI gets a camp of 4-8 enemies, biased toward harder types in the tier pool. Every enemy in a camp spawns with a gold multiplier of 2x — the reward for leaving the safety of the road.

Spawning the boss

A single call places the boss in the final chunk.

typescript
function spawnBoss(
  world: World,
  lastChunkCenter: HexCoord,
  tierConfig: TierConfig
): void

The boss uses the tier's stat scale factor doubled, and gets the ECONOMY.BOSS_GOLD_MULTIPLIER applied to its gold drop. The IsLevelBoss tag ensures it won't respawn and that killing it triggers level completion.

The respawn system

All enemies except the boss come back after a timer. The tickRespawn system runs every game tick, querying for entities with both IsEnemy and IsDead.

typescript
function tickRespawn(world: World): void

For each dead enemy (that isn't a boss), the system decrements RespawnTimer.ticksRemaining. When it hits zero, the enemy's IsDead tag is removed, health is restored to Health.max, and their position resets to SpawnPosition. The timer resets for the next death cycle.

This creates a living world — the player can't just clear a road and have it stay empty forever. There's always something to fight, which means there's always experience and gold flowing.

Rendering enemies

On the React Three Fiber (R3F) side, you'll need an EnemyRenderer component that queries for all IsEnemy entities (without IsDead) and renders a 3D model for each one. A floating EnemyHpBar component above each enemy gives the player visual feedback during combat.

Relevant files

src/core/actions/enemies.ts         — spawnEnemy, spawnCluster, spawnCamp, spawnBoss
src/core/systems/tick-respawn.ts    — Respawn timer system
src/features/enemies/
├── EnemyRenderer.tsx               — R3F component for enemy models
└── EnemyHpBar.tsx                  — Floating HP bar above enemies

We now have a populated world. But enemies standing around doing nothing isn't much of a game — let's make them fight back with the combat tick system.