Zone Grid

The zone grid is a worldgen-emitted spatial lookup that assigns every cell of the precomputed world to one of five zone types. It is the structural map the spawner uses to decide what enemies appear at any (x, y) and at what rate. Built once during the BAKE phase of mission load, queried every frame at runtime.

What it is

A flat row-major array of ZoneType values covering a rectangular region of world space, addressable by world coordinates via a fixed cell size. Each cell stores structural zone only (which kind of place this is) — illumination state (lit vs dark) is layered on at lookup time from the live illuminationMap.

The five zone types:

ZoneWhen a point falls here
wildsOutside every hub circle and farther than spokeHalfWidth from every spoke center line. The default.
spoke_darkWithin spokeHalfWidth of a spoke center line where neither endpoint hub is lit.
spoke_litSame geometry as spoke_dark, but at least one endpoint hub is illuminated.
hub_darkInside a hub circle (distance <= hub.r) where that hub is not illuminated.
hub_litInside a hub circle where the hub is illuminated.

Hub > spoke > wilds in priority: a point inside both a hub and a spoke classifies as the hub.

How it is built

bakeLevel in engine/world/chunk-manager.ts runs once at mission load:

  1. Generates all hubs and spokes (precomputed or scripted mode).
  2. Computes world bounds — either the bounding box of all scripted hubs plus 2000px padding, or gridRadius chunks in each direction from spawn.
  3. Computes spokeHalfWidth = lerp(spokeWMin, spokeWMax, spokeWidth) / 2.
  4. Calls buildZoneGrid with cellSize = 64px (trades spatial precision for 16× fewer cells than the default 16px).

buildZoneGrid in engine/world/zone-classifier.ts walks every cell, sampling the center point, and calls classifyZone against an empty illumination map (all hubs dark at build time). The result is stored as ZoneGrid:

interface ZoneGrid {
  cellSize: number;        // 64
  originX, originY: number;
  cols, rows: number;
  grid: ZoneType[];        // row-major: grid[row * cols + col]
}

The grid lives on LevelData.zoneGrid for the entire run.

How it is queried

lookupZone(px, py, zoneGrid, hubs, spokes, illuminationMap) is the per-frame entry point. It:

  1. Maps (px, py) to a cell index. Returns wilds if out of precomputed bounds.
  2. Reads the structural zone from the flat array — O(1).
  3. If wilds, returns immediately.
  4. If hub_*, scans hubs to find which hub the point is in and resolves dark/lit from the live illuminationMap.
  5. If spoke_*, scans spokes with a bounding-box prefilter to find the containing spoke and resolves dark/lit from its endpoint hubs.

The spawner consumes this via querySpawnZone in engine/enemies/zone-spawn-adapter.ts, which returns the matching ZonePool from LevelConfig.spawnZones — base spawn rate and weighted enemy pool — for the AI Director to multiply against.

Why precompute it

Per-point zone classification without a grid is O(hubs + spokes) per query — scanning every hub circle and every spoke segment. The spawner queries zones every frame for every potential spawn position. Precomputing collapses the structural test to a single array index. Illumination state is the only thing checked live, because it changes when the player lights a lantern.

Wired by

  • Spawner (zone-spawn-adapter.ts) — pool + base rate routing per spawn position.
  • Terrain generator (engine/world/generation.ts) — filters terrain placement to wilds when pathableWilds is on.
  • Bridge debug overlays (engine/bridge-debug-overlays.ts) — visualizes the grid for the playground LevelTab.

Cross-refs