A chess engine written from scratch in Python, with a Pygame desktop game, a chess.com-style game review screen, and a UCI adapter that runs the same engine as a bot on Lichess, where it plays rated games as @PyCheckmate. No chess libraries: the move generation, search and evaluation are all here.
Built as a learning project, and written to be read: the comments explain why each part is the way it is, and the build log records what broke, what was measured, and what each dead end cost.
Rapid 2187 · Blitz 2050 · 269 rated games · challenge it any time
I stopped developing this on 2026-08-05. The engine works, it has a rating I can
quote, and the questions it was built to answer are answered. The bot itself is
still deployed and bot up puts it back online against rated opposition whenever
I want it playing, so this is finished rather than abandoned.
The ceiling is the part worth reading about. The search sustains about 43,000 nodes per second, where a C engine does millions, and by the end every cheap way of raising that had been measured and come back empty. The measurements are more useful than the optimizations would have been:
| Candidate optimization | Measured ceiling | Verdict |
|---|---|---|
| Bitboard board representation | slower than our mailbox in Python, 335k vs 393k perft NPS | rejected on evidence |
| Incremental evaluation accumulator | 1.3% of search time | rejected before building |
| Pawn hash | about 3% of search time | rejected before building |
| Staged move ordering | neutral, overlapping sample ranges | built, measured, reverted |
| Better move ordering | already at 88.4% first-move cutoffs | a few points of headroom |
What is left sits outside this architecture: a JIT-compiled hot path, which I never tried, and a neural evaluation. Both are worth real Elo and both mean building a different engine rather than improving this one. That analysis, plus the smaller items that would still fit here, is in docs/FUTURE_PLAN.md.
If you are starting something similar, the thing I did not expect is that the
chess was never the hard part. Alpha-beta is a weekend. Knowing whether a change
helped is the actual skill, and about a third of engine/ ended up being
measurement tooling because of it. The build log has every
dead end and what it cost, including a night of self-play that produced nothing,
43 games that could not answer the question they were collected for, and an
opening book that shipped and then measured nothing at all because the bridge
was answering first.
Three instruments, deliberately not averaged together, because each answers a different question:
| Measured by | Question it answers | Result |
|---|---|---|
calibrate.py vs Stockfish |
How strong in absolute terms? | ~2300 (exactly 50% vs Stockfish@2300) |
| Lichess rapid, 130 rated games | How strong against strangers? | 2187 (settled, RD 50) |
| Lichess blitz, 114 rated games | The same engine, half the clock | 2050 (settled, RD 50) |
Final ratings as of 2026-08-05. Both pools are established rather than provisional, and the games are all public on the bot's profile.
The Stockfish number and the Lichess number are different scales rather than a
disagreement. UCI_Elo is Stockfish's own internal ruler and was never portable.
Both of them put the engine in the low 2200s against opposition that does not
share its blind spots, which is what I wanted to find out.
That 137-point gap between the two pools is the honest headline of this project. Halving the time control should not cost that much, and the 113-game analysis that first measured it put the performance gap nearer 160. The engine is not mismanaging its clock. It is depth-starved. Even hosted on PyPy, which is how it actually runs, the search sustains around 43,000 nodes per second where a C engine does millions. PyPy is doing real work here, lifting raw move generation from 467k to 1.07M nodes per second on perft, but it narrows the gap rather than closing it. Quote the rapid number, because a blend of two rating pools describes neither of them.
uv is the only thing you install yourself; it manages Python 3.14 and every
dependency from there.
# macOS / Linux
brew install uv # or: curl -LsSf https://astral.sh/uv/install.sh | sh# Windows (PowerShell)
winget install --id=astral-sh.uv -e
# or: powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"The rest is the same on every platform:
git clone https://github.com/pdloc06/PyCheckmate.git
cd PyCheckmate
uv sync
uv run main.py # playOptional, and worth it: PyPy hosts the engine in a subprocess and speeds it up. The engine is pure standard library precisely so it can run there.
uv python install pypy3.11 # auto-detected; the GUI stays on CPythonWindows: uv sync fails with "An Application Control policy has blocked this file"
Windows 11's Smart App Control blocks unsigned executables, and the Python that
uv downloads for itself is unsigned. Install a signed one and tell uv to use
it instead of downloading:
winget install --id=Python.Python.3.14 -e # python.org build, PSF-signed
uv sync --no-managed-pythonAlready have Python 3.10 or newer? Point uv at it rather than installing
another. .python-version asks for 3.14, but the project only requires 3.10+:
uv sync --no-managed-python -p 3.13setx UV_NO_MANAGED_PYTHON 1 makes the flag the default. Turning Smart App
Control off (Windows Security → App & Browser Control) also works, but the
signed-Python route costs nothing and leaves it on.
| Play | Against the engine or another person. Full rules: castling, en passant, promotion with a picker, threefold repetition, the 50-move rule. |
| Review | After any game, a chess.com-style pass: evaluation bar, per-move grades from brilliant down to blunder, a badge on the moved piece, best-move arrow, and an accuracy score per player. |
| Analyse | Paste a FEN or a full PGN from the main menu and review any game, not just ones played here. |
| Explore | Play a move that differs from the game mid-review and the analysis follows your side line. A "Back to game" strip returns you. |
| Deploy | The same engine speaks UCI, so it runs as a Lichess bot. Setup is one script: deploy/. |
Controls:
| Action | Control |
|---|---|
| Select / move a piece | Click the piece, then the target square |
| Undo | Ctrl/Cmd + Z |
| Reset | Ctrl/Cmd + R, or Restart Game |
| Flip board | Flip (in a computer game this also switches your colour) |
| Browse history | Click the move log, or < / > |
| Review | Review Game, once the game ends |
A list[list[int]] of small integer piece codes. It began as 'wP'/'--'
strings; moving to ints took string comparison out of the hot loops entirely.
Row 0 is rank 8, so the array reads like a printed board.
a b c d e f g h
row 0 rank 8 10 8 9 11 12 9 8 10 0 = empty
row 1 rank 7 7 7 7 7 7 7 7 7
row 2 rank 6 0 0 0 0 0 0 0 0 1 = wP 7 = bP
row 3 rank 5 0 0 0 0 0 0 0 0 2 = wN 8 = bN
row 4 rank 4 0 0 0 0 0 0 0 0 3 = wB 9 = bB
row 5 rank 3 0 0 0 0 0 0 0 0 4 = wR 10 = bR
row 6 rank 2 1 1 1 1 1 1 1 1 5 = wQ 11 = bQ
row 7 rank 1 4 2 3 5 6 3 2 4 6 = wK 12 = bK
Two facts fall out of the numbering and get used everywhere: 0 < piece < 7
tests colour with one comparison, and PIECE_TYPE[piece] recovers a
colour-independent 1-6 type index. String codes survive only at the FEN,
SAN/UCI and image-loading boundaries.
Two packages and a thin root. engine/ never imports pygame, which is what
lets it run under PyPy and as a subprocess; the dependency chain inside it is
strictly one-way, so a cycle can only appear by adding an import that points
backwards along it.
flowchart LR
subgraph cp ["CPython, needs pygame"]
main["main.py"] --> gui["gui/"]
main --> client["uci_client.py"]
end
subgraph eng ["engine/ (pure stdlib, no pygame anywhere)"]
direction LR
search["search.py"] --> ev["eval.py"] --> movegen["movegen.py"] --> board["board.py"]
uci["uci.py"] --> search
end
client -. "spawns, then speaks UCI over a pipe" .-> uci
main -. "fallback if PyPy is absent" .-> search
lichess["lichess-bot"] -. "UCI" .-> uci
tools["engine/tools/"] --> eng
pypy(["PyPy hosts uci.py: 2.3x on move generation, 1.3x on the full search"]) -.- uci
Solid arrows are imports; dotted ones cross a process boundary. board
imports none of the others, so a cycle can only appear by adding an import that
points backwards along that chain. The engine having no pygame import is what
lets the whole package run under PyPy, and uci_client.py is what puts it
there, spawning uci.py in a PyPy subprocess and talking UCI to it down a
pipe. If PyPy is missing, main.py calls the search in-process instead and
nothing else changes. lichess-bot drives exactly the same uci.py, which is
why the bot and the desktop game are provably the same engine.
The central design decision, and the reason the engine is fast enough to be worth measuring. The hot loop cannot afford the bookkeeping the UI needs, and the UI cannot work without it.
| UI path | AI path | |
|---|---|---|
| Generate | generate_legal(gs) |
generate_legal(gs, for_ai=True) |
| Move is | a rich Move object |
a bare 5-tuple |
| Apply | gs.make_move() |
gs.make_ai_move() |
| Maintains | move log, state log, repetition counts, full Zobrist recompute | Zobrist updated incrementally, logs skipped |
| Undo | unmake_move() |
a 5-tuple undo package |
They meet at exactly one seam, Move.from_ai_tuple(), and an AI result is
always converted and applied through gs.make_move() so animation and undo
stay in sync.
Iterative-deepening negamax, each layer added only after it was measured:
| Layer | What it buys |
|---|---|
| Alpha-beta + aspiration windows | The base pruning, narrowed around the last score |
| Transposition table | Game-long, Zobrist-keyed, survives across moves |
| Quiescence + SEE pruning | Search captures to a quiet position, skip losing ones |
| MVV-LVA / killer / history | Good moves first, which is what makes alpha-beta bite |
| Null-move pruning | If passing still wins, the position is won |
| Late move reductions | Search unlikely moves shallower first |
| Check extensions | Never stop the search in the middle of a forcing line |
| Opening book | 13,230 Zobrist-keyed positions, played without searching |
Evaluation is material, piece-square tables, mobility, pawn structure, rook activity, king safety, and a mop-up term for won endgames. Its 24 constants were Texel-tuned against 725,000 labelled positions.
The half of this project that took longest to get right, and the part worth copying. An engine improves in steps of tens of Elo, far below what watching games can reveal, so every claim here comes from an instrument chosen to fit the question:
| Instrument | Answers | Cost |
|---|---|---|
bench.py |
Did a change that shouldn't alter play, not alter it? Node counts must be identical, which makes it a proof rather than a p-value | seconds |
sprt.py |
Is B stronger than A? Sequential test, stops when decisive, declines when neutral | hours |
calibrate.py |
How strong in absolute terms, against a foreign engine? | ~1 hour |
sf_watch.py |
Where did the engine actually go wrong? Grades every bot game with Stockfish, version-stamped | per game |
| perft | Is move generation still exactly correct? | seconds |
Two rules were learned the expensive way and now govern all of it: one change in flight at a time, and never average across engine versions.
The most costly lesson had nothing to do with chess:
Three separate times in this project, a measurement silently never exercised the thing it was supposed to measure. Games played casual, so matchmaking read a provisional rating instead of a real one. A scaled test clock that moved the constants under test outside the band being tested. An opening book that the bridge's own book answered before the engine was ever asked. Each returned a confident, well-formatted, meaningless number.
uv run pytest tests/ -q # 159 tests
uv run mypy main.py config.py engine/ gui/ tests/ # strict
uv run ruff check . # lint
uv run --no-project python -m engine.tools.bench # node counts + speed
uv run --no-project python -m engine.tools.selfplay # 20-game smoke testThe first three gate every change. Engine correctness is anchored by perft node counts, depths 1-4 from the start position plus Kiwipete, an exact-value oracle, which once caught a bug worth two nodes out of 4.8 million that no amount of playing would have found.
Run the engine over UCI directly:
uv run --no-project python -m engine.uci # add -p pypy3.11 for speedThen position startpos moves e2e4, go depth 4, and it answers bestmove.
| Path | Purpose |
|---|---|
main.py |
Game driver: menu, event loop, turn handling, threaded AI |
config.py |
Layout, theme, and AI settings |
engine/board.py |
Board state and rules: make/unmake, attack detection, FEN, Zobrist |
engine/movegen.py |
Legal and capture generation, as free functions over a GameState |
engine/eval.py |
Static evaluation: material, PSTs, positional terms |
engine/search.py |
Negamax, quiescence, ordering, time management |
engine/tt.py |
Transposition-table entry layout and flags |
engine/book.py |
Opening book: one dict probe on the Zobrist key |
engine/analysis.py |
Game review: move grading, win-percent model, accuracy |
engine/pgn.py |
PGN/SAN and FEN import |
engine/uci.py, uci_client.py |
The engine-as-a-process pair |
engine/tools/ |
Measurement and operations (bench, sprt, calibrate, sf_watch, tune) |
gui/ |
Rendering: board, animations, menus, panels, review screen |
pieces/, evaluate_icons/ |
SVG art; drop a folder in pieces/ to add a set |
deploy/ |
Config, patch and setup script for running the Lichess bot |
bot |
Bot control script (bot up/down/status/log) |
docs/BUILD_LOG.md |
How it was built and measured: benchmarks, dead ends, costs |
docs/LICHESS_BOT.md |
Bot deployment and operations manual |
MIT for the code and for the badges in evaluate_icons/. The piece graphics in
pieces/standard/ are CC BY-SA by Uray M. János and stay under those terms.
See LICENSE and Credits below.
This engine is written from scratch, but it stands on a lot of other people's work: the tooling that measures it, and the data that tuned it.
Tools and libraries
| Stockfish (GPL-3.0) | The referee. Grades every bot game and provides the UCI_Elo-limited opponent that calibration brackets against. An engine cannot grade itself: it misjudges a position identically when playing and when reviewing. |
| fastchess | Runs the SPRT matches behind engine/tools/sprt.py. |
| lichess-bot (AGPL-3.0) | The bridge between this engine and the Lichess API. Kept as a separate clone; see deploy/. |
| python-chess | Not a dependency. Used once as the bitboard baseline in the representation benchmark, which is how that rewrite got rejected on evidence. |
| pygame-ce, PyPy, uv, pytest, mypy, ruff | The GUI, the JIT that hosts the engine, the environment manager, and the three quality gates. |
Data
| Lichess Elite Database | The 2400+ rated games engine/tools/build_book.py tallies into books/book.json. |
| quiet-labeled.epd | 725,000 labelled quiet positions, the training set for the Texel fit of the evaluation constants. |
| UHO_Lichess_4852_v1, via Stockfish's books | Unbalanced human openings, sampled into books/uho_5000.epd. Lopsided openings cut the draw rate, which raises information per game in an SPRT. |
Art
pieces/standard/ |
By Uray M. János (2013 to 2018), derived from Chess_klt45.svg on Wikipedia (the Cburnett set). Licensed CC BY-SA; the attribution and terms are preserved in each SVG's header. |
evaluate_icons/ |
Mine, drawn for this project as plain geometry: a coloured disc and a mark built from paths, with no typefaces involved. MIT with the rest of the repo. |
Ideas
The Chess Programming Wiki is where most of
these techniques are documented, including
SPRT.
Texel tuning is Peter Österlund's method. The win-percentage curve and accuracy
model in engine/analysis.py follow the ones Lichess publishes.
- Build log, the development record, including every measured negative result and what it cost.
- Lichess bot manual, covering deployment, operations and traps.
- deploy/, the command that sets the Lichess bot up on a fresh machine.

