Procedural generation
How Meep's marker-and-rule model generates levels - GridData, layers, the task pipeline, generation resources, 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 | Transform64 | 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 |
GridTaskGenerateRoads | Connect marker groups with a road network |
NoopGridTaskGenerator | Does nothing - useful as a dependency anchor |
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.
Generation resources and computeWrites()
Generators inside a GridTaskGroup are scheduled concurrently: the task executor interleaves whatever is ready, by wall-clock time slices. Two generators that write the same store therefore produce a result that depends on how the scheduler happened to cut them - the same seed yields a different world on a different machine, or in the same session behind different asset traffic.
Resources are how a generator declares what it writes so that the group can serialise the ones that collide. A GridDataLayer is already a resource under its own layer id; the two stores that are not layers get an @-prefixed name, which keeps them disjoint from layer ids (those are plain words):
import { GridGenerationResource } from
"@woosh/meep-engine/src/generation/GridGenerationResource.js";
GridGenerationResource.Markers; // '@markers' - the marker index, GridData#markers
GridGenerationResource.Dataset; // '@dataset' - the EntityComponentDataset written into,
// including the terrain entity's components
GridTaskGenerator.computeWrites() returns that list, and it is abstract - the base implementation throws Not Implemented. Every custom generator must implement it:
import { GridTaskGenerator } from
"@woosh/meep-engine/src/generation/grid/GridTaskGenerator.js";
import { GridGenerationResource } from
"@woosh/meep-engine/src/generation/GridGenerationResource.js";
class PlaceShrines extends GridTaskGenerator {
computeWrites() {
return [GridGenerationResource.Markers, "obstacles"];
}
build(grid, ecd, seed) { /* … returns a Task or TaskGroup … */ }
}
Implement it from what the generator actually holds - the layer it targets, the actions it runs, its children - never from a guess about how it will be used. An under-reported write is a race; an over-reported one only costs a little ordering.
GridCellAction.computeWrites() is the same idea one level down, and there the base returns [] rather than throwing, matching its no-op execute. Any action that writes must override it: GridCellActionPlaceMarker returns [GridGenerationResource.Markers], GridCellActionWriteFilterToLayer returns [this.layerId]. Generators that simply run an action usually delegate - GridTaskApplyActionToCells, GridTaskDensityMarkerDistribution and GridTaskCellularAutomata all return this.action.computeWrites().
For a generator or action that composes several children, compute_writes_union collects the union:
import { compute_writes_union } from
"@woosh/meep-engine/src/generation/compute_writes_union.js";
computeWrites() {
return compute_writes_union(this.children);
}
The ordering contract
GridTaskGroup.build walks its children in a topological order of the dependencies they already declare (ties broken by declaration order, so the result is a function of the group alone), and for each resource links every writer to the one immediately before it. The chain makes the rest transitive.
Two consequences worth holding on to:
- Generators writing disjoint resources stay concurrent. They cannot observe each other’s writes, so interleaving them is safe and faster.
- Every added edge points the same way as an existing one, so declaring writes can never introduce a cycle. A cycle among the declared dependencies still throws, as does naming a dependency that is not a direct sibling in the same group - use
GridTaskSequence, or restructure the groups, for ordering across group boundaries.
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.