API reference
seeded-maze, as ESM or CommonJS. All functions are pure and synchronous. Coordinates come in two spaces: grid coordinates (x to the east, y to the south, both counted in cells from the top-left) and world coordinates (x and z in whatever unit MazeConfig.cellSize is expressed in, centred on the maze's centre cell).Everything is exported from seeded-maze, as ESM or CommonJS. All functions are pure and synchronous.
Coordinates come in two spaces: grid coordinates (x to the east, y to the south, both counted
in cells from the top-left) and world coordinates (x and z in whatever unit MazeConfig.cellSize
is expressed in, centred on the maze's centre cell).
Layout
generateMaze(width, height, seed)
function generateMaze(width: number, height: number, seed: string): MazeLayout;Generates a perfect maze (every cell reachable, no loops) with the Growing Tree algorithm, starting
from the centre cell. An even width or height is bumped up by one so the centre is a single cell.
Four exits are cut into the perimeter, one per side, never at a corner. The result is a pure function
of its arguments.
interface MazeLayout {
width: number;
height: number;
cells: MazeCell[][]; // cells[y][x]
passages: Passage[]; // every opened wall, in carving order
center: { x: number; y: number };
exits: { x: number; y: number }[];
}
interface MazeCell {
x: number;
y: number;
walls: { north: boolean; south: boolean; east: boolean; west: boolean };
visited: boolean;
isCenter: boolean;
isExit: boolean;
}
interface Passage {
from: { x: number; y: number };
to: { x: number; y: number };
direction: 'north' | 'south' | 'east' | 'west';
}getConnections(layout, x, y)
function getConnections(
layout: MazeLayout,
x: number,
y: number,
): { x: number; y: number; direction: 'north' | 'south' | 'east' | 'west' }[];The cells one step away through an open wall. For an exit cell the list also includes the position just outside the maze. Returns an empty array for a coordinate outside the grid.
Authored module composition
composeMazeModules(layout, placements)
function composeMazeModules(layout: MazeLayout, placements: readonly ModulePlacement[]): ComposedMaze;
type ModuleSide = 'north' | 'east' | 'south' | 'west';
interface ModuleCell {
walls: { north: boolean; east: boolean; south: boolean; west: boolean };
}
interface MazeModule {
id: string;
width: number;
height: number;
cells: readonly (readonly (ModuleCell | null)[])[];
ports: readonly { id: string; side: ModuleSide; offset: number; type: string }[];
anchors: readonly { id: string; kind: string; x: number; y: number }[];
}
interface ModulePlacement { x: number; y: number; module: MazeModule }Supply exactly one module per macro cell. Every module has the same positive, safe-integer
rectangular footprint. The expanded rectangle is bounded by exported MAX_COMPOSED_CELLS (1,000,000
entries, including solid tiles), with a safe-integer area check before output allocation. cells[y][x]
is either a traversable tile with four wall booleans or null
for solid space. All local walls must be reciprocal, every module boundary initially sealed, and
all traversable tiles within each module connected. A port names a candidate boundary tile:
offset is local x on north/south and local y on east/west. It must land on a traversable tile.
Multiple offsets model a wider opening; duplicate IDs or side/offset pairs are invalid.
The macro cells.walls are the adjacency authority; passages, center, exits and cell flags
do not select openings. Authored macro cycles are supported. Interior macro walls must be
reciprocal. Every open macro side requires a nonempty port set; an interior edge must have
exactly the same offsets and types on its opposite side. The composer opens those tile walls.
Candidate ports on closed macro sides remain sealed. Open perimeter sides become exterior ports;
no other module boundary opens. There is no automatic carving, content choice, repair or rotation.
interface ModuleProvenance {
moduleId: string;
macro: { x: number; y: number };
local: { x: number; y: number };
}
interface ComposedCell extends ModuleCell, ModuleProvenance { x: number; y: number }
interface ComposedAnchor extends ModuleProvenance {
id: string; kind: string; x: number; y: number;
}
interface ComposedPort extends ModuleProvenance {
id: string; side: ModuleSide; offset: number; type: string; x: number; y: number;
status: 'sealed' | 'joined' | 'exit';
}
interface ComposedMaze {
width: number; height: number;
cells: (ComposedCell | null)[][];
modules: { moduleId: string; macro: { x: number; y: number }; origin: { x: number; y: number } }[];
anchors: ComposedAnchor[];
ports: ComposedPort[];
joins: {
from: { macro: { x: number; y: number }; portId: string };
to: { macro: { x: number; y: number }; portId: string };
type: string;
}[];
reachableCells: number;
}Origins are (macro.x * module.width, macro.y * module.height); x/y in cells, anchors and ports
are expanded tile coordinates. Provenance includes the original local coordinates. Reusing a
module ID or local anchor ID across placements is supported: scope identities by macro coordinates.
An anchor needs a nonempty ID/kind and must be on a traversable tile; IDs are unique within its
module. Multiple differently named anchors may share a tile.
The result is deliberately distinct from MazeLayout: solid mask tiles are not navigable. Do not
cast it to MazeLayout or pass it to buildGeometry, buildCurvedWalls or assertSolvable.
Consumers create their own rendering/navigation adapters over non-null cells and choose gameplay
entry/goal anchors. reachableCells counts all non-null tiles and equals the number proven reachable
by tile-level BFS after stitching; every anchor and port endpoint is therefore reachable. It does
not prove a consumer's gated objectives or progression rules.
Composition draws no randomness, changes no inputs and returns independent data. Output placements
are row-major, anchors use code-unit ID order within each placement, ports use north/east/south/west
then offset, and joins use row-major placement then east/south. Reordering input arrays does not
change output. Invalid masks, missing/overlapping placements, mismatched footprints or ports,
unsealed boundaries and disconnected modules/grids throw an Error prefixed composeMazeModules:.
Geometry
MazeConfig and DEFAULT_CONFIG
interface MazeConfig {
cellSize: number;
wallHeight: number;
wallThickness: number;
}
const DEFAULT_CONFIG: MazeConfig; // { cellSize: 6.5, wallHeight: 5.5, wallThickness: 0.2 }buildGeometry(layout, config?)
function buildGeometry(layout: MazeLayout, config?: MazeConfig): MazeGeometry;Turns a layout into render-ready boxes and a navigation graph. Interior walls are emitted once, not once per side.
interface MazeGeometry {
walls: WallSegment[]; // axis-aligned boxes: x, z, width, height, depth, rotation
floor: FloorTile; // one tile covering the maze plus a margin
ceilings: CeilingTile[]; // one per cell, keyed by nodeId
railNodes: Map<string, RailNode>; // keyed "x,y"
centerNodeId: string;
exitNodeIds: string[];
}
interface RailNode {
id: string; // "x,y"
gridX: number;
gridY: number;
worldX: number;
worldZ: number;
connections: string[]; // ids of reachable neighbours
isCenter: boolean;
isExit: boolean;
}getNodeConnections(geometry, nodeId)
function getNodeConnections(geometry: MazeGeometry, nodeId: string): RailNode[];The neighbour nodes of nodeId, or an empty array for an unknown id.
gridToWorld(gridX, gridY, config, mazeWidth, mazeHeight)
function gridToWorld(
gridX: number,
gridY: number,
config: MazeConfig,
mazeWidth: number,
mazeHeight: number,
): { x: number; z: number };The world position of a cell's centre. The maze's centre cell maps to (0, 0).
worldToGrid(worldX, worldZ, config, mazeWidth, mazeHeight)
function worldToGrid(
worldX: number,
worldZ: number,
config: MazeConfig,
mazeWidth: number,
mazeHeight: number,
): { x: number; y: number };The cell containing a world position, rounded to the nearest cell. The inverse of gridToWorld for
cell centres.
Curved walls
buildCurvedWalls(layout, config?, options?)
function buildCurvedWalls(
layout: MazeLayout,
config?: MazeConfig,
options?: BuildCurvedWallsOptions,
): CurvedWall[];
interface BuildCurvedWallsOptions {
smoothing?: number; // Chaikin passes, default 2; 0 keeps the raw polyline
wobble?: { amplitude: number; wavelength: number; seed: string };
}
interface CurvedWall {
points: { x: number; z: number }[];
closed: boolean;
}Chains every contiguous run of wall into one polyline, smooths it, and optionally displaces it with a
seeded sine wobble. Every vertex is clamped to within maxDeviation(config) of the original wall
line, so a curve can never open a walled boundary or seal an open passage.
maxDeviation(config)
function maxDeviation(config: MazeConfig): number;The largest lateral distance a curved-wall vertex may move from the grid-aligned wall line.
distanceToWall(point, wall) and minDistanceToWalls(point, walls)
function distanceToWall(point: Point2, wall: CurvedWall): number;
function minDistanceToWalls(point: Point2, walls: CurvedWall[]): number;The shortest distance from a point to one polyline (a closed wall counts as a loop), or to the
nearest of many. minDistanceToWalls returns Infinity for an empty list.
Layers
generateLayeredMaze(options)
function generateLayeredMaze(options: GenerateLayeredMazeOptions): LayeredMaze;
interface GenerateLayeredMazeOptions {
layers: number; // at least 1
width: number;
height: number;
seed: string;
connectorsPerLayer?: number; // default 3, at least 1
}
interface LayeredMaze {
layers: MazeLayout[];
connectors: LayerConnector[];
entry: LayerCellRef; // centre of layer 0
goal: LayerCellRef; // centre of the last layer
}
type LayerConnectorKind = 'stair' | 'drop' | 'jump';Stacks layers independent mazes and joins each adjacent pair with connectors, preferring dead-end
cells. The first connector of every pair is a stair (two-way), so neighbouring layers are always
connected in both directions; the rest alternate drop (one-way downward) and jump (two-way,
flagged so a game can gate it). Throws if layers or connectorsPerLayer is below 1.
assertSolvable(layered)
function assertSolvable(layered: LayeredMaze): SolvabilityReport;
interface SolvabilityReport {
solvable: boolean;
pathLength: number | null; // BFS edges from entry to goal, null when unreachable
reachablePerLayer: number[];
totalPerLayer: number[];
unreachableLayers: number[];
}Breadth-first search over the union of every layer's passages and every connector, respecting one-way
drops. It reports rather than throws: check solvable.
findDeadEnds(layout)
function findDeadEnds(layout: MazeLayout): DeadEnd[];
interface DeadEnd {
cell: { x: number; y: number };
depth: number; // cells from the nearest cell on the centre-to-exit path
}Every dead-end cell (exactly one open side) that is off the main solution path, with its distance from that path (centre to the nearest exit), deepest first. Depth 1 is a one-cell alcove off the critical route; larger values are real detours.