Skip to content

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.GenerateMapGenerator.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)

  1. resolved = base corridor config shallow-merged with byBiome[biomeIndex]; override wins per key, absent key inherits base. No deep merge — a range key ([min,max]) is replaced wholesale.
  2. Out-of-range biome index (or empty table) ⇒ base config unchanged.
  3. Both the plan step and the carve/populate step must read the same resolved object (web: corridorPlan and genCorridorMap both call corridorCfg(biome)). Resolving in only one of the two desynchronizes geometry from population.
  4. 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):

  • CorridorShapeConfig SO — all base keys above (already implied by the existing "one SO per config group" rule; ranges as Vector2Int/MinMaxRange).
  • CorridorShapeOverride SO — 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):

  1. Reachability: spawn → chamber path exists; every side room's center reachable. Zero failures.
  2. Counts within authored bounds: chain rooms within chainRooms; placed side rooms ≤ authored and ≥ ~70% fill; link length never exceeds linkLen.max + corridorW (elbow share).
  3. Shape distribution: side-room aspect buckets (tall/square/wide at 0.9/1.15 thresholds) each ≥ 20% over the sweep.
  4. 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 by raidStep() (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.