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.

Source at github.com/larsi-org/cyd-chess. ☕ If you build one, consider sponsoring.

Touch-select a piece, legal destinations highlight, touch again to move. The board fills the top of the screen edge-to-edge; the status line and file/rank coordinates sit in the strip below it, with the Menu/Undo buttons along the very bottom. 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 four 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. Every button prints its own current value (PLAYER: WHITE, DIFFICULTY: EASY, PIECES: CLASSIC, etc.), so there's exactly one place to look for each setting:

What each DIFFICULTY level actually changes, measured on this hardware (~16,300 search nodes/sec):

Difficulty Search node budget ~Time per move Chance of a random move
EASY500~0.1s20%
MEDIUM2,000~0.3s8%
HARD8,000~1.4s2%
EXPERT30,000~5s0%

Node budget, not a fixed ply depth - micro-Max deepens iteratively until it runs out of budget, so how many plies that actually buys varies by position.

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.

After each of your own moves, once the AI has replied, a message on its own line just below the main status line rates how it went - Good move!, OK move, or Not the best - a rough read on how close it was to what the engine itself would have played, aimed at a young player learning the game rather than at competitive analysis. It's a separate line on purpose: sharing the status line with "Your turn"/"AI thinking" meant the rating got overwritten within a fraction of a second, before there was any real chance to read it. It now just sits there until the next one replaces it (or a New Game, side switch, or Undo clears it).

The comparison follows the same idea real chess analysis tools use: search the position before your move to see what micro-Max's own best line would have scored there, then compare that against what its very next search - its actual reply, which runs right afterward anyway - says the position is worth once you've moved. The gap between the two is the "loss"; a small gap reads as Good move!, a larger one as Not the best. A book move or a difficulty-driven random blunder move (see Menu, above) never runs a real search, so there's nothing to rate against on those turns - the message just doesn't appear. It also stays quiet for your first couple of moves after a new game or a switch of sides - this early, a shallow search's small score gaps between perfectly reasonable opening choices aren't a meaningful "mistake" worth flagging.

The tricky part was doing this without slowing the game down. Scoring a move this way means running micro-Max's full search twice as often per round instead of once, and at Expert's ~5-second budget, tacking that on as a pause after your move would have been a real, noticeable wait. Instead, the moment it becomes your turn, a second search starts immediately on the ESP32's otherwise completely idle other core (via xTaskCreatePinnedToCore - this sketch has no Wi-Fi and no other background tasks to compete with it), so it overlaps your own thinking time instead of adding to it. By the time you actually tap a move, the score is normally already sitting there waiting; on the rare occasion you're faster than the search (more likely at Easy's shallower budget), the game just waits the second or two it needs rather than skip the rating.

That speculative search runs micro-Max's real root search - the exact same one that finds its own moves - against a snapshot of the position that gets thrown away afterward, so it never actually disturbs the real game (micro-Max's search commits its chosen line onto its own board as a side effect of finding it, which is fine when that line is the move about to be played, not here). Sharing micro-Max's board/search state with the real game this way means a couple of edge cases need explicit handling so the two searches never collide: tapping MENU mid-thought to start a new game or switch sides safely waits out (and discards) whatever speculative search was still running first; tapping UNDO - the case most likely to actually catch a search in flight, since it's normally tapped during your own turn - does the same before touching any of micro-Max's state directly.

That background search also needs its own small stack, separate from the main game loop's -- first sized at a guess (never actually confirmed against real hardware), which turned out to be too tight and occasionally crashed the search outright: selecting a piece still worked, but tapping where to move it did nothing, needing a power cycle to clear. Fixed two ways - a larger stack, and, regardless of the exact cause, a timeout on how long the game will ever wait for that background search before giving up on it and simply continuing without that move's rating, rather than risk hanging forever again for some other reason down the road.

Getting an actual number out of micro-Max for this turned out to be its own small investigation. The obvious thing - the root search's own return value - looked plausible (compiles, no crash, the AI's moves were still fine) but was actually wrong: once the search's node budget runs out, micro-Max's root call takes a dedicated internal path solely to confirm "found and committed this move," returning the search window's own bound rather than a real evaluation - which every real call does, at any difficulty. Nobody had ever needed that return value before this feature, so it had never been checked against anything. The real position value turned out to live in a different variable (mmQ, already exposed for Undo's own snapshot) that the search updates one line above that same shortcut - confirmed off-device by playing out a capture and checking it moved by exactly the piece's expected value on micro-Max's own scale, and snapped back after an immediate recapture.

That fix alone still wasn't enough - the sign convention it implied meant a second value (the AI's own reply score, already correctly oriented) needed to stop being flipped, not start. Both bugs together meant the rating had been comparing the wrong numbers since this feature first shipped, silently producing a meaningless "loss" on essentially every move despite looking completely normal in on-device play - nothing about tapping through a game surfaces whether the number behind a two-word status message is actually right.

Caught and fixed by building an off-device self-play harness rather than more on-device testing: link the real chess_rules.cpp/micromax.cpp against a plain g++ build (a stubbed-down Arduino.h substitutes for the ESP32/FreeRTOS calls), then play out several thousand moves per difficulty level with the "human" side split across three quality tiers - micro-Max's own choice, a plain "take the best capture or give check" heuristic, and a uniformly random legal move - to see whether the loss formula actually separated them. Before the fix it didn't (every sample landed on the same wrong number); after, the engine's own moves clustered at zero loss with a noise tail out to a couple hundred (comparing two independent searches of the same position doesn't always agree exactly, even on the objectively same move), the heuristic tier spread further, and random legal moves showed a much heavier tail reaching well past a thousand on real blunders. Good move!/OK move/Not the best now sit at 150/400 on that same scale - roughly 50-75× the placeholder guess this section used to describe, which (like the broken score behind it) had never been checked against anything real. The same run also settled the opening-move grace period on its own terms: the very first move showed zero variance at all across every sample and every quality tier - even a uniformly random first move scored a perfect 0 - confirming there's genuinely no signal to rate that early, not just a hunch that there shouldn't be.

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.