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 chain across tiles and eventually close into a loop.

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.

A real recorded game · move 0 of 37 closed loops tint amber / steel by who sealed them
score 0 — 0
Two greedy players alternate. Watch for the moment a long chain seals. The score jumps by the area it just captured.
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 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.

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. 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.

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 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.

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 real scoring happens.
The question that ate the project

Is any of this strategic?

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.

Attempt 01 · a smarter heuristic

Blocking the opponent doesn't help.

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.

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. 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.

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.

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.

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.

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 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.

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. 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.

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.
  1. Depth is not what makes the search strong. 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.
  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.

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 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 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. 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.

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. 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.

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 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.

Difficulty as shippedsearch strength barely moves · slip does the work
PresetKDepthGreedy-slipCharacter
Random–––harmless
Easy1–0%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.

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.

Play it.

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

Play Hex Truchet

Start on Medium. Easy plays pure greedy, which is harder than 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.
The RL finding is narrow. These RL attempts failed, for reasons I can name; I'm not claiming RL can't do this. 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.
Hex Truchet · Orbitope · source on GitHub