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.

Schematik's original working chess display, procedurally-drawn pieces
What we started from - Schematik's own working display, cropped from their build photo.

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.

Starting position, rendered with the Classic piece set
Classic
Starting position, rendered with the Pixel piece set
Pixel
Starting position, rendered with the Playful piece set
Playful

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:

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:

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.