Skip to content
All articles
AI8 min

How we taught a neural network to play checkers for Damka.uz

Damka.uz is our own product, an online platform for Russian checkers. In two years we rewrote its engine three times. Here's why, and which of those decisions were worth it.

Azamat AbduyaminovFounder, lead engineer

The first version of Damka had a TODO.md file in the repository, and its first item said roughly this: the bot uses minimax, search depth is set to 3 because Python is slow, and it's not a strong opponent. Two possible fixes followed: take a ready-made engine written in C or Go, or build an AI that teaches itself.

In the end we did both. It just took two years and three architectures.

Why a studio needs its own checkers

People in Uzbekistan play checkers everywhere: in courtyards, in teahouses, in Telegram groups. Russian checkers, on an 8×8 board, with mandatory captures and a king that moves any distance. Yet there was no decent online place to play in Uzbek. We decided to build Damka.uz as our own product: play online, against a friend via a link, or against a bot.

For a studio it's also a testing ground. Nobody lets you experiment with WebSocket load and Rust on a client project. On your own, you can.

Three versions in two years

  1. 2024
    v1: Django + Nuxt 3. Games over WebSocket through Django Channels, rules from the pydraughts library, a minimax bot with alpha-beta pruning at depth 3.
  2. Early 2026
    v2: Django 6 + Channels + Nuxt 4. Our own negamax search engine, matchmaking, ELO, spectators, a live evaluation bar for viewers.
  3. 2026
    In parallel: training an evaluation neural network in a separate Go engine, away from production.
  4. Now
    v3: a Rust game server (actix-web, WebSocket) with the engine and neural network inside, a React front end, a Next.js admin panel, and the @DamkaUzBot bot for playing in groups.

Version 1: it works, but the bot is weak

The first version was an honest MVP. Games ran over WebSocket, pydraughts checked the rules, and the bot searched moves with minimax. Depth 3 means the bot looks a move and a half ahead for each side. Anyone who has played even in a courtyard could beat it. Pushing the depth to 5 or 6 in Python meant waiting several seconds for the bot to reply.

Version 2: our own engine and the first real load

In the second version we discovered something unpleasant: the library's built-in engine didn't work correctly on the Russian 8×8 board. We had to write the search from scratch: negamax with alpha-beta pruning, bitboards for evaluation, a transposition table, and iterative deepening with a time budget. All of it ran in a process pool so that one bot game wouldn't block the rest.

Along the way we added matchmaking on a Redis sorted set, ELO ratings, and a spectator mode with a live evaluation bar, like on chess.com.

Then we ran a load test, and that produced the main lesson of this version.

Where it was actually slow

We ran the real flow: players connect, find an opponent, make moves. We capped the server at the size of a typical 2-core VPS. At 300 concurrent games, median move latency was 6.7 seconds, and nearly every game crashed with errors.

The first thought was: Python is slow, let's rewrite. But the CPU was only 50% loaded. The server wasn't busy. It was waiting in line.

Profiling found three causes. A single Python process was stuck behind the GIL and used one core out of two. ORM queries ran one at a time on a single thread. And database connections were reopened on every request, while matchmaking scanned Redis keys with a blocking KEYS.

6,700 ms
median move latency before the fixes (300 games, 2 cores)
17 ms
the same latency after three fixes
~100
concurrent games a 2 vCPU server handles comfortably
~350
on 6 vCPU, scaling almost linearly

Several uvicorn workers, concurrent database access and persistent connections got us to 17 ms without a single line in another language. If we'd jumped straight to Rust back then, we'd never have learned that the language wasn't the problem.

Why Rust anyway

If Python coped with the load, why rewrite? Because of the bot.

A Python game server passes messages around just fine. But searching a move tree means millions of positions per second, and there an interpreter loses to a compiled language by a factor of tens. We discussed "engine in Rust, server in Python, connected through FFI". That meant two languages, two deployments, and a boundary between them that every move would have to be serialised across.

We decided the engine and the server would live in one Rust binary. The server pulls in the engine as an ordinary Cargo crate, so validating a move costs as much as a function call. The same engine compiles to WebAssembly, so the browser can show legal moves on its own without asking the server.

A board in the engine takes 16 bytes: two u64 values. They hold whose turn it is, 32 playable squares at 3 bits each, and a counter of king-only moves for the draw rule.

A neural network instead of hand-written evaluation

In version 2, position evaluation was written by hand: so many points for a man, so many for a king, bonuses for the centre and for advancing. Functions like that hit a ceiling quickly, because a person can't describe everything that makes a position good.

In v3, positions are evaluated by a small fully connected neural network. It was trained separately, in the Go engine, and we moved the finished weights into Rust.

// Input: 32 squares, each holding a value from -K to +K
// (man = ±1, king = ±K, empty = 0).
// Hidden layers use tanh; a plain material sum
// is added at the last layer.
pub const LAYERS: [u16; 4] = [32, 40, 10, 1];
pub const WEIGHTS_LEN: usize = 1280 + 400 + 10;

The network is small on purpose. It's called at every leaf of the search tree, and a big network would eat all the speed we'd gained. Ten trained variants of the network are built into the engine, about 140 KB in total. Different variants play with a different character, which is handy for difficulty levels.

In the endgame, when only kings are left on the board, five or fewer, the engine doesn't think at all: it looks the answer up in a precomputed table. Each entry takes one byte.

206 KB
engine size in WebAssembly
715
positions in the test set used to check against the reference
10
trained network variants inside the engine
5
maximum kings covered by the endgame table

How we checked we hadn't broken anything

Porting an engine from one language to another is frightening: any difference in the rules changes which move gets picked, and you can't spot that by eye. We built a set of 715 positions with correct answers from the reference engine and run it on every change.

That set caught a real bug, only not in the new code but in the reference. In Russian checkers, if a capture can be continued, it must be continued, and that applies to both men and kings. In the reference engine the continuation flag got lost, and for kings it sometimes generated a short capture the rules don't allow. In the Rust version the rule works correctly, and the discrepancy is recorded separately in the tests.

Checkers in Telegram

The @DamkaUzBot bot is a separate story. We noticed that people don't want to "go to a website to play". They're sitting in a group chat and want to play right there. The bot lets you start a game in a group and run group tournaments under IDF rules: pairings, rounds, Buchholz tie-breaks. We build bots like this for clients too; more on our Telegram bot development page.

v1 v2 v3
Server Django + Channels Django 6 + Channels Rust, actix-web
Front end Nuxt 3 Nuxt 4 React
Position evaluation Material and mobility Hand-written function Neural network
Bot depth 3 Time budget Time budget
Endgame None None Table up to 5 kings

What I'd do differently

First, I'd profile earlier. We lost a few weeks to talk about "Python can't handle it" when it could. The problem was one process and one thread to the database.

Second, I'd write the reference position set straight away. At first we compared engines by eye, playing against them, which was slow and unreliable.

Third, and I'm still unsure about this one: maybe we should have rewritten only the engine in Rust, not the whole server. Right now we have a single binary that's easy to deploy, which is a plus. But for a team that has written Django for years, the bar for working on a Rust server is high. If Damka were a client project with years of maintenance ahead, I'd have kept the server in Python. I've written more about how we pick a stack in general in the post on Nuxt, Next.js and Rust.

For our own product we could afford it. And the bot now beats me, which none of its predecessors could.

Got a task? Message us — let’s talk.

Write on Telegram directly to the studio’s founder. Describe what you need in a couple of lines — we’ll reply with questions and a rough estimate.