Per-Biome Level Shape — Config Spec (web → Unity)¶
Status: implemented in the web prototype (2026-08-04, corridor generator) · Unity: PORTED 2026-08-11 —
for the ROOMS warren (the shipped generator; web's corridor byBiome itself stays unported with the corridor).
Unity implementation notes:
- WarrenShapeOverride (Config/WarrenShape.cs) — the §5 pattern-1 Optional<T> sparse override, embedded
(not separate SO assets) on both BiomeDef.shape (biome-wide) and MapDefinition.shape (per-floor —
§7's stage granularity landed, resolution cfg.rooms → biome.shape → floor.shape, later wins per key).
- Resolved ONCE per generation in MapGenerator.Generate → MapGenerator.RoomsCfg; WarrenGen.Roll/Carve
and MapPopulator all read that instance (§3.3 invariant).
- Acceptance: WarrenSmokeTest per-floor sweep (menu: Warren Gen Smoke Test (per-floor defs)) walks every
authored (biome, floor), asserting counts within the RESOLVED bounds. First pass 2026-08-11, seed 20260811.
- Floor assets author exact total/nests/loot (min == max) on all 15 floors; biome shape blocks start empty.
Web reference: config.js → CONFIG.corridor.byBiome, game.js → corridorCfg(biome), consumed by
corridorPlan() and genCorridorMap().
1. Intent¶
Level-shape progression is authored as data, not generator code: each biome can override any subset of the corridor generator's shape knobs. The authored plan this exists for:
- Biome 1 — small, straightforward floors, almost no sidetracks (onboarding: learn the climb).
- Biome 2 — bigger floors with predictable room counts (the game's stated room economy appears).
- Biome 3+ — long two-door halls, dense dressing, more sidetracks (mastery space).
The generator itself never branches on biome. It reads one resolved config object; progression is entirely a data-authoring activity.
2. Web data model¶
CONFIG.corridor = {
// ~30 shape keys (see §4), plus:
byBiome: [
/* index = biome (0-based). Each entry: a sparse object of corridor keys. */
{ chainRooms: [2, 3], roomsLoot: [1, 1], roomsNest: [0, 1], jogChance: 0.5 },
{ chainRooms: [4, 6], roomsLoot: [3, 3], roomsNest: [2, 3] },
{ chainRooms: [6, 8], longRoomChance: 0.35, chainDress: 0.9 },
],
}
3. Resolution semantics (normative)¶
resolved = base corridor configshallow-merged withbyBiome[biomeIndex]; override wins per key, absent key inherits base. No deep merge — a range key ([min,max]) is replaced wholesale.- Out-of-range biome index (or empty table) ⇒ base config unchanged.
- Both the plan step and the carve/populate step must read the same resolved object (web:
corridorPlanandgenCorridorMapboth callcorridorCfg(biome)). Resolving in only one of the two desynchronizes geometry from population. - Resolution happens per floor generation, at generation time — a live edit to the table affects the next generated floor, never the current one (all shape keys are re-roll/reload class).
4. Key inventory (what an override may contain)¶
Everything in CONFIG.corridor. The progression-relevant subset:
| Key | Type | Meaning |
|---|---|---|
chainRooms |
int range | through-rooms on the spine — the floor-length knob |
chainRoomW / chainRoomH |
int ranges | through-room proportions |
longRoomChance / longRoomH |
float / int range | tall two-door halls (distance covered by one room) |
linkLen |
int range | corridor link length between chain rooms (hard cap on bare corridor) |
corridorW |
int range | corridor width — the combat-grammar knob (3-4 = frontal; 6+ = arena) |
jog / jogChance |
int range / float | zig-zag turn size and frequency |
junctionRooms |
bool | landing boxes on jog corners |
roomsLoot / roomsNest |
int ranges | authored sidetrack counts per type |
roomArea / roomSize / roomMaxH |
ranges | side-room sizing (aspect is rolled internally) |
maxMouth |
int | side-room entrance stub cap (anti-tunnel) |
roomElbow / roomTwinDoor |
floats | corner-pocket rooms and their shortcut second door |
chainDress |
float | fraction of through-rooms that roll an identity (waystation/pillars/grove/guard) |
wayGuardChance / pillarCount |
float / int range | dressing internals |
pinchChance / pinchGap |
float / int | single-file squeezes on links |
saturateEvery |
int | no-dead-walk beat cadence (crackables/timber/bush) |
crateChance / barrelTime |
float / float | path-crackable mix and crack speed |
packs / packSize / packGap |
ranges / px | encounter density |
width |
int range | map width (reload-class: needs a fresh map buffer) |
chamber |
int range | extraction chamber size |
5. Unity mapping (recommendation)¶
Mirror the split used by the rest of the port (config.js groups → ScriptableObjects):
CorridorShapeConfigSO — all base keys above (already implied by the existing "one SO per config group" rule; ranges asVector2Int/MinMaxRange).CorridorShapeOverrideSO — same fields, each optional. C# has no "absent key", so sparse overrides need an explicit representation. Two acceptable patterns, in preference order:- Per-field
Optional<T>wrapper (bool use; T value;with a property drawer) — closest to the web semantics, override assets stay small and readable. - Full-copy override assets (every field set, no inheritance) — simpler code, but base-value edits stop propagating to biomes; only choose this if the Optional drawer is judged too costly.
BiomeDef.shapeOverride(nullable reference) — biome N's override asset, replacing the array indexing. Resolution:resolved = baseSO.Clone(); shapeOverride?.ApplyTo(resolved);executed once per floor generation, and the SAME resolved instance handed to both the plan and populate phases (§3.3 is the invariant to preserve).
Per the config-asset rule (config-asset-vs-cs-defaults): when the port lands, author the actual
.asset files for the example biomes — code defaults alone don't reach the running game.
6. Acceptance criteria (port parity)¶
Generate ≥60 floors per biome entry and assert, as the web harness does
(tools/gen-smoke.js — run with node tools/gen-smoke.js):
- Reachability: spawn → chamber path exists; every side room's center reachable. Zero failures.
- Counts within authored bounds: chain rooms within
chainRooms; placed side rooms ≤ authored and ≥ ~70% fill; link length never exceedslinkLen.max + corridorW(elbow share). - Shape distribution: side-room aspect buckets (tall/square/wide at 0.9/1.15 thresholds) each ≥ 20% over the sweep.
- Override isolation: a biome override changes only its own biome's floors (base biome regenerates byte-identical distributions).
7. Explicitly out of scope¶
- Per-STAGE (floor-within-biome) overrides — the web mechanism indexes by biome only. If stage
granularity is wanted later, extend the table to
byStep[]indexed byraidStep()(biome × stagesPerBiome + stage), same semantics; do it in web first, then mirror. - Difficulty/economy scaling (
prog.dangerPerBiome, mob budgets) — separate, existing system; shape overrides deliberately do not touch it.