Hex Truchet · game design & search

Three ways to fail to beat the obvious move.

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.

scroll
First, the game

Arcs connect. Eventually one bites its own tail.

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.

A real recorded game · move 0 of 37 closed loops tint amber / steel by who sealed them
score 00
Two greedy players alternate. Watch for the moment a long chain seals — the score jumps by the area it just captured, not by one.
The vocabulary

There are only five tiles.

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.

Click a tile to rotate ittile 0 · rotation 0
Distinct rotations = the size of the tile's orbit under the rotation group. They sum to 15, which is where the five come from.
The design problem

The deck is the design.

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.

Real boards, filled at random from each deck

Uniform · all five tiles

loops here
0.70mean of 2,000
48.5%scoreless

Tuned · 12 × tile 0, 25 × tile 2

loops here
4.19mean of 2,000
0.8%scoreless
Closed loops are highlighted and their enclosed cells tinted. Every board is generated by the real engine; the means come from 2,000 trials of each.
The finding that shaped scoring

Most loops are the smallest possible loop.

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.

Loop length distribution · tuned deck, 2,000 boards
Length is arcs per loop, always a multiple of three. The first bar is the minimal loop; everything to its right is where the game actually lives.
The question that ate the project

Is any of this actually strategic?

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.

Attempt 01 · a smarter heuristic

Blocking the opponent doesn't help.

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.

Attempt 02 · self-play RL

It learned to beat random and stopped.

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.

Attempt 03 · train directly against greedy

A plateau at nine percent.

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.

Attempt 04 · no learning at all

Thirty lines of search.

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.

The verdict

Each method failed for its own reason.

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.

Tuning the opponent

More search is not reliably better.

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.

Rollout search vs greedy · sorted by win rate
ConfigKDepthWin vs greedyMarginSec / move
n=30 per configuration (n=50 for K=8). At a ~90% win rate that's a standard error of about 5.5 points — so everything from 80% to 93% is statistically indistinguishable. Read this for magnitudes, not rankings.
  1. Depth is not where the strength lives. K=3 at two plies matches or beats far deeper configurations at a fraction of the cost. Raise K before you raise depth. Depth does buy margin — +12.4 at eight plies versus +10.2 at two — so it makes the bot win harder, not more often.
  2. More search can be worse. The K=6, depth-6 configuration scored the lowest win rate of any search config while costing ten times what the cheap one does.
  3. The one solid, enormous difference is greedy at 46.7% versus any search at 80–93%. Everything else in that table is noise wearing a decimal point.

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.

From research code to a game

Two bugs worth the retelling.

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 arc that escaped its cell

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.

Compute the tangent-continuous arc yourself and sample it into explicit points — nineteen per arc here — then draw a polyline.

The board that froze the tab

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.

The last problem

Then I had to teach it to lose.

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.

Difficulty as shippedsearch strength barely moves · slip does the work
PresetKDepthGreedy-slipCharacter
Randomharmless
Easy10%pure greedy
Medium3235%beatable
Hard380%not being fair
Expert (K=8, full rollout) exists in the codebase but never ships — at 29 seconds a move it's a research instrument, not an opponent.

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.

Play it.

Hot-seat, four bot difficulties, free placement, replays. Runs in the browser.

Play Hex Truchet

Start on Medium. Easy is not as easy as it sounds.

What this doesn't claim

The sweep is small. 30 games per configuration. Differences inside the 80–93% band are not real differences, and I've tried not to lean on them.
The headline bot cheats. The K=8 configuration sees the opponent's hand during its rollouts. +22.64 is an upper bound on exploitability under perfect information, not a fair-agent result.
"RL can't do this" is not the finding. The finding is that these RL attempts failed, for reasons I can name. Dense reward shaping and a spatially-aware observation are the obvious next levers, and neither has been tried.
Difficulty is unvalidated. One playtester. The 35% slip is a reasoned guess, not a measurement.
Hex Truchet · Orbitope · source on GitHub