
I built the exact tiling bug a graphics legend warns against
A playtester said my maps looked empty. I rebuilt the floor four ways before realizing per-tile tiling can't not draw a grid — the fixes are all shaders my 2D engine doesn't have. So I shipped a git revert.
Two notes from a playtester. “Maps look too empty.” “Add more variety.” Five words. I figured: quick fix, done before lunch.
I was not done before lunch. I burned a day, wrote six commits, and shipped a git revert. Here’s how
that happened — and the bit of graphics theory that, had I read it first, would’ve stopped me cold.
The ground in Overserved is an isometric grid: 256×128 diamond tiles, three baked variants per world, picked per tile by a hash so it reads as random-ish. Stand back, it’s fine. Lean in and you catch it — the same three crops, repeating to the edge of the board. So I decided to kill the repetition. That was the mistake. Not the goal — the deciding, before I’d read a single thing about how this is actually done.
The rabbit hole
Every fix solved the last fix’s problem and added a fresh one.
More tiles. Three variants became twelve. To multiply that further I flattened the directional light in the bake so each tile could mirror on both axes — twelve crops times four flips, around fifty orientations. More variety, obviously. It looked worse. Here’s the mechanism I didn’t see coming: the soft shadow baked around each tile was the only thing hiding the grid. Kill the directional light and that per-tile ambient-occlusion rim stops masking the seams and starts outlining them. A faint mesh, penciled over the whole floor. Trade-offs travel in pairs: the flat lighting that bought me flips also stripped the shading that hid the repeat.
Blend the edges. I cut the AO rim, widened the alpha feather, overlapped tiles ~1.06× so their edges cross-faded, and floated a layer of soft grey grime on top for some macro tonal drift. Better — until I opened the USA and China maps and recoiled. Those worlds have bright lines: cyan circuit traces, red-gold neon. Slice a continuous line into twelve tiles and flip half of them and it never reconnects across an edge. The maps looked like a smashed neon sign. Fix: regenerate both worlds as calm, low-contrast materials, no lines. Lesson banked — tiling textures want grime, not graphics, because grime has no global structure to break, and a bright line is nothing but global structure.
The article that ended it
Then I finally did the thing I should’ve done first: read how people who do this for a living do it. Inigo Quilez — the demoscene/shader name behind half the techniques on Shadertoy — has a short piece called Texture Repetition. Reading it felt like getting politely owned.
His technique #1 — pick a per-tile variant with a random offset and mirror — is, line for line, the thing I’d built. And he lists its failure modes right there: visible seams at tile boundaries, broken texture filtering across them. I hadn’t built it wrong. The method itself is a grid; the whole point of his article is that hiding that grid is the hard part. His two fixes that actually hold up:
- Voronoi texture-bombing — for each pixel, find the nearest few scattered tile centers and blend between them, so there’s no shared edge to seam. Gorgeous, and roughly nine texture lookups per pixel.
- Low-frequency index modulation — vary which variant you sample using a slow noise function, then blend at the boundaries. Cheapest of the three, genuinely seamless.
Both run in a fragment shader — per-pixel math executed on the GPU. My engine is Phaser drawing diamond sprites: it composites whole images per quad, with no per-pixel hook to do that blending. I hadn’t hit a bug. I’d picked an approach whose ceiling was “subtly gridded,” then spent a day sanding the ceiling.
The two real options, and the quit
So what can a 2D sprite engine do? Two moves, and only two. One: draw a single big material per world as one image, masked to the board’s rhombus — genuinely seamless, but stretch one texture across a large board and it goes blurry and monotonous, the same crack network everywhere. Two: lay big soft-edged stamps over that base on a coarse grid (I used 8×8), each oversized so its feathered rim cross-fades into its neighbours. Now detail repeats every eight tiles instead of every one, at native resolution, with the base guaranteeing coverage. It’s texture-bombing’s idea done with sprites instead of a shader. The stamps actually looked good on USA and Africa.
And then I typed, into my own notes: “i give up, bring back the original.”
Shipping the revert
So I nuked it. Six commits, the twelve-variant baker, eighty-odd baked PNGs — git checkout back to
the pre-saga floor: three tiles and a hash. The floor that had been fine the entire time.
The gut-punch: the variety the playtester wanted came from a decal pass — scattered props on top of the floor — that I’d already shipped, separately, before any of this started. “Empty” and “repetitive” felt like one note. They weren’t. Empty was solved before I started. Repetitive was a problem I invented, then heroically failed to fix. Net change from a day’s work: zero.
Best code I wrote that day: git checkout.
What stuck
- Per-tile tiling draws a grid by definition. Variants, flips, feather, grime — all symptom management on a method whose seams are inherent, not a bug you can polish out.
- Match the technique to the engine before you start. The fixes that actually erase the seam (Voronoi bombing, index modulation) are fragment shaders. A 2D sprite engine gets two options instead: one masked mega-texture, or feathered stamps. Don’t spend a day forcing the GPU answer into a tool with no per-pixel hook.
- AI tiling materials want grime, not graphics. A bright line is the worst possible input — flipped and cropped it shatters into disconnected streaks, because it has global structure to break.
- Read the prior art first. Twenty minutes of Inigo Quilez would’ve saved a day. I keep relearning this one.
- Know your actual brief. The ask was “less empty.” Decals already did that. The floor rebuild was scope I handed myself — and handing it back was the whole job.