260 lines
15 KiB
Markdown
260 lines
15 KiB
Markdown
# 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).
|