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
| Zone | Base rate (enemies/s) | Pool weights | Role |
|---|---|---|---|
wilds | 0.4 | orb 50 / charger 30 / shooter 20 | Baseline — open space between hubs/spokes |
spoke_dark | 0.6 | orb 45 / shooter 25 / charger 20 / mortar 10 | Hostile corridor; mortars enter the pool |
spoke_illuminated | 0.2 | orb 75 / shooter 25 | Safe corridor; soft pool only |
hub_dark | 0.8 | shooter 35 / orb 30 / mortar 20 / charger 15 | Spike — the densest, toughest zone |
hub_illuminated | 0.1 | orb 70 / shooter 30 | Floor — 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
eliteChancecurve, 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.spawnRatefurther or shrink its pool to a single soft type. - Want hubs to be terrifying? Push
hub_dark.spawnRatehigher 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.ts—SpawnZoneConfig,ZonePool,DEFAULT_SPAWN_ZONES,ZoneTypesrc/starship-survivors/engine/enemies/spawner.ts—_pickTypeForZone, player-zone director hooksrc/starship-survivors/engine/enemies/zone-spawn-adapter.ts—querySpawnZone,getZonePool,pickFromPool,computeDirectorZoneOutputs