How it works
Everything – the chess engine, the game server, the Wi-Fi access point and the web app – runs
on one microcontroller with 512 kB of RAM. The firmware is written in Rust without the
standard library (no_std), on top of Embassy and
esp-hal.
flowchart LR
phone["Phone / laptop<br/>browser"] -- "Wi-Fi<br/>HTTP + JSON" --> web
subgraph ESP32
direction LR
web["web server<br/>(picoserve)"] --> hub["game hub<br/>players, games, clocks"]
hub -- "engine jobs" --> engine["chess engine<br/>search + NNUE"]
hub --> display["display<br/>(ESP32-C6)"]
wifi["Wi-Fi AP + home network<br/>DHCP, captive DNS"] --- web
end
The pieces
| Part | What it does |
|---|---|
chess-core | Board representation (bitboards), move generation, SAN notation, draw rules |
chess-engine | Search and evaluation – a Rust port of the Dog / tinychess engine |
nnue-core | The neural network evaluation, shared with the training code |
chess-hub | Players, challenges, games, clocks, the engine queue and the JSON API |
chess-web | HTTP routes; the whole web app is one gzip-compressed file of ~28 kB |
chess-display | Draws only what changed on the 240×240 display |
firmware | Wi-Fi, networking, storage, tasks – glue for the ESP32 |
sim | The same hub, engine and web app on a PC, for development and testing |
All libraries are no_std, allocation-free and tested on a PC.
Running a chess engine next to a web server
The engine searches synchronously for seconds at a time, while the web server must answer within milliseconds. On the single-core C3 and C6 the engine therefore runs as a separate low-priority thread: whenever a network packet arrives or a timer fires, the scheduler pre-empts the engine immediately, and the engine gets the CPU back as soon as the network tasks are idle. On the dual-core S3 the engine has the second core to itself.
The engine checks every 1,024 nodes whether its job is still valid (a player moved, a game ended, a human needs the engine more than the demo game) and stops early if not.
The engine
- Search: iterative deepening with aspiration windows, principal variation search, a transposition table, null-move pruning, late move reductions, futility pruning and razoring, check and recapture extensions, and a quiescence search with static exchange evaluation. Moves are ordered with the hash move, MVV-LVA, capture history and history / continuation heuristics.
- Evaluation: an NNUE network 768 → 128×2 → 1 with 8-bit weights in the feature transformer and 8 output buckets (104 kB), trained for this project with bullet on 947 million positions. The accumulator is updated incrementally and lazily; on the ESP32-S3 the inner loops use the chip’s SIMD instructions.
- Levels: lower levels limit the depth and add noise to the evaluation so the engine makes believable mistakes.
Making it fast enough took a series of measured optimisations – putting hot code into RAM, cheaper attack tables, lazy accumulator updates, branch-free legality checks – which took the ESP32-C6 from about 8,900 to 19,400 positions per second. Every optimisation keeps the search bit-for-bit identical: same moves, scores and node counts.
The web app
A single-page app in plain JavaScript, styled after lichess. It polls the board (the game every 0.7 s, the lobby every 1.5 s) instead of keeping WebSocket connections open, so the small TCP stack can serve more players. Joining the board’s network opens the app automatically via a captive portal (DHCP + a DNS server that answers every name).
Limits
| Item | Limit |
|---|---|
| Players connected | 24 |
| Games at once (including finished ones and the demo) | 8 |
| Open challenges | 8 |
| Game length | 400 plies, then a draw |
| Disconnected player | loses after 3 minutes |