# 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<T>
│   ├── 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<Tile>,   // base terrain layer: what occupies each cell
    pub regions: Vec<Region>,  // 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<T> { width: u32, height: u32, cells: Vec<T> }
```
- 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<Tile>` is the map; `Grid<u32>`/`Grid<f32>` 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<Point>,   // 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>,        // 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<Tile>,
    pub regions: Vec<Region>,
    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<Box<dyn Pass>> }
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<Snapshot>);
}
```
- 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 = "<pass.name()>#<occurrence>"` 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::<Vec<Rect>>("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<Rect>`.
- **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<Tile>=Wall, ... } + root Rng::from_seed(seed)
   │   each pass below receives ctx + its own forked sub-stream (root.fork("<name>#<n>"))
   │
   ├─ 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).
