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.
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 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:
drawPiece() reads.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 |
|---|---|---|---|
| EASY | 500 | ~0.1s | 20% |
| MEDIUM | 2,000 | ~0.3s | 8% |
| HARD | 8,000 | ~1.4s | 2% |
| EXPERT | 30,000 | ~5s | 0% |
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:
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. Also
microMaxStartBackgroundEval()/microMaxConsumeBackgroundEval() (see
Move Rating, below) and microMaxSyncBackgroundEval(), which anything reaching into
those same globals directly (today, just undo.cpp) has to call first.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. Reads/writes micro-Max's board state directly
(see micromax.h/.cpp, above), so it syncs the move-rating background
eval first - the one place outside micromax.cpp itself that has to.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.