Worldgen Chunk System
The game world is an infinite 2D plane tiled by square chunks. The ChunkManager (engine/world/chunk-manager.ts) and hub-spoke-gen (engine/world/hub-spoke-gen.ts) work together to precompute hubs/spokes/zones at mission load and then activate, simulate, and cull chunks around the player every frame.
Five-phase lifecycle
- BAKE —
bakeLevel(config, spawnX, spawnY, gridRadius)runs once at mission load. Target: <5s on mobile, <2s on desktop. Produces aLevelDataobject containing the full precomputed world (hubs, spokes, zone grid, triggers, illumination map). - GENERATE — Hub and spoke placement (done inside BAKE via
precomputeWorld()). - POPULATE — Terrain fill, event placement, illumination init (done inside BAKE).
- SIMULATE — Enemy spawning, AI ticking, player interaction (per-frame, scoped to the sim range).
- CULL — Enable/disable rendering and ticking based on distance from the camera (per-frame).
The key architectural rule: all generation happens at load time. The per-frame loop only reads precomputed data.
Chunks, hubs, spokes, zone cells
- Chunk — A square cell of fixed
chunkSize(derived from pattern + knobs viacalcChunkSize(), range 1500–4000px). In scripted mode the size is hardcoded toSCRIPTED_CHUNK_SIZE = 2000. Each chunk holds 1–4 hubs depending on pattern + density. - Hub — A clearing in the terrain where events, enemies, and crates spawn. Each hub has
{x, y, r, id, chunkKey}and lives in exactly one chunk. Hub IDs are${cx},${cy}:${i}. - Spoke — A corridor connecting two hubs. Spokes are
{a, b}index pairs into the global hub array, built either bybuildSpokes_grid(orthogonal only) orbuildSpokes_planar(maximal non-crossing graph, shortest-first). - Zone cell — A 64px cell in the zone classification grid (
zone-classifier.ts). Each cell is taggedhub,spoke, orwilds. Used for fast per-frame zone lookups (terrain fill checks, enemy spawn checks).
Determinism
Generation is fully deterministic given a seed. chunkRng(globalSeed, cx, cy) returns a Mulberry32 PRNG seeded by mixing the global config seed with the chunk coordinates:
mulberry32((seed * 73856093) ^ (cx * 19349663) ^ (cy * 83492791))
Same seed + same chunk coords always produces the same hubs and the same warp puddles. This means the world can be regenerated on demand (e.g. during dynamic expansion) without storing chunk data on disk.
Hub patterns
Four patterns are routed via genHubsForPattern():
- grid — One hub per chunk, centered with ±8% jitter. Spokes are orthogonal only.
- zigzag — One hub per chunk, alternating left/right by row. Planar spokes.
- rings — Center hub + 1–3 concentric rings of smaller hubs. Planar spokes.
- chaotic — 1–4 Poisson-disc-like random hubs per chunk. Planar spokes.
Per-frame chunk activation
computeActiveChunks(levelData, cameraX, cameraY, viewW, viewH) returns three sets of chunk keys, in widening rings around the viewport:
| Set | Range (chunks beyond viewport) | Purpose |
|---|---|---|
active | ACTIVE_RANGE = 2 | Hub/spoke data visible (widest ring) |
spawn | SPAWN_RANGE = 1 | Terrain + events rendered |
sim | SIM_RANGE = 0.5 | Enemy AI ticked (tightest) |
Chunks outside the active ring are culled — their hubs/spokes are not iterated, their terrain is not drawn, and their enemies are not ticked. getVisibleHubs() and getVisibleSpokes() filter the global arrays through the active set every frame.
Dynamic terrain expansion
For mission-time infinite exploration, WorldGenerator.expandTerrain() (engine/world/generation.ts) uses a separate 800px super-chunk grid (_SUPER_CHUNK = 800) tracked in _generatedChunks: Set<string>. As the player moves, any super-chunk within EXPAND_R = 2200 that hasn’t been generated gets a cluster (1 center + medium ring + small ring) seeded deterministically from (world.seed) ^ scx ^ scy. Terrain beyond 3000px from the player is garbage-collected and its super-chunk key is removed from _generatedChunks so it regenerates if the player returns.