Generation

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:

FieldTypePurpose
terrainTerrainThemeList of TerrainLayerRule objects that paint terrain layer weights
nodesMarkerNodeProcessingRuleSetRules that consume MarkerNode objects and produce ECS entities
cellsCellProcessingRuleSetPer-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

ClassImport pathMatches when
CellMatcherAnyrules/CellMatcherAny.jsAlways - use as a wildcard
CellMatcherFromFilterrules/CellMatcherFromFilter.jsThe underlying CellFilter.execute returns > 0
CellMatcherLayerBitMaskTestrules/CellMatcherLayerBitMaskTest.jsA bit-mask test on an integer GridDataLayer passes at the cell
CellMatcherContainsMarkerWithinRadiusrules/cell/CellMatcherContainsMarkerWithinRadius.jsA marker matching a given MarkerNodeMatcher exists within a radius of the cell
CellMatcherRemovalPreservesConnectivityrules/cell/CellMatcherRemovalPreservesConnectivity.jsTaking 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

ClassImport pathBehaviour
CellMatcherAndrules/logic/CellMatcherAnd.jsBoth children must match
CellMatcherOrrules/logic/CellMatcherOr.jsEither child must match
CellMatcherNotrules/logic/CellMatcherNot.jsInverts 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

ClassImport pathMatches when
MarkerNodeMatcherAnymarkers/matcher/MarkerNodeMatcherAny.jsAlways
MarkerNodeMatcherNonemarkers/matcher/MarkerNodeMatcherNone.jsNever
MarkerNodeMatcherByTypemarkers/matcher/MarkerNodeMatcherByType.jsnode.type === type
MarkerNodeMatcherContainsTagmarkers/matcher/MarkerNodeMatcherContainsTag.jsnode.tags contains the given tag string

Logic combinators for MarkerNodeMatcher

ClassImport pathBehaviour
MarkerNodeMatcherAndmarkers/matcher/MarkerNodeMatcherAnd.jsBoth children match
MarkerNodeMatcherOrmarkers/matcher/MarkerNodeMatcherOr.jsEither child matches
MarkerNodeMatcherNotmarkers/matcher/MarkerNodeMatcherNot.jsInverts 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.

ClassImport pathBehaviour
GridDataNodePredicateAnymarkers/predicate/GridDataNodePredicateAny.jsAlways true; .INSTANCE singleton
GridDataNodePredicateNotmarkers/predicate/GridDataNodePredicateNot.jsInverts a child predicate
GridDataNodePredicateAndmarkers/predicate/GridDataNodePredicateAnd.jsBoth children must be true
GridDataNodePredicateOverlapsmarkers/predicate/GridDataNodePredicateOverlaps.jsTrue 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:

TransformerImport pathEffect
MarkerNodeTransformRotateRandommarkers/transform/Random Y rotation
MarkerNodeTransformerYRotateByFiltermarkers/transform/Y rotation driven by a CellFilter value
MarkerNodeTransformerYRotateByFilterGradientmarkers/transform/Y rotation aligned to the gradient direction of a CellFilter
MarkerNodeTransformerAddPositionYFromFiltermarkers/transform/Adds filter value to the marker’s world Y position (terrain snap)
MarkerNodeTransformerOffsetPositionmarkers/transform/Translates position by a fixed offset
MarkerNodeTransformerRecordPropertymarkers/transform/Writes a filter value into node.properties
MarkerNodeTransformerRecordUniqueRandomEnummarkers/transform/Picks a unique random value from a set and stores it in properties
MarkerNodeTransformerRemoveTagmarkers/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.