15 KiB
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).
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
Mapcan 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. Mapand its constituent types deriveserde::{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)
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)
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; notthread_rng). Rng::from_seed(u64).- Convenience:
range(lo..hi),chance(p),choose(&[T]),shuffle(&mut [T]). - Named sub-streams:
fork(label: &str) -> Rngderives a child generator deterministically from the parent seed + label hash. ThePipelineowns stream derivation, not individual passes (see §4.6):runhands 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)
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)
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.
runcreates a rootRng::from_seed(seed)and, for each pass, derives a dedicated sub-streamroot.fork(&key)wherekey = "<pass.name()>#<occurrence>"andoccurrenceis a per-name counter owned by the run loop. So twoCorridorCarvers (or a recipe usingRoomCarvertwice) each get a distinct, stable stream, and thePasstakingrng: &mut Rngcannot accidentally read a sibling's randomness. A pass needing multiple independent phases may furtherrng.fork("phase")internally. This is whyapplytakes&self(config is immutable; no per-pass occurrence state to track — the pipeline tracks it). Blackboard: named typed slots, not bareTypeId. 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 emittingVec<Rect>.- Snapshots:
run_with_snapshotsrecords, 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 —BspPartitionpopulates regions while the grid is still all-Wall;MstConnectadds 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 normalrunpath 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.
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-identicalMap, on any machine, every run. - All randomness flows through
Rng(ChaCha8). NoHashMapiteration order, system time, orthread_rngmay 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)andrun_with_snapshots(seed)must produce identicalMaps — 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:
DungeonConfigvalidated at recipe construction — e.g. min room size must fit within the smallest possible BSP leaf, map dimensions > 0. Invalid config returns aResult/ConfigErrorrather 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
Gridaccessors; 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
Maps are equal (and ASCII output is identical). - Connectivity test: flood-fill the floor tiles of a generated dungeon; assert every
Roomregion 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,Gridget/set bounds behavior. - Snapshot count test:
run_with_snapshotsyields one snapshot per pass. - ASCII output is the primary human-readable assertion vehicle.
10. v1 Acceptance Criteria
cargo build/cargo testpass at the workspace root.reikhelm-coreproduces a connected dungeonMapfrom a seed, with rooms, corridors, and doors, fully deterministic.map.print_ascii()renders a recognizable dungeon.cargo run -p reikhelm-vizopens a window showing the dungeon in colored tiles; pressing the reroll key produces a new dungeon; the seed is visible.- The dependency boundary holds:
reikhelm-corehas no rendering dependency;reikhelm-vizcontains 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
Tilevariants (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).