Enemy Spawn Zones

Every spawn position in the world resolves to one of five zones. The zone decides how fast enemies appear at that spot and which pool they’re drawn from. Zones are the level designer’s main lever for shaping pacing: dark territory is a spike, illuminated territory is a floor, wilds are baseline.

The five zones

ZoneBase rate (enemies/s)Pool weightsRole
wilds0.4orb 50 / charger 30 / shooter 20Baseline — open space between hubs/spokes
spoke_dark0.6orb 45 / shooter 25 / charger 20 / mortar 10Hostile corridor; mortars enter the pool
spoke_illuminated0.2orb 75 / shooter 25Safe corridor; soft pool only
hub_dark0.8shooter 35 / orb 30 / mortar 20 / charger 15Spike — the densest, toughest zone
hub_illuminated0.1orb 70 / shooter 30Floor — calmest spot in the world

Defaults live in DEFAULT_SPAWN_ZONES in data/level-config.ts. Every level inherits these unless it overrides spawnZones on its LevelConfig. Weights are relative — pool selection rolls a weighted pick, weights don’t need to sum to 100.

Dark vs illuminated

The hub-and-spoke world is in one of two states at every point: dark (default) or illuminated (a lantern was lit). Illumination flips both the spoke and hub variants of the zone:

  • Dark zones spawn faster and pull from harder pools (chargers, mortars).
  • Illuminated zones spawn slow and pull from soft pools (orbs, shooters only).

hub_dark and hub_illuminated bracket the range — 0.8/s down to 0.1/s, an 8× swing. That’s the steepest gradient in the system and the main reason illumination feels meaningful.

Zone resolution

The spawner doesn’t classify zones itself. It calls querySpawnZone(x, y, levelData) (engine/enemies/zone-spawn-adapter.ts), which looks up the position in the precomputed zoneGrid built by worldgen. That grid encodes which hubs and spokes cover which world tiles; the lookup also reads the live illuminationMap to decide dark-vs-lit.

querySpawnZone returns:

  • the resolved ZoneType ('wilds' | 'spoke_dark' | 'spoke_lit' | 'hub_dark' | 'hub_lit')
  • the zone’s baseRate
  • the zone’s weighted pool

The spawner uses the pool for _pickTypeForZone (every spawn position picks its type from its local zone) and uses the player’s zone to feed the AI Director for a global spawn-rate multiplier (computeDirectorZoneOutputs).

Naming wrinkle

SpawnZoneConfig uses the keys spoke_illuminated and hub_illuminated. The runtime ZoneType uses the shorter spoke_lit and hub_lit. The adapter’s getZonePool maps between the two. Treat them as synonyms.

What zones don’t do

  • They don’t set caps. The kill-cap table (KILL_CAP_TABLE) and on-screen target (_targetOnScreen) are global.
  • They don’t pick elites. Elites are rolled by the AI Director’s eliteChance curve, independent of zone.
  • They don’t override the basics-only window (first 60s of every tier), which restricts pools to the level’s swarm archetype regardless of zone.

Tuning notes

  • Want a “safe corridor” feel? Drop spoke_illuminated.spawnRate further or shrink its pool to a single soft type.
  • Want hubs to be terrifying? Push hub_dark.spawnRate higher and add a tougher type at low weight.
  • Pool weights are pure ratios — adding a { type: 'mortar_common', weight: 5 } to wilds adds an ~8% chance without rebalancing the rest.

Source

  • src/starship-survivors/data/level-config.tsSpawnZoneConfig, ZonePool, DEFAULT_SPAWN_ZONES, ZoneType
  • src/starship-survivors/engine/enemies/spawner.ts_pickTypeForZone, player-zone director hook
  • src/starship-survivors/engine/enemies/zone-spawn-adapter.tsquerySpawnZone, getZonePool, pickFromPool, computeDirectorZoneOutputs