<!-- Sludge procedural map generation — SL-25 / DF-260 · design · 2026-06-19 -->

# Sludge — Procedural Map Generation (validating generator)

This is the design spec for **`mapgen`** — the generator that produces `sludge-map@1`
maps procedurally and **validates each one against the SL-01 flood model before it is
playable**, so a generated map is vetted the way the two authored maps (SL-02
`First Blood`, SL-24 `Stranglehold`) are vetted by hand. Read [`balance.md`](balance.md)
(the flood model) and [`maps.md`](maps.md) (the map format + channel/chokepoint craft)
first; this doc builds directly on both.

| Thing | Lives in | Source of truth for |
| --- | --- | --- |
| The generator + gate | [`../sim/mapgen.js`](../sim/mapgen.js) | the **algorithm** |
| Its tests | [`../sim/mapgen.test.js`](../sim/mapgen.test.js) | the **contract** |
| Frictions / spread / child boost | [`../sim/balance.json`](../sim/balance.json) | the **values** (R1) |
| This file | here | the **reasoning** |

> **House rule (unchanged from SL-01):** no magic constants in the engine. Every
> friction/spread number the gate's verdict depends on is read from `balance.json` at
> runtime; a map only ever *names* a terrain id. The generator is the same — it bakes
> no friction (R1, a launch blocker).

It does **not** touch the SL-02 starter map ([DF-181](https://linear.app/distancefields/issue/DF-181)).
Generated maps load through the same dimension-agnostic path as the authored maps
(SL-03 sim, SL-22 match, SL-13 flow all read `map.size`), so nothing that boots the
protected starter changes. Generation is the *path to new maps*, not a replacement for
the authored ones.

---

## 1. What it produces

A standard `sludge-map@1` file (`size` / `legend` / `grid` / 2 `spawns`) plus an
additive, **sim-ignored `_generator` block**:

```jsonc
"_generator": {
  "generator": "sludge-mapgen@1",   // pinned version — bump on ANY algorithm/gate change (R3)
  "mode": "soft",
  "seed": 777,                       // the displayed/shared seed
  "attempt": 0,                      // which re-roll succeeded
  "subSeed": 1234567890,             // the derived sub-seed that produced this attempt
  "params": { /* every knob, resolved */ },
  "tunablesSource": "sludge/sim/balance.json",
  "balanceVersion": "1.1.0",
  "validation": { /* the gate's report — the numbers it checked */ }
}
```

So every generated map is **reproducible** (regenerate from `seed` + `params` against
the recorded generator version + `balance.json`) and **carries its own design-time
proof** — the way maps.md's "Verified against the model" tables do for the authored
maps, but emitted automatically by the gate.

---

## 2. Inputs

* **`w × h`** — size is an input, **not** tied to 39×23 / 40×30. Verified at 32×24,
  48×32, 64×40 (the generator and the whole load path are dimension-agnostic).
* **`seed`** — deterministic: same `{ w, h, params, seed, mode }` ⇒ **byte-identical**
  map (mulberry32, stable key order, stable JSON).
* **`mode`** — the encirclement dial (§4), default `soft`.
* **`params`** — texture/fairness/gate knobs (§6), all with size-scaled defaults.

```js
const { generate } = require('./mapgen.js');
const res = generate({ w: 48, h: 32, seed: 7, mode: 'soft' });
if (res.ok) emit(res.map);              // a sludge-map@1 object with _generator
else reroll(res.reason);                // 'attempt-cap' — a clean reroll, never a hang
```

There's also a CLI: `node sludge/sim/mapgen.js --w 48 --h 32 --seed 7 --mode soft`.

---

## 3. Strategy — structure first, texture second, validate last

Noise alone produces **no chokepoints**, and the encirclement loop (cut a mouth →
starve a lobe — maps.md §3/§5) is the core game. So generation is three phases, **in
this order** (the order matters — see R2):

1. **Skeleton.** Border ring; two balanced spawns mid-height, inset; a central food
   **prize**; and (soft/hard only) a **wall ring** around it with two **mouths** (one
   facing each spawn), the mouth row y-offset so the approach *winds* rather than runs
   straight at the prize.
2. **Texture.** Short diagonal **scree/crystal streaks** + corner-biased scattered
   food (the IMG_7075 look), plus a **guaranteed safe-food cluster near each spawn** so
   a cut-off player can consolidate and survive (balance.md §4.2). Painted so it
   **cannot break the skeleton** — never onto the border, a spawn neighbourhood, the
   prize ring/interior, a mouth, or the spawn→mouth corridor band (R2 backstop is the
   gate's chokepoint check).
3. **Validate + regenerate.** Run the gate (§5); on failure, re-roll a **sub-seed**
   (the displayed/shared seed stays stable) up to an attempt cap.

**Fairness by construction, fairness by measurement.** The skeleton and texture are
painted **180° rotationally symmetric** about the board centre (every `set(x,y)` also
sets `(w−1−x, h−1−y)`), so neither spawn has a structural advantage — exactly the
device `Stranglehold` uses. Symmetry makes the *balance* checks (coverage, prize-cost,
safe-food) green by construction; the gate still **measures** them on the real model,
so a symmetry bug can't sneak a lopsided map through (R4: a green gate is the floor,
not match-balance).

---

## 4. Encirclement dial (front-end option; default `soft`)

| Mode | Ring | Play | Looks like |
| -- | -- | -- | -- |
| `open` | none | encirclement incidental | the reference image (scatter + streaks) |
| `soft` | `sinter` | acid-openable door; mouth = cheap supply line. **Default** | image + faint chamber |
| `hard` | `crystal` | mouth is the only way in, ever | image + hard vault |

All three ship. Default is `soft` (pending playtest) — compare feel in the SL-21
harness before locking the default (R4).

---

## 5. The validation gate (fairness is MEASURED, not geometric)

All checks run on the **real flood model**: cost-to-enter a tile = its friction `F`
(from `balance.json` terrain); a tile is alive while `S = organismStartS − cost ≥
cullThresholdS`; a child chain adds `+child.boostS` of reach budget per child. The cost
field is a least-cost (Dijkstra) computation with per-tile weight `F` — the SL-03
spread rule (maps.md: `reach = ⌊(S₀ − cull)/F⌋`) cast as shortest path. A candidate
**passes iff all** of:

* **`coverage-balanced`** — bare-field coverage of the two spawns within tolerance
  (neither side gets a bigger free field).
* **`safe-food-balanced` + `safe-food-min`** — safe food in bare reach balanced, and
  each spawn ≥ `minSafeFood` (a cut-off player can survive — balance.md §4.2).
* **`prize-cost-balanced`** — cheapest cost into the prize balanced (the core fairness
  lever for asymmetric maps; exactly equal for the symmetric layouts here).
* **`prize-paid`** — the prize is **not** bare-reachable (a *paid* push), and
* **`prize-contestable`** — it **is** reachable within `maxChildren` (actually worth
  fighting for, not uncontestable).
* **`mouth-chokepoint@x,y`** (soft/hard) — **each mouth is a real chokepoint.** The two
  mouths feed one contested chamber, each facing a different spawn; the far mouth is the
  *opponent's* supply line through enemy space, not a realistic re-supply for this
  owner. So severance is tested with the mouths cut — *is the lobe still fed?* A crystal
  ring seals completely (re-entry = **∞**, a true cut); a sinter ring can still be
  burned back through, paying **~5/tile** (finite, but far above a bare tendril's
  reach). The cut must raise re-entry above bare reach by at least `severMargin` (R5).
  Faithful to maps.md's "a glob severs the whole prize lobe **from its owner**."

The gate writes the numbers it checked — per-spawn coverage/safe-food/prize-cost,
per-mouth normal vs. re-entry cost (and `trueCut`), coverage-imbalance %, prize-cost
diff, bare reach, child budget — into `_generator.validation`.

---

## 6. Knobs (`params`)

All have size-scaled defaults; override any via `opts.params`.

| Knob | Default | Role |
| --- | --- | --- |
| `spawnInset` | `2` | how far each spawn sits off the border |
| `prizeFrac` | `0.22` | central prize rectangle as a fraction of each dimension |
| `mouthOffset` | `1` | mouth row offset from the prize top (winding approach) |
| `foodScatter` | `~wh/90` | corner-biased scattered food count |
| `screeStreaks` | `~wh/130` | diagonal scree/crystal streak count |
| `crystalStreakFrac` | `0.35` | fraction of streaks that are hard crystal vs. scree |
| `maxChildren` | `⌈(w+h)/8⌉` | child budget the prize must be reachable within (scales with board) |
| `minSafeFood` | `3` | minimum safe food in each spawn's bare reach |
| `coverageTolerancePct` | `5` | allowed bare-coverage imbalance |
| `prizeCostTolerance` | `0` | allowed prize-cost imbalance (exact for symmetric layouts) |
| `severMargin` | `1` | how far a cut must push re-entry above bare reach (R5) |
| `attemptCap` | `20` | re-rolls before a clean failure (R6) |

---

## 7. Determinism

`mulberry32` seeded from the **displayed seed**; each regeneration attempt derives a
**sub-seed** (`subSeed(seed, attempt)`) so the shared seed stays stable while each
re-roll explores a fresh layout. The emitted JSON has deterministic key order, so
identical inputs ⇒ identical **bytes**. Reproducible **within a fixed generator version
+ `balance.json`** (both recorded in `_generator`).

---

## 8. Guardrails (design risks)

* **R1 — tunables drift (LAUNCH BLOCKER).** The gate's verdict is only as right as its
  frictions. Frictions + spread + child boost are **read from `balance.json` at
  runtime**; no constants are baked. The generator asserts every required tunable is
  present and refuses to run if one is missing; the test asserts the loaded values
  *equal* `balance.json` and that the gate genuinely drives off them.
* **R2 — structure/texture order.** Texture painted after walls could silently re-open
  a sealed chamber (a scree streak across a wall = a passable hole). The generator
  paints streaks only on non-protected open tiles (never the ring/mouths/corridor), and
  the **chokepoint check is the backstop** — a candidate whose texture broke a mouth
  fails the gate and re-rolls.
* **R3 — false reproducibility.** A shared seed only reproduces against a pinned version
  + tunables. `_generator` records `generator` (version) + `tunablesSource` +
  `balanceVersion`; bump the version on any algorithm/gate change; never promise "this
  seed is forever."
* **R4 — "fair" ≠ "fun".** The gate enforces *structural* fairness (equal reach /
  prize-cost / safe-food), not match balance or first-move neutrality. Validate feel in
  the SL-21 harness; treat a green gate as a floor.
* **R5 — soft-door depends on acid.** If acid tuning makes sinter trivially cheap to
  cross, soft chambers stop being chokepoints. `severMargin` is exposed; re-check soft
  sweeps on any acid retune. (The gate uses the static sinter `f`; in-game acid
  smoothing is the contestable layer on top.)
* **R6 — degenerate sizes.** Very small / extreme-aspect boards (or an impossible
  `param`) can spike the attempt cap. Clamp size in the UI; the attempt-cap path returns
  a clean `{ ok:false, reason:'attempt-cap' }` reroll, **never hangs**.

---

## 9. Verified

100-seed sweeps at 32×24 / 48×32 / 64×40 across all three modes pass the gate; the
symmetric construction passes on the first attempt. Determinism is asserted
(seed 777 emits byte-identical twice). Generated maps load through the SL-21 `loadMap`
and run a seeded autoplay without error, and the emitted bytes preserve the measured
fairness (symmetric coverage through the real loader). See
[`../sim/mapgen.test.js`](../sim/mapgen.test.js): `node sludge/sim/mapgen.test.js`.

---

## 10. Hand-off

* **Map-picker v2 / SL-13 UI:** a generated map is a `sludge-map@1` object — accept it
  anywhere a map is accepted; expose `w`/`h`/`seed`/`mode` as front-end options
  (default `soft`; clamp size per R6). To persist a generated map as selectable, drop
  its `<id>.json` into `../sim/maps/` and add a line to `index.json` (the `default`
  stays `starter-channels`, so the protected starter is unaffected).
* **SL-21 harness:** fold a fixed `{seed, mode, size}` generated map into the seeded
  autoplay as a regression, so a `balance.json` retune that breaks a generated map's
  gate is caught loudly.
* **SL-20 map editor:** the generator's output is the same export contract; a generated
  map can be opened, hand-tweaked, and re-emitted (re-run the gate after editing).
