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

16 KiB

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/Snapshots 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:
    { "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.