← Back to Articles

Generating Island Maps with Wave Function Collapse

So you want hundreds of maps. Your game needs a fresh battlefield for every fight, islands, reefs, beaches, open water, and no two encounters on the same layout. You sit down and paint one good hex map in an afternoon, feel pretty good about it, then do the math on the other three hundred and ninety nine. Painting them by hand is going to eat months, and it's the kind of work that gets more painful the longer you do it. If only something could draw endless islands for you, following the same rules you would, never repeating itself.

There is, and you already met it. The map at the top of this page wasn't a screenshot, it was generated live, one cell at a time, the moment the page loaded. Press Generate and it builds a brand new one. The algorithm behind it is called wave function collapse, and this article is a friendly walk through exactly how it works. Fair warning: the name sounds like a physics exam. The idea is closer to sudoku.

The Idea

Think about how you actually solve a sudoku. You don't fill squares left to right. You hunt for the square with the fewest legal digits, commit to one, and follow the consequences: this row can't hold a 7 anymore, so that square over there is now forced, which pins its column, and so on. One decision ripples outward through the rules.

That is wave function collapse, with terrain instead of digits and rules you invent instead of rows and columns. Four moves and a restart:

  1. Superposition. Every cell starts holding every terrain it could possibly be: deep water, sand, forest, all of them at once. The cell hasn't decided yet, so it is all of them.
  2. Lowest entropy first. Find the cell with the fewest possibilities left. Entropy is just the size of that list, and the shortest list goes first for the same reason the most constrained sudoku square does: fewest ways to go wrong.
  3. Collapse. Commit that cell to one of its remaining options, picked at random and weighted by how much of each terrain you want.
  4. Propagate. Every neighbour throws away the options that can't sit next to what you just placed. If a neighbour's list shrinks, its neighbours recheck too, and the change ripples across the map exactly like the sudoku deduction.

Repeat until every cell holds exactly one terrain. There is a single way to fail: if a cell's list ever shrinks to zero, nothing legal fits there, the map is a contradiction, and you bin it and restart with a new seed. That's the whole algorithm. Everything interesting lives in the rules you feed it.

Here is that loop slowed all the way down. The number in each cell is its entropy, how many terrains it could still become, and the ringed cell is the lowest entropy one that collapses next. Press Step to place one cell and watch the choice ripple into its neighbours, whose numbers drop as options get ruled out. Cells the last step just constrained flash violet.

Notice you never once place a cell that breaks a rule. Every collapse only picks from options that are already legal, and propagation keeps that true for everyone nearby. That is the entire trick. Everything from here on is about where those options come from.

Rules That Scale

The rules answer one question: what may sit next to what. The obvious way to encode that is a big table of pairs, but Privateer has 10 terrain subtypes, which means 55 pairs to author by hand, and every new subtype drags 10 more along with it. Nobody keeps that table honest for long.

So skip the table. Each subtype declares a single integer instead, an elevation band on a low to high axis:

new() { SubType = TerrainSubType.DeepWater,    Parent = TerrainType.Sea,    Band = 0 },
new() { SubType = TerrainSubType.MediumWater,  Parent = TerrainType.Sea,    Band = 1 },
new() { SubType = TerrainSubType.ShallowWater, Parent = TerrainType.Sea,    Band = 2 },
new() { SubType = TerrainSubType.Reef,         Parent = TerrainType.Sea,    Band = 2 },
new() { SubType = TerrainSubType.Sand,         Parent = TerrainType.Sand,   Band = 3 },
new() { SubType = TerrainSubType.Grassland,    Parent = TerrainType.Jungle, Band = 4 },
new() { SubType = TerrainSubType.Forest,       Parent = TerrainType.Jungle, Band = 5 },
new() { SubType = TerrainSubType.Houses,       Parent = TerrainType.Jungle, Band = 5 },

Now adjacency falls out of the bands for free: two subtypes may touch only if their bands differ by 1 or less. A short affinity override list handles the exceptions:

public float Affinity(TerrainSubType a, TerrainSubType b)
{
    foreach (var o in Affinities)
    {
        if ((o.A == a && o.B == b) || (o.A == b && o.B == a))
        {
            return Mathf.Max(0f, o.Weight);
        }
    }

    return Mathf.Abs(Band(a) - Band(b)) <= 1 ? 1f : 0f;
}

Zero means forbidden, one means allowed, anything above one means preferred. The only override the whole game ships with is ShallowWater next to Sand at weight 5, so beaches actively pull shallow water toward themselves.

Here is the part that earns the whole scheme its keep. Deep water is band 0 and sand is band 3, three apart, so they can never touch. Which means every scrap of land is forced to grow a gradient around itself: forest needs grassland beside it, grassland needs sand, sand needs shallows, shallows need medium water before the deep. You get coastlines out of one integer per subtype.

Nobody authored a beach. The beach is what "bands differ by 1" looks like from above.

The Loop in Code

Here's the neat part. With 10 subtypes, a cell's entire possibility list fits in a single int, one bit per subtype. Collapsing a cell sets one bit, and propagation becomes a bitwise AND per neighbour, no allocations, no set objects, just integers:

private static bool Propagate(int[,] mask, Queue<Vector2Int> queue, int[] allowedAdj, int n, int w, int h)
{
    while (queue.Count > 0)
    {
        var c = queue.Dequeue();
        var cm = mask[c.x, c.y];
        var allowedForNeighbour = 0;
        for (var t = 0; t < n; t++)
        {
            if ((cm & (1 << t)) != 0) allowedForNeighbour |= allowedAdj[t];
        }

        foreach (var nb in Neighbours(c, w, h))
        {
            var before = mask[nb.x, nb.y];
            var after = before & allowedForNeighbour;
            if (after == before) continue;
            if (after == 0) return false;
            mask[nb.x, nb.y] = after;
            queue.Enqueue(nb);
        }
    }

    return true;
}

allowedAdj[t] is a precomputed bitmask of everything allowed next to subtype t, built once from the affinity rules. A cell ORs together the allowed sets of its remaining possibilities, each neighbour ANDs its own list against that, and any neighbour that shrank goes back on the queue to constrain its own neighbours. A result of zero is the contradiction from step 5, the whole attempt returns false, and the generator retries with the next seed, up to 20 attempts before falling back to open sea rather than throwing during a loading screen.

The weighted pick in step 3 uses the same affinity numbers. A candidate's chance is its base abundance weight multiplied by its affinity to every neighbour that has already collapsed, which is how that ShallowWater and Sand override of 5 turns from "allowed" into "beaches hug islands". One hex specific wrinkle: neighbour offsets differ between even and odd rows, which is exactly the kind of detail a unit test catches and an eyeball never does.

Those abundance weights are the design dial. Privateer ships seven configs, Archipelago, OpenSea, ReefShoals, AtollLagoon, Skerries, JungleIsles, HousesHeavy, all sharing the one ruleset. The only thing that differs between them is a list of floats. Skerries drowns the map in reef and shallows, HousesHeavy stamps settlements onto every island, and neither one touches a single line of adjacency logic.

What WFC Won't Do

The first time I wired the whole thing together and hit Generate, I got open water. Every hex. I rerolled, certain it was a fluke, and got more water. Nothing was broken, which is exactly what made it confusing, and it turned out to be the lesson worth the whole article. WFC promises that every pair of neighbours is legal. It promises nothing about the map as a whole. Local validity, not design.

The all water result even has a tidy explanation. The outer ring is forced to deep water so islands don't clip the edge, water carries the biggest weights, and land sits 3 bands uphill from that border. For a single forest to appear, the random picks have to stack a four step gradient by accident, and they almost never do. The statistically safest map is one giant sea, and the algorithm is nothing if not a statistics machine.

The fix isn't more rules. It's planting facts the algorithm has to build around. Before the main loop runs, the generator jams the highest band land subtype into a handful of interior cells:

var landBit = 1 << landSeedIndex;
var planted = 0;
var guard = 0;
while (planted < config.IslandSeeds && guard++ < config.IslandSeeds * 20)
{
    var rx = rng.Next(loX, hiX + 1);
    var ry = rng.Next(loY, hiY + 1);
    if (PopCount(mask[rx, ry]) > 1 && (mask[rx, ry] & landBit) != 0)
    {
        mask[rx, ry] = landBit;
        queue.Enqueue(new Vector2Int(rx, ry));
        planted++;
    }
}

Then propagation does the rest for you. A forest cell demands grassland or sand nearby, the band rules push ring after ring outward, and islands stop being suggested by weights and start being guaranteed by geometry.

That same local versus global gap bit me a second time, and this one made it into a playtest. Every neighbour pair was legal, the map looked great, and a ship spawned inside a lagoon that two islands had quietly sealed off from the open sea. It spent the entire battle circling its own private pond while the fight happened somewhere it could never reach. Connectivity is a global property, and WFC's vocabulary can't even express it. So the generator doesn't ask. It repairs the map afterwards:

var components = NavigableComponents(grid, rules, playable);
if (components.Count <= 1) return;

components.Sort((a, b) => b.Count.CompareTo(a.Count));
var main = new HashSet<Vector2Int>(components[0]);

for (var i = 1; i < components.Count; i++)
{
    var comp = components[i];
    if (comp.Count < config.FillSeaPocketsBelow)
    {
        foreach (var c in comp) grid[c.x, c.y] = TerrainSubType.Sand;
    }
    else
    {
        CarveChannel(grid, comp, main, rules, playable);
        foreach (var c in comp) main.Add(c);
    }
}

A flood fill finds every connected region of navigable sea. The biggest region is the main sea. Pockets under 6 cells get filled with sand and become island interior, bigger ones get a channel: a breadth first search finds the shortest path to the main sea and converts the blocking cells along it to shallow water. Every map ends with exactly one sea, either because the collapse produced it or because the repair pass carved it.

Try It

Everything in the last three sections is running in the playground at the top of this article. Scroll back up, and this time watch it knowing what you know: dim cells are still in superposition, their color is the blend of everything they could still become, and the border and the island seeds constrain the map before a single random pick happens.

Three experiments worth running. Drop the island seeds to 0 and watch water win the map, that's the open ocean problem happening in front of you. Uncheck the repair pass on the Reef & shoals preset and reroll a few times until a lagoon gets pinched off, then turn it back on and watch the carved channel appear, outlined in blue. And slow the speed right down to see the lowest entropy rule at work: the algorithm always burrows into the most constrained region instead of wandering the open board.

Keep It Pure

I keep the whole generator as a static class with a single entry point: config in, ruleset in, seed in, list of cells out. No scene access, no tilemaps, no singletons. It sounds like architectural fussiness until you need it. Same seed, same map, so the level editor's Reroll button just bumps an integer, and any map that misbehaves in a playtest is reproducible from that one number.

Purity is also what makes the guarantees checkable. The EditMode test suite asserts them directly: determinism per seed, the forced sea border, deep water never touching sand across many seeds, weight driven abundance, and the single connected sea. Eyeballing rerolls catches the map in front of you. The tests catch the map a player would have hit three weeks after launch.

The Takeaway

Wave function collapse is not magic and it is not a level designer. It's a constraint rippler: you hand it local rules, it hands you back a grid where every neighbour pair obeys them. The sudoku analogy really is the whole mental model. Superposition is the list of options, entropy is the length of that list, collapse is committing, propagation is working out the consequences.

All the craft lives in what you wrap around it. Encode the rules so they scale, bands instead of pair tables. Plant the facts the map must contain before the loop runs. And for every global property your game leans on, the islands exist, the sea is connected, go find the exact line of code that guarantees it.

If the answer is "the weights make it very likely," you don't have a guarantee. You have a bug with a random trigger date.