Procedural generation
How Meep's marker-and-rule model generates levels — GridData, layers, task pipeline, and where generation fits in the ECS.
Meep’s procedural-generation system lives under @woosh/meep-engine/src/generation/. The central idea is a two-phase pipeline: first, rules visit every cell of a grid and emit typed markers into a spatial index; second, a processing pass matches those markers against rules and instantiates ECS entities or paints terrain. The grid itself can carry multiple typed data layers, each independently sized and sampled.
GridData — the shared workspace
GridData is the mutable workspace that every generator reads and writes. It has three parts:
width/height— cell dimensions of the grid.layers— an array ofGridDataLayerobjects, each a named typed raster.markers— aQuadTreeNodespatial index ofMarkerNodeobjects placed during generation.
import { GridData } from "@woosh/meep-engine/src/generation/grid/GridData.js";
import { GridDataLayer } from "@woosh/meep-engine/src/generation/grid/layers/GridDataLayer.js";
import { BinaryDataType } from "@woosh/meep-engine/src/core/binary/type/BinaryDataType.js";
const grid = new GridData();
grid.resize(128, 128);
grid.computeScale(1.0); // tileSize → world-space offset/scale transform
const obstacleLayer = GridDataLayer.from("obstacles", BinaryDataType.Uint8);
grid.addLayer(obstacleLayer);
GridDataLayer.from(id, type, resolution) creates a named layer backed by a Sampler2D. The optional resolution multiplier lets a layer carry higher-resolution data than the grid cell count — useful for splat maps that need sub-cell precision. Once added, grid.getLayerById(id) retrieves it by name, and layers resize automatically when grid.resize(w, h) is called.
grid.addMarker(node) inserts a MarkerNode into the spatial index. grid.containsMarkerInCircle(x, y, radius, matcher) and grid.countMarkerInCircle(x, y, radius, matcher) query the index spatially, using a MarkerNodeMatcher to filter by type or tag.
Markers
A MarkerNode is a lightweight record that the generation pipeline places in the grid:
| Field | Type | Purpose |
|---|---|---|
type | string | Semantic type identifier — matched by rules |
tags | string[] | Arbitrary labels for matcher predicates |
position | Vector2 | Grid-space position |
transform | Transform | World-space position, rotation, scale |
size | number | Radius — used for spatial queries and overlap rejection |
priority | number | Higher-priority nodes are processed first |
properties | Object | Arbitrary key-value data for downstream rules |
Markers are emitted by GridCellActionPlaceMarker, which is a GridCellAction that runs when a rule fires on a grid cell. Multiple related markers can be emitted atomically with GridCellActionPlaceMarkerGroup, which writes a shared groupId into all properties.
The rule-and-action loop
GridActionRuleSet is the engine of the emission phase. It iterates over every cell (or sub-sample, given a resolution parameter), selects rules by the configured policy, and for each rule:
- Tests the rule’s
pattern(CellMatcher) against the current cell at up to four 90° rotations ifallowRotationis true. - Rolls against the rule’s
probability(CellFilter, default 1.0). - If both pass, calls the rule’s
GridCellAction.execute(grid, x, y, rotation).
import { GridActionRuleSet } from "@woosh/meep-engine/src/generation/markers/GridActionRuleSet.js";
import { GridCellPlacementRule } from "@woosh/meep-engine/src/generation/placement/GridCellPlacementRule.js";
import { RuleSelectionPolicyType } from "@woosh/meep-engine/src/generation/markers/RuleSelectionPolicyType.js";
import { CellFilterLiteralFloat } from "@woosh/meep-engine/src/generation/filtering/numeric/CellFilterLiteralFloat.js";
const ruleSet = GridActionRuleSet.from({
rules: [myRule],
policy: RuleSelectionPolicyType.Sequential, // or Random
pattern: [0, 0], // sub-sample offsets within each cell
});
const task = ruleSet.process(grid, seed, /* resolution= */ 1);
RuleSelectionPolicyType.Sequential tries rules in order and stops at the first match; Random shuffles before each cell, producing more varied output at higher cost. process() returns an async Task suitable for Meep’s task scheduler.
Task generators
For larger pipelines, GridTaskGenerator subclasses compose generation stages. Each generator has a build(grid, ecd, seed) method that returns a Task or TaskGroup, and dependencies can be wired between generators with addDependency / addDependencies.
| Generator | Purpose |
|---|---|
GridTaskSequence | Run a list of child generators in order |
GridTaskApplyActionToCells | Apply a GridCellAction to every cell returned by a CellSupplier |
GridTaskExecuteRuleTimes | Execute a GridCellPlacementRule exactly N times at random cells |
GridTaskDensityMarkerDistribution | Scatter markers according to a density CellFilter, with collision rejection |
GridTaskCellularAutomata (discrete) | Advance a cellular automaton over a grid layer |
GridTaskConnectRooms (discrete) | Corridor generation between room cells |
GridTaskDensityMarkerDistribution is the primary tool for organic scatter. It tiles the grid into 16×16 blocks to approximate blue-noise distribution, samples the density filter at random positions, rolls against the density value, and rejects candidates that overlap an existing marker’s radius. The expected marker count is estimated analytically before the run.
Multi-layer grids
Layers are independent: a Uint8 obstacle layer can coexist with a Float32 heightmap layer and a Uint16 biome-index layer on the same GridData. CellFilterSampleLayerLinear and CellFilterSampleLayerCubic read back from any named layer inside a filter expression. CellMatcherLayerBitMaskTest tests a bitmask against an integer layer directly inside a matcher. This lets rules in one pass use data written by a previous pass.
Where generation fits
The procedural pipeline is separate from the ECS but operates on the same EntityComponentDataset. A typical sequence is:
- Allocate a
GridDataand add layers. - Run one or more
GridActionRuleSetpasses to populate markers. - Optionally apply cellular automata or room-connection passes.
- Apply a
ThemeEngine— see Themes, matchers & rules — which converts markers into ECS entities and paints the terrain.
The filter algebra used in every step is described in Cell filters & automata.