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 carries any information 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 has a practical consequence. 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 playing.
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. That's a screensaver.
The fix was 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 you cannot tune it away:
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.
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 backwards. Spacers concentrated even more probability onto the minimal case.
So the design accepts it instead. Scoring by enclosed area rather than by loop count makes minimal loops cheap, 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 what the geometry produces for free.
The obvious way to play is greedy. Every turn, look at all your legal placements and make the one that scores the most right now. It's the first thing anyone would code, and roughly what a new player does.
Greedy is 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. 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.
Four attempts follow, in the order I made them. Only one of them worked.
The obvious strategic idea, weighing denial of 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 plays differently: denial-vs-denial games have consistently higher margins than greedy-vs-greedy.
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. 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. It averaged 0.46 points per game against greedy's 28.6, while scoring 13.6 against random. It had learned something, but nothing that survived 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.
Most of that margin gain came from suppressing greedy's score (28.6 → 12.8); its own scoring barely moved (0.46 → 3.83). It had learned to disrupt greedy.
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.
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 different explanations on the table. Either the game's strategic ceiling 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:
The three failures are facts about the methods. They say nothing about Hex Truchet.
That +22.64 configuration costs about 29 seconds per game of pure CPU, which makes it a research instrument. It also cheats by seeing the opponent's exact hand during its rollouts, so it's an upper bound on exploitability under perfect information.
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 I didn't trust it. A Python script dumps complete games as packs: every move, the points gained, the exact set of loops closed, and both scores at every 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. That doesn't work. SVG-style arc commands re-derive the circle center from the radius and flags, and will 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. That constraint 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 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 neighborhood 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, so recomputing everything on every placement is instant and there's no reason to optimize.
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 only have varied how badly it beat you.
So difficulty here is 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.
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 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. I picked it by reasoning. 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.
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 plays pure greedy, which is harder than it sounds.