Dedicated chess computers have traditionally meant old, slow hardware that still wasn't cheap. This one flips that: a $15 ESP32 board and free, open-source software. No internet connection at all, so it's distraction-free and to the point - just a board, a screen, and a game - which makes it a nice teaching and learning tool.
A touchscreen chess game on a Cheap Yellow Display, started from Schematik's "Touchscreen Chess Game on a Cheap Yellow Display" guide (hand-copied into a fresh repo, since Schematik embeds the sketch inline rather than linking an actual repo to clone) and substantially rebuilt from there: a real chess engine and opening book, full move and draw rules, a menu for color/difficulty/piece set/new game, pawn promotion, single-level undo, and persistence across a power cycle. Source at github.com/larsi-org/cyd-chess. ☕ If you build one, consider sponsoring. Filed here rather than in CYD's Fun Projects roundup, since that page is for other people's projects tried as-is - this one's had real changes made to it.
Touch-select a piece, legal destinations highlight, touch again to move. The panel needs
TFT_eSPI configured via the machine-global User_Setup.h (pins, HSPI,
ILI9341, BGR order, and USE_HSPI_PORT, since the TFT and touch controller share the
ESP32's two SPI hosts) rather than a sketch-local override - TFT_eSPI.cpp is a
separately-compiled library file that only reads that machine-global config.
The pieces themselves are two stacked 24×24 monochrome bitmaps each - a solid silhouette
plus a contrasting detail layer on top - rather than the original's procedurally-drawn circles and
triangles, which reads far better at this size. MENU's PIECE SET option picks between three:
Classic, converted from the "SmallPng" set in samboy/ChessGraphics (public domain - those
particular images predate 1922; 46×48 source PNGs where white/black variants share an
identical fill-and-outline shape, differing only by fill color); Pixel and
Playful, both cropped from Brianna Cason's ("hanahoa") "Chess and Checkers" itch.io asset (CC BY 4.0
- free with credit), whose own preview image doubles as a sprite sheet showing off two different
piece-set styles: an 8×8 grid of 32×32 cells, each already just 3 flat colors (the
square's own background, a near-black fill, a near-white outline stroke) - no antialiasing to
fight, and only a mild ~25% downscale to 24px (vs. Classic's roughly 2x), which is why both read
noticeably crisper. Pixel takes the pack's plain Staunton-style silhouettes; Playful takes its more
ornate, rounder, robed-figure style. All three go through the same pipeline otherwise: every piece
but the (deliberately directional) knight is mirrored - one source half copied onto the other -
before either bitmap is derived, so the result is bilaterally symmetric; the whole silhouette
becomes PIECE_FILL and just the stroke becomes PIECE_OUTLINE, both
downscaled with Lanczos resampling and thresholded. Lives in pieces.h - just the
bitmap tables, no drawing logic - so a fourth set later is just another array plus a name in
PIECE_SET_NAMES.
Board square colors match WinBoard/XBoard's
own classic default theme (#C8C365/#77A26D), chosen for solid contrast
against both piece colors' outline stroke.
All three rendered straight from this sketch's own board colors and piece bitmaps - exactly what the 240×240 display shows, just picked via MENU's PIECE SET option.
H.G. Muller's micro-Max -
negamax with quiescence search, null-move pruning, king safety, and a transposition table, in
under 2000 characters of deliberately obfuscated C - vendored near-verbatim rather than rewritten,
since the source is dense enough (single-letter globals, macros doing double duty) that
re-deriving its logic by hand would risk a bug neither testing nor review would reliably catch.
Adapted for the ESP32 beyond a mechanical K&R-to-C++ cleanup: explicit signed char
(Xtensa defaults char unsigned, unlike x86), a watchdog feed placed inside the search
loop, and the root node budget scaled to this board's actual speed. Both the search-depth counter
and micro-Max's own internal board reset at the start of every move/game respectively, so engine
state never carries over stale between games.
micro-Max keeps its own separate board/search state entirely (a packed 0x88-style array, its own
hash table) rather than being adapted to run against this sketch's own GameState
directly - which means chess rules genuinely exist twice in this codebase, not by accident.
chess_rules.cpp is authoritative: it drives the UI, decides what's legal, and detects
every game-ending condition. micro-Max's own move generation, baked into its search, exists purely
to propose candidates - every move it suggests is cross-checked against
chess_rules.cpp's own legal-move list before being trusted, never read from its board
directly. That's deliberate defense-in-depth: even a bug inside the vendored engine can never
produce an illegal move on the actual board. It's also why an under-promotion (see Game Rules,
below) leaves a small, accepted gap - micro-Max's own internal evaluation always assumes a
promoted pawn became a queen, regardless of what piece the human actually chose, since there's no
way to tell its search otherwise through its narrow square-coordinates-only interface.
14 named lines pulled from lichess-org/chess-openings (Ruy Lopez,
Italian, Scotch, Vienna, Petrov, Sicilian, French, Caro-Kann, QGD, Slav, QGA, KID, Nimzo-Indian,
Réti) - checked before micro-Max's own search ever runs, for either color, falling silent for the
rest of that game the moment the position deviates from all 14. Where several lines share a prefix,
the candidates are deduplicated by move before a uniform random pick via the ESP32's hardware RNG,
so a reply backed by five named sub-lines doesn't get favored over one backed by just one. All 14,
in this sketch's own fromsquaretosquare notation:
Ruy Lopez e2e4 e7e5 g1f3 b8c6 f1b5
Italian Game e2e4 e7e5 g1f3 b8c6 f1c4
Scotch Game e2e4 e7e5 g1f3 b8c6 d2d4
Vienna Game e2e4 e7e5 b1c3 f8c5
Petrov's Defense e2e4 e7e5 g1f3 g8f6
Sicilian Defense e2e4 c7c5 g1f3 d7d6 d2d4
French Defense e2e4 e7e6 d2d4 d7d5
Caro-Kann Defense e2e4 c7c6 d2d4 d7d5
Queen's Gambit Declined d2d4 d7d5 c2c4 e7e6
Slav Defense d2d4 d7d5 c2c4 c7c6
Queen's Gambit Accepted d2d4 d7d5 c2c4 d5c4
King's Indian Defense d2d4 g8f6 c2c4 g7g6 b1c3
Nimzo-Indian Defense d2d4 g8f6 c2c4 e7e6 b1c3 f8b4
Réti Opening g1f3 d7d5 c2c4
The bottom-row MENU button opens a full-screen takeover with five options, each a standalone, always-live setting rather than a step in starting a new game - pick color, difficulty, and piece set in whichever order, mid-game or not, then NEW GAME just starts fresh with whatever's currently set:
drawPiece() reads.Checkmate and stalemate, plus three draw conditions beyond them: threefold repetition, the
50-move rule, and a deliberately conservative insufficient-material check - only the two
configurations that can never be forced to checkmate (bare kings; king + one lone minor
piece vs. bare king), not the fuller theoretical rule, since some multi-minor-piece endings can
still be won. Repetition needs a running history of positions seen this game, kept as its own
side-table rather than inside GameState itself, since that struct already gets
deep-copied once per candidate move during ordinary legal-move generation - growing it for a
feature that only needs to grow when a move is actually played would slow down every legality
check for no benefit.
A pawn reaching the back rank shows a Queen/Rook/Bishop/Knight choice screen for the human's own promoting moves; the AI and opening book always auto-queen, which is virtually always correct anyway (see The Engine, above, for the one accepted trade-off that comes with it).
A single-level UNDO button reverts a snapshot taken right before the human's move (erasing the AI's reply too, if it already answered) - no stack, no full history, just "let me try that again." Four pieces of state travel together in that snapshot: this sketch's own board, micro-Max's own board/search state, the opening book's move history, and the draw-detection position history - or the AI and the rest of the game would desync from whatever got undone. Switching color or difficulty invalidates the pending snapshot - there's no coherent "my last move" left to offer once the sides themselves have changed.
The board, engine state, book history, and draw-detection history are saved to the ESP32's NVS
flash (via the Preferences library) after every move, human's and AI's both, so
pulling the power mid-game resumes exactly where it left off - including whose turn it is; if
it's the AI's turn on resume, the normal turn-check just routes there automatically. Color and
difficulty are kept in a separate, tiny settings record instead of inside that save, written
unconditionally on every change: flash physically erases in whole sectors, never per-byte, so
folding a color toggle into the same multi-kilobyte game blob would mean rewriting the whole thing
just to change two bytes' worth of actual information. NEW GAME and any checkmate/stalemate/draw
clear the saved game outright, so a finished or abandoned game doesn't linger and resurrect itself
on the next boot. A small helper also skips writing a record at all if it would be byte-identical
to what's already stored - free, since reads don't wear flash, only writes do.
Eight files, each a real seam:
cyd-chess.ino - setup()/loop(), touch handling, and
board rendering (drawBoard/drawSquare/drawPiece, the
180° flip for playing as Black). Also the game-lifecycle glue that ties every other file
together - resetGame(), switchHumanColor(),
switchAiStrength(), and completeHumanMove()/endGame(),
which apply a move and check every game-ending condition.chess_rules.h/.cpp - the authoritative rules: board/move
representation, move generation (castling/en passant/promotion included), check/checkmate/
stalemate, and draw detection. Zero display or touch dependency, usable and tested completely
independent of the ESP32 side of the sketch.micromax.h/.cpp - the vendored engine. Exposes
microMaxInit()/microMaxApplyMove()/microMaxGetBestMove()
plus the handful of running-state globals (mmB, mmJ/mmZ/
mmk/mmR/mmQ/mmO, mmNodeBudget)
that Undo and the difficulty selector need to reach directly.book.h/.cpp - the opening book. BOOK_LINES itself
stays private to the .cpp; the header exposes only the three
bookXxx() functions and the move-history globals Undo needs.pieces.h - just the piece bitmap tables, no drawing logic.menu.h/.cpp - the MENU/difficulty/promotion screens: drawing and
touch-hit-testing only. Needs the TFT/touch objects (declared extern, defined in
cyd-chess.ino) but nothing else - given constants and touch input, it draws things
and reports hits. Deliberately excludes resetGame()/switchHumanColor()/
switchAiStrength() even though they're menu-triggered, since those reset engine/
book/undo/persisted-game state too - game-lifecycle glue, not menu code.undo.h/.cpp - the single-level undo snapshot. Zero display
dependency, like chess_rules.persistence.h/.cpp - saves and resumes the game and settings
records described above, via the Preferences library. Zero display dependency.A set of off-device g++ test harnesses - self-play against the real
chess_rules.cpp/micromax.cpp/book.cpp, opening-line replay,
undo/persistence round-trip exactness, draw-condition edge cases - link the actual sketch files
directly (stubbing only Arduino.h/Preferences.h) and run after every
change, alongside a clean on-device boot.