reikhelm/.dev/2026-05-28-procgen-map-core/spec/design.md
Parley Hatch 08e99e38a3 chore: scaffold reikhelm cargo workspace (core + viz crates)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 20:56:24 -06:00

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 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)

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; 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)

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. 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 CorridorCarvers (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.

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 Maps — 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 Maps 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).