Wave Function Collapse examples
Wave Function Collapse (WFC) is a constraint solver that is mostly used to make textures and maps. Give it a grid, a set of tiles, and a table of which tiles may sit next to which, and it fills the grid: every cell starts holding every tile as a possibility, and the solver repeatedly picks the cell with the fewest options left, commits it to one tile chosen by weight, and then propagates the consequences outward until nothing else can be narrowed. Repeat until every cell is decided. The name is borrowed from quantum mechanics. Usually the constraints are learned: you draw one small example of the thing you want, and the solver reads the adjacency table off it and produces more.
You describe what is locally legal, and the solver searches for an arrangement that obeys those rules. That can make the output look designed, but the rules take care: too many constraints can lead to a contradiction, while too few can leave a shapeless result.
Source files
The solver is wfc.kt, with no dependencies beyond java.awt.image and ImageIO.
The views that put it on the canvas (ToHTML, heatmaps, montages) are in
wfc_field.kt. The solver covers:
- The core solver on integer tile IDs —
WfcSolver, with weighted Shannon-entropy cell selection, weighted collapse, and bitset propagation that reusesLongArrayscratch rather than allocating per neighbour. - Rules from a sample grid (the simple-tiled model) and rules from overlapping N×M patterns
(the overlapping model), learned from a PNG, a
BufferedImageor a 2-DFloatArray. - Rules by hand, via
wfcRules { … }. - Configurable symmetric neighborhoods —
WfcNeighborhoodis not hardwired to von Neumann;Moore,chebyshev(r),manhattan(r)and arbitrary offset sets all work, for learning and propagation. - An explicit
step(), so the collapse is observable rather than a black box. That is what the animation further down is made of.
Things it does not do, so you are not surprised:
- No backtracking. A contradiction is terminal. The remedy is to start the attempt again, which
is what
solveWithRetriesdoes (see Contradictions, seeds and reproducibility). - Symmetry expansion for patterns, not for hand-authored tiles.
expandSymmetrycloses a learned overlapping pattern set under the rotations and reflections of the square, which is what stops a model from believing in whichever orientation its source happened to contain most of (Not pixels shows it). It does not exist for the simple-tiled model: most tileset formats let you declare a tile and ask for its four rotations, and here a rotated tile is still a different tile that you supply and constrain yourself, because rotating tile art is a question about your pixels rather than about adjacency. - 2-D only, and the output never wraps.
wrapX/wrapYonfromSampleGridaffect how the sample is read, not how the result is generated. - No absolute position. The rules are translation-invariant, so "sky belongs at the top" is not
something WFC can know. You pin it yourself with
forceTile(see the overlapping model).
The model, by hand
Before any image loading, the smallest thing that is recognisably WFC: three tile IDs and a table. The tiles form a chain (sea, sand, grass) and sand is the only thing allowed to touch both sea and grass. Nothing here is learned; every constraint is stated.
Note that allow is directional and symmetric: allow(WFC_EAST, a, b) says "b may sit east of
a", and therefore also "a may sit west of b". It does not say a may sit east of b. An
undirected "these two may touch" is that call in both orders, which is a two-line helper rather than
a built-in, because the directional form is the one you need the moment tiles stop being symmetric.
:::static
val SEA = 0
val SAND = 1
val GRASS = 2
val names = ["sea", "sand", "grass"]
// The primitive is directional. "May touch", for tiles that have no orientation, is that
// primitive applied both ways round in both authored directions.
fun WfcRulesBuilder.mayTouch(a: Int, b: Int) {
for (d in [WFC_EAST, WFC_SOUTH]) {
allow(d, a, b)
allow(d, b, a)
}
}
val rules = wfcRules(tileCount = 3) {
weights(3.0, 1.0, 3.0) // sea and grass equally likely; sand rare, so it stays an interface
mayTouch(SEA, SEA)
mayTouch(SEA, SAND)
mayTouch(SAND, SAND)
mayTouch(SAND, GRASS)
mayTouch(GRASS, GRASS)
// Note what is absent: mayTouch(SEA, GRASS). That single omission is the whole model.
}
val solver = WfcSolver(width = 22, height = 14, rules = rules, seed = 12L)
/*
Seed both phases: sea pinned down the left edge, grass down the right. Without this the field
simply phase-separates — with only a one-cell-wide legal interface between them, whichever of
the two nucleates first tends to fill the whole grid. (The scalar field below shows that happening.)
*/
val outcome = solver.solveWithRetries {
for (row in 0 until 14) {
forceTile(0, row, SEA)
forceTile(21, row, GRASS)
}
}
val grid = solver.resolvedTileGridOrNull()!!
// Read the table back out of the model rather than restating it from the source above.
val table =
(0..2).joinToString("<br>") { a ->
"may sit east of <b>${names[a]}</b>: " +
(0..2).filter { rules.allows(WFC_EAST, a, it) }.joinToString(", ") { names[it] }
}
:::fold render the rule table and the tallies
// One display() per channel: a second display() on the same channel REPLACES the first.
markdown(
"rules",
"""
$table
solved in ${outcome.attempts} attempt(s) · symmetric: ${rules.findAsymmetry() ?: "yes"}
${grid.count { it == SEA }} sea, ${grid.count { it == SAND }} sand, ${grid.count { it == GRASS }} grass of ${grid.size} cells
""",
)
:::endfold
:::fold render one flat colour per cell, big enough to count them
val palette = [
Triple(0.07f, 0.20f, 0.33f),
Triple(0.85f, 0.78f, 0.55f),
Triple(0.28f, 0.52f, 0.22f),
]
val img = Img.generateRGB(22, 14) { x, y -> palette[grid[x + y * 22]] }
showImgGrid("22x14 cells, seed 12" to img.upscaleNearest(26), columns = 1, cellSide = 700)
:::endfold

Sand appears exactly where it has to: as a one-cell-wide coastline wherever sea meets grass, because that is the only arrangement the table permits. Nowhere does sea touch grass. The solver was not told to draw coastlines. It was told that a sea-grass edge is illegal, and coastlines are what is left.
That is also the first thing to check on any WFC result: every adjacent pair in the output must be
a pair the rules allow. allows is there so you can assert it. The
solver itself depends on adjacency being symmetric, that is,
allows(d, a, b) == allows(opposite(d), b, a). Propagation only ever pushes constraints outward from
a cell that changed, one direction per arc, so an asymmetric rule set has arcs the solver
structurally cannot check and it will emit pairs its own rules forbid. findAsymmetry() is that
check.
Learning from an image: the simple-tiled model
Now the usual way round. sliceIntoUniqueTiles cuts a source image into fixed-size tiles, dedupes
them, and hands back both the tile atlas and the grid of IDs it came from; toRules() then reads the
adjacency table straight off that grid: every pair of neighbours it saw becomes a legal pair, and
how often each tile occurred becomes its weight.
The tile size goes all the way down to 1 × 1, and at that size the model is at its most legible: a
tile is then just a colour, and "learning the rules" is literally "note which colours were seen
touching".
:::static
:::fold build the source: three gaussian bumps minus a lake, quantised into five terrain bands
fun bump(u: Double, v: Double, cx: Double, cy: Double, r: Double): Double {
val dx = u - cx
val dy = v - cy
return exp(-(dx * dx + dy * dy) / (2.0 * r * r))
}
val palette = [
Triple(0.08f, 0.20f, 0.31f), // deep water
Triple(0.18f, 0.48f, 0.66f), // shallow water
Triple(0.85f, 0.78f, 0.55f), // sand
Triple(0.31f, 0.55f, 0.24f), // grass
Triple(0.12f, 0.29f, 0.14f), // forest
]
val sw = 26
val sh = 18
// Keep the band index per cell as well as the picture: it is what lets the histogram below talk
// about terrain instead of about opaque tile IDs.
val bands = IntArray(sw * sh)
for (cy in 0 until sh) for (cx in 0 until sw) {
val u = cx / (sw - 1.0)
val v = cy / (sh - 1.0)
val e =
bump(u, v, 0.30, 0.40, 0.25) + 0.90 * bump(u, v, 0.70, 0.62, 0.21) + 0.55 * bump(u, v, 0.82, 0.22, 0.13) -
0.62 * bump(u, v, 0.34, 0.44, 0.085)
bands[cx + cy * sw] = if (e < 0.16) 0 else if (e < 0.32) 1 else if (e < 0.44) 2 else if (e < 0.74) 3 else 4
}
val source = Img.generateRGB(sw, sh) { x, y -> palette[bands[x + y * sw]] }
:::endfold
// One pixel per tile. Five bands in, five unique tiles out.
val tiles = source.toBufferedImage().sliceIntoUniqueTiles(1, 1)
val rules = tiles.toRules()
// Tile IDs are discovery order, not band order. sampleIds is row-major over the same grid the
// source was drawn on, so one pass recovers the correspondence both ways.
val bandOfTile = IntArray(tiles.tileCount)
val tileOfBand = IntArray(palette.size)
for (cy in 0 until sh) for (cx in 0 until sw) {
val i = cx + cy * sw
bandOfTile[tiles.sampleIds[i]] = bands[i]
tileOfBand[bands[i]] = tiles.sampleIds[i]
}
val outW = 40
val outH = 26
fun solveWith(model: WfcRules): IntArray {
val solver = WfcSolver(outW, outH, model, seed = 12L)
solver.solveWithRetries(maxAttempts = 30)
return solver.resolvedTileGridOrNull()!!
}
val learned = solveWith(rules)
// The learned weights are just a DoubleArray, and the solver reads it live — so a learned model is
// editable. Boost the rare bands, and above all the *interface* band, sand.
val reweighted = tiles.toRules()
reweighted.weights[tileOfBand[0]] *= 2.0 // deep water
reweighted.weights[tileOfBand[1]] *= 1.5 // shallow water
reweighted.weights[tileOfBand[2]] *= 10.0 // sand, the only band that may touch both sides
val boosted = solveWith(reweighted)
val bandNames = ["deep", "shallow", "sand", "grass", "forest"]
fun histogram(cells: IntArray, bandOf: (Int) -> Int): String =
palette.indices.joinToString(" ") { b ->
bandNames[b] + " " + (cells.count { bandOf(it) == b } * 100 / cells.size).toString().padStart(2) + "%"
}
:::fold render the three band histograms as text
val bandReport =
"source ${histogram(bands) { it }}\n" +
"learned ${histogram(learned) { bandOfTile[it] }}\n" +
"boosted ${histogram(boosted) { bandOfTile[it] }}"
markdown(
"bands",
"""
~~~
$bandReport
~~~
""",
)
:::endfold
// Scale factors chosen so every panel is 360px tall: montage gives each cell the height of the
// tallest one, so mismatched panels leave a big empty band.
showImgGrid(
"source 26x18" to source.upscaleNearest(20),
"learned weights" to tiles.toImg(learned, outW, outH).upscaleNearest(14),
"sand boosted x10" to tiles.toImg(boosted, outW, outH).upscaleNearest(14),
columns = 1,
cellSide = 620,
)

The middle panel is correct: every adjacent pair in it is a pair present in the source. It is also nothing like the source: a speckle of grass and forest with a little sand and, as the histogram shows, no water at all, where the source was about a quarter water.
Every seed does this, because the model is bistable. Grass and forest are the source's majority bands and they may touch each other, so they form a mutually reinforcing phase; water is a minority band reachable only across a one-cell sand interface, and once the first few cells commit to land there is no path back. Whichever phase nucleates first fills the grid.
The bottom panel is the same rules with three weights multiplied. rules.weights is a plain
DoubleArray that the solver reads live, so a learned model is editable. Boosting sand, the only
band allowed to touch both sides, is the move: interface cells are what let the two phases sit near
each other, so making them cheap lets all five bands coexist. It does not, however, reproduce the
source's proportions: sand is now far more common than it ever was in the source.
This model cannot reproduce global proportions, because it does not represent them. It is a table of which colours may touch plus a marginal frequency per colour; "water covers about a quarter of the map in large connected regions" is not expressible in that language, so no amount of weight tuning will produce it.
The same source, with context
The diagnosis points somewhere specific. A cell that holds one colour can only ever learn which colours touch, and the source's structure (a coast is water, then a one-cell sand fringe, then land) is a statement about a run of three cells, which a one-cell alphabet cannot hold. So give each cell more to hold.
That is the overlapping model, and it is the answer to the figure above; The overlapping model has the rest of it. Instead of cutting the source into disjoint tiles, slide an N×N window over every position and keep the distinct windows you find; each solver cell now stands for one whole window, and two cells may be neighbours only if their windows agree where they overlap. Nothing else changes: same solver, same four directions, same learned weights. Each cell simply knows nine pixels instead of one.
:::static
:::fold the terrain source from above, unchanged — one pixel per cell
fun bump(u: Double, v: Double, cx: Double, cy: Double, r: Double): Double {
val dx = u - cx
val dy = v - cy
return exp(-(dx * dx + dy * dy) / (2.0 * r * r))
}
val palette = [
Triple(0.08f, 0.20f, 0.31f), // deep water
Triple(0.18f, 0.48f, 0.66f), // shallow water
Triple(0.85f, 0.78f, 0.55f), // sand
Triple(0.31f, 0.55f, 0.24f), // grass
Triple(0.12f, 0.29f, 0.14f), // forest
]
val sw = 26
val sh = 18
val bands = IntArray(sw * sh)
for (cy in 0 until sh) for (cx in 0 until sw) {
val u = cx / (sw - 1.0)
val v = cy / (sh - 1.0)
val e =
bump(u, v, 0.30, 0.40, 0.25) + 0.90 * bump(u, v, 0.70, 0.62, 0.21) + 0.55 * bump(u, v, 0.82, 0.22, 0.13) -
0.62 * bump(u, v, 0.34, 0.44, 0.085)
bands[cx + cy * sw] = if (e < 0.16) 0 else if (e < 0.32) 1 else if (e < 0.44) 2 else if (e < 0.74) 3 else 4
}
val source = Img.generateRGB(sw, sh) { x, y -> palette[bands[x + y * sw]] }
:::endfold
// The one changed line: 3x3 windows of the source instead of its five colours. wrapY = false so
// the model never learns that the top of the map sits above the bottom of it.
val patterns = source.toBufferedImage().sliceIntoOverlappingPatterns(3, 3, wrapX = false, wrapY = false)
val rules = patterns.toRules()
val outW = 40
val outH = 26
// Twelve seeds, because one picture of a stochastic process is an anecdote. No pinning, no
// re-weighting: this is the learned model exactly as `toRules()` handed it over.
val seeds = [1L, 3L, 5L, 7L, 9L, 11L, 12L, 17L, 23L, 42L, 101L, 2026L]
val panels = mutableListOf<Pair<String, Img>>()
var firstAttempt = 0
for (seed in seeds) {
val solver = WfcSolver(outW, outH, rules, seed = seed)
val outcome = solver.solveWithRetries(maxAttempts = 30)
if (outcome.attempts == 1) firstAttempt++
val grid = solver.resolvedTileGridOrNull() ?: continue
panels.add("seed $seed" to patterns.toImg(grid, outW, outH).upscaleNearest(6))
}
/*
Why this figure has no ground constraint, read out of the model rather than asserted. The source's
corner is deep water, so that pixel identifies the band without re-deriving the palette's rounding.
An explicit loop with a typed `px`, not `.first { ... .all { it == deepArgb } }`: a box's scope is
crowded enough that `all` inside a nested lambda whose receiver has no written-down type resolves
to something else entirely, and the error surfaces on a later line.
*/
val deepArgb: Int = source.toBufferedImage().getRGB(0, 0)
var uniformDeep: Int = -1
for (id in 0 until patterns.tileCount) {
val px: IntArray = patterns.patterns[id]
if (px.all { v -> v == deepArgb }) {
uniformDeep = id
break
}
}
val successors: String = (0..3).joinToString(" ") { d -> "dir " + d + " " + rules.allowedCount(d, uniformDeep) }
:::fold render the model summary
val modelReport =
"${patterns.tileCount} distinct 3x3 windows · ${panels.size}/${seeds.size} seeds solved, " +
"$firstAttempt on the first attempt\n" +
"the all-deep window may be followed by: " +
successors
markdown(
"model",
"""
~~~
$modelReport
~~~
""",
)
:::endfold
display("main", montage(panels, columns = 4, cellSide = 300).toPngBytes())

Every adjacent pair in every output is a pair the source contained.
These are coastlines, not islands. The model reproduces the source's local rules: the bands appear in chain order, every land-water boundary carries a one-cell sand fringe, and forest sits inside grass and never touches water. It does not reproduce the way the source arranges all of this into one closed ring, because that is a fact about the whole picture and an adjacency table only knows about neighbours. Each output is best read as a crop of a much larger world, and a coast running across a crop is a correct answer.
The outputs are, if anything, more coherent than the source: neighbouring cells hold the same band more often than they do in the source, since the model has learned that interiors are large and boundaries thin, and nothing asks it to put a shoreline anywhere in particular.
No cell is pinned in this figure, and the readout in the box says why:
the all-deep window may be followed by: dir 0 4 dir 1 2 dir 2 1 dir 3 1
South and west, the all-deep-water 3 × 3 window has exactly one legal successor: itself. For a
window to sit south of it, that window's top two rows must be entirely deep, and this source's water
band is mostly thinner than three cells, so no such window exists except the uniform one. Pin a
single cell to it and propagation paints the entire grid deep; add a second pin anywhere on land and
you get an instant contradiction, on every seed and every retry. Any uniform window drawn from a
band thinner than the window behaves this way, and allowedCount is how you find one.
The 2 × 2 model has no such window (its all-deep window has 2–4 successors per direction) but produces
visibly shattered, one-cell-speckled coastlines, so 3 × 3 is the pick here.
3 × 3 windows, not 2 × 2 tiles
Both give a cell more context, and sliceIntoUniqueTiles(2, 2) looks like the cheaper way to get it
(fewer tiles than there are 3 × 3 patterns). It costs more than it saves, and not only on this
source.
Bigger disjoint tiles mean most tiles are seen exactly once, at a sample edge, which leaves their
mask in some direction empty. fromSampleGrid then widens each empty mask to "any tile may sit
there", and because adjacency has to stay symmetric, filling one mask must also tell every other
tile that this one may sit on its far side. One empty mask therefore adds 2 × tileCount pairs:
"anything may sit east of t" is an enormous claim.
On this source a handful of empty masks leave 71% of the table invented by the fallback rather than
observed, and the outputs then contain band pairs the source never contained. At 1 × 1 nothing is empty and nothing is invented, so the speckled figure
above stays trustworthy as evidence about the model where a 2 × 2 version of it would not.
So: ask. widenedMaskCount says whether the fallback fired at all, wasWidened(direction, tile)
says where, and observedAllows is allows restricted to what the sample actually showed: the
predicate to assert a learned model against, since allows on a widened table permits a great deal
it never saw. The inline rules view in Looking at the rules paints
invented pairs amber for the same reason.
Tiles with structure: the circuit set
Context is one way to give the model more to work with. The other is to put the structure in the tiles themselves. When each tile carries internal art whose edges have to line up, the tile set encodes structure that a flat colour cannot, and the same solver produces something that looks constructed rather than grown.
Nothing here is learned. This is an eight-tile set of circuit traces with rules derived from edge
sockets. b may sit east of a exactly when a's east socket matches b's west socket. That is
the original "simple tiled model", and it is about ten lines once the sockets are written down.
:::static
// Sockets per tile, in WFC_NORTH, WFC_EAST, WFC_SOUTH, WFC_WEST order. 1 = a trace leaves this edge.
val sockets = [
intArrayOf(0, 0, 0, 0), // blank
intArrayOf(0, 1, 0, 1), // horizontal
intArrayOf(1, 0, 1, 0), // vertical
intArrayOf(1, 1, 0, 0), // elbow N-E
intArrayOf(0, 1, 1, 0), // elbow E-S
intArrayOf(0, 0, 1, 1), // elbow S-W
intArrayOf(1, 0, 0, 1), // elbow W-N
intArrayOf(1, 1, 1, 1), // cross
]
// Edge matching, stated once. Everything else about the picture follows from it.
val rules = wfcRules(tileCount = sockets.size) {
weights(6.0, 2.0, 2.0, 1.0, 1.0, 1.0, 1.0, 0.35)
for (a in sockets.indices) for (b in sockets.indices) {
if (sockets[a][WFC_EAST] == sockets[b][WFC_WEST]) allow(WFC_EAST, a, b)
if (sockets[a][WFC_SOUTH] == sockets[b][WFC_NORTH]) allow(WFC_SOUTH, a, b)
}
}
:::fold draw 11x11 tile art from the same socket table: a centre pad plus a trace to each live edge
val side = 11
val c = side / 2
val bg = 0xFF10241C.toInt()
val trace = 0xFF64E0A0.toInt()
val pad = 0xFFB8FFDA.toInt()
// A top-level fun, not one nested inside the Array(...) lambda below: a local fun declared inside
// a lambda in a Field box trips a K2 codegen assertion ("has no callableId").
fun traceArt(s: IntArray): IntArray {
val px = IntArray(side * side) { bg }
if (s[WFC_NORTH] == 1)
for (y in 0..c) {
px[c + y * side] = trace
px[(c - 1) + y * side] = trace
}
if (s[WFC_SOUTH] == 1)
for (y in c until side) {
px[c + y * side] = trace
px[(c - 1) + y * side] = trace
}
if (s[WFC_WEST] == 1)
for (x in 0..c) {
px[x + c * side] = trace
px[x + (c - 1) * side] = trace
}
if (s[WFC_EAST] == 1)
for (x in c until side) {
px[x + c * side] = trace
px[x + (c - 1) * side] = trace
}
if (s.any { it == 1 }) for (y in (c - 1)..c) for (x in (c - 1)..c) px[x + y * side] = pad
return px
}
// A PngTileSet can be assembled from hand-drawn art; sampleIds is just the atlas row here,
// because nothing is being learned from it.
val tiles = PngTileSet(
side,
side,
Array(sockets.size) { traceArt(sockets[it]) },
IntArray(sockets.size) { it },
sockets.size,
1,
)
:::endfold
val solver = WfcSolver(34, 22, rules, seed = 20260728L)
val outcome = solver.solveWithRetries()
val grid = solver.resolvedTileGridOrNull()!!
// Separate channels rather than one montage: the atlas strip and the output have wildly different
// aspect ratios, and a uniform grid would give the strip a cell 5 times taller than it needs.
display("tiles", tiles.sampleToImg().upscaleNearest(6).toPngBytes())
display("main", tiles.toImg(grid, 34, 22).upscaleNearest(2).toPngBytes())
:::fold render the cell tallies
markdown(
"counts",
"${outcome.attempts} attempt(s) · ${grid.count { it == 7 }} crossings · " +
"${grid.count { it == 0 }} blank of ${grid.size} cells",
)
:::endfold

Every trace connects. There are no dangling ends anywhere in the output, and there cannot be: a live socket on one tile is only ever adjacent to a live socket on the next, so "wires join up" is a consequence of edge matching rather than something the generator tries to achieve. The weights are doing the aesthetic work. Blank is weighted 6 and the four-way cross 0.35, so the result reads as sparse circuitry rather than a dense maze.
The overlapping model and absolute position
The terrain example used the overlapping model to fix a texture problem. Here is the rest of the mechanism, and the limit that no amount of context removes.
First the trade, which is combinatorial. A 3×3 window over a 26×18 source can yield hundreds of distinct patterns where the simple-tiled model had five tiles, and both the rule-learning cost (it compares every pattern against every other) and the per-step entropy scan grow with that count. Larger windows are not free and are not automatically better: they buy coherence and they cost variety, because a window big enough to contain a whole feature can only ever reproduce that feature verbatim.
And then the limit. The terrain's coastlines were a success at reproducing local grammar and made no attempt
at global composition, because WFC's rules are translation-invariant: they say what may sit next
to what, and cannot say where anything belongs. That is not a texture problem and context does not
touch it. The only cure is to supply position yourself, from outside the model, with forceTile.
This source is built to make that visible: sky over soil, with flowers rooted in the soil. Every 3×3 window of it says something ("stem continues upward", "blossom sits on a stem", "stem meets soil") so the model has plenty to preserve, and it will still cheerfully put the soil nowhere at all.
:::static
val SKY = Triple(0.75f, 0.86f, 0.94f)
val SOIL = Triple(0.42f, 0.29f, 0.16f)
val STEM = Triple(0.24f, 0.56f, 0.23f)
val BLOSSOM = Triple(0.88f, 0.34f, 0.42f)
val sw = 26
val sh = 18
val soilTop = 13
val flowers = [3 to 4, 8 to 6, 13 to 3, 18 to 5, 22 to 4] // column to stem height
val source =
Img.generateRGB(sw, sh) { x, y ->
val flower = flowers.firstOrNull { it.first == x }
when {
y >= soilTop -> SOIL
flower != null && y == soilTop - flower.second - 1 -> BLOSSOM
flower != null && y >= soilTop - flower.second -> STEM
else -> SKY
}
}
// Every 3x3 window, deduped. wrapY = false: wrapping vertically would teach it that soil sits
// above sky, which is the one thing this source is trying to say is untrue.
val patterns = source.toBufferedImage().sliceIntoOverlappingPatterns(3, 3, wrapX = true, wrapY = false)
val rules = patterns.toRules()
// Named outW/outH, not w/h: inside a lambda that has its own receiver (solveWithRetries' prepare
// block below), a bare `h` resolves to the Box's own frame height rather than to this local.
val outW = 60
val outH = 16
// WFC has no notion of absolute position. Left alone it will happily satisfy every rule with
// "soil everywhere", which is a perfectly legal tiling of this pattern set.
val free = WfcSolver(outW, outH, rules, seed = 9L)
free.solveWithRetries(maxAttempts = 30)
val freeOut = patterns.toImg(free.resolvedTileGridOrNull()!!, outW, outH)
/*
So pin the boundary: the classic WFC "ground" constraint. Note that reset() clears forced cells
too, so the pinning goes in solveWithRetries' prepare block to be re-applied on every attempt.
(Named groundPattern/skyPattern because a bare `sky` is already taken in a Field box's scope.)
The all-soil and all-sky 3x3 patterns, found by looking for a flat window and testing its blue.
*/
var groundPattern: Int = -1
var skyPattern: Int = -1
for (id in 0 until patterns.tileCount) {
val px: IntArray = patterns.patterns[id]
if (!px.all { v -> v == px[0] }) continue
val blue: Int = px[0] and 0xff
if (blue < 0x60) groundPattern = id
if (blue > 0xC0) skyPattern = id
}
val solver = WfcSolver(outW, outH, rules, seed = 4L)
val outcome = solver.solveWithRetries(maxAttempts = 30) {
for (x in 0 until outW) {
forceTile(x, outH - 1, groundPattern)
forceTile(x, 0, skyPattern)
}
}
val out = patterns.toImg(solver.resolvedTileGridOrNull()!!, outW, outH)
:::fold render the pattern summary
markdown(
"model",
"${patterns.tileCount} distinct 3x3 patterns · ground pattern \\#$groundPattern, " +
"sky pattern \\#$skyPattern · solved in ${outcome.attempts} attempt(s)",
)
:::endfold
showImgGrid(
"source 26x18" to source.upscaleNearest(9),
"no ground constraint" to freeOut.upscaleNearest(9),
"ground + sky pinned" to out.upscaleNearest(9),
columns = 1,
cellSide = 620,
)

The middle panel shows what translation invariance costs. It is a correct answer (every 3×3 window in
it appears in the source and every neighbouring pair agrees on its overlap), and it has no ground
at all: a sky full of flowers whose stems run off the bottom edge with nothing to grow out of. Nothing
in an adjacency table can say where anything belongs, so "the soil goes at the bottom" is not a fact
this model has access to. Pinning the top and bottom rows with forceTile supplies it, and the
bottom panel is what the same rules produce once they have it.
The bottom panel's flowers are not the source's flowers: different columns, different heights, more of them than the source had. But every blossom sits on a stem, and the soil runs unbroken along the bottom, because those are 3×3-local facts and the model enforces them.
Note that reset() clears the whole wave, forced cells included. Boundary
conditions therefore have to be re-applied on every retry, and solveWithRetries takes a prepare
block to do it in.
Neighborhoods beyond von Neumann
WfcNeighborhood is a set of relative offsets plus the guarantee that every offset has its opposite
in the set. VonNeumann is four offsets; Moore is eight; chebyshev(r) and manhattan(r) generate
the obvious families, and WfcNeighborhood.of(WfcOffset(2, 0), WfcOffset(-2, 0)) is as legitimate as
either. The neighborhood is used for both learning and propagation, so widening it means the model
observes more relations in the source and then enforces more of them in the output.
Whether that matters depends entirely on whether the source has structure along the new offsets. Both halves of the following figure are the same code with one argument changed.
:::static
// Diagonal stripes two cells wide. Colour depends on (x + y) mod 4, so along the anti-diagonal
// offset (1, -1) the colour never changes, while east and south change only half the time.
val stripeA = Triple(0.17f, 0.25f, 0.45f)
val stripeB = Triple(0.91f, 0.71f, 0.29f)
val source = Img.generateRGB(24, 16) { x, y -> if ((x + y) % 4 < 2) stripeA else stripeB }
val tiles = source.toBufferedImage().sliceIntoUniqueTiles(1, 1)
fun solveStripes(neighborhood: WfcNeighborhood): Pair<Img, Int> {
val rules = tiles.toRules(neighborhood = neighborhood)
val solver = WfcSolver(40, 26, rules, seed = 5L)
solver.solveWithRetries(maxAttempts = 30)
val grid = solver.resolvedTileGridOrNull()!!
// How many cells disagree with their anti-diagonal neighbour: 0 means the stripes survived.
var breaks = 0
for (y in 1 until 26) for (x in 0 until 39) {
if (grid[x + y * 40] != grid[(x + 1) + (y - 1) * 40]) breaks++
}
return tiles.toImg(grid, 40, 26) to breaks
}
val (vn, vnBreaks) = solveStripes(WfcNeighborhood.VonNeumann)
val (moore, mooreBreaks) = solveStripes(WfcNeighborhood.Moore)
:::fold render the break counts
markdown(
"breaks",
"anti-diagonal breaks out of 39 x 25 pairs — " + "von Neumann: **$vnBreaks** · Moore: **$mooreBreaks**",
)
:::endfold
showImgGrid(
"source 24x16" to source.upscaleNearest(12),
"von Neumann (4)" to vn.upscaleNearest(7),
"Moore (8)" to moore.upscaleNearest(7),
columns = 3,
cellSide = 340,
)

The von Neumann model learns nothing at all from this source. Sweeping east it observes every
possible pair (A-A, A-B, B-B, B-A) because a two-wide stripe means the colour changes on
half the steps and stays on the other half. So the adjacency table ends up completely permissive, and
the output is exactly what a completely permissive table produces: correct, and noise.
Moore additionally observes the (1, -1) offset, where the colour is constant, and that one extra
relation is enough to reproduce the stripes exactly: the break count in the box drops to zero.
So choose the neighborhood to match the source; bigger is not better. On the five-band
terrain the diagonals are almost implied by the cardinals, and moving to Moore barely changes the
picture. A wider
neighborhood costs directionCount bitset intersections per propagation step and buys you nothing
unless the source has something to say along those offsets.
Watching the collapse
step() is public and does exactly one WFC iteration: choose the lowest-entropy undecided cell,
collapse it, propagate to a fixed point, return the new status. Everything the solver knows is
readable between steps (possibilityCountAt, possibilitiesAt, resolvedTileAt,
lastObservation), which means the collapse can be drawn.
wfc_field.kt provides the two views worth drawing. waveToImg renders a partially collapsed solver
by averaging each cell's remaining possibilities, so an undecided cell is a blur of what it might
become and a decided one is itself; possibilityCountImg gives the remaining option count per cell
as a scalar field to colour-map.
:::static
:::fold the terrain source and re-weighted model from above
fun bump(u: Double, v: Double, cx: Double, cy: Double, r: Double): Double {
val dx = u - cx
val dy = v - cy
return exp(-(dx * dx + dy * dy) / (2.0 * r * r))
}
val palette = [
Triple(0.08f, 0.20f, 0.31f),
Triple(0.18f, 0.48f, 0.66f),
Triple(0.85f, 0.78f, 0.55f),
Triple(0.31f, 0.55f, 0.24f),
Triple(0.12f, 0.29f, 0.14f),
]
val bands = IntArray(26 * 18)
for (cy in 0 until 18) for (cx in 0 until 26) {
val u = cx / 25.0
val v = cy / 17.0
val e =
bump(u, v, 0.30, 0.40, 0.25) + 0.90 * bump(u, v, 0.70, 0.62, 0.21) + 0.55 * bump(u, v, 0.82, 0.22, 0.13) -
0.62 * bump(u, v, 0.34, 0.44, 0.085)
bands[cx + cy * 26] = if (e < 0.16) 0 else if (e < 0.32) 1 else if (e < 0.44) 2 else if (e < 0.74) 3 else 4
}
val source = Img.generateRGB(26, 18) { x, y -> palette[bands[x + y * 26]] }
val tiles = source.toBufferedImage().sliceIntoUniqueTiles(1, 1)
val rules = tiles.toRules()
// The terrain's boost, so the collapse has all five bands in it and is worth watching.
val tileOfBand = IntArray(palette.size)
for (i in bands.indices) tileOfBand[bands[i]] = tiles.sampleIds[i]
rules.weights[tileOfBand[0]] *= 2.0
rules.weights[tileOfBand[1]] *= 1.5
rules.weights[tileOfBand[2]] *= 10.0
:::endfold
val solver = WfcSolver(40, 28, rules, seed = 3L)
// Sample the wave every N steps. A solved cell has exactly one possibility, so the last frame of
// this sequence IS the finished output — the same picture, by construction rather than coincidence.
val waves = mutableListOf<Pair<String, Img>>()
val entropy = mutableListOf<Pair<String, Img>>()
fun sample() {
val pct = (solver.resolvedFraction() * 100).toInt()
waves += "$pct% decided" to tiles.waveToImg(solver).upscaleNearest(6)
entropy +=
"$pct% decided" to
solver.possibilityCountImg().colormap("magma", lo = 0f, hi = rules.tileCount.toFloat()).upscaleNearest(6)
}
sample()
var steps = 0
while (solver.step() == WfcStatus.Running) {
steps++
if (steps % 200 == 0) sample()
}
sample()
markdown("steps", "$steps steps · ${waves.size} sampled frames · ended ${solver.status}")
// display() the raster directly rather than via showImg: showImg clamps to maxSide with an
// area-averaging resize, and area-averaging a pixel grid is exactly what upscaleNearest avoided.
display(
"main",
vstack(
montage(waves, columns = 4, cellSide = 260),
montage(entropy, columns = 4, cellSide = 260),
gap = 14,
)
.toPngBytes(),
)

The top row is the wave; the bottom row is the possibility count on the same frames, magma-mapped
with an explicit lo = 0f, hi = tileCount so the colours mean the same thing in every frame. (Left
to the default, the range would rescale per frame and an animation of the collapse would appear to go
nowhere.)
Read either row and you can see this run grow. The first collapse creates an island of certainty; later decisions spread outward as propagation narrows the choices around it. The bright rim in the entropy row is the frontier: cells that have been constrained but not yet decided. Interior cells stay at the full option count, untouched, until the front reaches them. That is also why WFC is fast in practice despite being a constraint solver on a large grid: almost all of the work is local to a moving boundary.
Here is the same run recorded live, one frame per handful of steps.
:::static
:::fold the terrain source and re-weighted model from above
fun bump(u: Double, v: Double, cx: Double, cy: Double, r: Double): Double {
val dx = u - cx
val dy = v - cy
return exp(-(dx * dx + dy * dy) / (2.0 * r * r))
}
val palette = [
Triple(0.08f, 0.20f, 0.31f),
Triple(0.18f, 0.48f, 0.66f),
Triple(0.85f, 0.78f, 0.55f),
Triple(0.31f, 0.55f, 0.24f),
Triple(0.12f, 0.29f, 0.14f),
]
val bands = IntArray(26 * 18)
for (cy in 0 until 18) for (cx in 0 until 26) {
val u = cx / 25.0
val v = cy / 17.0
val e =
bump(u, v, 0.30, 0.40, 0.25) + 0.90 * bump(u, v, 0.70, 0.62, 0.21) + 0.55 * bump(u, v, 0.82, 0.22, 0.13) -
0.62 * bump(u, v, 0.34, 0.44, 0.085)
bands[cx + cy * 26] = if (e < 0.16) 0 else if (e < 0.32) 1 else if (e < 0.44) 2 else if (e < 0.74) 3 else 4
}
val source = Img.generateRGB(26, 18) { x, y -> palette[bands[x + y * 26]] }
val tiles = source.toBufferedImage().sliceIntoUniqueTiles(1, 1)
val rules = tiles.toRules()
// The terrain's boost, so the collapse has all five bands in it and is worth watching.
val tileOfBand = IntArray(palette.size)
for (i in bands.indices) tileOfBand[bands[i]] = tiles.sampleIds[i]
rules.weights[tileOfBand[0]] *= 2.0
rules.weights[tileOfBand[1]] *= 1.5
rules.weights[tileOfBand[2]] *= 10.0
:::endfold
val solver = WfcSolver(44, 30, rules, seed = 3L)
val displayFps = 20
val stepsPerDisplay = 12
println("wfc collapse animation ready")
/*
Take stepsPerDisplay solver steps per output update, at no more than displayFps updates per second.
One display() per update, with the label drawn INTO the raster by montage(). showImg(caption = …)
would also be a single payload, but it is an HTML figure the browser re-parses every frame; baked
pixels keep a 20fps recording cheap, and the label cannot lag the picture it describes.
*/
frames {
while (true) {
repeat(stepsPerDisplay) {
if (solver.status == WfcStatus.Running) solver.step()
}
val label = "${(solver.resolvedFraction() * 100).toInt()}% decided — ${solver.status}"
display(
"main",
montage(
[label to tiles.waveToImg(solver).upscaleNearest(8)],
columns = 1,
cellSide = 460,
)
.toPngBytes(),
)
// Leave the final solved (or contradicted) wave visible without rebuilding it.
if (solver.status != WfcStatus.Running) break
delay(1000 / displayFps)
}
}
The solver's own inline view
Everything above had to be arranged: pick a view, colour-map it, choose a range, montage it, push it to a channel. That is the right amount of work for a figure on a page and far too much for the thing you actually do a hundred times an hour while live coding, which is: look at the object.
So WfcSolver implements ToHTML. Evaluating a solver (as a box's last expression, or via .h
anywhere) draws its state instead of a reflective property tree. No tile set is needed, because none
of these panels claims to show the tileset's colours:
| panel | colour means | scale |
|---|---|---|
| possibilities left | how many tiles are still possible in that cell | viridis, fixed 1 (decided) … tileCount (untouched) |
| entropy | weighted Shannon entropy, in nats — the quantity the solver actually minimises | magma, fixed 0 … rules.fullSetEntropy() |
| decided tiles | which tile a cell settled on, as a stable hue per id | dark grey where still undecided |
The two heatmaps' domains are fixed by meaning rather than per frame, so successive looks at a
running solver are comparable; auto-ranging is what makes an animated collapse appear to stand still.
Values with nowhere to sit on a scale are taken off it and marked rather than clamped: a cell with
zero possibilities is magenta in the count panel, because clamping 0 onto a 1..n ramp would
paint the one failure you need to see at the decided end and read as success. Entropy has a second
such case. fromSampleGrid gives weight 0 to a tile the sample never contained, and a cell whose
only survivor is such a tile has genuinely undefined entropy; those are drawn cyan, counted in the
caption, and excluded from the range. Each caption names its colour scale and both endpoints in units.
The two ramps are not interchangeable, because a marker colour is only a marker if it is outside
the ramp it sits on. Drawn in magma, whose middle is pink and a shade away from the contradiction
magenta, a perfectly healthy collapse frontier reads as a field of contradictions. So counts are
viridis (purple → teal → yellow, no pink) with a magenta marker, and entropy is magma (black → pink →
cream, no cyan) with a cyan one.
:::static
:::fold the terrain source and re-weighted model from above
fun bump(u: Double, v: Double, cx: Double, cy: Double, r: Double): Double {
val dx = u - cx
val dy = v - cy
return exp(-(dx * dx + dy * dy) / (2.0 * r * r))
}
val palette = [
Triple(0.08f, 0.20f, 0.31f),
Triple(0.18f, 0.48f, 0.66f),
Triple(0.85f, 0.78f, 0.55f),
Triple(0.31f, 0.55f, 0.24f),
Triple(0.12f, 0.29f, 0.14f),
]
val bands = IntArray(26 * 18)
for (cy in 0 until 18) for (cx in 0 until 26) {
val u = cx / 25.0
val v = cy / 17.0
val e =
bump(u, v, 0.30, 0.40, 0.25) + 0.90 * bump(u, v, 0.70, 0.62, 0.21) + 0.55 * bump(u, v, 0.82, 0.22, 0.13) -
0.62 * bump(u, v, 0.34, 0.44, 0.085)
bands[cx + cy * 26] = if (e < 0.16) 0 else if (e < 0.32) 1 else if (e < 0.44) 2 else if (e < 0.74) 3 else 4
}
val source = Img.generateRGB(26, 18) { x, y -> palette[bands[x + y * 26]] }
val tiles = source.toBufferedImage().sliceIntoUniqueTiles(1, 1)
val rules = tiles.toRules()
val tileOfBand = IntArray(palette.size)
for (i in bands.indices) tileOfBand[bands[i]] = tiles.sampleIds[i]
rules.weights[tileOfBand[0]] *= 2.0
rules.weights[tileOfBand[1]] *= 1.5
rules.weights[tileOfBand[2]] *= 10.0
:::endfold
// Halfway through a collapse. `.h` is the fragment ToHTML returns, so this is exactly what the box
// shows when its last expression is `solver` — nothing here is a special-purpose figure.
val solver = WfcSolver(34, 24, rules, seed = 5L)
while (solver.resolvedFraction() < 0.45 && solver.step() == WfcStatus.Running) {}
display("a. mid-collapse", solver.h)
/*
The same view of a failure. The over-constrained chain from the contradictions section: nothing may sit east of tile
2, so
a 6x6 grid cannot be filled, and propagation empties a cell.
*/
val strict = wfcRules(tileCount = 3) {
allow(WFC_EAST, 0, 1)
allow(WFC_EAST, 1, 2)
allow(WFC_SOUTH, 0, 1)
allow(WFC_SOUTH, 1, 2)
}
val stuck = WfcSolver(6, 6, strict, seed = 1L)
stuck.run()
display("b. contradicted", stuck.h)
:::fold render the numbers behind the two panels
// And the numbers the panels are drawn from, so the picture can be checked against them.
val readBack =
"mid-collapse: ${solver}\n" +
" entropy range drawn: 0 .. ${rules.fullSetEntropy().f3} nats" +
" (${solver.entropyHeatmap().nonFiniteCount} cells undefined)\n" +
"contradicted: ${stuck}\n" +
" cells with no possibilities left: ${stuck.possibilityCountGrid().count { it == 0 }}"
markdown(
"c. read back",
"""
~~~
$readBack
~~~
""",
)
:::endfold

The mid-collapse row shows the same story as the montage above, without having built one: the decided mass is dark in both heatmaps, the untouched region is bright, and the speckled band between them is the frontier: cells propagation has narrowed without deciding. The count and entropy panels are not redundant, because a cell with three heavy options and a cell with three nearly-impossible ones have the same count and very different entropy, and it is entropy the solver sorts on, so the entropy panel is the one that predicts where it will work next. The decided-tiles panel is the answer to "on what?".
The contradicted row is the case this view exists for. One cell is magenta in the count panel and
cyan in the entropy panel (no possibilities left, and therefore no entropy), which localises the
failure to a coordinate immediately. Reading WfcStatus.Contradiction off a println tells you only
that something went wrong somewhere.
Not pixels: WFC on a scalar field
Nothing in the solver knows what a pixel is. It works on integer tile IDs, and the image helpers are
a convenience layered on top, so the same machinery runs on a row-major FloatArray via
sliceIntoUniqueTiles2D and sliceIntoOverlappingPatterns2D, and produces numbers rather than
colours. A scalar field is geometry waiting to happen: colour-map it, contour it, and the result is
FLines that go into the same drawing and export machinery as anything
hand-drawn.
The catch is that sliceIntoUniqueTiles2D dedupes on exact float equality, so a continuous field
would yield one unique tile per distinct value. Quantise first. At the six levels used here, "level
n may only touch level n±1" becomes a rule the model can learn.
:::static
:::fold build the source field: a sum of bumps quantised to six levels
fun bump(u: Double, v: Double, cx: Double, cy: Double, r: Double): Double {
val dx = u - cx
val dy = v - cy
return exp(-(dx * dx + dy * dy) / (2.0 * r * r))
}
val sw = 26
val sh = 18
val levels = 6
val source = FloatArray(sw * sh)
for (cy in 0 until sh) for (cx in 0 until sw) {
val u = cx / (sw - 1.0)
val v = cy / (sh - 1.0)
val e =
(bump(u, v, 0.32, 0.42, 0.26) + 0.85 * bump(u, v, 0.70, 0.60, 0.22) + 0.50 * bump(u, v, 0.80, 0.24, 0.14))
.coerceIn(0.0, 1.0)
// Quantise, or exact-float dedup gives one tile per distinct value instead of six.
source[cx + cy * sw] = (Math.round(e * (levels - 1)).toDouble() / (levels - 1)).toFloat()
}
:::endfold
/*
Both float paths, on the same source. sliceIntoUniqueTiles2D is the FloatArray counterpart of
sliceIntoUniqueTiles(1, 1): one value per tile. sliceIntoOverlappingPatterns2D takes 3x3 windows
of *values* instead of colours.
*/
val tiles = source.sliceIntoUniqueTiles2D(sw, sh, tileWidth = 1, tileHeight = 1)
val patterns = source.sliceIntoOverlappingPatterns2D(sw, sh, 3, 3, wrapX = false, wrapY = false)
val outW = 40
val outH = 26
:::fold find the lowest level in each model, by value rather than by ID
// The lowest level, found by value rather than by ID: IDs are discovery order. For the overlapping
// set the equivalent is the 3x3 window that is uniformly at that level.
var lowestTile = 0
for (id in 0 until tiles.tileCount) {
if (tiles.tiles[id][0] < tiles.tiles[lowestTile][0]) lowestTile = id
}
// Not "the uniformly-lowest window": this source has no 3x3 block entirely at level 0, because the
// lowest level only occupies a corner or two. The lowest window *on average* always exists.
var lowestPattern = 0
var lowestSum = Float.MAX_VALUE
for (id in 0 until patterns.tileCount) {
var sum = 0f
for (v in patterns.patterns[id]) sum += v
if (sum < lowestSum) {
lowestSum = sum
lowestPattern = id
}
}
:::endfold
/*
Pin the border to the lowest level in both. Without it the field settles on whichever level
nucleates first, for exactly the reason the terrain did; with it, the field has to rise
inward through the chain.
*/
val tiledSolver = WfcSolver(outW, outH, tiles.toRules(), seed = 5L)
val tiledOutcome = tiledSolver.solveWithRetries(maxAttempts = 30) {
for (x in 0 until outW) {
forceTile(x, 0, lowestTile)
forceTile(x, outH - 1, lowestTile)
}
for (y in 0 until outH) {
forceTile(0, y, lowestTile)
forceTile(outW - 1, y, lowestTile)
}
}
/*
render() on a FloatTileSet gives back a FloatArray, not an image.
`throw`, not `error(...)`: a Field box has its own `error` in scope, and it does not return
Nothing, so the elvis would widen this to Any.
*/
val tiledGrid: IntArray =
tiledSolver.resolvedTileGridOrNull() ?: throw IllegalStateException("tiled model ended " + tiledSolver.status)
val tiledHeights: FloatArray = tiles.render(tiledGrid, outW, outH)
/*
The pattern model gets no pinning at all. Pinning the whole border to one window over-constrains
it into a contradiction (a corner has to satisfy the ring in both directions at once) -- and it
does not need the help: a 3x3 window of values already encodes what a slope looks like.
*/
val patternSolver = WfcSolver(outW, outH, patterns.toRules(), seed = 5L)
val patternOutcome = patternSolver.solveWithRetries(maxAttempts = 30)
val patternGrid: IntArray =
patternSolver.resolvedTileGridOrNull() ?: throw IllegalStateException("pattern model ended " + patternSolver.status)
val patternHeights: FloatArray = patterns.render(patternGrid, outW, outH)
/*
And the same pattern set closed under the symmetries of the square. A heightfield has no up, so
a rotated legal arrangement is also legal -- expandSymmetry says exactly that, and it is the one
line that turns the banded result below into terrain.
Three seeds rather than one, because the claim is about the ensemble: each field keeps a grain,
and what symmetry closure removes is every field having the SAME grain. One panel cannot show that.
*/
val symmetric = patterns.expandSymmetry(WfcSymmetry.All)
val symSeeds = [5L, 23L, 42L]
val symFields = mutableListOf<Pair<Long, FloatArray>>()
for (seed in symSeeds) {
val solver = WfcSolver(outW, outH, symmetric.toRules(), seed = seed)
solver.solveWithRetries(maxAttempts = 30)
val grid: IntArray =
solver.resolvedTileGridOrNull()
?: throw IllegalStateException("symmetric model, seed " + seed + ", ended " + solver.status)
symFields.add(seed to symmetric.render(grid, outW, outH))
}
val symHeights: FloatArray = symFields[0].second
val tiledField = floatsToImg(tiledHeights, outW, outH)
val patternField = floatsToImg(patternHeights, outW, outH)
// The contour panel is taken from seed 23, the middle one below, so it can be matched to its field.
val symField = floatsToImg(symFields[1].second, outW, outH)
:::fold measure the grain along rows and down columns
// Mean |difference| between neighbours along rows vs down columns. One number per axis is all it
// takes to say "banded" out loud instead of pointing at the picture.
fun grain(v: FloatArray, w: Int, h: Int): Pair<Double, Double> {
var hs = 0.0
var vs = 0.0
var hn = 0
var vn = 0
for (y in 0 until h) for (x in 0 until w) {
if (x + 1 < w) {
hs += kotlin.math.abs(v[x + y * w] - v[x + 1 + y * w])
hn++
}
if (y + 1 < h) {
vs += kotlin.math.abs(v[x + y * w] - v[x + (y + 1) * w])
vn++
}
}
return (hs / hn) to (vs / vn)
}
val plainGrain = grain(patternHeights, outW, outH)
val symLines =
symFields.joinToString("\n") { entry ->
val g = grain(entry.second, outW, outH)
"3x3 + D4 seed " +
entry.first.toString().padEnd(3) +
" grain along rows ${g.first.f3} / down columns ${g.second.f3}" +
", ratio ${(maxOf(g.first, g.second) / maxOf(1e-6, minOf(g.first, g.second))).f1}x"
}
:::endfold
:::fold render the grain statistics
val statsReport =
"${tiles.tileCount} levels; ${patterns.tileCount} distinct 3x3 windows -> " +
"${symmetric.tileCount} once closed under D4\n" +
"1x1 tiles mean ${tiledHeights.average().f2}, " +
"${tiledOutcome.attempts} attempt(s)\n" +
"3x3 patterns grain along rows ${plainGrain.first.f3} / down columns " +
"${plainGrain.second.f3}" +
", ratio ${(maxOf(plainGrain.first, plainGrain.second) / maxOf(1e-6, minOf(plainGrain.first, plainGrain.second))).f1}x\n" +
symLines
markdown(
"stats",
"""
~~~
$statsReport
~~~
""",
)
:::endfold
:::fold contour the smoothed D4 field, and lay out the six panels
/*
Terraced levels contour into blocky steps, so smooth before extracting geometry. This is the whole
point of ending up in a FloatArray rather than in pixels: iso-contours are FLines. Contour the D4
field -- the banded one has nothing worth contouring.
*/
val smooth = symField.gaussianBlur(1.2)
val contours = ArrayList<FLine>()
for (iso in [0.15, 0.35, 0.55, 0.75]) {
for (line in smooth.isoContourFLines(iso)) {
line.color = vec(0.30 + iso * 0.65, 0.95 - iso * 0.45, 0.40 + iso * 0.45, 0.95)
line.thickness = 1.6
contours.add(line)
}
}
val panels = mutableListOf(
"source 26x18, 6 levels" to
floatsToImg(source, sw, sh).colormap("viridis", lo = 0f, hi = 1f).upscaleNearest(14),
"1x1 tiles, border pinned" to tiledField.colormap("viridis", lo = 0f, hi = 1f).upscaleNearest(9),
"3x3 patterns, as learned" to patternField.colormap("viridis", lo = 0f, hi = 1f).upscaleNearest(9),
)
for (entry in symFields) {
panels.add(
"3x3 + D4, seed ${entry.first}" to
floatsToImg(entry.second, outW, outH).colormap("viridis", lo = 0f, hi = 1f).upscaleNearest(9),
)
}
:::endfold
showImgGrid(*panels.toTypedArray(), columns = 3, cellSide = 400)
lines(contours, channel = "contours", w = 900.0, h = 560.0, fitToSize = true)

The two float models go wrong in different ways.
The 1 × 1 tile model does what the terrain did: every pair is legal, and the result is speckle.
Pinning the border to the lowest level keeps it from plateauing, but a single value per cell carries
no information about slope, so the interior is noise rather than the source's smooth ramp.
The 3 × 3 pattern model uses its context: the result is smooth, with coherent bands. But they are
horizontal bands, where the source was a concentric island. Over most of a concentric island a 3 × 3
window says "the level changes vertically and is constant horizontally", because the wide top and
bottom contribute far more windows than the curved left and right edges. The model reproduced the
most common local arrangement and tiled the plane with it. The readout in the box measures this as
the difference between neighbouring values along rows and down columns.
Taking the orientation out of the model
The bottom row of the figure fixes this. The banding is there because the model learned which way up the source was, so take orientation out of the model.
expandSymmetry closes a pattern set under the symmetry group of the square: every window plus its
three rotations, each with its mirror, deduplicated, with each pattern's observed weight shared out
across its orbit so the expansion does not change how likely any arrangement is. One line, applied to
the pattern set rather than the solver:
val symmetric = patterns.expandSymmetry(WfcSymmetry.All)
The stripes become plateaus, basins and ridges: something you would accept as a heightfield. The pattern count roughly triples here, and both the rule-learning cost (every pattern compared against every other) and the per-step entropy scan grow with it. It is less than eight times, because variants that coincide are deduplicated.
Three seeds are shown because the banded model has the same strong grain on every seed, while the symmetric model's remaining grain is weaker and points a different way each time. One panel cannot show that.
Measuring the grain
Two measures of anisotropy, because they ask different questions:
| Model | ensemble anisotropy | per-output median |
|---|---|---|
3 × 3 as learned |
8.1× | 14.6× |
3 × 3 closed under D4 |
1.4× | 1.9× |
| the source itself | — | 2.1× |
Ensemble anisotropy averages row-grain and column-grain across eight seeds and then divides: it asks whether the model has a preferred direction, and closing under D4 (the eight symmetries of the square) removes nearly all of it. Per-output anisotropy takes the ratio within each field and then the median: it asks whether any single field is directionless, and lands close to the source's own. Terrain has ridges and ridges have directions, so a grain per field is what you want; the problem was that every field had the same grain.
Reach for WfcSymmetry.None (the default) whenever up is real: the flowers grow upward, and a
model free to rotate them would grow them sideways out of the soil. Reach for All for heightfields,
masks, noise and terrain. Rotations and Reflections are the in-between cases; a source with a
left-right symmetry but a genuine up wants Reflections alone.
More context buys coherence, symmetry closure buys isotropy, and neither buys global composition: the same limit as the terrain, two levels up.
The contours channel is four iso-levels of the blurred seed-23 field, extracted with
isoContourFLines and drawn as FLines. Those are not a picture of geometry,
they are geometry: the same values fitInto, transform, animate and export like anything drawn by
hand.
Contour the banded field and you get a stack of near-parallel horizontal lines; contour this one and you get closed loops, saddles and a ridge running between two basins: a topographic map.
Some practical notes. Quantising is required, because sliceIntoUniqueTiles2D dedupes on exact
float equality; the number of levels is your tile alphabet and so sets the cost. Pinning the whole
border of the pattern model over-constrains it into a contradiction (a corner would have to
satisfy the ring in both directions at once), so only the tiled model is pinned here.
sliceIntoOverlappingPatterns2D grows fast, since rule learning compares every pattern against
every other, and expandSymmetry multiplies the pattern count, so time it before reaching for All
on a large pattern set.
Use this section if you want WFC to produce something other than pictures. The output is a
FloatArray, and nothing downstream cares that a constraint solver made it: it is
equally a heightfield to displace geometry with, a mask to composite through, a weight map to drive a
particle system, or (as here) a thing to contour into FLines and then draw, animate and export
like anything you laid out by hand.
Contradictions, seeds and reproducibility
Propagation can empty a cell's possibility set. When that happens the solver sets
WfcStatus.Contradiction and stops, and there is no undo stack to unwind to the choice that doomed
it, because this implementation does not backtrack. The standard remedy, and the original WFC's, is
to throw the attempt away and start again: contradictions are usually the product of one unlucky early choice,
so a fresh attempt on the same rules very often succeeds.
That works because of a deliberate asymmetry in reset(): it clears the wave but does not reset the
random stream. The solver's SplittableRandom is built once from seed and keeps going across
resets, so attempt two explores different choices rather than replaying the one that just failed.
The same seed and the same sequence of calls give byte-identical output.
But reproducibility is a property of the whole session, not of each attempt: the third attempt after
two contradictions is not the same as a fresh solver's first attempt.
:::static
/*
Over-constrained: a strict 0 -> 1 -> 2 chain and nothing else. Hand-authored rules do
NOT get the widening that fromSampleGrid applies, so "nothing may sit east of tile 2" stands.
The longest fillable row is therefore three cells, and a 6x6 grid cannot be filled at all.
*/
val strict = wfcRules(tileCount = 3) {
allow(WFC_EAST, 0, 1)
allow(WFC_EAST, 1, 2)
allow(WFC_SOUTH, 0, 1)
allow(WFC_SOUTH, 1, 2)
}
val stuck = WfcSolver(6, 6, strict, seed = 1L)
val stuckOutcome = stuck.solveWithRetries(maxAttempts = 8)
// The same rules plus the fallback: every mask that was empty becomes "anything", symmetrically.
val relaxed = wfcRules(tileCount = 3) {
allow(WFC_EAST, 0, 1)
allow(WFC_EAST, 1, 2)
allow(WFC_SOUTH, 0, 1)
allow(WFC_SOUTH, 1, 2)
allowMissingAnything()
}
val relaxedOutcome = WfcSolver(6, 6, relaxed, seed = 1L).solveWithRetries()
val report =
[
"strict rules: ${stuckOutcome.status} after ${stuckOutcome.attempts} attempt(s)",
" east of 2: ${(0..2).filter { strict.allows(WFC_EAST, 2, it) }} (nothing may follow it)",
" symmetric? ${strict.findAsymmetry() ?: "yes"}",
"relaxed rules: ${relaxedOutcome.status} after ${relaxedOutcome.attempts} attempt(s)",
" east of 2: ${(0..2).filter { relaxed.allows(WFC_EAST, 2, it) }}",
" symmetric? ${relaxed.findAsymmetry() ?: "yes"}",
]
.joinToString("\n")
:::fold render the deadlock report
markdown(
"deadlock",
"""
~~~
$report
~~~
""",
)
:::endfold
:::fold same rules, two seeds, side by side
val chain = wfcRules(tileCount = 3) {
weights(3.0, 1.0, 4.0)
for (d in [WFC_EAST, WFC_SOUTH]) {
allow(d, 0, 0)
allow(d, 0, 1)
allow(d, 1, 0)
allow(d, 1, 1)
allow(d, 1, 2)
allow(d, 2, 1)
allow(d, 2, 2)
}
}
val shades = [Triple(0.07f, 0.20f, 0.33f), Triple(0.85f, 0.78f, 0.55f), Triple(0.28f, 0.52f, 0.22f)]
fun draw(seed: Long): Img {
val s = WfcSolver(34, 22, chain, seed = seed)
s.solveWithRetries()
val g = s.resolvedTileGridOrNull()!!
return Img.generateRGB(34, 22) { x, y -> shades[g[x + y * 34]] }.upscaleNearest(8)
}
showImgGrid(
"seed 42" to draw(42L),
"seed 42 again" to draw(42L),
"seed 43" to draw(43L),
columns = 3,
cellSide = 300,
)
:::endfold

The two seed-42 panels are identical and seed 43 is not: the reproducibility contract in full. The
text panel is the more useful half: it shows why the strict rules deadlock (nothing may sit east of
tile 2, so no row longer than three cells can ever be completed) and that allowMissingAnything()
fixes it while keeping the relation symmetric.
The symmetric line matters. Widening a mask in one direction only produces a rule set the
solver cannot enforce: it would let allowed[d][t] admit a neighbour that was never told it may sit
on the other side, and the solver would then emit adjacent pairs its own rules forbid when read the
other way round. So allowMissingAnything() and fromSampleGrid's fallback both widen the two
directions together, and findAsymmetry() is the check that they did.
Looking at the rules
WfcRules implements ToHTML for the same reason WfcSolver does, but the failures it exists to
show are different, and they are four that a println cannot show you at all:
- an empty mask: a
(direction, tile)pair with no legal neighbour whatsoever. Not a strict rule but an unsatisfiable one, the moment the tile is placed anywhere but off an edge. - an asymmetry:
allows(d, a, b)withoutallows(opposite(d), b, a). Propagation pushes constraints one arc at a time and can only check arcs that exist both ways round, so an asymmetric table lets the solver emit a pair its own rules forbid: the hazard above. - a zero weight: a tile that is in the model and can never be chosen.
fromSampleGridhands one out, silently, to every tile ID its sample never contained. - an invented pair: adjacency that exists only because the widening fallback filled an empty mask. Legal, enforced, and not evidence of anything: the distinction the terrain measured.
Each of the four is marked in the picture and stated in the header.
:::static
// The same two rule sets as the figure above, this time just looked at. One adjacency matrix per
// direction: row = tile, column = the neighbour that may sit at that offset from it.
val strict = wfcRules(tileCount = 3) {
weights(3.0, 1.0, 2.0)
allow(WFC_EAST, 0, 1)
allow(WFC_EAST, 1, 2)
allow(WFC_SOUTH, 0, 1)
allow(WFC_SOUTH, 1, 2)
}
display("a. strict", strict.h)
val relaxed = wfcRules(tileCount = 3) {
weights(3.0, 1.0, 2.0)
allow(WFC_EAST, 0, 1)
allow(WFC_EAST, 1, 2)
allow(WFC_SOUTH, 0, 1)
allow(WFC_SOUTH, 1, 2)
allowMissingAnything()
}
display("b. widened", relaxed.h)
// And a learned model, for the zero-weight case: tile 2 never appears in the sample.
val learned = WfcRules.fromSampleGrid(
sampleTileIds = intArrayOf(0, 1, 0, 1),
sampleWidth = 4,
sampleHeight = 1,
tileCount = 3,
wrapX = true,
)
display("c. learned, with an unused tile", learned.h)
println("strict: " + strict)
println(
"relaxed: " +
relaxed +
", empty masks: " +
(0 until 4).sumOf { d -> (0 until 3).count { relaxed.allowedCount(d, it) == 0 } },
)
println("learned: " + learned + ", weights " + learned.weights.toList())

The strict set's magenta rows are why it deadlocks: one row per tile with nowhere to go, in each
direction. After
allowMissingAnything() the same matrices have no magenta at all and the header still says
symmetric: every previously empty mask became "anything", and every tile it now admits was told in
turn that it may sit on the other side.
They do not, however, turn teal. They turn amber, and the header says why: 4 widened masks → 8
observed + 20 invented pairs (71% of the table). Four authored constraints went in and the finished
model is mostly not them. That is the same 71% the terrain measured on a real source, from the same cause and
at a much smaller scale: the fallback did not repair the rules, it replaced most of them. Reach for
it when an unplaceable edge tile is the actual problem, and read the header afterwards.
The learned set is the last two cases at once. Its fault is in the weights strip, where tile 2 sits off the ramp because the sample never contained it and it can therefore never be chosen, and because it never appeared, all four of its masks were empty, so widening them leaves that model 87% invented. That unseen tile also dilutes the patterns the sample did show.
Recipes
Reach for the simple-tiled model when you have, or can draw, a tile set whose edges are the
interface: dungeon corridors, pipes, roads, circuitry, Wang tiles. Author the rules from edge sockets
with wfcRules { } rather than learning them; it is fewer lines than drawing a sample layout that
happens to contain every legal pair, and far easier to debug.
Reach for the overlapping model when you have a small picture whose look you want more of and
no natural tile decomposition. Start at 3 × 3 and only go up if the output loses structure you care
about; pattern count, and therefore rule-learning cost, grows fast. This is also the first thing to
try when a learned model gives you correct speckle, as the terrain did, because more context per cell is what
turns speckle into shape.
Prefer overlapping windows to bigger disjoint tiles when you want that context. Both work in
principle; only one of them keeps the model faithful. Disjoint tiles above 1 × 1 are mostly seen once
each, at a sample edge, and the widening fallback then invents the majority of the adjacency table:
71% of it on the terrain source. Check widenedMaskCount before trusting a learned model, and
assert against observedAllows rather than allows when what you want to know is "did this reuse
the source's structure".
Reach for expandSymmetry whenever the thing you are generating has no up. Heightfields, masks,
noise, terrain, cave systems. WFC learns orientation along with content, so on a source with any
directional bias at all it will find that bias and tile the plane with it; the horizontal stripes on the
scalar field are what that looks like. WfcSymmetry.All costs pattern count (180 → 541 there) and buys you a
model that has no opinion about which way up you are. Leave it at None when up is real.
Reach for a wider neighborhood only when you can name the offset that carries the structure you
are losing. Diagonal features need Moore; a two-cell rhythm needs chebyshev(2) or a bespoke
offset set. If you cannot name it, widening will cost time and change nothing, and on a source with
a majority phase it is actively counterproductive, because the extra constraints reinforce whichever
phase nucleates first rather than teaching the model anything new.
Do not expect global composition from any of it. Band ordering, fringe widths, "this never
touches that". All local, all learnable. "Water covers a quarter of the map, in one connected
region" is not, at any window size or neighborhood radius. Supply it with forceTile or accept that
you are generating a crop of a larger world.
Before pinning a cell in an overlapping model, check what that pattern can be followed by. A
uniform window (all one colour) is a trap when the band it came from is thinner than the window:
allowedCount will show it has itself as its only successor in some direction, so pinning one cell
to it floods the whole grid and any second pin contradicts. Pin a window that contains a boundary
instead, or use a smaller window.
When it deadlocks, do not reach for more retries first. Print the branching factor
(allowedCount) and look for a mask with one entry or none, check findAsymmetry(), and ask whether
the output is simply larger than the sample can consistently tile. solveWithRetries needing 20
attempts is a statement about the rules, not the seed.
When the output is correct but boring, the model is too weak rather than the solver too dumb: add context (overlapping, larger patterns), add structure to the tiles themselves (edge sockets), or add offsets to the neighborhood. Changing the seed will not help.
To make it a picture, toImg and then upscaleNearest, never resize or scaleBy, which
filter, and whose first casualty is the pixel-scale structure the whole exercise was about.
To debug rules, watch the wave. waveToImg in a step() loop shows you where the solver ran out
of room, and possibilityCountImg shows you which cells were over-constrained from the start.
Magenta in a wave frame is a cell whose possibility set has been emptied.
Names and shapes that bite inside a Field box. Every snippet on this page runs in one, and neither of these is WFC-specific:
error(...)in a box shows a message and does not throw. It is the box's ownerror, which marks the box, so?: error("...")leaves the expression nullable. Write?: throw IllegalStateException("...")to stop.display(channel, x)replaces that channel's contents; it does not append. Text and a picture need two channels, or one payload that contains both.showImg(img, caption = …)is the latter (a single<figure>), but in an animation prefer drawing the label into the raster withmontageand pushing the bytes: onedisplayper frame, and nothing for the browser to re-parse.
The loadable demo
Everything above as a Field document you can open and edit: wfc_worked_example (six connected boxes, one per section, each of which runs on its own).
Agents: how this page's figures are checked
The transcript that builds the demo, dev_docs/agents/transcripts/wfc_worked_example.yaml, is executed on every build.
afield.examples.stdlib.WfcGallery (stdlib.wfc.gallery) renders the figures, and asserts for every
grid that every adjacent pair is an allowed pair, that the adjacency relation is symmetric, and that
the output is not degenerate. On the terrain figures it also asserts that the set of terrain edges in
the output is a subset of the set in the source.
Three further checks hold particular claims about the terrain to account; they are listed in
dev_docs/WFC_GALLERY.md.
The public declarations used on this page are listed, with their KDoc, in the codebase reference:
wfc.kt and wfc_field.kt. Start from
what's already in the box.