Skip to content

Repository files navigation

PyCheckmate

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.

Python Engine dependencies Runtime Tests mypy ruff Status

Play PyCheckmate on Lichess
Rapid 2187 · Blitz 2050 · 269 rated games · challenge it any time

The game review screen: evaluation bar, graded move list, and a best-move arrow on the board

Status: feature-complete (2026-08-05)

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.

How strong is it

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.

Quickstart

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                   # play

Optional, 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 CPython
Windows: 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-python

Already 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.13

setx 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.

What it does

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

How the board is stored

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.

Architecture

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
Loading

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.

Two move pipelines

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.

Inside the search

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.

Measurement

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:

Bar chart: Lichess assumed a provisional rating of 3000 and matched the bot against opponents averaging 2928, while the engine was actually about 2133

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.

Development

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 test

The 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 speed

Then position startpos moves e2e4, go depth 4, and it answers bestmove.

Project layout

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

Licence

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.

Credits

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.

Further reading

  • 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.

About

A chess engine written from scratch in Python: negamax search, Texel-tuned evaluation, a Pygame GUI with chess.com-style game review, and a UCI adapter that runs it as a Lichess bot. Around 2200 Lichess rapid. Every change measured by SPRT, node counts or Stockfish rather than guessed.

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Contributors

Languages