Terrain, version one: every kit now generates its own ground
The first release of our terrain system. Every kit ships a generator with its own knobs, and terrain from two different kits joins with no seam, because the joining is arithmetic rather than coordination.
Terrain is out, and this is version one. Every kit now ships a procedural generator for its own ground: a module with knobs, sold as an asset like anything else, and the first two are live (Coral Reef, Nature & Forest). Terrain from two different kits joins with no seam.
Until this week, the ground under a Polyfork kit was a prop. Each kit shipped two or three "terrain blobs": irregular flattened patches six to twelve metres across that a scene clones and overlaps into a landscape. It worked, and it had two problems that got worse the longer we did it.
The first is duplication. "Grass Terrain Blob" existed in six separate kits. Six build passes, six judging rounds, six near-identical models, because every kit planned its own ground from scratch.
The second is that cloning patches is not how ground works. Overlapping blobs at the same height z-fight along their coplanar tops, so the composition contract had to tell the composer to stagger every blob's Y by a few millimetres. That is a workaround standing in for a generator.
So natural ground stopped being a part. Every kit now ships a terrain program: a module with knobs that generates that kit's ground, sold as an asset like anything else.
The hard part is not the terrain
Writing a generator per kit is easy. Writing generators that join is not, and it cannot be added later, because joining is a property of how every generator computes its heights. It has to be true of the first one.
The contract is three fixed numbers and one rule.
A chunk is 64 m. It divides evenly by every module size in the catalogue (4 m for the city kits, 2 m for Medieval, 1 m for the farm kits), so a grid kit's floor tiles land exactly on chunk edges instead of half a tile over the seam.
The skirt is 4 m. The band inside each edge where a kit's own character has to fade out. On the boundary itself the kit contributes exactly nothing.
Vertex pitch is 0.5, 1, 2 or 4 m, and two blocks only join at the same one. Different pitch means different vertex counts along a shared edge, and no height rule can close that.
The rule is one line of arithmetic:
const base = baseHeight(worldX, worldZ, seed); // pure function of WORLD position const w = skirtWeight(localX, localZ, width); // 0 at the edge, 1 by 4 m in if (w <= 0) return base; // the boundary IS the shared field let h = base; for (const f of features) h += w * f.height(worldX, worldZ, ctx);
Every kit adds its own character multiplied by a weight that is zero on the boundary. So on the edge, every kit returns baseHeight and nothing else. Since that is a pure function of world coordinates, two neighbours evaluating it at the same coordinates get the same number, from the same operations, in the same order.
Neither block knows the other exists. There is no registry, no neighbour lookup, no handshake at runtime. They agree because they are computing the same thing.
What that looks like measured
Two blocks side by side: the Coral Reef kit's terrain on the left, the Nature & Forest kit's on the right, sharing an edge at x = 128.
| z | reef | forest | baseHeight | delta |
|---|---|---|---|---|
| 0 | -3.507042 | -3.507042 | -3.507042 | 0 |
| 1 | -3.503529 | -3.503529 | -3.503529 | 0 |
| 2 | -3.493267 | -3.493267 | -3.493267 | 0 |
| 3 | -3.476675 | -3.476675 | -3.476675 | 0 |
Across all 129 shared vertices the maximum difference is 0. Not "within tolerance". Identical, and equal to the shared field.
Four metres inside, they diverge freely: reef -2.12, forest -0.82. That is the point. The kits are only constrained at the join.
bin/terrain-check.mjs runs that comparison for every pair of kits, in both axes, at every resolution, and requires exactly zero. A tolerance would let the contract rot quietly: two kits a centimetre apart today are a metre apart in six months, and a seam that is nearly right is a visible crack in somebody's game.

Colour is allowed to know about its neighbour
Height must match exactly. Colour must not, and that difference is useful: colour has no geometric consequence, so unlike height it can look across the seam.
Two kits meeting on a hard line reads as a biome border, which is often right. A reef running into a beach is not one of those times. So each block blends toward what its neighbour paints, over the last 14 m.
The obvious version of this does not work. If each block blends fully into its neighbour's colour at the seam, it does not blend at all: it swaps the two and leaves a hard line in exactly the same place. The weight has to reach 0.5 at the seam, so both sides arrive at the same half-and-half mix.
There is a second trap under that one. Colour is decided per face from its centroid, so the reef's edge faces sit at x = 127.5 and the forest's at x = 128.5. Each side has to ask the other about the mirrored point, or they are comparing two different places.

Measured at the seam itself, as the colour difference between the two sides:
| difference | |
|---|---|
| no blending | 0.575 |
| blend toward the neighbour's average tone | 0.078 |
| blend toward the neighbour's actual colour function | 0.024 |
| the same, with the query mirrored across the seam | 0.0097 |
Rivers are a program, not a stripe
The forest terrain's first watercourse was a noise contour dyed blue: wherever a warped fractal crossed 0.5, cut a groove and paint it. That has two defects you cannot patch.
It runs uphill as happily as down, because a contour of noise knows nothing about elevation. And its "water" was a colour on the sloping faceted bed, so every facet of the bank caught the light at a different angle and the river read as a jagged blue sawtooth.
The replacement traces an actual watercourse. Sources sit on a lattice in world coordinates, climb the gradient to a local high, then walk downhill with a bounded meander until they reach the sea. What comes back is a polyline whose elevation only ever falls, which gives three things a contour cannot: the channel always descends, it widens and deepens downstream, and the water surface is the traced elevation, so it is level across the channel and falling along it.
Three bugs on the way there, all of them now in the code comments:
- A meander written as
dx += -dz*m; dz += dx*mis not a rotation. The second line reads thedxthe first just changed, which compounds the turn every step. Courses spiralled inside a 9 m box. - A course on flat ground has no fall line, so the meander is the only thing steering it and it goes in a circle. Water does not flow across flat ground, it pools. The course has to stop.
- Sources dropped straight onto a lattice point start most of the way down the hill already, so every river was 90 m long with a 6 m drop. Climbing to the local high first is what makes a river a river.
The water is its own mesh with its own material, and its level lives on the shared vertex grid. Both other arrangements were tried and both are visibly wrong: per-corner levels throw white shards wherever the level steps, and one flat level per quad breaks the sheet into a checkerboard of floating tiles with the bed showing through.
Which tiles still earn their slot
If the ground is generated, most ground tiles are dead weight. The test we settled on is simple: a tile earns its slot when the surface is something people built.
A street, a sidewalk, a plaza paving stone, an interior floor, a deck plate, a tilled plot. Those have made edges, a thickness and a repeat, and no height field produces them. Grass, dirt, sand, mud, snow and seabed do not, and every one of those is now refused at planning time.
Loose ground dressing is still very much a part, and still wanted: single rocks, grass tufts, stumps, flowers, shells. Each its own scatter atom, never baked into a slab.
The knobs
Terrain is the one asset in the catalogue that honestly supports a lot of them. Coral Reef ships 15 (9 geometry), Nature & Forest 14 (8 geometry). The ones worth knowing:
- Size, 1 to 4 chunks, so 64 m up to 256 m. Any size joins any other, because the outer edge still lands on chunk lines.
- Detail, the vertex pitch. Size and detail share one triangle budget, so a small block can be sharper: 64 m at half-metre pitch costs the same as 256 m at two metres.
- Level clearing, which clears a flat area in the MIDDLE of the block for a town, a village or a base to stand on, and leaves the landscape around it alone. The number is the size of that clearing, with a soft rim so the ground rises out of it rather than stepping. It levels the world field's own slope as well as the kit's features, because that slope runs 8 to 14 m across a block and is most of what makes ground unbuildable. The block's edges are never touched at any setting, so a levelled block still joins.
- Then the kit's own: the reef has rock formations, deep cracks, sand channels and a wall-stepping knob that snaps relief to its 2 m reef-wall module. The forest has hillocks, a river and boulder fields.
The clearing exists for a specific reason. The rest of the catalogue is flat-bottomed: a building, a road tile and a paving slab all assume level ground, so terrain that is level nowhere is terrain none of them can stand on. What a scene actually needs is not a plain but somewhere buildable with real landscape around it, so the village sits in a valley floor rather than on a table.
What is not done
Eight kits still have no terrain program: Medieval, NYC, Space Base, Retro Cars, Spaceship Wars, Pirate Cove, Little Tokyo and Steampunk Farm. Their existing blobs stay published and stay useful for hand-dressing; nothing was deleted.
There is no terrain judge. The checks cover correctness and nothing yet looks at the picture and says the landforms are dull, which is the failure the checks cannot catch.
And the skirt is visible on deep features. A river or a fissure fades to nothing over the last four metres before a seam, so it shallows slightly as it approaches and resumes on the other side. That is the price of the contract, and it is not free.
Frequently asked questions
How does procedural terrain join across chunks without gaps?
Every generator computes its height as a shared world function plus its own displacement multiplied by a weight that falls to zero at the chunk boundary. On the boundary itself every generator therefore returns the same shared value, so two neighbouring chunks evaluating the same pure function at the same world coordinates produce identical heights. No runtime coordination is involved: the chunks agree because they are computing the same thing. Polyfork's chunks measure 0 difference across all 129 shared edge vertices.
Can terrain from two different kits be placed next to each other?
Yes, and that is the point of the shared contract. A Coral Reef terrain block and a Nature & Forest terrain block sitting side by side match vertex for vertex along their shared edge, and their colours cross-fade over the last 14 m rather than meeting on a hard line. Both blocks must use the same world seed and the same vertex pitch; block size may differ.
Why did Polyfork stop selling terrain blobs and ground tiles?
Because a generator does the same job better at any size and on any slope, and modelled ground was the single biggest source of duplicate parts in the catalogue: "Grass Terrain Blob" existed in six separate kits. Tiles are still planned where the surface is man-made, such as streets, sidewalks, paving, interior floors and tilled plots, because those have made edges and a repeat that no height field produces.
What file formats does a terrain program export to?
The same as every other Polyfork asset: GLB, FBX, USDZ and OBJ, plus the ES module itself. The module is the product, so a buyer can also change the knobs and regenerate rather than being stuck with the mesh they downloaded.
How many triangles is a terrain block?
At the default settings, about 25,000 for a 128 m island, roughly half of which is the closed underside. A 64 m block at coarse pitch is around 500. The size and detail knobs share one budget with a ceiling of 36,000, and the ceiling is checked at every knob extreme rather than only at the defaults.


