Zone-Based Map Generation — Design (Unity port, slice 1)¶
Date: 2026-06-26
Status: Approved (design)
Migration workflow: web context → configs/defs (no magic numbers) → audit vs web (see unity-migration-workflow memory)
1. Goal & context¶
Replace the Unity tester build's throwaway random scatter (RaidBootstrap inline params +
EnemySpawner/RaiderSpawner random-floor placement) with the real zone-based map architecture,
ported faithfully from the web prototype's genMap (game.js:444).
A web map = light connective cover in gray space, with a few colored tiered zones (rare=blue / epic=purple / legendary=gold) carved into it. Each zone has a theme (its signature structure), a roster of leashed guard mobs, and a case chest of the zone's tier. The zone's tier is an item rarity, so zone tint == chest glow == loot color (one consistent language).
This is slice 1 of the larger map architecture. It delivers the map's actual identity and the data-asset foundation everything else builds on.
Faithfulness requirement¶
The generator must be able to reproduce the same map layouts the web offers. Generation logic
(cover scatter, max-spread zone placement, zone footprint scaling, theme carving, leashing) is ported
verbatim from genMap/ZONE_TIERS/ZONE_THEMES/placeCenter, and a step-3 audit diffs the result.
2. Scope¶
In scope¶
- Three designer-authored ScriptableObjects:
MapDefinition,ZoneTierDef,ThemeDef. - Two-stage generate → populate pipeline (approved option A).
- Colored tiered zones: max-spread placement, footprint scaling, theme carving, case chest, leashed guards.
- Mob leashing on
EnemyController(zone box + gray loose-leash), weben.leash/leashPad. - Gray-space connective cover + gray roamers, rivals, portals, player spawn — all from map data.
- Migrate per-map size/density settings from
GameConfigontoMapDefinition. - Two theme carves ported now (Graveyard + Witch's Swamp);
ThemeDefstructure supports the rest.
Out of scope (later slices)¶
- Vault + key/Keykeeper + force-gate (slice 3) — incl. the stage boss (it guards the vault portal).
- Biome ladder /
stageZoneSpecs/ progression danger scaling (slice 4);prog.biomeCount/ stagesPerBiome/dangerPerBiome/stageDangerstay inGameConfiguntouched. - The remaining 3 theme carves (Abandoned Church, Old Ruins, Burned Village) — fast-follow.
MapLayoutfixed FTUE layouts (slice 5).- Valuable loot, in-run inventory — remain parked.
3. Data model (ScriptableObjects)¶
ZoneTierDef (one asset per tier: rare / epic / legendary)¶
Mirrors web ZONE_TIERS[tier].
- LootRarity rarity — the tier; drives chest rarity, loot, and tint (via the rarity color ladder).
- string[] mobKeys — guard roster (e.g. rare → goblin/hound/spider/emberball).
- Vector2Int guardCount — [lo,hi] guards before area-scaling (web count).
- bool allowMiniBoss — may host an "Elder" mini-boss (buffed guard) when the map asks for it.
Authored values (from web ZONE_TIERS):
- rare: goblin, hound, spider, emberball — [3,4]
- epic: warden, skeleton, emberball, rocketeer — [3,4]
- legendary: rocketeer, bombardier, sentinel, warden — [2,3]
ThemeDef (one asset per theme)¶
Mirrors web ZONE_THEMES[i]. A zone gets a distinct theme from the map's pool.
- string displayName, string emoji — for the zone label.
- A carve descriptor: rather than a C# delegate, carve params are data (enum CarveKind +
tunable counts/chances), so themes are authorable and the carve runs in MapGenerator.
- CarveKind: Graveyard (gravestone wall dots + creeping bush), Swamp (dense thicket, cleared
core), Church (wall-ring + breach doors), Ruins (rubble wall segments), Village (charred
hut-stub grid). Each kind reads a small param set (e.g. gravestoneCount, bushChance, doorCount).
- This slice ships Graveyard + Swamp; the other three CarveKind values exist but their
assets are authored in the fast-follow.
MapDefinition (the per-stage map recipe — your "stagedef")¶
The procedural map params, replacing RaidBootstrap's inline values + the per-stage size table.
- int width, height — tile grid size (replaces prog.stageSizesB1/B2/B3).
- Cover densities: float wallClusters, bushLines, bushClusters (× map area; web map.cover*).
- ZoneEntry[] zones — each: ZoneTierDef tier, bool miniBoss. (Boss/vault deferred.)
- ThemeDef[] themePool — distinct themes assigned per zone (shuffled, web themePool).
- Gray roamers: string[] roamerKeys, int roamDiv (1 roamer / N tiles; web prog.roamDiv).
- Rivals: int rivalDiv (web prog.botDiv), int rivalCap (web map.bots).
- int portalCount — authored per-map (the tester sets 2 on small maps, 3 on big).
- int seed — -1 = fresh per run.
4. Generation pipeline (option A)¶
MapGenerator.Generate(MapDefinition def) — tiles + zone data only¶
Pure map structure; spawns no entities. Mirrors genMap lines 444–551.
1. Border walls; size from def.
2. Connective cover scatter (wall clusters, bush lines, bush clusters) scaled to area — already
ported in the current generator; move the densities to def.
3. For each ZoneEntry:
- PlaceCenter(hw, hh) — max-spread (over ~240 candidates pick the one whose nearest placed
center is farthest; web placeCenter). Footprint scales with map size, capped at the classic
13×11 (web hw/hh clamps).
- ClearArea the footprint, run the assigned ThemeDef carve, re-open a small core pocket.
- Record a Zone { Box (tile rect + world rect), tier, themeName, center } into MapData.Zones.
4. Expose MapData.Zones (read-only list) for the populator + later the minimap/tactical map.
MapData gains a Zones list; nothing else downstream (fog/pathing/vision) changes — it still
consumes MapData tiles agnostically.
MapPopulator.Populate(MapData, MapDefinition) — entities¶
Consumes the generated zones + def to spawn everything (mirrors the rest of genMap + the current
spawner logic, now unified):
- Per zone: a case chest at the center (Chest.rarity = zone.tier); guards = guardCount
area-scaled (web areaScale), spawned inside the footprint, each leashed to the zone box; an
optional mini-boss (buffed guard) if the entry asks and the tier allows.
- Gray roamers: area / roamDiv mobs in gray space, each with a loose spawn-anchored leash
(web grayLeash).
- Rivals: clamp(area / rivalDiv, 1, rivalCap) (existing RaiderSpawner math), spawn-safe.
- Portals + player spawn: as the current RaidBootstrap does (spawn-safe bubble, portals ≥
spawn-safe from the player).
EnemySpawner/RaiderSpawner (tester scaffolding) are absorbed into MapPopulator and removed.
RaidBootstrap slims to: pick the stage's MapDefinition → MapGenerator.Generate →
MapPopulator.Populate.
5. Leashing (EnemyController)¶
Port web en.leash (updateMob patrol/return + genMap leash boxes).
- New Leash (world-space box) + Home point, set at spawn by the populator (zone box, or a loose
box around a gray roamer's spawn via prog.grayLeash).
- Patrol/wander: stay inside the leash box (clamp the wander target).
- Chase: may leave the box to pursue a target (web: chasing overrides the leash).
- Lost trail / give-up: path back inside the leash, then resume patrol (web homeX/homeY).
- Null leash = free roam (current behavior; keeps non-zone spawns working).
This is the one gameplay-logic change; it's small and self-contained (a clamp in the wander target + a "return home" branch when leashed and outside the box with no target).
6. Config migration¶
| Setting | Today | After |
|---|---|---|
| Map width/height | MapGenerator.mapW/H ← prog.stageSizesB1[stage] |
MapDefinition.width/height |
prog.stageSizesB1/B2/B3 |
in GameConfig |
removed (per-map size lives on the def) |
cover densities map.cover* |
GameConfig |
MapDefinition.wallClusters/bushLines/bushClusters |
map.roamDiv |
GameConfig |
MapDefinition.roamDiv |
prog.botDiv / map.bots |
GameConfig |
MapDefinition.rivalDiv / rivalCap |
prog.leashPad / prog.grayLeash |
GameConfig (deferred) |
used by the populator/leashing |
prog.zoneSeparation |
GameConfig (deferred) |
used by PlaceCenter |
map.tile |
GameConfig |
stays (universal px→world divisor) |
map.roundTime, map.spawnSafe |
GameConfig |
stays (raid-level rules) |
prog.biomeCount/stagesPerBiome/dangerPerBiome/stageDanger, bonusZonesPerBiome, vaultFromStage |
GameConfig |
stays untouched (biome ladder, slice 4) |
zones.fillAlpha |
GameConfig |
stays (zone tint look) |
The tester's stage cursor (TesterRunState.Stage) selects among a few authored MapDefinition
assets instead of indexing the size table.
7. File plan¶
New: Config/ZoneTierDef.cs, Config/ThemeDef.cs, Config/MapDefinition.cs, World/MapPopulator.cs,
plus authored assets (3 ZoneTierDef, ≥2 ThemeDef, a few MapDefinition).
Changed: World/MapGenerator.cs (Generate(MapDefinition) + zone carving + MapData.Zones),
World/EnemyController.cs (leash), Tester/RaidBootstrap.cs (slim orchestrator),
Config/GameConfig.cs (remove moved fields). Removed: World/EnemySpawner.cs, World/RaiderSpawner.cs.
Editors: ZoneTierDef/ThemeDef/MapDefinition get grouped custom inspectors only if they grow
non-trivial (reuse the EnemyDef editor pattern; otherwise default inspector is fine).
8. Faithfulness audit (step 3)¶
After it compiles, diff against game.js:
- PlaceCenter max-spread, zone footprint clamps, areaScale guard scaling — values + formulas.
- ZONE_TIERS rosters/counts; tier→rarity→tint mapping.
- The two ported theme carves vs ZONE_THEMES Graveyard/Witch's-Swamp (counts/chances/structure).
- Leashing: patrol-inside / chase-out / return-home vs updateMob.
- Cover-scatter densities + case-chest tier + gray-roamer leash.
Note any deltas blocked on deferred systems (e.g. progression danger scaling, the boss).
9. Verification¶
- EditMode-ish checks via the Unity MCP: generate a
MapDefinition→ assertMapData.Zonescount, boxes within bounds, non-overlapping (max-spread), case chest per zone at center with correct rarity. - Play smoke test: zones render tinted/themed, guards stay leashed (don't wander cross-map), gray roamers + rivals + portals + player place spawn-safe, a raid is completable.
- Confirm no orphaned references after removing
EnemySpawner/RaiderSpawner.
10. Open questions / risks¶
- Connectivity: themed carves (wall-rings, gravestone fields) could wall off a chest/zone core. The web re-opens a core pocket + uses doors; we port that. If a zone still strands content, add a flood-fill reachability check from the player spawn (cheap insurance) — note as a possible follow-up.
- Asset count: authoring several
MapDefinitions by hand is fine for the tester; a custom map editor window (migration §8) is a separate, later tool.