Themes, matchers & rules
The theme and biome system, pattern matchers for grid cells and markers, predicate combinators, and weighted random placement.
Once markers are in the grid, a ThemeEngine drives the final placement pass. It organises Theme objects into AreaTheme regions with binary masks, applies terrain layer rules, and processes each MarkerNode against a rule set. The matching and predicate layer is a small algebra of composable types, all verified in source.
Source: @woosh/meep-engine/src/generation/
Theme structure
A Theme holds three independent rule sets:
| Field | Type | Purpose |
|---|---|---|
terrain | TerrainTheme | List of TerrainLayerRule objects that paint terrain layer weights |
nodes | MarkerNodeProcessingRuleSet | Rules that consume MarkerNode objects and produce ECS entities |
cells | CellProcessingRuleSet | Per-cell rules applied during ThemeEngine.applyCellRules |
theme.initialize(seed, ecd, grid) initialises all three sub-sets in one call.
Terrain layer rules
TerrainLayerRule binds a CellFilter (the weight function) to a TerrainLayerDescription (which texture/material to use). The ThemeEngine evaluates every rule’s filter at each grid cell, normalises the weights across all active themes at that cell (accounting for distance-field blending at biome boundaries), and writes the result into the terrain’s splat map.
import { TerrainLayerRule } from "@woosh/meep-engine/src/generation/theme/TerrainLayerRule.js";
import { TerrainLayerDescription } from "@woosh/meep-engine/src/generation/theme/TerrainLayerDescription.js";
import { CellFilterSimplexNoise } from "@woosh/meep-engine/src/generation/filtering/numeric/complex/CellFilterSimplexNoise.js";
import { CellFilterSaturate } from "@woosh/meep-engine/src/generation/filtering/numeric/math/CellFilterSaturate.js";
const rule = TerrainLayerRule.from(
CellFilterSaturate.from(CellFilterSimplexNoise.fractal(3, 40)),
new TerrainLayerDescription() // fill in texture asset refs
);
theme.terrain.rules.push(rule);
ThemeEngine and area masks
ThemeEngine stores AreaTheme objects in a quad-tree. Each AreaTheme pairs a Theme with an AreaMask:
import { ThemeEngine } from "@woosh/meep-engine/src/generation/theme/ThemeEngine.js";
import { AreaTheme } from "@woosh/meep-engine/src/generation/theme/AreaTheme.js";
import { AreaMask } from "@woosh/meep-engine/src/generation/theme/AreaMask.js";
const engine = new ThemeEngine();
engine.seed = 42;
engine.assetManager = assetManager;
const area = new AreaTheme();
area.theme = myTheme;
area.mask.resize(128, 128);
// paint area.mask.mask.data with 0/1 …
engine.add(area); // auto-computes bounds + distance field
const taskGroup = engine.apply(grid, ecd);
engine.apply(grid, ecd) returns a TaskGroup holding: initialise themes, process markers into nodes, apply per-cell rules, and - only when the dataset contains a Terrain - apply terrain themes, ensure every terrain tile is built, and re-synchronise the terrain’s GPU state. Dependencies between them are declared rather than sequential, so independent stages interleave. When multiple areas overlap a cell, each area’s distance-field value is used to proportionally blend the terrain weights.
There is no lightmap or ambient-occlusion bake step, and generation needs no GPU at all. engine.assetManager must be set before apply - the terrain pass asserts on it.
engine.getThemesByPosition(result, x, y) returns all AreaTheme objects whose mask contains the point - used internally during both terrain and node passes, and accessible for custom logic.
CellMatcher - pattern predicates on grid cells
CellMatcher is the base class for predicates over GridData. Its interface is match(grid, x, y, rotation) → boolean. It is used as the pattern field of GridCellPlacementRule.
Primitive matchers
| Class | Import path | Matches when |
|---|---|---|
CellMatcherAny | rules/CellMatcherAny.js | Always - use as a wildcard |
CellMatcherFromFilter | rules/CellMatcherFromFilter.js | The underlying CellFilter.execute returns > 0 |
CellMatcherLayerBitMaskTest | rules/CellMatcherLayerBitMaskTest.js | A bit-mask test on an integer GridDataLayer passes at the cell |
CellMatcherContainsMarkerWithinRadius | rules/cell/CellMatcherContainsMarkerWithinRadius.js | A marker matching a given MarkerNodeMatcher exists within a radius of the cell |
CellMatcherRemovalPreservesConnectivity | rules/cell/CellMatcherRemovalPreservesConnectivity.js | Taking this cell out of a region would not break the region apart |
Keeping a region connected
CellMatcherRemovalPreservesConnectivity.from(region) wraps another CellMatcher that defines the region - traversable ground, buildable ground, anything connected - and matches only cells that can be removed from it without splitting it.
import { CellMatcherRemovalPreservesConnectivity } from
"@woosh/meep-engine/src/generation/rules/cell/CellMatcherRemovalPreservesConnectivity.js";
import { CellMatcherAnd } from "@woosh/meep-engine/src/generation/rules/logic/CellMatcherAnd.js";
const safeToBuildOn = CellMatcherAnd.from(
myPlacementPattern,
CellMatcherRemovalPreservesConnectivity.from(walkable)
);
Placement rules that clear traversability - a town footprint, an interactable that occupies its tile - decide from purely local evidence, and local evidence cannot see that a tile is the only way through. Composing this matcher in makes “keeps the region connected” a precondition of placement instead of something checked afterwards, or not at all.
The test is the classical simple-point criterion of digital topology, read from the 3×3 neighbourhood, so it is O(1) regardless of region size. Three conditions must hold: the region cells of the 8-ring form exactly one contiguous arc (so any path through this cell can be re-routed along the ring); no orthogonally adjacent region cell is left with no other region neighbour; and the cell has an orthogonal neighbour at all, so it is not a component of its own. The ring is traversed in cycle order under the same 4-adjacency an agent walks with.
It is a sufficient condition, not a necessary one: a cell whose ring splits into two arcs that reconnect somewhere far away is rejected even though a long detour exists. Rejecting costs a placement opportunity and the rule fires on another cell; accepting wrongly costs the level.
Pattern matcher
CellMatcherGridPattern matches a multi-cell neighbourhood. Each cell in the pattern is described by a GridPatternMatcherCell - a (x, y) local offset plus a child CellMatcher to evaluate at that offset. The whole pattern is tested after rotating each offset by rotation, so a single rule fires regardless of orientation when allowRotation is true.
import { CellMatcherGridPattern } from "@woosh/meep-engine/src/generation/rules/cell/CellMatcherGridPattern.js";
import { GridPatternMatcherCell } from "@woosh/meep-engine/src/generation/rules/cell/GridPatternMatcherCell.js";
import { CellMatcherFromFilter } from "@woosh/meep-engine/src/generation/rules/CellMatcherFromFilter.js";
const pattern = CellMatcherGridPattern.from([
GridPatternMatcherCell.from(someFilter, 0, 0), // origin
GridPatternMatcherCell.from(edgeFilter, 1, 0), // one cell to the right
GridPatternMatcherCell.from(edgeFilter, -1, 0), // one cell to the left
]);
Logic combinators for CellMatcher
| Class | Import path | Behaviour |
|---|---|---|
CellMatcherAnd | rules/logic/CellMatcherAnd.js | Both children must match |
CellMatcherOr | rules/logic/CellMatcherOr.js | Either child must match |
CellMatcherNot | rules/logic/CellMatcherNot.js | Inverts a single child |
All three have a static from(left, right) / static from(source) factory that asserts isCellMatcher.
MarkerNodeMatcher - predicates over marker nodes
MarkerNodeMatcher evaluates a MarkerNode - not a grid position - and is used in MarkerProcessingRule and as the filter argument to spatial queries like countMarkerInCircle.
Primitive marker matchers
| Class | Import path | Matches when |
|---|---|---|
MarkerNodeMatcherAny | markers/matcher/MarkerNodeMatcherAny.js | Always |
MarkerNodeMatcherNone | markers/matcher/MarkerNodeMatcherNone.js | Never |
MarkerNodeMatcherByType | markers/matcher/MarkerNodeMatcherByType.js | node.type === type |
MarkerNodeMatcherContainsTag | markers/matcher/MarkerNodeMatcherContainsTag.js | node.tags contains the given tag string |
Logic combinators for MarkerNodeMatcher
| Class | Import path | Behaviour |
|---|---|---|
MarkerNodeMatcherAnd | markers/matcher/MarkerNodeMatcherAnd.js | Both children match |
MarkerNodeMatcherOr | markers/matcher/MarkerNodeMatcherOr.js | Either child matches |
MarkerNodeMatcherNot | markers/matcher/MarkerNodeMatcherNot.js | Inverts child |
GridDataNodePredicate - spatial node predicates
GridDataNodePredicate evaluates both a GridData and a MarkerNode together - useful for decisions that depend on spatial context. Its evaluate(grid, node) → boolean method receives the full grid.
| Class | Import path | Behaviour |
|---|---|---|
GridDataNodePredicateAny | markers/predicate/GridDataNodePredicateAny.js | Always true; .INSTANCE singleton |
GridDataNodePredicateNot | markers/predicate/GridDataNodePredicateNot.js | Inverts a child predicate |
GridDataNodePredicateAnd | markers/predicate/GridDataNodePredicateAnd.js | Both children must be true |
GridDataNodePredicateOverlaps | markers/predicate/GridDataNodePredicateOverlaps.js | True if any marker matching a MarkerNodeMatcher overlaps the node’s circle |
Marker processing rules
MarkerProcessingRule is the rule that binds a matcher, optional transformers, and an action for the marker processing pass:
import { MarkerProcessingRule } from "@woosh/meep-engine/src/generation/markers/actions/MarkerProcessingRule.js";
import { MarkerNodeMatcherByType } from "@woosh/meep-engine/src/generation/markers/matcher/MarkerNodeMatcherByType.js";
import { MarkerNodeActionEntityPlacement } from "@woosh/meep-engine/src/generation/markers/actions/MarkerNodeActionEntityPlacement.js";
const rule = MarkerProcessingRule.from({
matcher: MarkerNodeMatcherByType.from("tree"),
transformers: [],
action: MarkerNodeActionEntityPlacement.from({ blueprint: treeBlueprint }),
consume: true, // stop processing this node after this rule fires
});
theme.nodes.add(rule);
MarkerNodeActionEntityPlacement reads the marker’s transform and properties to position the entity blueprint. It copies the marker’s GridPosition if the entity has one, then multiplies the node’s world transform with the rule’s base transform.
Marker transformers
Transformers mutate a MarkerNode clone before the action runs:
| Transformer | Import path | Effect |
|---|---|---|
MarkerNodeTransformRotateRandom | markers/transform/ | Random Y rotation |
MarkerNodeTransformerYRotateByFilter | markers/transform/ | Y rotation driven by a CellFilter value |
MarkerNodeTransformerYRotateByFilterGradient | markers/transform/ | Y rotation aligned to the gradient direction of a CellFilter |
MarkerNodeTransformerAddPositionYFromFilter | markers/transform/ | Adds filter value to the marker’s world Y position (terrain snap) |
MarkerNodeTransformerOffsetPosition | markers/transform/ | Translates position by a fixed offset |
MarkerNodeTransformerRecordProperty | markers/transform/ | Writes a filter value into node.properties |
MarkerNodeTransformerRecordUniqueRandomEnum | markers/transform/ | Picks a unique random value from a set and stores it in properties |
MarkerNodeTransformerRemoveTag | markers/transform/ | Removes a tag from the node |
Weighted random placement
MarkerNodeActionSelectWeighted picks one action from a list based on per-position weights. Each option is a MarkerNodeActionWeightedElement - an action paired with a CellFilter that returns the weight at the marker’s position. The selection is deterministic given the seed.
import { MarkerNodeActionSelectWeighted } from "@woosh/meep-engine/src/generation/markers/actions/probability/MarkerNodeActionSelectWeighted.js";
import { MarkerNodeActionWeightedElement } from "@woosh/meep-engine/src/generation/markers/actions/probability/MarkerNodeActionWeightedElement.js";
const selector = MarkerNodeActionSelectWeighted.from([
MarkerNodeActionWeightedElement.from(placeOak, highElevationFilter),
MarkerNodeActionWeightedElement.from(placePine, lowElevationFilter),
MarkerNodeActionWeightedElement.from(placeShrub, CellFilterLiteralFloat.from(0.3)),
]);
The weight filters are evaluated at the marker’s grid position and accumulated into a cumulative distribution; a seeded random roll selects the bucket. A weight of 0 or less is skipped. Weights do not need to sum to 1 - the selector normalises by total weight automatically.
Putting it together
import { GridCellPlacementRule } from "@woosh/meep-engine/src/generation/placement/GridCellPlacementRule.js";
import { GridCellActionPlaceMarker } from "@woosh/meep-engine/src/generation/markers/GridCellActionPlaceMarker.js";
import { CellMatcherFromFilter } from "@woosh/meep-engine/src/generation/rules/CellMatcherFromFilter.js";
import { CellFilterSimplexNoise } from "@woosh/meep-engine/src/generation/filtering/numeric/complex/CellFilterSimplexNoise.js";
import { CellFilterSaturate } from "@woosh/meep-engine/src/generation/filtering/numeric/math/CellFilterSaturate.js";
import { CellFilterLiteralFloat } from "@woosh/meep-engine/src/generation/filtering/numeric/CellFilterLiteralFloat.js";
import { GridActionRuleSet } from "@woosh/meep-engine/src/generation/markers/GridActionRuleSet.js";
// 1. A filter that is high in noisy forest areas
const forestMask = CellFilterSaturate.from(CellFilterSimplexNoise.fractal(3, 40));
// 2. A rule that emits a "tree" marker when the forest mask is > 0
const treeRule = GridCellPlacementRule.from({
matcher: CellMatcherFromFilter.from(forestMask),
action: GridCellActionPlaceMarker.from({ type: "tree", size: 1.5 }),
probability: forestMask, // denser near high forest values
allowRotation: false,
});
// 3. Run the rule set over the grid
const ruleSet = GridActionRuleSet.from({ rules: [treeRule] });
const emitTask = ruleSet.process(grid, seed);
// 4. After emission, theme engine converts "tree" markers to entities
theme.nodes.add(MarkerProcessingRule.from({
matcher: MarkerNodeMatcherByType.from("tree"),
action: MarkerNodeActionEntityPlacement.from({ blueprint: treeBlueprint }),
consume: true,
}));
See Procedural generation overview for how to run the full pipeline and Cell filters & automata for the complete filter reference.