Adds the design for a live browser-based dungeon generator: reikhelm-core compiled to WASM (after dropping the unused rand dep), a thin reikhelm-wasm bridge, and a vanilla-JS/Canvas atmospheric renderer driven + screenshotted via Playwright. Plus C2: room shape variety (rect/octagon/ellipse/plus) to escape rectangular rooms. Aesthetic target: atmospheric + polygonal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
14 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:
-
A live, browser-based dungeon playground that runs the real
reikhelm-corecompiled 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). -
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-wasmcalls a recipe and serializes the resultingMap/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-coreas 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
randdep (WASM enabler + cleanup).reikhelm-corelistsrand = "0.10"but never uses it (the only reference is a doc comment; the RNG usesrand_chacha::ChaCha8Rngand reachesrand_coretraits viarand_chacha's re-export — verified). Removing it dropsgetrandom+libcfrom the tree, so the crate compiles towasm32-unknown-unknownwith no getrandom backend gymnastics. Tests must stay green afterward. -
C2 — room shape variety (the headline generator change). Extend
RoomCarver/RoomConfigso 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), socellsis populated with exactly the interior floor cells andboundsstays the tight AABB. Contracts preserved: determinism (all draws from the pass's ownrng); theDoorPlacerpierce rule (cells= exact room interior);MstConnectusesbounds.center(), which for every shape above is an interior floor cell. Small rooms below a threshold fall back toRectso tiny shapes don't degenerate. -
C3 — iteration backlog (additive, no engine change; built in later rounds driven by the playground):
CellularAutomatacave Shaper → organic blobs (RegionKind::Cave, additive variant).Tile::Water/Tile::Lavavariants + a pool Decorator.PillarPlacer/FeatureScatterDecorator (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/RegionKindvariant.
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 forseedwith the given config (or defaults ifconfig_jsonis empty/invalid → returns the validation error in the JSON envelope), returns a JSON envelope:
(Tiles sent as a compact integer grid, not the verbose serde{ "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 }MapJSON, to keep payloads small and the JS renderer simple. The envelope is a wasm-crate shape, not a core type — the core's serdeMapis unchanged.)default_config_json() -> String— the defaultDungeonConfigas JSON, so the UI can populate sliders without hardcoding defaults.
Dependencies: reikhelm-core, wasm-bindgen, serde_json. No getrandom (thanks to C1).
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:
Doortiles 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
cellshull), connectivity graph (edge lines), grid lines.
- Floor: warm stone base + subtle per-cell value noise (hash of
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.serverfromreikhelm-web/(WASM needs correct MIME;file://is unreliable). Playwright navigates tohttp://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 WASM envelope's tile grid must match the native core's output (compare WASM JSON vs. a native render of the same seed). Same core, same seed, same map — on the web too.
5. Build & iteration loop (how Playwright is used)
cargo test -p reikhelm-coregreen (after C1/C2).wasm-pack build … --out-dir reikhelm-web/pkg.python3 -m http.serverinreikhelm-web/(background).- Playwright: navigate → screenshot baseline → exercise reroll/seed/sliders/scrubber → screenshot → judge.
- 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-wasmcrate + build pipeline; minimalreikhelm-webshell 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)
cargo testgreen at workspace root after C1/C2; clippy-clean.reikhelm-corecompiles towasm32-unknown-unknown;reikhelm-wasmbuilds via wasm-pack toreikhelm-web/pkg/.- 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).
- Rooms are visibly non-rectangular (octagon/round/plus present across seeds), driven by shape-weight controls.
- Determinism crosscheck test passes: WASM envelope tiles == native core tiles for a fixed seed+config.
- Contract holds:
reikhelm-wasmandreikhelm-webcontain no generation logic; the macroquadreikhelm-vizstill 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.