A tile game where the thing you score — a closed loop — belongs to the board rather than to either player. Finding a deck worth playing took 2,000 simulated boards. Beating the dumbest possible strategy took three failed attempts and then about thirty lines of code with no learning in it at all.
A Truchet tile is a square with a decoration that isn't rotationally symmetric — two quarter-circles, say. Tile a plane with them at random angles and the arcs chain across tile boundaries into long meandering curves. It's a century-old idea that still shows up in generative art, and the appeal is that the pattern is entirely emergent: no single tile knows anything about the curve it belongs to.
Hex Truchet is what happens if you put that on a hex grid and make two people fight over it.
Every cell is a hexagon with six edges, and every tile is three arcs pairing those six edges up. Place a tile beside an existing one and the arcs join across the shared edge. Keep going and eventually a chain closes into a loop. Closing a loop scores you the number of cells it encloses. Board is 37 cells, game is 37 turns, highest score wins.
Six edges can be paired up 15 ways. Quotient those by rotation and you're left with five distinct tiles — a small enough alphabet to learn in a minute, with real texture between the extremes. Tile 0 curls everything back on itself. Tile 4 sends everything straight across and never turns.
That last column below isn't trivia. Tile 4 looks identical in all six rotations, so in the finished game, pressing the rotate key on a straight-through tile does nothing, six times in a row — which reads to a player as a broken control. The UI has to skip aliased rotations. It's the sort of thing you only find by holding the thing in your hands.
The first real finding was that using all five tiles equally produces a game where mostly nothing happens. Fill a 37-cell board from a uniform deck and you close 0.70 loops on average, with 48% of boards closing nothing at all. Half your games end scoreless. That isn't a game, it's a screensaver.
The fix is blunter than I expected: throw out three of the five tiles. Use only tile 0 and tile 2 in a 1:2 ratio — twelve and twenty-five on a 37-cell board. Tile 0 curls arcs back toward each other, tile 2 carries a chain across distance, and the mix makes closure common without making it automatic. Same board, same random placement, 4.19 loops and a 0.8% chance of a dead game.
And there is nothing you can do about it:
No matter how the deck is reweighted, between 61% and 75% of every closed loop is the minimal one — three tight turns curling around a single vertex.
That's not a tuning problem, it's a property of hexagonal geometry. Three tight turns meeting at a corner is by far the easiest way for arcs to close, and it stays dominant under every deck and board size I tried. I spent a while trying to engineer around it with "spacer" tiles meant to push closures apart, and the experiment came back not merely negative but backwards — spacers concentrated even more probability onto the minimal case. That path is closed.
So the design accepts it instead. Scoring by enclosed area rather than by loop count makes minimal loops cheap, fast, incidental points, and puts the real decision weight on the rare long loops that wrap actual territory. Scoring purely by loop count, tested head to head, was consistently the weakest separator of skill — it pays out exactly the thing the geometry hands you for free.
There's an obvious way to play. Every turn, look at all your legal placements and make the one that scores the most right now. Call it greedy. It's the first thing anyone would code, and roughly what a new player does.
Greedy is very good. Against a player making random legal moves it wins 100% of games in eleven of the twelve configurations I tested. That's a low bar. The worrying part was what happened when I tried to beat greedy itself — because if nothing beats it, then the strategic depth I thought I was designing doesn't exist, and the game is a dice roll with extra steps.
What follows is four attempts, in the order I made them. Only one of them worked, and it isn't the one I spent the most time on.
The obvious strategic idea — weigh denying the opponent's future scoring against your own immediate gain — is the thing you'd tell a new player to start thinking about. I built it.
Against greedy it won between 41.7% and 58.3% of games. A coin flip. It genuinely plays differently — denial-vs-denial games have consistently higher margins than greedy-vs-greedy — it just doesn't play better.
So I built the whole apparatus: a batched reinforcement-learning environment, a readable reference implementation differentially tested bit-for-bit against a vectorized tensor version, twelve invariants, the works. Then trained a policy against itself.
Across six checkpoints its win rate against greedy was 0.000, 0.000, 0.000, 0.000, and finally 0.004. The diagnostic was brutal: it averaged 0.46 points per game against greedy's 28.6 — while scoring 13.6 against random. It hadn't learned nothing. It learned something that evaporated on contact with a competent opponent.
Cold-start never moved off zero and lost ground against random while it did. Warm-starting from the self-play checkpoint produced the only real progress of the entire RL effort — climbing to 8.6% before flattening into a noisy 4–9% band with the margin pinned around −9.5.
And here's the detail I keep coming back to. Most of that margin gain came from suppressing greedy's score (28.6 → 12.8), not from scoring more itself (0.46 → 3.83). It had learned to be a nuisance. It had not learned to play.
Rank your legal moves by immediate score — that ranking is the greedy heuristic — take the top K, and for each one simulate the rest of the game with both sides playing plain greedy. Keep whichever candidate produced the best final margin.
That's it. One step of policy improvement over greedy, no network anywhere. At K=8, rolling every candidate to the end of the game: 50 wins out of 50, average score 27.98 to 5.34, margin +22.64. At that sample size a perfect record against a true win probability under 90% is a sub-1% event.
Three independent approaches all said "greedy is approximately unbeatable," and that left two very different explanations on the table. Either the game's strategic ceiling really is that low — or all three methods failed for their own reasons and none of them is evidence about the game at all.
The second one is uncomfortable, because "my methods were bad" is exactly what you say when you don't want to accept a result. But the search bot settles it, and in hindsight the three failures are cleanly separable:
None of that is a fact about Hex Truchet. It's three facts about three methods.
That +22.64 configuration costs about 29 seconds per game of pure CPU, which makes it a research instrument rather than something a person can play against. It also cheats: it sees the opponent's exact hand during its rollouts, so it's an upper bound on exploitability under perfect information, not proof a fair agent gets there.
The useful result came from sweeping the parameters — and three things fell out that I didn't expect.
| Config | K | Depth | Win vs greedy | Margin | Sec / move |
|---|
Which means the shipped game needs no distillation, no neural network, and no ML runtime. The strong opponent is thirty lines of search running in 30 milliseconds.
The research all lived in Python. The game is Godot 4 and GDScript, which means the entire rules engine — legality, loop detection, scoring — had to be ported into a second language where it could quietly diverge from the first.
So it didn't get to be trusted. A Python script dumps complete games as packs: every move, the points gained, the exact set of loops closed, and both scores at every single step. A headless Godot test replays each pack through the new engine and asserts a match at every step. Only once that passed did anything get built on top. It's the same discipline that validated the RL environment's reference and vectorized implementations against each other, and it's why I could rewrite rendering later without wondering whether I'd broken the rules underneath.
The obvious way to draw a circular arc is to hand a curve primitive a start point, an end point and a radius. Don't. SVG-style arc commands re-derive the circle centre from the radius and flags, and will cheerfully pick the reflected one — throwing the arc outside the hex it belongs to.
The game targets WebAssembly, and in WASM the main thread must never block. A bot that thinks for a full second doesn't show a spinner — it freezes the tab. No input, no rendering, nothing. That single constraint is what shapes the search budget, and it's why the bot yields between candidates rather than running its search to completion in one call.
The same constraint reappeared at deployment. Godot's web export defaults to real thread support, which requires cross-origin-isolation headers that itch.io's embed doesn't reliably serve. Turning thread support off makes the build header-independent and costs nothing — the search was never using threads, it was yielding across frames.
A note on loop detection, the one genuinely tricky port: a loop is a connected component of arcs where every arc has both ports matched, which is a union-find problem, and enclosed area is a ray-cast parity count. The tempting optimization is to examine only the neighbourhood of the tile you just placed. An early version did exactly that and was silently wrong — it under-reported whenever one tile's arcs belonged to two independent loops at once. The failing case was a single placement closing a loop of length 3 and a loop of length 18 simultaneously. The board holds at most 111 arcs. Recomputing everything on every placement is instant. Don't be clever.
Every search configuration beats greedy around 90% of the time, and greedy is roughly "a reasonable human." Turn search up or down and that barely moves — which makes search strength useless as a difficulty dial. The ladder would have varied in how badly it beat you, not whether it did.
So difficulty here isn't a search parameter. It's a greedy-slip: a fixed probability that the bot deliberately plays the plain greedy move instead of the one its search chose. Medium runs at 35% slip on top of a K=3, two-ply search. Hard runs the same search with no slip at all.
It's a better handicap axis than depth because it degrades the bot's judgment rather than its reach. A slipping bot still plays coherently — it just periodically fails to see the trap it's walking into, which is a recognizably human way to lose.
| Preset | K | Depth | Greedy-slip | Character |
|---|---|---|---|---|
| Random | – | – | – | harmless |
| Easy | 1 | – | 0% | pure greedy |
| Medium | 3 | 2 | 35% | beatable |
| Hard | 3 | 8 | 0% | not being fair |
What's still open is whether 35% is the right number. It was picked by reasoning, not measured. I have exactly one playtester's worth of evidence that Medium is fun to lose to, and that playtester is me. Tuning it properly is a playtesting problem rather than a coding one, which makes it simultaneously the most interesting thing left and the thing least likely to get solved by staring at the code.
Which is a nice inversion of where this started. The first half of the project was a long fight to build something that could beat the obvious strategy. The second half was teaching it how to lose convincingly.
Hot-seat, four bot difficulties, free placement, replays. Runs in the browser.
Play Hex TruchetStart on Medium. Easy is not as easy as it sounds.