# reikhelm — Procedural 2D Map Generator (Core + Viz) **Status:** Draft spec for review **Date:** 2026-05-28 **Scope of this spec:** The core generation library and a first interactive renderer. Ships a dungeon recipe end-to-end. Towns, caves, and other map types are explicitly designed-for but deferred. --- ## 1. Goal & Philosophy Build a procedural 2D map generator as a set of **small, composable, deterministic primitives** that combine into recipes for different map types (dungeons first; towns, caves later). The organizing principle: > A map is built by composing small, deterministic **passes** over a shared **context**. Towns vs. dungeons are different **recipes** drawn from one shared **vocabulary** of passes. This is a learning-oriented, exploratory project (the author is new to procgen and learning Rust). Architecture should be idiomatic, debuggable, and expandable without rewrites — adding a new map type should mean "write a few new passes and a recipe," never "restructure the engine." Non-goals for this spec: a full game, gameplay logic, pathfinding/AI, networking, persistence beyond optional serialization, performance tuning. --- ## 2. Workspace Layout A Cargo workspace with a strict dependency direction (`viz` depends on `core`; `core` depends on nothing rendering-related). ``` reikhelm/ (cargo workspace) ├── Cargo.toml (workspace manifest) ├── reikhelm-core/ pure library: no rendering dependencies │ ├── geometry Point, Rect, Line │ ├── grid Grid │ ├── rng deterministic seeded RNG + named sub-streams │ ├── region Region, RegionKind, connectivity graph │ ├── map Map (the output type — the core/viz contract) │ ├── pass Pass trait, GenContext, Pipeline │ ├── passes/ the primitive vocabulary (partitioners, shapers, ...) │ ├── recipes/ dungeon() recipe │ └── ascii ASCII renderer (debug + tests, lives in core) └── reikhelm-viz/ binary: macroquad window, consumes reikhelm-core ``` **Why this split:** `reikhelm-core` compiles fast, is fully unit-testable without a window, and is reusable by *any* future consumer — a different renderer, a CLI, or a game loop (macroquad or Bevy). The renderer is swappable; generation never changes when you change how maps are drawn. --- ## 3. The Core/Viz Contract — `Map` (load-bearing) The single most important interface in the project. The core's entire job is to produce a `Map`; a renderer's entire job is to read one. The `Map` describes **what is where** and contains **no rendering information** (no colors, sprites, or pixels). ```rust pub struct Map { pub tiles: Grid, // base terrain layer: what occupies each cell pub regions: Vec, // semantic areas (Room #3 = this rect, kind = Room) pub graph: ConnGraph, // how regions connect (edge = door/corridor/road) pub seed: u64, // the seed that produced this map (reproducibility) pub width: u32, pub height: u32, } ``` - Renderers map data → visuals. The same `Map` can be drawn as ASCII characters, colored rectangles, or sprites — without the core changing. - **Sprite/autotiling support is a property of the renderer, not the core.** Renderers pick a sprite for a cell by querying that cell's neighbors via `Grid` (e.g. wall-corner vs. wall-edge). The core supplies neighbor queries; all art logic stays in the renderer. - `Map` and its constituent types **derive `serde::{Serialize, Deserialize}`** (cheap), so a generated map can be saved/loaded as JSON. This keeps the door open for tooling and cross-program use. *(Serialization round-trip is a nice-to-have, not a v1 acceptance gate.)* --- ## 4. Component Design ### 4.1 Geometry (`geometry`) Integer grid-coordinate types: - `Point { x: i32, y: i32 }` — arithmetic, neighbor offsets. - `Rect { x, y, w, h }` — `center()`, `contains(Point)`, `intersects(Rect)`, `iter()` over contained points, inflate/deflate. - `Line` — for corridor/segment carving; iterate cells along a line. ### 4.2 Grid (`grid`) ```rust pub struct Grid { width: u32, height: u32, cells: Vec } ``` - Bounds-safe access: `get(Point) -> Option<&T>`, `get_mut`, `set`. - `in_bounds(Point) -> bool`, `index(Point)` internal. - Neighbors: `neighbors4(Point)`, `neighbors8(Point)` returning in-bounds points. - Iterators: `iter()` (point + value), `iter_rect(Rect)`. - Constructors: `new(w, h, fill)`, `from_fn(w, h, f)`. - Generic on purpose: `Grid` is the map; `Grid`/`Grid` are scratch buffers (region-id maps, distance fields, noise) used during generation. ### 4.3 Tile (`map`) ```rust pub enum Tile { Wall, Floor, Door } // extensible: Water, Lava, Road, Rubble, ... later ``` Starts minimal. New variants are additive; passes match on the variants they care about. ### 4.4 RNG (`rng`) - Wraps `rand_chacha::ChaCha8Rng` (reproducible across platforms; not `thread_rng`). - `Rng::from_seed(u64)`. - Convenience: `range(lo..hi)`, `chance(p)`, `choose(&[T])`, `shuffle(&mut [T])`. - **Named sub-streams:** `fork(label: &str) -> Rng` derives a child generator deterministically from the parent seed + label hash. The **`Pipeline` owns stream derivation, not individual passes** (see §4.6): `run` hands each pass its *own* forked sub-stream, keyed by pass name + occurrence index. This is what makes *"inserting or reordering a pass does not reshuffle unrelated randomness"* a structural guarantee rather than a convention each pass must remember to honor — a seed that made a good map keeps making it. Built in from the start because retrofitting it later invalidates all existing seeds. ### 4.5 Region & connectivity (`region`) ```rust pub enum RegionKind { Room, Corridor, /* later: District, Plaza, Building */ } pub struct Region { pub id: RegionId, // index/handle pub kind: RegionKind, pub bounds: Rect, // bounding box pub cells: Vec, // exact occupied cells (supports non-rect regions like caves) } pub struct ConnGraph { // nodes = RegionId, edges carry the connection cell(s) (where the door/corridor is) edges: Vec, // Edge { a: RegionId, b: RegionId, at: Point } } ``` The semantic layer that lets different map types share machinery: a dungeon is rooms + corridor edges; a town (later) is districts/buildings + road edges. Same types, different passes producing them. ### 4.6 Generation context & passes (`pass`) ```rust pub struct GenContext { pub tiles: Grid, pub regions: Vec, pub graph: ConnGraph, pub blackboard: Blackboard, // named typed scratch for inter-pass handoff } pub trait Pass { fn name(&self) -> &str; fn apply(&self, ctx: &mut GenContext, rng: &mut Rng); // rng is this pass's OWN sub-stream } pub struct Pipeline { passes: Vec> } impl Pipeline { pub fn new() -> Self; pub fn then(self, pass: impl Pass + 'static) -> Self; // builder-style composition pub fn run(&self, seed: u64) -> Map; // produces final Map pub fn run_with_snapshots(&self, seed: u64) -> (Map, Vec); } ``` - One responsibility per pass; passes communicate via `regions`/`graph`/`blackboard`. - **RNG isolation is owned by the pipeline, not the pass.** `run` creates a root `Rng::from_seed(seed)` and, for each pass, derives a dedicated sub-stream `root.fork(&key)` where `key = "#"` and `occurrence` is a per-name counter owned by the run loop. So two `CorridorCarver`s (or a recipe using `RoomCarver` twice) each get a distinct, stable stream, and the `Pass` taking `rng: &mut Rng` cannot accidentally read a sibling's randomness. A pass needing multiple independent phases may further `rng.fork("phase")` internally. This is why `apply` takes `&self` (config is immutable; no per-pass occurrence state to track — the pipeline tracks it). - **`Blackboard`: named typed slots**, not bare `TypeId`. API shape: `bb.insert("bsp_leaves", leaves)` / `bb.get::>("bsp_leaves")`. Named keys (not type-keyed) so multiple passes can stash values of the *same type* without silently overwriting each other — e.g. a town recipe with both a district partition and a building partition, each emitting `Vec`. - **Snapshots:** `run_with_snapshots` records, after each pass, a label plus a copy of the tile grid **and** the region bounds + graph edges. Capturing regions/graph (not just tiles) matters because some passes change *only* the semantic layer — `BspPartition` populates regions while the grid is still all-`Wall`; `MstConnect` adds graph edges but carves nothing. A tile-only snapshot would render those (arguably the most interesting) stages as visually unchanged. Snapshotting is opt-in so the normal `run` path stays allocation-light. ### 4.7 The primitive vocabulary (`passes/`) Organized by role. v1 implements the **bold** ones; the rest are designed-for slots. | Role | Responsibility | v1 | Later | |------|---------------|----|----| | **Partitioner** | divide space → produce regions | **`BspPartition`** | `GridPartition`, `Voronoi` | | **Shaper** | carve space within regions | **`RoomCarver`** | `CellularAutomata`, `RandomWalk` | | **Connector** | wire regions together | **`MstConnect`** + **`CorridorCarver`** | extra-loop connectors | | **Placer** | place features at chosen cells | **`DoorPlacer`** | `FeatureScatter` (poisson-disk) | | **Decorator** | finishing passes | *(none required v1)* | water, tagging, wall cleanup | ### 4.8 Recipes (`recipes/`) A recipe is a named function returning a configured `Pipeline`. ```rust pub fn dungeon(cfg: DungeonConfig) -> Pipeline { Pipeline::new() .then(BspPartition::new(&cfg.partition)) .then(RoomCarver::new(&cfg.rooms)) .then(MstConnect::new(&cfg.connect)) .then(CorridorCarver::default()) .then(DoorPlacer::default()) } ``` `DungeonConfig` exposes the knobs (map size, min/max room size, partition depth, extra-corridor chance) with sensible defaults. ### 4.9 ASCII renderer (`ascii`, in core) `map.to_ascii() -> String` and `map.print_ascii()`. Lives in core because it has no external deps and is the workhorse for unit tests and quick CLI checks (`#` wall, `.` floor, `+` door). --- ## 5. Data Flow ``` seed: u64 │ ▼ Pipeline::run(seed) │ creates GenContext { empty Grid=Wall, ... } + root Rng::from_seed(seed) │ each pass below receives ctx + its own forked sub-stream (root.fork("#")) │ ├─ BspPartition → fills ctx.regions with leaf rects (kind=Room placeholder) ├─ RoomCarver → carves Floor inside each region; finalizes Room regions ├─ MstConnect → builds ConnGraph edges (which rooms connect) ├─ CorridorCarver → carves Floor corridors along graph edges; adds Corridor regions └─ DoorPlacer → sets Door tiles where corridors meet rooms; records edge.at │ ▼ Map { tiles, regions, graph, seed, width, height } │ ▼ reikhelm-viz → draw_rectangle per cell (color by Tile) [today] sprite per cell (chosen via neighbor query) [your art, later] ``` --- ## 6. The Viz Crate (`reikhelm-viz`) A thin macroquad binary. Responsibilities only: - Call a recipe, get a `Map`. - Draw the grid: one colored rectangle per cell, color chosen by `Tile`. - **Reroll:** press a key (e.g. `Space`) → new seed → regenerate → redraw. - Display the current seed on screen. - **Stretch (in scope if time allows):** generate with `run_with_snapshots`; left/right arrows scrub through generation stages. Viz contains **no generation logic** and **no algorithm knowledge** — only "given a `Map`, paint it." This is the proof that the contract holds: if the viz crate needs to reach into generation internals to draw, the boundary is wrong. --- ## 7. Determinism Contract - Same `seed` → byte-identical `Map`, on any machine, every run. - All randomness flows through `Rng` (ChaCha8). No `HashMap` iteration order, system time, or `thread_rng` may influence generation. - The pipeline hands each pass a dedicated forked sub-stream keyed by name + occurrence (§4.6), so inserting or reordering a pass does not silently change other passes' output for an existing seed. - `run(seed)` and `run_with_snapshots(seed)` must produce identical `Map`s — snapshotting only *reads* context state after each pass and must never consume the RNG or otherwise perturb generation. --- ## 8. Error Handling Generation is mostly infallible by construction, but inputs can be invalid: - **Config validation:** `DungeonConfig` validated at recipe construction — e.g. min room size must fit within the smallest possible BSP leaf, map dimensions > 0. Invalid config returns a `Result`/`ConfigError` rather than panicking deep in a pass. - **Degenerate outcomes** (e.g. a BSP leaf too small to hold a room): the pass skips that leaf gracefully and records nothing, rather than failing the whole generation. A map with fewer rooms is valid; a panic is not. - **In-pass bounds:** all grid writes go through bounds-safe `Grid` accessors; an out-of-bounds carve is a no-op, never a panic. - Internal invariant violations (a corridor referencing a nonexistent region id) use `debug_assert!` to catch bugs in development without costing release performance. --- ## 9. Testing Strategy - **Determinism test:** generate twice with the same seed; assert `Map`s are equal (and ASCII output is identical). - **Connectivity test:** flood-fill the floor tiles of a generated dungeon; assert every `Room` region is reachable (no orphaned rooms). - **Bounds test:** assert no carve ever writes outside the grid; all rooms lie within map bounds. - **Geometry/Grid unit tests:** `Rect::intersects`, `contains`, neighbor queries, `Grid` get/set bounds behavior. - **Snapshot count test:** `run_with_snapshots` yields one snapshot per pass. - ASCII output is the primary human-readable assertion vehicle. --- ## 10. v1 Acceptance Criteria 1. `cargo build` / `cargo test` pass at the workspace root. 2. `reikhelm-core` produces a connected dungeon `Map` from a seed, with rooms, corridors, and doors, fully deterministic. 3. `map.print_ascii()` renders a recognizable dungeon. 4. `cargo run -p reikhelm-viz` opens a window showing the dungeon in colored tiles; pressing the reroll key produces a new dungeon; the seed is visible. 5. The dependency boundary holds: `reikhelm-core` has no rendering dependency; `reikhelm-viz` contains no generation logic. --- ## 11. Explicitly Deferred (designed-for, not built in v1) - **Town recipe** (district partition, building placement, road network) — new passes + recipe, no architecture change. - **Cave recipe** (cellular automata, random walk, keep-largest-region). - Additional `Tile` variants (water, lava, road), decorators. - `FeatureScatter` / poisson-disk placement. - Sprite renderer + autotiling (the architecture supports it; building it is future art work). - Serialization round-trip tooling, CLI, performance tuning, any gameplay/engine integration (macroquad game or Bevy).