Building a Procedural Hex Map with Wave Function Collapse

2026-03-0917:0257686felixturner.github.io

Procedural medieval islands from 4,100 hex tiles, built with WebGPU and a lot of backtracking. Run the live demo · Source code on GitHub Every map is different. Every map is seeded and deterministic.…

Procedural medieval islands from 4,100 hex tiles, built with WebGPU and a lot of backtracking.

Run the live demo · Source code on GitHub

Hero shot
Every map is different. Every map is seeded and deterministic.

I've been obsessed with procedural maps since I was a kid rolling dice on the random dungeon tables in the AD&D Dungeon Master's Guide. There was something magical about it — you didn't design the dungeon, you discovered it, one room at a time, and the dice decided whether you got a treasure chamber or a dead end full of rats.

Years later, I decided to build my own map generator. It creates little medieval island worlds — with roads, rivers, coastlines, cliffs, forests, and villages — entirely procedurally. Built with Three.js WebGPU and TSL shaders, about 4,100 hex cells across 19 grids, generated in ~20 seconds.

The core technique is Wave Function Collapse (WFC), an algorithm originally created by Maxim Gumin that's become a darling of procgen gamedev.

Carcassonne board game
Hours of fun with map tiles.

If you've ever played Carcassonne, you already understand WFC. You have a stack of tiles and place them so everything lines up. Each tile has edges — grass, road, city. Adjacent tiles must have matching edges. A road edge must connect to another road edge. Grass must meet grass. The only difference is that the computer does it faster, and complains less when it gets stuck.

The twist: hex tiles have 6 edges instead of 4. That's 50% more constraints per tile, and the combinatorial explosion is real. Square WFC is well-trodden territory. Hex WFC is... less so.

For this map there are 30 different tiles defining grass, water, roads, rivers, coasts and slopes. Each tile in the set has a definition which describes the terrain type of each of its 6 edges, plus a weight used for favoring some tiles over others.

Tile set showing road, river, and terrain variants
30 tile types, each with 6 rotations and 5 elevation levels. That's 900 possible states per cell.

For example this 3-way junction has 3 road edges and 3 grass edges. Tile definition:

{ name: 'ROAD_D', mesh: 'hex_road_D',
  edges: { NE: 'road', E: 'grass', SE: 'road', SW: 'grass', W: 'road', NW: 'grass' },
  weight: 2 }
A 3-way road junction tile with its 6 edge types labeled
Each tile defines 6 edge types. Matching edges is the only rule.
  1. Start with chaos. Every cell on the grid begins as a superposition of all possible tiles — all 30 types, all 6 rotations, all 5 elevation levels. Pure possibility.
  2. Collapse the most constrained cell. Pick the cell with the fewest remaining options (lowest entropy). Randomly choose one of its valid states.
  3. Propagate. That choice constrains its neighbors. Remove any neighbor states whose edges don't match. This cascades outward — one collapse can eliminate hundreds of possibilities across the grid.
  4. Repeat until every cell is solved — or you get stuck.

Getting stuck is the interesting part.

WFC is reliable for small grids. But as the grid gets bigger, the chance of painting yourself into a dead end goes up fast. A 217-cell hex grid almost never fails. A 4123-cell grid fails regularly.

The solution: modular WFC. Instead of one giant solve, the map is split into 19 hexagonal grids arranged in two rings around a center — about 4,100 cells total. Each grid is solved independently, but it has to match whatever tiles were already placed in neighboring grids. Those border tiles become fixed constraints.

And sometimes those constraints are simply incompatible. No amount of backtracking inside the current grid can fix a problem that was baked in by a neighbor. This is where I spent a lot of dev time.

Debug view showing grid boundaries
5 of 19 grids solved. Each grid is an independent WFC solve, constrained by its neighbors' border tiles.

Here's the dirty secret of WFC: it fails. A lot. You make a series of random choices, propagate constraints, and eventually back yourself into a corner where some cell has zero valid options left. Congratulations, the puzzle is unsolvable.

The textbook solution is backtracking — undo your last decision and try a different tile. My solver tracks every possibility it removes during propagation (a "trail" of deltas), so it can rewind cheaply without copying the entire grid state. It'll try up to 500 backtracks before giving up.

But backtracking alone isn't enough. The real problem is cross-grid boundaries.

The Recovery System

After many failed approaches, I landed on a layered recovery system:

Layer 1: Unfixing. During the initial constraint propagation, if a neighbor cell creates a contradiction, the solver converts it from a fixed constraint back into a solvable cell. Its own neighbors (two cells out — "anchors") become the new constraints. This is cheap and handles easy cases.

Layer 2: Local-WFC. If the main solve fails, the solver runs a mini-WFC on a small radius-2 region around the problem area — re-solving 19 cells in the overlap area to create a more compatible boundary. Up to 5 attempts, each targeting a different problem cell. Local-WFC was the breakthrough. Instead of trying to solve the impossible, go back and change the problem.

Layer 3: Drop and hide. Last resort. Drop the offending neighbor cell entirely and place mountain tiles to cover the seams. Mountains are great — their cliff edges match anything, and they look intentional. Nobody questions a mountain.

Before: neighbor conflict blocks the solve Before: neighbor conflict blocks the solve
After: Local-WFC patches the boundary After: Local-WFC patches the boundary
Debug mode showing the carnage. Purple = neighbor conflict. Red = broken tiles.

This map isn't flat — it has 5 levels of elevation. Ocean and Grass start at level 0, but slopes and cliffs can move up or down a level. Low slopes go up 1 level, high slopes go up 2 levels. A road tile at level 3 needs to connect to another road tile at level 3, or a slope tile that transitions between levels. Get it wrong and you end up with roads that dead-end into cliff faces or rivers flowing uphill into the sky. The elevation axis turns a 2D constraint problem into a 3D one, and it's where a lot of the tile variety (and a lot of the solver failures) comes from.

Natural Natural
Debug Colors Debug Colors
Elevation colors.

Tiles are colored with a node-based PBR material - MeshPhysicalNodeMaterial - with a custom TSL color node. Each tile's elevation is encoded in the instance color, which the shader uses to blend between two palette textures — low ground gets summer colors, high ground gets winter.

Hex math is weird. Since there are 6 directions instead of 4, there's no simple mapping between hex positions and 2D x,y coordinates. The naive approach is offset coordinates — numbering cells left-to-right, top-to-bottom like a regular grid. This works until you need to find neighbors, compute distances, or do anything involving directions. Then it gets confusing fast, with different formulas for odd and even rows.

Hexagonal Offset Coords
Offset coordinates: simple until you need to do anything useful with them.

The better approach: cube coordinates (q, r, s where s = -q-r). It's a 3D coordinate system for the three hex axes. Neighbor finding becomes trivial — just add or subtract 1 from two coordinates.

The good news is that WFC doesn't really care about geometry. It's concerned with which edges match which — it's essentially a graph problem. The hex coordinates only matter for rendering and for the multi-grid layout, where the 19 grids are themselves arranged as a hex-of-hexes with their own offset positions.

If you've ever worked with hex grids, you owe Amit Patel at Red Blob Games a debt of gratitude. His hex grid guide is the definitive reference.

Early on, I tried using WFC for tree and building placement. Bad idea. WFC is great at local edge matching but terrible at large-scale patterns. You'd get trees scattered randomly instead of clustered into forests, or buildings spread evenly instead of gathered into villages.

The solution: good old Perlin noise. A global noise field determines tree density and building placement, completely separate from WFC. Areas where the noise is above a threshold get trees; slightly different noise drives buildings. This gives you organic clustering — forests, clearings, villages — that WFC could never produce. I also used some additional logic to place buildings at the end of roads, ports and windmills on coasts, henges on hilltops etc.

WFC handles the terrain. Noise handles the decorations. Each tool does what it's good at.

Village with clustered buildings and surrounding forests
Buildings cluster along roads. Forests form natural groups. None of this is WFC — it's all noise-based placement.

Water effects were the hardest visual problem to solve. The ocean isn't just a blue plane — it has animated caustic sparkles and coastal waves that emanate from shorelines.

Close-up of ocean debug view
Pink is the best debug color.

I wanted that 'Zelda: The Wind Waker' cartoon shimmer on the water surface. Originally I tried generating caustics procedurally with four layers of Voronoi noise. This turned out to be very GPU heavy and did not look great. The solution was sampling a small scrolling caustic texture with a simple noise mask, which looks way better and is super cheap. Sometimes the easy solution is the correct solution.

Coast Waves

Waves are sine bands that radiate outward from coastlines, inspired by Bad North's gorgeous shoreline effect. To know "how far from the coast" each pixel is, the system renders a coast mask — a top down orthographic render of the entire map with white for land and black for water — then dilates and blurs it into a gradient. The wave shader reads this gradient to place animated sine bands at regular distance intervals, with noise to break up the pattern.

Flat blue plane Flat blue plane
Water mask Water mask
Caustic sparkles Caustic sparkles
Coast waves fat in coves Coast waves fat in coves
Surroundedness mask Surroundedness mask
Final water Final water
Building up the water effect layer by layer.

This worked great on straight coastlines. In concave coves and inlets, the wave lines got thick and ugly. The blur-based gradient spreads the same value range over a wider physical area in coves, stretching the wave bands out.

I tried multiple fixes:

  • Screen-space derivatives to detect gradient stretching — worked at one zoom level, broke at others.
  • Texture-space gradient magnitude to detect opposing coast edges canceling out — only detected narrow rivers, not actual problem coves.
  • Extra dilation passes — affected straight coasts too.

The fundamental issue: blur encodes "how much land is nearby," not "how far is the nearest coast edge." These are different questions, and no amount of post-processing the blur can extract true distance.

The solve was to do a CPU-side "surroundedness" probe that checks each water cell's neighbors to detect coves, writing a separate mask texture that thins the waves in enclosed areas. It's kind of a hack but it works and the wave edges thin out nicely at the edges.

The 3D tile assets come from KayKit's fantastic low-poly Medieval Hexagon pack. But it was missing some key connectors needed for a sub-complete tileset, so I dusted off my Blender skills and built new tiles: sloping rivers, river dead-ends, river-to-coast connectors, and several cliff edge variants.

The key constraint: every tile is exactly 2 world units wide, and edge types must align perfectly at the hex boundaries. Getting UVs right means the texture atlas maps correctly across tile seams. A misaligned UV by even a few pixels creates a visible seam line that breaks the illusion.

Blender viewport showing hex tiles

The algorithm gets you a valid map. Making it look like a place you'd want to visit is a whole separate problem.

Completed map beauty shot

The renderer is Three.js with WebGPU and TSL (Three.js Shading Language) — the new node-based shader system that replaces raw GLSL. All the custom visual effects are written in TSL, which reads like a slightly alien dialect of JavaScript that runs on your GPU.

The Post-Processing Stack

The raw render looks... fine. Flat. Like a board game photographed under fluorescent lights. The post-processing pipeline is what gives it atmosphere:

  1. GTAO Ambient Occlusion — darkens crevices between tiles, around buildings and trees. This makes everything feel more solid. The AO result is denoised to reduce speckling. This runs at half resolution since AO and denoising is expensive.
  2. Depth of Field — tilt-shift blur based on camera distance gives it that miniature/diorama feel. The DOF focal length scales with the camera zoom to give more DOF when zoomed in.
  3. Vignette + Film Grain — subtle edge darkening and noise. Just enough to feel analog.
Normals Normals
AO AO
Raw render Raw render
AO Composite AO Composite
DOF DOF
Vignette and Grain Vignette and Grain
Post-processing. AO, depth of field and grain do a lot of heavy lifting.

The shadow map frustum is fitted to the camera view every frame. The visible area is projected into the light's coordinate system to compute the tightest possible bounding box, so no shadow map texels are wasted on off-screen geometry. Zoomed out, shadows cover the whole map at lower resolution. Zoom in, and the shadow map tightens to give you crisp, detailed shadows on individual tiles. This prevents blocky shadow artifacts when you zoom in.

Fixed shadow map Fixed shadow map
Dynamic shadow map Dynamic shadow map
Dynamic frustum for crisp detail.

The complete map has thousands of tiles and decorations. Drawing each one individually would kill the frame rate. The solution is two-fold:

BatchedMesh — each hex grid gets 2 BatchedMeshes: one for tiles, one for decorations. The beauty of a BatchedMesh is that each mesh can have separate geometry and transforms, but they all render in a single draw call. The GPU handles per-instance transforms and geometry offsets, so CPU cost is essentially zero after setup.

The whole scene renders in a handful of draw calls regardless of map complexity. That means the base render is cheap, so you can spend your GPU budget on AO, DoF, and color grading instead.

One Shared Material — every mesh in the scene shares a single material. The Mesh UVs map into a small palette texture, so they all pull their color from the same image, like a shared paint-by-numbers sheet. One material means zero shader state switches between draw calls, so the GPU can blast through 38 BatchedMeshes without stalling.

The result: 4,100+ cells, 38 BatchedMeshes, and the whole thing renders at 60fps on desktop and mobile.

snow day
Snow day.

No dice required this time — but the feeling is the same. You hit a button, the map builds itself, and you discover what the algorithm decided to put there. It's super satisfying to see the road and river systems matching up perfectly. Every time it's different, and every time I find myself exploring for a while. The kid rolling dice on the dungeon tables would be into this.

30 tile types

19 hexagonal grids

~4,100 total cells

2 draw calls per grid

500 max backtracks

5 Local-WFC attempts

~20s to build all grids

100% success rate (500 runs)

  • Three.js r183 with WebGPU renderer
  • TSL (Three.js Shading Language) for all custom shaders
  • Web Workers for off-thread WFC solving
  • Vite for builds
  • BatchedMesh for efficient tile rendering (one draw call)
  • Seeded RNG for deterministic, reproducible maps

Play with the live demo — click the hex buttons to expand the map, or hit 'Build All' to generate the whole thing. There's a full GUI panel with 50+ tweakable parameters if you want to mess with the lighting, color grading, water effects, and WFC settings.

Full source code on GitHub

I'm Felix Turner, a creative developer and founder of Airtight Interactive. I build interactive visual experiments, WebGL/WebGPU experiences, and generative art.

Twitter · Bluesky


Read the original article

Comments

  • By porphyra 2026-03-0918:433 reply

    The post glosses over the "backtracking" and says they just limit it to 500 steps but actually constraint programming is an extremely interesting and complicated field with lots of cool algorithms and tricks. In this case we could solve it with Knuth's Algorithm X [1] with dancing links, which is a special kind of backtracking. Algorithm X should, in theory, be able to solve the border region described in the article's "Layer 2" with a higher success rate as opposed to 86%.

    Furthermore, various heuristics can speed up the backtracking a lot compared to a brute force approach. As anyone who has implemented a Sudoku solver can attest, a brute force backtracking is easy to implement but will immediately get bogged down with slowness.

    [1] https://en.wikipedia.org/wiki/Knuth%27s_Algorithm_X

    • By shoo 2026-03-0920:591 reply

      there's also a bunch of dedicated constraint programming solvers / high level modelling languages for these kinds of constraint-y combinatorial optimisation problems

      e.g. https://www.minizinc.org/ offers a high level modelling language that can target a few different solver backends

      might be pretty good results to completely ignore writing a custom algorithm and drop in an existing industrial-grade constraint programming solver, model your procgen problem using a high level language, and use the existing solver to find you random solutions (or exhaustively enumerate them). then more time to iterate on changing the problem definition to produce more interesting maps rather than getting bogged down writing a solver.

    • By mzl 2026-03-107:07

      I have a (very slight) beef with the name Algorithm X, as it is more of a data-structure to manage undo-information for the backtracking than an algorithm. It is a very fun, useful, and interesting data-structure, but it doesn't really change what steps are performed in the backtracking search.

    • By felixturner 2026-03-102:39

      Thanks for the feedback. Feel free to drop a PR :)

  • By vintermann 2026-03-107:546 reply

    It's beautiful. But also pretty unsatisfying in a way. Roads don't make sense. Rivers barely make sense. There's no higher-scale structure - the reason why most games with incremental procedural generation usually feel stale after a while, and always static. Every planet is different, yet feels the same.

    (Dwarf Fortress is the one procedural game I know - besides maybe its more faithful clones - which isn't static. But the procedural generation there is also mostly not incremental, it generates the whole world beforehand and only some of the fill-in details are incrementally generated).

    The holy grail has to be a graph-based refinement/embellishment system, where it generates just the nodes, temporally and spatially, which matter for the situation the player is in.

    • By bhaak 2026-03-1014:02

      That's a general problem with procedurally generated content.

      Remember that wave function collapse focuses on local optimization. The algorithm can’t take a step back and look at the whole map. That’s why you won’t get a sensible road network. Rivers are only slightly better when the follow height gradients.

      What you can do, and this is also a general advice for procgen, is to mix in some templates before WCF runs. Often, a bit of post-processing is needed as well.

      The templates can be hand-designed, or generated with simpler procgen code. Place a few towns on the map, connect them with roads, and then let WFC fill in the gaps to create a more interesting landscape.

    • By antonvs 2026-03-1012:44

      > Roads don't make sense. Rivers barely make sense. There's no higher-scale structure

      It feels a bit like the graphical equivalent of Markov chain text generation.

    • By VorpalWay 2026-03-1012:45

      I think it helps in Dwarf Fortress that you are not really exploring the world (well, unless you play adventurer mode, but that seems far less popular), you pick a site and settle and start building. You see far less of the world than in something like Minecraft. Yes, you get to see more of the world over multiple runs, but it is still far more limited.

      Rimworld is interesting here, as it is what I would consider a DF style game. And I would have said the same for it, except that the latest expansion (Oddessy) added space ships that you can build, and fly to another area. While fun this has made the procedural generation show its weaknesses.

      (That said, DF world gen is top notch, but probably not quite as good as it may seem due to what I mentioned.)

    • By MITSardine 2026-03-1016:13

      As I recall, a lot goes into DF's world generation, including erosion simulation and the like to achieve a realistic result. That game is the embodiment of over-engineering, after all.

    • By orbital-decay 2026-03-1012:21

      Situation-adaptive generation also gets predictable and boring once you can read it easily. Classic procedural generation is good for perceived variety and breaking small patterns, but it doesn't introduce novelty on its own, it just shifts it from the asset design to the algorithm design.

    • By elestor 2026-03-1016:031 reply

      Noita is also procedural, but not static, although I'm not sure if that's what you're talking about.

      • By caspar 2026-03-1314:43

        Noita uses herringbone wang tiles where each wang tile pixel is 16x16 simulated in-game CA pixels, with tiles selected from a per-biome pool and the ability for biome-specific scripts to override certain areas too. As part of expanding each wang tile pixel into 16x16 pixels, some noise is applied to terrain to add the curvy look, with another layer of (thresholded perlin?) noise that controls which bits of inner terrain get variations (gold veins etc).

        Source: working on a Noita-like so have spent a bit of time looking at prior art. Noita wiki.gg will explain a lot of it though (warning: many spoilers).

  • By rhdunn 2026-03-0918:24

    Oskar Stålberg used wave function collapse for various games, including Townscaper. He talks about it here: https://www.youtube.com/watch?v=Uxeo9c-PX-w&pp=ygUhdG93bnNjY... (SGC21- Oskar Stålberg - Beyond Townscapers).

HackerNews