Procedural Generation: Where the Models Kept Failing, and Stacked Hexes Did Not

Guildhall turns a code repository into a living low-poly island. In part 1 I wrote about the Studio, the tools that let me and the AI see the world before shipping it.
This part is about the world itself: the map generator. It was the hardest part of the project, and the place where AI helped me the least.
Prompting got me a map in minutes. Getting a map worth looking at took experience, precision and a lot of real engineering.
Three islands that were wrong
I restarted map generation twice. Each failed attempt failed in a different way, and the same three mistakes kept coming back, whichever model was writing the code.
Mistake 1: mixing styles the assets don't support
Guildhall is built from KayKit packs: chunky, bevelled, hand-modelled low-poly pieces. Hex tiles, houses, trees. The kit has no mountains.
In V1 I asked for real mountains: tall massifs, ridges, switchback paths. The model did what models do. It generated them, as a procedural mesh, and dropped that mesh among the hex lowlands.

Up close, it didn't.

My note at the time: the mountains looked "10x weird quality" next to everything else. The diagnosis was simple once I said it out loud: a procedural flat-shaded mesh is not KayKit's hand-modelled language. No amount of shading tweaks fixes a style mismatch. We went through several relief styles before landing on the rule that finally worked: stop chasing realism and keep the low-poly terrace language all the way up.
Mistake 2: generic islands with nothing on them
For V2 the plan said: flat plane first, terrain later. Relief was marked optional, to be dropped if it threatened quality. So the mountains went away completely.
I didn't like that. And the island that came back was worse than the mountains.

When I wrote down how that generator worked, the diagnosis was one sentence: the generator was feature-first (put N things somewhere), and the land between the features belonged to no stage. Farms only existed where a road passed. Nature filled the leftovers as meadow. From above, meadow is just green.
Mistake 3: saturation
The first hex island fixed the emptiness by overcorrecting. Ask a model for "more nature" and you get nature everywhere.

Low-poly hex reads badly when crowded. That became a written rule in the restart: spacing and clear silhouettes are rules, not afterthoughts.
What the models could not do
I tried this with every frontier model I had access to: Kimi K3, Opus, Fable, DeepSeek. They all wrote working generators. Tests passed. And they all made the same three mistakes, because every one of them is a taste and judgement problem, not a coding problem.
A model doesn't know that KayKit has no mountains unless you tell it. It doesn't know "empty" from "calm" or "full" from "noisy". It optimises what you described, and "an island with mountains, towns and forests" describes all three bad islands above.
What finally worked was me doing the part I'm actually good at: spending time on the technical detail, showing exactly what I wanted with mocks and side-by-side examples, and being precise. Then Opus and Sonnet were excellent at building it.
Why hexagons
On paper, hex had been rejected. The early V2 decision said it "doesn't scale to mountains, lakes and slopes". A day later, after putting V2 next to V1 and seeing it look worse, we did a one-hour spike on a hex grid. It looked right immediately. We restarted on hexagons the same afternoon, with one condition:
A procedural technique built natively on the hex grid, not a hex skin over the old generator.
That matters more than it sounds. On axial hex coordinates, placement, adjacency and connection become grid math. A tile has exactly six neighbours. A road is a path of tiles. A river is a tile draining into its lowest neighbour. And every KayKit building already sits on one hex tile, so the assets and the grid speak the same language.
Relief from stacked layers
So how do you get mountains back without a mountain mesh? I asked a simple question: why not build relief from whole layers of hex tiles, stacked?
Each land tile gets a height in whole levels, never a smooth value. Height rises with the distance from the coast, plus two hill massifs off-centre whose tiles climb in steps, plus bluffs where the coast stands high:
1// simplified from packages/world/src/hex/island.ts
2const D = coast[i]; // steps from the sea
3const bump = fbm(seed + 23, x / 9, y / 9, 2) > 0.25 ? 1 : 0;
4const low = Math.min(2, Math.floor(D / 3)) + bump;
5const rough = 0.75 + 0.5 * ridged(seed + 31, x / 8, y / 8, 2);
6// hillAt: the hill massifs' pull here, 0 to 1
7const hill = Math.round(hillAt(i) * spec.hills * rough * Math.min(1, D / 2));
8level[i] = Math.max(D <= 1 ? 0 : 1, Math.min(low, D)) + hill + bluff;The ground mesh then draws a wall only where a tile stands higher than its neighbour, a terrace step or a cliff. Every wall is shaded by its height in the world, not by its own top and foot, with a lighter band every 0.74 m, so the walls line up across the island like rock strata.

Stepped hex layers read as mountains. And because heights are whole levels, the walkable rules became simple too: a road that changes level gets stairs or a ramp.
The order: sites, then roads, then nature
The second big idea fixed both "generic" and "saturated" at once. The pipeline has a fixed order, and the things that always exist go first.
1. Land
Grow the island from its centre to the size the repo asks for. Coast, height in whole levels, the hill massifs.
2. Sites
Reserve the town core and the castle site: dry, level, inland, picked from land every bigger island also has.
3. Water
Every tile drains to its lowest neighbour. Rivers form where enough rain gathers and run round the reserved sites. Every map ships a nature lake.
4. Places
Score every tile for each kind of place (town, harbour, farm village, fishing village, mining camp...) and take the best ones in order.
5. Roads
A spanning tree plus one loop, each road the cheapest tile path; stairs where no gentle way exists.
6. Jobs
Every tile gets a use: square, street, building, yard, field. Then landmarks in the open country, so nothing is bare.
7. Nature
Trees, bushes, rocks and flowers last, sized and thinned by a chosen preset.
Sites first sounds obvious. It wasn't, and I learned it the hard way.

I was playing with the hex map's knobs and opened that island in 3D. The castle had rivers running through it and no gate you could reach. Every part of the system had been correct on its own. The castle had simply been stamped where the water already was.
The fix was a change in the model, not a patch: big places are composed for a site instead of stamped on a tile. A site is a connected set of dry tiles, levelled, that a road reaches; water may border it, never lie inside it. The castle's walls follow the site's real outline, the gate faces the road, the keep stands farthest from the gate, and a river on the edge becomes a moat.
And the town core and the castle site are reserved before the rivers are routed, so the water flows round them.
Density rules instead of "more"
Saturation and emptiness are the same bug seen from two sides: nobody owns density. So density became explicit data, per kind of place:
1// simplified from packages/world/src/hex/zones.ts
2const PLAN = {
3 town: { per1k: 62, min: 30, density: 0.8 },
4 harbour: { per1k: 9, min: 6, density: 0.55 },
5 farm: { per1k: 10, min: 6, density: 0.32 },
6 // ...
7};
8
9// a building fronts a street; never a solid block
10const cap = z.kind === 'town' ? 3 : 2; // at most this many built neighbours
11const roll = (salt: number) => hash2(seed + salt, i, zi);
12if (byRoad && built <= cap && roll(21) < P.density + 0.25) use[i] = U.building;
13else if (!byRoad && built < cap && roll(23) < P.density * 0.45) use[i] = U.building;
14else use[i] = U.yard;Places scale with the island (so many per thousand land tiles), every building fronts a street, and no building ever becomes part of a solid block. Then the countryside gets its own pass, so every tile has a job and nothing is left as bare green.
Nature was decided the way part 1 describes: on a board, not in a document.

The surprising lesson: bigger trees made the world read better and cost less, because the woods were thinned at the same time. Fewer, bigger trees give silhouettes. Many small trees give noise.
Deterministic, and growing without moving
The island has to grow with the repo. A bigger repo means a bigger island, and the history replay shows it growing over the years. That gives two hard requirements.
Deterministic. Same seed and spec, same island, on every machine. Every random draw is a hash of the seed and a salt, never a shared random stream, so changing how farms are placed doesn't reshuffle the streets.
Stable growth. If the island just scaled its shape with size, every commit would move the coast. Instead, the island grows outward from its centre in one fixed, seed-determined order, the most land-like tile on its edge first:
1// simplified from packages/world/src/hex/island.ts
2export const landTiles = (files: number) =>
3 Math.min(2800, Math.round(2 * (40 + 13 * Math.sqrt(files))));
4
5// grow from the centre, the most land-like tile on the edge first:
6// an island of n tiles is the first n tiles of every bigger one
7push(landness[origin], origin);
8while (heap.length && n < target) {
9 const i = pop();
10 kind[i] = GRASS;
11 rank[i] = n++;
12 for (const j of grid.neighbours(i))
13 if (!grown[j]) {
14 grown[j] = 1;
15 push(landness[j], j);
16 }
17}The grid is one fixed size for every island, so coordinates never shift. A test checks that every land tile of a smaller island is still land in a bigger one. Here is the same seed at four repo sizes, same camera:
The sites use the same trick: they are picked among the first 330 tiles of the growth order, the land every island of that size or bigger has. So the castle and the town square stay put while the coast moves outward. I'll be honest about the limit, too: villages and roads are re-planned as the island grows, so a few of them do shift. The land, the lake, the castle and the town core don't.
Fuzz the knobs, not the examples
A procedural world doesn't fail on seed 1. It fails in combinations you never imagined, like coast 2, hills 0 and rivers 2.5. So the castle bug became a test that sweeps combinations instead of checking examples:
Each island is checked for invariants: no building over water, no overlapping buildings, no wall ending in nothing, every place and every door reachable by road, the castle's keep reachable from its gate. When the sites were reserved before the water, violations across those 720 islands went from about 2,000 per class to zero.
Regions, so a big island isn't one big green
Once the island was full and readable, it was still one colour. The last layer is regions: two to four gentle stretches of country per island, picked by the seed and the land, blended over a few tiles, and stable under growth like everything else.
On top of that, seasons follow the repo's own dates, so the same land can be spring green or under snow. Same tiles, re-tinted in KayKit's own palette ramps, so it stays in the house style.
The anti-patterns, in one place

The lesson
Every model I tried could write a procedural generator. None of them could tell me the mountains were in the wrong style, the island was empty, or the forest was noise. Those were judgement calls. The fixes that mattered were engineering ideas that came from looking at the screen and naming the root cause: hexes as the native grid, relief from layers, sites first, density as data, growth in a fixed order, invariants over examples.
AI amplified those ideas into thousands of lines of good code. It didn't have them.
Next in the series: collisions. A castle that stands on dry ground is half the job; the other half is townsfolk who never walk through its walls. The ideas behind that came from a Super Mario clone I built in Unity for a math class.
Generate your own island at guildhall.codestz.dev (try /town/7?files=12000), and the generator lives in packages/world/src/hex on GitHub.