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 that in order: initialises themes, applies terrain, ensures terrain tiles are built, processes markers into nodes, applies per-cell rules, and builds the lightmap. When multiple areas overlap a cell, each area’s distance-field value is used to proportionally blend the terrain weights.
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 |
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.