reikhelm/.dev/2026-05-30-browser-viz-and-organic-dungeons/spec/design.md
Parley Hatch 9d8d8b28f0 docs(spec): address critic findings on viz playground spec
- C1.5: derive serde on DungeonConfig + 5 sub-configs (bridge needs it)
- C2: make "shape contains bounds.center()" a hard carver invariant (not a
  size heuristic the room-size sliders could defeat for Plus rooms)
- specify workspace placement (member + default-members excludes wasm from
  host cargo test/clippy)
- determinism crosscheck compares decoded tiles, excludes gen_ms

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 23:48:27 -06:00

184 lines
16 KiB
Markdown

# reikhelm — Browser Viz Playground + Organic Dungeons
**Status:** Draft spec for review
**Date:** 2026-05-30
**Builds on:** `.dev/2026-05-28-procgen-map-core/` (v1 core complete: BSP → rooms → MST → corridors → doors, 115 tests green).
**Aesthetic target (user-chosen):** *Atmospheric + polygonal* — moody, torch-lit dungeon, walls with depth/shadow, rooms that break out of pure rectangles (octagon, round, plus). Caverns/water/pillars layered on later.
---
## 1. Goal
Two things, tightly coupled:
1. **A live, browser-based dungeon playground** that runs the *real* `reikhelm-core` compiled to WebAssembly. Reroll, type a seed, drag config sliders, scrub the generation stages — all client-side, instant. Rendered to a `<canvas>` in the atmospheric style above. This is driveable + screenshottable via the Playwright MCP, which unlocks a tight visual iteration loop the native macroquad viz can't (it needs a display).
2. **Evolve the generator's shapers** from always-rectangular rooms toward polygonal/organic shapes, then (backlog) caverns, water, and decoration — judged by what actually looks good in the playground.
The playground is the *instrument*; the generator evolution is the *music*. We build the instrument first, prove the loop, then iterate.
**Non-goals:** gameplay, pathfinding, networking, a JS build toolchain/framework (vanilla JS only), replacing the macroquad viz (it stays). No change to the determinism contract.
---
## 2. Guiding Constraint — the contract is sacred
The project's load-bearing idea (spec §3 of the core) is: **the core produces a `Map`; a renderer reads one; the `Map` holds no rendering info.** This effort adds a *second renderer* (browser) and a *thin adapter* (WASM bridge). Neither may contain generation logic. The proof: if the browser or the WASM crate needs to know *how* a dungeon is built in order to draw it, the boundary is wrong.
Concretely:
- `reikhelm-wasm` calls a recipe and serializes the resulting `Map`/`Snapshot`s to JSON. Nothing else.
- The browser reads that JSON and paints it. All art — depth, light, color, polygon edges — lives here.
- All generator evolution happens in `reikhelm-core` as new/extended **passes**, exactly as the architecture intends.
---
## 3. Architecture (three layers)
```
┌─ reikhelm-core (pure lib, existing) ───────────────────────────┐
│ Map { tiles, regions, graph, seed, w, h } ← serde-ready │
│ passes: bsp · room(+SHAPES) · mst · corridor · door · …(new) │
└────────────────────────────────────────────────────────────────┘
│ (Cargo dep) │ (Cargo dep, native, unchanged)
▼ ▼
┌─ reikhelm-wasm (NEW, cdylib) ──┐ ┌─ reikhelm-viz (macroquad, kept) ─┐
│ #[wasm_bindgen] generate(...) │ │ native window renderer │
│ → Map+Snapshots as JSON │ └──────────────────────────────────┘
└────────────────────────────────┘
│ wasm-pack build --target web → pkg/{*.js,*.wasm}
┌─ reikhelm-web/ (NEW, static) ──────────────────────────────────┐
│ index.html · main.js · render.js · style.css │
│ loads WASM · canvas renderer · controls · stage scrubber │
│ served by `python3 -m http.server` · driven by Playwright │
└────────────────────────────────────────────────────────────────┘
```
### 3.1 `reikhelm-core` changes (additive + one cleanup)
- **C1 — drop unused `rand` dep (WASM enabler + cleanup).** `reikhelm-core` lists `rand = "0.10.1"` but never uses it (the only reference is a doc comment; the RNG uses `rand_chacha::ChaCha8Rng` and reaches `rand_core` traits via `rand_chacha`'s re-export — *verified, and the critic empirically compiled a rand-free core to `wasm32-unknown-unknown` with all 115 tests still green*). Removing it drops `getrandom` + `libc` from the tree, so the crate compiles to wasm32 with **no getrandom backend gymnastics**.
- **C1.5 — derive serde on the config types (additive prerequisite for the bridge).** `DungeonConfig` and its five sub-configs (`BspConfig`, `RoomConfig`, `ConnectConfig`, `DoorConfig`, and the new shape-weight config from C2) currently derive only `Clone/Copy/Debug/PartialEq`. The wasm bridge must deserialize a `DungeonConfig` from the page's JSON and serialize the default back out, so each gains `#[derive(Serialize, Deserialize)]` (additive, no behavior change). `serde_json` is presently a *dev*-dependency of core — that's fine; the configs only need the serde *derives* (already a normal core dep), and the bridge crate owns its own `serde_json`.
- **C2 — room shape variety (the headline generator change).** Extend `RoomCarver`/`RoomConfig` so each room, after its rectangle is chosen within the BSP leaf, is carved in one of several **shapes** selected per-room from the pass RNG with configurable weights:
- `Rect` — current behavior (the rectangle itself).
- `Octagon` — rectangle with chamfered corners (cut triangular corners proportional to size).
- `Ellipse` — rounded room inscribed in the rectangle.
- `Plus` / cross — central cross with cut corners.
Each shape is a predicate over the chosen rect (`cell ∈ shape`), so `cells` is populated with exactly the interior floor cells and `bounds` stays the tight AABB. **Contracts preserved:** determinism (all draws from the pass's own `rng`); the `DoorPlacer` pierce rule (`cells` = exact room interior — the critic confirmed the pierce logic survives ellipse/octagon/plus, since a corridor runs center-to-center and a room's carved cells along any row form a contiguous run, so exactly one room→corridor transition is detected).
**`MstConnect`/`CorridorCarver` rely on `bounds.center()` being a carved floor cell. This is a *hard invariant the carver enforces*, not a size heuristic:** for `Octagon`/`Ellipse` the center is always carved, but a `Plus` at small slider-driven sizes can put `bounds.center()` (integer division) in a cut corner. So the carver's rule is: carve the chosen shape **only if its cell set contains `bounds.center()`; otherwise carve `Rect`.** (Equivalently, force the center cell carved.) Since the playground exposes room min/max as live sliders, this guard — not the recipe's `min_size` — is what guarantees every room's center is floor for *any* config.
- **C3 — iteration backlog (additive, no engine change; built in later rounds driven by the playground):**
- `CellularAutomata` cave Shaper → organic blobs (`RegionKind::Cave`, additive variant).
- `Tile::Water` / `Tile::Lava` variants + a pool Decorator.
- `PillarPlacer` / `FeatureScatter` Decorator (interior obstacles that never disconnect a room).
- Corridor variety: jittered/dog-leg routing, width-2 halls.
C3 is explicitly *not* committed scope — it is the menu we draw from while iterating. Each item is a new pass + (maybe) an additive `Tile`/`RegionKind` variant.
### 3.2 `reikhelm-wasm` (new crate, `crate-type = ["cdylib"]`)
A thin `wasm-bindgen` bridge. Public surface (all *to be created*):
- `generate(seed: u64, config_json: &str) -> String` — runs the dungeon recipe for `seed` with the given config (or defaults if `config_json` is empty/invalid → returns the validation error in the JSON envelope), returns a JSON envelope:
```json
{ "ok": true,
"seed": 0,
"width": 64, "height": 40,
"tiles": [[0,1,2,...]], // row-major tile codes (0=Wall,1=Floor,2=Door)
"regions": [{ "id":0,"kind":"Room","bounds":{...},"cells":[[x,y],...] }],
"edges": [{ "a":0,"b":1,"at":[x,y] }],
"snapshots": [{ "label":"bsp_partition","tiles":[...],"regions":[...],"edges":[...] }],
"gen_ms": 1.2 }
```
(Tiles sent as a compact integer grid, not the verbose serde `Map` JSON, to keep payloads small and the JS renderer simple. The envelope is a *wasm-crate* shape, not a core type — the core's serde `Map` is unchanged.)
- `default_config_json() -> String` — the default `DungeonConfig` as JSON, so the UI can populate sliders without hardcoding defaults.
Dependencies: `reikhelm-core`, `wasm-bindgen`, `serde_json`. No `getrandom` (thanks to C1). The bridge uses **only plain `#[wasm_bindgen]` functions** taking `u64`/`&str` and returning `String` — no `js-sys`/`web-sys` — so it also compiles on the host target.
**Workspace placement:** `reikhelm-wasm` joins the workspace `members`, but the workspace sets `default-members = ["reikhelm-core", "reikhelm-viz"]` so a bare `cargo test`/`cargo clippy` at the root (acceptance criterion 1) operates only on the host-native crates and never pulls `wasm-bindgen` into the default host build. The bridge is built deliberately with `cargo build -p reikhelm-wasm --target wasm32-unknown-unknown` (via wasm-pack). Phase 0 confirms `cargo build -p reikhelm-wasm` is also host-green as a safety net.
**Build:** `wasm-pack build reikhelm-wasm --target web --out-dir ../reikhelm-web/pkg`. Primary toolchain: `rustup target add wasm32-unknown-unknown` + `cargo install wasm-pack`. *Fallbacks if wasm-pack is unavailable:* (a) `cargo install wasm-bindgen-cli` + manual `cargo build --target wasm32-… && wasm-bindgen …`; (b) ultimate fallback — a native `--bin export` that writes the same JSON envelope to a file the page fetches, with Playwright re-running it to "reroll" (loses live sliders, keeps the loop).
### 3.3 `reikhelm-web/` (new, static front-end — vanilla JS + Canvas 2D)
- **`index.html`** — canvas + a controls panel.
- **`main.js`** — loads the WASM module, wires controls → `generate(...)` → render; owns app state (seed, config, current stage, toggles).
- **`render.js`** — the atmospheric Canvas 2D renderer (the art):
- **Floor:** warm stone base + subtle per-cell value noise (hash of `(x,y)`) so it isn't flat.
- **Light pooling:** compute a distance-to-nearest-wall field in JS; brighten floor by depth-into-room and apply a soft global vignette → rooms glow, corridors read darker. (Deterministic, renderer-only; no core light data needed for v1.)
- **Walls with depth:** wall cells drawn with a darker body + a lighter top lip / drop shadow onto adjacent floor, so walls read as raised. Crisp floor/wall boundary that follows the *actual* (now polygonal) room edge.
- **Doors vs. archways:** `Door` tiles get a distinct threshold mark (accent color + jambs); open archways (floor pierces with no door) render as plain gaps — the doors/openings mix the user asked for becomes visible.
- **Semantic overlays (toggle):** region outlines (follow `cells` hull), connectivity graph (edge lines), grid lines.
- **`style.css`** — dark UI shell.
- **Controls:** reroll (button + `R`), seed entry, sliders (map W/H, BSP depth/min-leaf, room min/max, extra-edge ratio, door chance, shape weights, later: cave/water/pillar knobs), stage scrubber (◀/▶ + slider over snapshots), toggles (outlines / graph / grid / lighting), readout (seed, room count, stage label, `gen_ms`).
- **Serving:** `python3 -m http.server` from `reikhelm-web/` (WASM needs correct MIME; `file://` is unreliable). Playwright navigates to `http://localhost:<port>`.
---
## 4. Data flow
```
UI (seed + config)
│ JSON.stringify(config)
wasm.generate(seed, config_json) ── runs the REAL core in-browser
│ JSON envelope { tiles, regions, edges, snapshots, gen_ms }
render.js ── distance field → light → draw floor/walls/doors → overlays
<canvas> ←─ Playwright screenshots ─→ visual judgment → iterate
```
Determinism crosscheck (test): for a fixed seed+config, the **decoded `tiles` grid** (and optionally `regions`/`edges`) from the WASM envelope must equal the native core's output — explicitly *excluding* the `gen_ms` timing field, which is wall-clock and differs every run (so this is a structured comparison of the tile array, never a raw JSON-string diff). Same core, same seed, same map — on the web too.
---
## 5. Build & iteration loop (how Playwright is used)
1. `cargo test -p reikhelm-core` green (after C1/C2).
2. `wasm-pack build … --out-dir reikhelm-web/pkg`.
3. `python3 -m http.server` in `reikhelm-web/` (background).
4. Playwright: navigate → screenshot baseline → exercise reroll/seed/sliders/scrubber → screenshot → judge.
5. Edit renderer and/or shapers → rebuild → re-screenshot → compare → refine. Repeat per round.
---
## 6. Phasing (committed scope vs. backlog)
- **Phase 0 — Enabler (committed):** C1; `reikhelm-wasm` crate + build pipeline; minimal `reikhelm-web` shell that loads WASM and renders the *current* (rectangular) dungeon plainly; Playwright loop proven end-to-end; determinism crosscheck test.
- **Phase 1 — Atmospheric renderer (committed):** full Canvas renderer (depth walls, light pooling, floor texture, distinct doors/archways), controls, stage scrubber, semantic overlays. Big visual leap with zero core change.
- **Phase 2 — Polygonal shapers (committed):** C2 (rect/octagon/ellipse/plus with config weights), surfaced as shape-weight sliders. The "escape rectangles" deliverable.
- **Phase 3+ — Iteration backlog (not committed):** C3 items (caverns, water/lava, pillars, corridor variety), chosen by what looks good in the loop.
---
## 7. Risks & mitigations
| Risk | Mitigation |
|---|---|
| `getrandom` won't build on wasm32 | **C1 removes it from the tree** (verified unused). Fallback: enable `getrandom`'s `wasm_js` backend via `.cargo/config.toml` rustflags. |
| `wasm-pack` install slow/unavailable | Fallbacks in §3.2: `wasm-bindgen-cli`, then native JSON export + page-fetch (Playwright reload to reroll). |
| WASM `file://` load fails (MIME/CORS) | Serve via `python3 -m http.server`; Playwright hits `http://localhost`. |
| Concave rooms (plus) break door/MST | `MstConnect` uses `bounds.center()` (interior floor for all shapes); doors are cosmetic (connectivity is floor). Watch visually; small rooms fall back to `Rect`. |
| Renderer drifts into generation logic | Code review gate: the browser only reads the JSON envelope; any "how it's built" knowledge is a boundary violation. |
| Changing `RoomCarver` alters existing seeds | Acceptable — determinism = "same seed → same map," which still holds. No external seed-stability contract on room *shape*. Per-pass RNG keying means other passes are unaffected. |
---
## 8. Acceptance criteria (committed phases)
1. `cargo test` green at workspace root after C1/C2; clippy-clean.
2. `reikhelm-core` compiles to `wasm32-unknown-unknown`; `reikhelm-wasm` builds via wasm-pack to `reikhelm-web/pkg/`.
3. The browser page loads, generates in-browser, and renders an atmospheric dungeon: depth walls, light pooling, distinct doors. Reroll, seed entry, ≥3 live config sliders, and stage scrubbing all work (verified by Playwright screenshots).
4. Rooms are visibly **non-rectangular** (octagon/round/plus present across seeds), driven by shape-weight controls.
5. Determinism crosscheck test passes: WASM envelope tiles == native core tiles for a fixed seed+config.
6. Contract holds: `reikhelm-wasm` and `reikhelm-web` contain no generation logic; the macroquad `reikhelm-viz` still builds.
---
## 9. Explicitly deferred (designed-for, not committed)
Caverns (cellular automata), `Tile::Water`/`Lava` + pool decorator, pillar/feature scatter, corridor variety (jitter, width-2), deterministic in-core light/feature placement (a future Placer that would extend the `Map` with a features layer), exporting PNG/JSON from the page, multiple recipes (town/cave) in the playground dropdown.