Published on

Reverse Engineering Colonist.io to Build a Catan Decision Engine

Authors

I play Settlers of Catan on Colonist.io and I am not very good at it. Chess has had engines whispering in players' ears for decades, so I wanted the same thing for Catan: a heads-up display that watches my game and tells me, in real time, what the best move is and how much it changes my odds of winning.

This post is the story of building it in a day. The short version: the engine and the browser side both came together quickly, the hard part was the undocumented wire protocol in the middle, and the first live game found two bugs that no amount of offline testing would have caught.

A caveat before the fun part. Colonist's terms of service may not love tooling like this. I only ran it against the site's own bots, and I would not point it at a ranked lobby.

The shape of the thing

Two components, one HTTP call between them.

The decision engine is a Python FastAPI service built on catanatron, an open-source Catan simulator that is fast enough to copy a game state and play forward thousands of times per second. It exposes one endpoint: post a normalized board state, get back the best move, the top three alternatives, and a four-element vector of win probabilities, one per seat.

The Chrome extension (Manifest V3, TypeScript, Vite, React, Tailwind) does the dirty work in the browser. A script injected into the page's main world wraps window.WebSocket before Colonist opens its socket, so every frame in both directions passes through it. A content script relays decoded frames to the service worker. The worker rebuilds the game state, debounces, calls the engine, and pushes the answer to a draggable overlay rendered inside a Shadow DOM so the site's CSS cannot touch it.

Catan Assistant HUD rendered over a live Colonist.io bot game, recommending a settlement at node 8 with a 27.9 percent win probability

That screenshot is the moment it all worked for the first time: a real four-player game, the opening placement phase, and the engine answering in 19 milliseconds.

Search: max-n expectimax with action masking

Catan is a four-player game with dice, so plain minimax does not fit. I used max-n, the multiplayer generalization where every node returns a vector of values and the player to move picks the child that maximizes their own component. Dice rolls become chance nodes that expand all eleven sums with their exact probabilities. Other randomness (which development card you draw, which card the robber steals) is sampled once, because at the shallow depths that are useful interactively the extra precision is not worth the branching.

Action masking here is not a neural-net trick, it is just discipline: the search only ever expands moves that catanatron says are legal in the current state, and the branching factor is capped by a heuristic ordering (cities before settlements before dev cards before roads). There is a PyTorch policy and value network scaffolded with a proper masked softmax head, but it is not the interesting part yet. More on that at the end.

Depth two answers in single-digit milliseconds. Depth three is around 30 ms on a mid-game position. Both are fine for a game where turns take a minute.

The protocol nobody documents

Here is where the day actually went. Colonist's client talks MessagePack over wss://socket.svr.colonist.io/?version=2, and there is no public schema. My first version of the state reconstructor was an honest stub full of TODO(protocol) comments, because guessing field names and shipping a parser that silently builds the wrong board is worse than shipping nothing.

So I captured a game. I opened Colonist in an automated Chromium, pre-installed a WebSocket hook with Page.addScriptToEvaluateOnNewDocument, created a room with three bots, and let it run. A useful discovery: bots play instantly and the server auto-plays your turn when the timer lapses, so a bot room generates a full game's worth of frames with zero manual input.

The first captured Colonist board, a solo practice game used to decode tile coordinates

What came out, after decoding a few hundred frames:

  • Every incoming frame is { id, data: { type, payload, sequence } }. Channel id "130" is the game. Type 4 is a full state sync, type 91 is a diff that you deep-merge into it (a null value deletes a key). Types 30, 31 and 32 list the legal settlement, road and city spots for you specifically, which turned out to be a great sanity check for my own legality logic.
  • Hexes use axial coordinates on a pointy-top grid. Corners are addressed as (x, y, z) where z is the top or bottom vertex of hex (x, y). Edges are (x, y, z) with z picking the northwest, west or southwest edge of the hex. I confirmed the conventions geometrically: corner ids 0 to 5 of hex 0 run clockwise from north, and edge ids 0 to 5 join consecutive corners.
  • Resources are small integers, 1 through 5 for wood, brick, sheep, wheat, ore, 0 for desert, 6 for a 3:1 harbour. Opponents' cards arrive as zeros, so you only ever know their counts. Hidden development cards arrive as 10.
  • Outgoing frames have a three-byte header plus the channel name before the MessagePack body, which is why they refused to decode until I looked at a hex dump.

The piece I am happiest with is the id mapping. Colonist and catanatron number the 54 vertices and 72 edges completely differently. Rather than build a lookup table by hand, I embedded both boards in the same 2D plane (hex centers at (sqrt3 * (q + r/2), 1.5 * r), vertices at fixed offsets) and matched by position. The whole mapping is about sixty lines and a test proves it is a bijection.

A replay test feeds the captured frames through the adapter, asserts things like "every player placed two settlements and my hand is three sheep, two wood, a brick and an ore", then writes the result into the server's fixture directory where pytest evaluates it. One captured game covers the whole chain end to end.

What the first live game found

Everything passed offline. Then I injected the pipeline into a real game, signed in, and it fell over immediately.

Colour ids are not seat numbers. In the bot game I captured as a guest, players were colours 1 through 4, and I mapped those straight to red, blue, orange, white. Signed in, I showed up as colour 8. It is a palette id, not a seat index. The fix was obvious once seen: assign engine colours by position in the server's playOrder array, since only consistency matters to the engine.

Equal pips, equal scores. My first value function was a quick linear mix of victory points, production pips and hand size. In the opening it ranked three different settlement spots at exactly the same value, because they had the same pip total and I had no term for resource variety or what you could reach with a road. Catanatron ships a hand-tuned evaluation behind its strongest built-in bots, with production variety, enemy production, two-road reachability and hand synergy. I wired that in. One wrinkle: its weights are tiered by factors of a million so that argmax behaves lexicographically, which collapses to pure victory points the moment you squash it into probabilities. Re-expressing the same features in victory-point equivalents fixed the ties.

Also, for anyone else automating this: Colonist's main-page Start Game button, with Beginner Mode selected, drops your account into a sticky single-player tutorial that it keeps bouncing you back into. The exit is the gear icon, then Leave Game. That cost me twenty minutes.

Why not train a model

I get asked this first and I think it is the wrong first move. There are no public neural weights for Catan. The catanatron repo's reinforcement learning experiments ship no checkpoints, and the published papers do not release theirs. The best public model is the tuned linear evaluation that is now my default, and that plus a two-ply search already beats the easy bots I am targeting.

The network scaffold is there for when the cheap wins run out: a feature extractor, a masked policy head and a four-way value head. The honest path to using it is self-play with the search producing training targets, which is days of compute and tuning rather than an evening. I would rather first lose a few games with the HUD on and learn where the evaluation is actually wrong.

What is left

  • The development card enum values are still a guess. No dev card was revealed during my captures, and I need a game where I buy and play one.
  • The discard prompt after a seven and incoming player-trade offers are not mapped yet.
  • The extension has run through its standalone bundle but not yet as an installed extension in my daily Chrome against a live game.

The engine is 10 tests and under half a second. The extension is 6 tests replaying a captured game. Both green, which after a day of poking at an undocumented socket feels like a good place to stop.