A game AI for 2-player Azul, and a neural network that learned to beat it.
It started as a classic game engine: exact rules, alpha-beta search, and a hand-written evaluation tuned by self-play. Then the engine generated 1.5 million positions by playing itself, a small neural network learned from them, and in 3,000 games at equal thinking time the network beat the hand-tuned evaluation it learned from.
Try it in your browser · Neural vs classic: the benchmark · Dev log · Technical write-up
| Neural network vs hand-tuned evaluation | Result |
|---|---|
| Equal thinking time, 40 ms per move | +112 ± 13 Elo (66% of points, 3,000 games) |
| Equal thinking time, 200 ms per move | +111 ± 36 Elo (400 games) |
| Equal positions searched | +141 ± 13 Elo (3,000 games, 128 × 32 network) |
- Machine learning: supervised learning on 1.5M self-play positions, feature engineering that turned a tie into +141 Elo, a training target study, and a neural network trained from scratch in Go and benchmarked against PyTorch/CUDA.
- Performance engineering: an NNUE-style evaluator with an incremental, fixed-point first layer: 3.1× faster inference, worth +50 Elo with the same weights.
- Algorithms: alpha-beta (PVS) search with a transposition table, exact round solving, and SPSA parameter tuning.
- Rigour: every strength claim measured in thousands of paired games with confidence intervals; gradient checks, perft and brute-force tests.
- Shipping: one Go codebase as a CLI, an HTTP API and WebAssembly in the browser; CI, tagged releases and a static deploy to Cloudflare.
- Rules engine. Every Azul rule, a 110-byte copyable game state, incremental hashing, and a move generator checked by perft counts.
- Search. Negamax with principal-variation search, iterative deepening, a transposition table and move ordering. Later in a round it solves the rest of the round exactly.
- Two evaluations, one search.
- Classic: fifteen hand-written terms, tuned by SPSA self-play.
- Neural: a 170 → 64 → 16 → 1 network that predicts the final score margin.
- Machine-learning pipeline. Self-play data generation, training written from scratch in Go (no libraries) and again in PyTorch on a GPU as a benchmark, and match-based evaluation with confidence intervals.
- Analysis board. Set up or play any position, see the top three moves with a point breakdown, step through each line, choose the brain, share a link.
- Runs anywhere. One native binary (engine, HTTP API and web page), or compiled to WebAssembly and run entirely in the visitor's browser: the live site has no backend.
- Browser overlay. A userscript that shows the suggested move inside games on buddyboardgames.com.
self-play games ──► 1.5M positions ──► train (Go or PyTorch) ──► network JSON
azul gendata 170 features azul train / train.py │
+ 3 labels ▼
matches vs classic ◄── plugged into the search
azul selfplay -a-net
- Data. The engine plays 30,000 games against itself. Each position becomes 170 integers seen from the side to move (walls, pattern lines, floors, scores, tiles on the table, and the board as it would look if the round were scored now), labelled with the search's value, the final margin and the result.
- Training. A two-hidden-layer perceptron learns to predict the final margin, with a target mixing the search's value and the real outcome half and half. Adam, minibatches, a held-out validation set.
- Inference. The network replaces the hand-written evaluation inside the same search. Like a chess NNUE, its first layer is kept between positions and only the inputs a move changed are applied, in exact integer arithmetic: three times faster than recomputing, worth about 50 Elo.
- Evaluation. Matches with every deal played twice, seats swapped, at equal positions searched (what it knows) and at equal time (how it plays).
What made the difference, measured:
| Experiment | Elo vs classic |
|---|---|
| Raw board only, 128 × 32 | −7 ± 34 (a tie) |
| + the board as if scored now, 128 × 32 | +141 ± 13 at equal positions |
| 128 × 32 at equal time, first version | +6 ± 24: knows more, too slow |
| 64 × 16 at equal time, first version | +64 ± 13 |
| 64 × 16 at equal time, 3× faster inference | +112 ± 13: the one that ships |
| Target: search value only / outcome only | −127 / −84 (raw board) |
Go and PyTorch, trained on the same data, reach the same validation error (153.0 vs 153.2) and the same strength (+66 vs +64 in their first version), which also checks the hand-written backpropagation. Details: docs/ML.md.
Download the file for your system from the latest release and run it (on Windows, double-click it). It starts the engine and opens the board at http://127.0.0.1:8765. Or build it (Go 1.26+):
go build -o azul ./cmd/azul
./azul./azul gendata -games 30000 -seed 1000000 -out ml/data.csv # about 7 minutes on 24 cores
./azul train -data ml/data.csv -h1 64 -h2 16 -out net.json # about 20 seconds
./azul selfplay -a-net net.json -b default -pairs 1500 -time 40msWith PyTorch instead (uses a CUDA GPU if there is one):
python -m venv ml/.venv
ml/.venv/Scripts/pip install torch --index-url https://download.pytorch.org/whl/cu128
ml/.venv/Scripts/pip install numpy pandas
ml/.venv/Scripts/python ml/train.py --data ml/data.csv --h1 64 --h2 16 --out net.json- Play by clicking tiles, then a pattern line or the floor. Score round and Deal next round at the end of a round, or edit in the real deal.
- Engine: the top three moves with their points, floor penalties and bonuses. Brain picks the neural network (default) or the classic evaluation.
- Lines: click a move to step through the line behind it with ◀ ▶.
- Edit board to copy a real game; Photo shows a picture of one beside the editor, and Read with AI fills the board in from it with your own Gemini or Anthropic key (kept in your browser).
- Share copies a link to the exact position; Import / export as JSON.
- Run the engine (
azul, or double-clickazul.exe). - Install Tampermonkey, then open azul-overlay.user.js and press Install.
- In a 2-player game a panel shows the suggested move on your turn. Alt+A hides it, Alt+S opens the position on the board, Alt+N switches between the neural and classic brain, and Alt+C switches to casual play (best for your own board, no blocking the opponent).
Please use it for study, solo practice, or games where everyone knows about it. Using an engine against people who don't know is cheating.
| Command | What it does |
|---|---|
azul |
Run the engine and open the board. |
azul serve |
The same without opening a browser (-public adds limits for a public site). |
azul analyze state.json |
Search a position in the terminal. |
azul gendata |
Self-play positions as training data (CSV). |
azul train |
Train a network on that data. |
azul selfplay |
Engine-vs-engine match (-a-net, -b-net for networks). |
azul tune |
Tune the classic evaluation with SPSA. |
azul show, perft, random |
Print a board and its moves, count move sequences, play a random game. |
| Endpoint | Body | Returns |
|---|---|---|
POST /bestmove |
{"state": {...}, "timeMs": 1000, "multiPV": 3, "engine": "neural"} |
Best move with its effect, eval in points for the side to move, the line of play, alternatives and a one-line reason. engine is classic (default) or neural; "style": "casual" gives a relaxed move instead. |
POST /apply |
{"state": {...}, "moves": ["F2 red->3", "score", "deal"]} |
Checks a position or plays a line, returning every position on the way. |
POST /new |
{"seed": 0} |
A freshly dealt game. |
Errors are {"error": "..."} with status 400, 422 (no legal moves), 429 (rate
limit) or 503 (all engines busy). Positions look like
testdata/midround.json.
- Tests at every layer: perft counts for move generation, the search checked against a plain alpha-beta reference and brute force (and mutation-tested), a numerical gradient check for the network's backpropagation, and API and HTTP tests. CI runs them on every push, plus the WebAssembly build.
- No runtime dependencies: the engine, server and network are standard-library Go. PyTorch is only for the benchmark.
- Measured, not guessed: every change to playing strength was adopted only after thousands of paired games. See How the engine works.
The Site workflow builds the static site (hosting/build-site.sh: the page
plus the engine as WebAssembly, about 3.8 MB) on every push and publishes it to
Cloudflare. azul serve -public runs it as a server instead, with a time cap per
search, an engine pool and per-visitor rate limits.
- Scores never go below 0.
- If nobody takes the first-player token, the same player starts the next round.
- The engine plays to win first, then to maximise the margin.
cmd/azul/ CLI
cmd/wasm/ the engine for the browser
internal/
game/ rules, move generation, scoring, hashing
search/ alpha-beta search, transposition table
eval/ the classic evaluation
dataset/ features and self-play training data
nn/ the network: inference, training, the shipped weights
selfplay/ matches and SPSA tuning
api/ requests and responses, shared by server and browser
server/ HTTP API and the web pages (server/ui)
ml/ PyTorch trainer and the results page generator
blog/ dev log posts; python blog/build.py writes their pages
docs/ how the engine works, the ML write-up
hosting/ static site build and Cloudflare config
userscript/ the buddyboardgames.com overlay
weights/ classic evaluation weights
testdata/ example positions
Unofficial fan project. Azul is a trademark of its publishers; this project is not affiliated with or endorsed by them.
MIT. See LICENSE.
