Community project
Touchscreen Chess Game
Build a playable chess game on an ESP32 with a capacitive touchscreen display. This project runs a complete chess engine with AI opponent using minimax algorithm with alpha-beta pruning, letting you play as White against the computer's Black pieces. The guide includes a wiring diagram, complete parts list, ready-to-flash firmware, and step-by-step assembly instructions to get your chess board up and running.
The touchscreen interface makes piece selection and movement intuitive—simply tap a piece to select it, then tap the destination square to move. The ESP32 handles all game logic, move validation, and AI decision-making in real time on the built-in display.
Wiring diagram
Assemble it in 3 steps
1. Place the touchscreen board
Put the Waveshare ESP32-S3-Touch-LCD-2 on a non-metallic surface with its screen facing upward. The chess game uses the screen and touch panel already fitted to this board, so do not connect separate display or touch wires.
- Keep the screen’s protective film in place until the game is working.
- Do not place the powered board on conductive metal; it can short the board.
2. Connect USB-C power
Plug a USB-C data cable into the board and your computer. This cable supplies power and is used by Schematik to load the game.
- Use a data-capable cable; some charging-only cables cannot transfer the game to the board.
- Do not force the USB-C plug; it should insert easily in either orientation.
3. Use the touchscreen
After deploying, tap one white chess piece and then tap one of its highlighted destination squares. The board plays black automatically after your move. Tap NEW GAME to start over.
- The touchscreen is capacitive, so use a fingertip rather than pressing hard.
- Avoid pressing the display with sharp tools because they can scratch the screen.
Deploy the firmware
// Chess game for ESP32-2432S028R with ILI9341 TFT display
// Player (White) vs AI (Black) using minimax with alpha-beta pruning
// Touch screen for piece selection and movement
#include <SPI.h>
#include <Wire.h>
#include <Arduino_GFX_Library.h>
// Waveshare ESP32-S3-Touch-LCD-2 built-in hardware.
// ST7789T3 display: SCK=39, MOSI=38, MISO=40, CS=45, DC=42, backlight=1.
// CST816D capacitive touch: I2C SDA=48, SCL=47, INT=46, address 0x15.
#define TFT_BLACK 0x0000
#define TFT_WHITE 0xFFFF
#define TFT_RED 0xF800
#define TFT_GREEN 0x07E0
#define TFT_YELLOW 0xFFE0
#define TFT_DARKGREEN 0x0320
class TS_Point {
public:
int16_t x, y;
TS_Point(int16_t px = 0, int16_t py = 0) : x(px), y(py) {}
};
class CST816DTouch {
public:
bool begin() {
Wire.begin(48, 47);
Wire.setClock(400000);
pinMode(46, INPUT_PULLUP);
return true;
}
void setRotation(uint8_t) {}
bool touched() {
Wire.beginTransmission(0x15);
Wire.write(0x02);
if (Wire.endTransmission(false) != 0 || Wire.requestFrom(0x15, (uint8_t)1) != 1) return false;
return (Wire.read() & 0x0F) != 0;
}
TS_Point getPoint() {
uint8_t data[4] = {0};
Wire.beginTransmission(0x15);
Wire.write(0x03);
if (Wire.endTransmission(false) != 0 || Wire.requestFrom(0x15, (uint8_t)4) != 4) return TS_Point();
for (uint8_t i = 0; i < 4; ++i) data[i] = Wire.read();
return TS_Point(((data[0] & 0x0F) << 8) | data[1], ((data[2] & 0x0F) << 8) | data[3]);
}
};
// Override loopTask stack size to 64KB. arduino-esp32 v2.0.17 declares
// `size_t getArduinoLoopTaskStackSize(void);` with C++ linkage in Arduino.h
// (not extern "C"), so our override must match that linkage exactly.
size_t getArduinoLoopTaskStackSize() {
return 64 * 1024;
}
// ─── Pin Definitions ───────────────────────────────────────────────────────
#define TFT_BL_PIN 1
// ─── Display & Touch Objects ───────────────────────────────────────────────
// Hoisted type definitions
struct Move {
int fromRow, fromCol, toRow, toCol;
int capturedPiece, capturedColor;
bool promotion;
int promotedFrom;
bool enPassant;
bool castling;
// castling rook info
int rookFromCol, rookToCol;
};
struct GameState {
int board[8][8]; // piece type
int color[8][8]; // piece color
bool whiteCastleK; // white can castle kingside
bool whiteCastleQ; // white can castle queenside
bool blackCastleK;
bool blackCastleQ;
int enPassantCol; // column of en passant target (-1 if none)
int enPassantRow; // row of en passant target
int currentPlayer; // WHITE_PIECE or BLACK_PIECE
};
// Forward declarations
void initBoard(GameState &gs);
void drawStatus(const char *msg);
void drawGameOver(const char *msg);
void drawPiece(int row, int col, int piece, int pieceColor);
void drawSquare(int row, int col, bool highlight, bool moveDot, GameState &gs);
void drawBoard(GameState &gs);
bool inBounds(int r, int c);
bool squareAttacked(GameState &gs, int row, int col, int byPlayer);
bool isInCheck(GameState &gs, int player);
void addMove(Move *moves, int &count, int fr, int fc, int tr, int tc, GameState &gs, bool enPassant = false, bool castling = false, int rookFromCol = -1, int rookToCol = -1);
void generatePseudoMoves(GameState &gs, int player, Move *moves, int &count);
void applyMove(GameState &gs, Move &m);
void undoMove(GameState &gs, Move &m);
void generateMoves(GameState &gs, int player, Move *moves, int &count);
bool isCheckmate(GameState &gs, int player);
bool isStalemate(GameState &gs, int player);
int pieceValue(int piece);
int getPST(int piece, int row, int col, int color);
int evaluateBoard(GameState &gs);
int minimax(GameState &gs, int depth, int alpha, int beta, bool maximizing);
Move getBestMove(GameState &gs);
void getTouchSquare(int &row, int &col);
Arduino_DataBus *displayBus = new Arduino_ESP32SPI(42, 45, 39, 38, 40);
Arduino_GFX *tft = new Arduino_ST7789(displayBus, GFX_NOT_DEFINED, 0, true, 240, 320);
CST816DTouch touch;
// ─── Chess Constants ───────────────────────────────────────────────────────
#define BOARD_SIZE 8
#define SQUARE_SIZE 30
#define BOARD_OFFSET_X 0
#define BOARD_OFFSET_Y 40
// Piece types
#define EMPTY 0
#define PAWN 1
#define KNIGHT 2
#define BISHOP 3
#define ROOK 4
#define QUEEN 5
#define KING 6
// Colors
#define WHITE_PIECE 1
#define BLACK_PIECE -1
// TFT Colors
#define COLOR_LIGHT_SQ 0xFFE0
#define COLOR_DARK_SQ 0x6B4D
#define COLOR_SELECTED 0x07E0
#define COLOR_MOVE_DOT 0x07FF
#define COLOR_BG 0x0000
#define COLOR_WHITE_P 0xFFFF
#define COLOR_BLACK_P 0x18C3
#define COLOR_TEXT 0xFFFF
#define COLOR_STATUS_BG 0x2104
// ─── Game State Structs ────────────────────────────────────────────────────
// ─── Forward Declarations ──────────────────────────────────────────────────
void initBoard(GameState &gs);
void drawBoard(GameState &gs);
void drawSquare(int row, int col, bool highlight, bool moveDot, GameState &gs);
void drawPiece(int row, int col, int piece, int pieceColor);
bool isValidMove(GameState &gs, Move &m);
void generateMoves(GameState &gs, int player, Move *moves, int &count);
void applyMove(GameState &gs, Move &m);
void undoMove(GameState &gs, Move &m);
bool isInCheck(GameState &gs, int player);
bool isCheckmate(GameState &gs, int player);
bool isStalemate(GameState &gs, int player);
int evaluateBoard(GameState &gs);
int minimax(GameState &gs, int depth, int alpha, int beta, bool maximizing);
Move getBestMove(GameState &gs);
void getTouchSquare(int &row, int &col);
void drawStatus(const char *msg);
void drawGameOver(const char *msg);
bool squareAttacked(GameState &gs, int row, int col, int byPlayer);
void handlePromotion(GameState &gs, int row, int col, int pieceColor);
// ─── Globals ───────────────────────────────────────────────────────────────
GameState gs;
int selectedRow = -1;
int selectedCol = -1;
bool pieceSelected = false;
Move legalMoves[256];
int legalMoveCount = 0;
bool gameOver = false;
char statusMsg[64] = "Your turn (White)";
// CST816D reports native display coordinates on this board.
#define TOUCH_X_MIN 0
#define TOUCH_X_MAX 239
#define TOUCH_Y_MIN 0
#define TOUCH_Y_MAX 319
// ─── Board Initialization ──────────────────────────────────────────────────
void initBoard(GameState &gs) {
memset(gs.board, 0, sizeof(gs.board));
memset(gs.color, 0, sizeof(gs.color));
// Back rows
int backRow[8] = {ROOK, KNIGHT, BISHOP, QUEEN, KING, BISHOP, KNIGHT, ROOK};
for (int c = 0; c < 8; c++) {
// Black back row (row 0)
gs.board[0][c] = backRow[c];
gs.color[0][c] = BLACK_PIECE;
// Black pawns (row 1)
gs.board[1][c] = PAWN;
gs.color[1][c] = BLACK_PIECE;
// White pawns (row 6)
gs.board[6][c] = PAWN;
gs.color[6][c] = WHITE_PIECE;
// White back row (row 7)
gs.board[7][c] = backRow[c];
gs.color[7][c] = WHITE_PIECE;
}
gs.whiteCastleK = true;
gs.whiteCastleQ = true;
gs.blackCastleK = true;
gs.blackCastleQ = true;
gs.enPassantCol = -1;
gs.enPassantRow = -1;
gs.currentPlayer = WHITE_PIECE;
}
// ─── Drawing Functions ─────────────────────────────────────────────────────
void drawStatus(const char *msg) {
tft->fillRect(0, 0, 240, 38, COLOR_STATUS_BG);
tft->setTextColor(COLOR_TEXT, COLOR_STATUS_BG);
tft->setTextSize(1);
tft->setCursor(4, 4);
tft->print(msg);
}
void drawGameOver(const char *msg) {
tft->fillRect(20, 100, 200, 60, TFT_RED);
tft->setTextColor(TFT_WHITE, TFT_RED);
tft->setTextSize(2);
tft->setCursor(30, 115);
tft->print(msg);
tft->setTextSize(1);
tft->setCursor(50, 140);
tft->print("Tap to restart");
}
void drawPiece(int row, int col, int piece, int pieceColor) {
int x = BOARD_OFFSET_X + col * SQUARE_SIZE + SQUARE_SIZE / 2;
int y = BOARD_OFFSET_Y + row * SQUARE_SIZE + SQUARE_SIZE / 2;
uint16_t fg = (pieceColor == WHITE_PIECE) ? COLOR_WHITE_P : COLOR_BLACK_P;
uint16_t outline = (pieceColor == WHITE_PIECE) ? TFT_BLACK : TFT_WHITE;
int r = SQUARE_SIZE / 2 - 4;
switch (piece) {
case PAWN:
tft->fillCircle(x, y + 3, r - 3, fg);
tft->drawCircle(x, y + 3, r - 3, outline);
tft->fillRect(x - 4, y + 7, 9, 3, fg);
tft->drawRect(x - 4, y + 7, 9, 3, outline);
break;
case ROOK:
tft->fillRect(x - r + 2, y - r + 4, (r - 2) * 2, (r) * 2 - 2, fg);
tft->drawRect(x - r + 2, y - r + 4, (r - 2) * 2, (r) * 2 - 2, outline);
tft->fillRect(x - r + 2, y - r, 3, 5, fg);
tft->fillRect(x - 1, y - r, 3, 5, fg);
tft->fillRect(x + r - 5, y - r, 3, 5, fg);
tft->drawRect(x - r + 2, y - r, 3, 5, outline);
tft->drawRect(x - 1, y - r, 3, 5, outline);
tft->drawRect(x + r - 5, y - r, 3, 5, outline);
break;
case KNIGHT:
tft->fillCircle(x + 2, y - 2, r - 2, fg);
tft->drawCircle(x + 2, y - 2, r - 2, outline);
tft->fillTriangle(x - r + 2, y + r - 2, x + r - 2, y + r - 2, x - 2, y, fg);
tft->drawTriangle(x - r + 2, y + r - 2, x + r - 2, y + r - 2, x - 2, y, outline);
break;
case BISHOP:
tft->fillTriangle(x, y - r + 1, x - r + 3, y + r - 2, x + r - 3, y + r - 2, fg);
tft->drawTriangle(x, y - r + 1, x - r + 3, y + r - 2, x + r - 3, y + r - 2, outline);
tft->fillCircle(x, y - r + 2, 3, fg);
tft->drawCircle(x, y - r + 2, 3, outline);
break;
case QUEEN:
tft->fillCircle(x, y, r - 1, fg);
tft->drawCircle(x, y, r - 1, outline);
// Crown points
for (int i = -2; i <= 2; i++) {
tft->fillCircle(x + i * 4, y - r + 1, 2, fg);
tft->drawCircle(x + i * 4, y - r + 1, 2, outline);
}
tft->fillCircle(x, y, 3, outline);
break;
case KING:
tft->fillCircle(x, y + 2, r - 2, fg);
tft->drawCircle(x, y + 2, r - 2, outline);
// Cross on top
tft->fillRect(x - 1, y - r, 3, 8, fg);
tft->fillRect(x - 4, y - r + 3, 9, 3, fg);
tft->drawRect(x - 1, y - r, 3, 8, outline);
tft->drawRect(x - 4, y - r + 3, 9, 3, outline);
break;
default:
break;
}
}
void drawSquare(int row, int col, bool highlight, bool moveDot, GameState &gs) {
int x = BOARD_OFFSET_X + col * SQUARE_SIZE;
int y = BOARD_OFFSET_Y + row * SQUARE_SIZE;
uint16_t bg;
if (highlight) {
bg = COLOR_SELECTED;
} else {
bg = ((row + col) % 2 == 0) ? COLOR_LIGHT_SQ : COLOR_DARK_SQ;
}
tft->fillRect(x, y, SQUARE_SIZE, SQUARE_SIZE, bg);
if (moveDot && gs.board[row][col] == EMPTY) {
tft->fillCircle(x + SQUARE_SIZE / 2, y + SQUARE_SIZE / 2, 4, COLOR_MOVE_DOT);
} else if (moveDot && gs.board[row][col] != EMPTY) {
// Highlight capture
tft->drawRect(x, y, SQUARE_SIZE, SQUARE_SIZE, COLOR_MOVE_DOT);
tft->drawRect(x + 1, y + 1, SQUARE_SIZE - 2, SQUARE_SIZE - 2, COLOR_MOVE_DOT);
}
if (gs.board[row][col] != EMPTY) {
drawPiece(row, col, gs.board[row][col], gs.color[row][col]);
}
}
// New Game button — bottom of screen, below the chess board.
// 240Ă—320 portrait: board occupies y=40..280; column labels at y=282..290;
// button gets the remaining strip y=294..318.
#define NEW_GAME_BTN_X 60
#define NEW_GAME_BTN_Y 294
#define NEW_GAME_BTN_W 120
#define NEW_GAME_BTN_H 24
void drawNewGameButton() {
tft->fillRoundRect(NEW_GAME_BTN_X, NEW_GAME_BTN_Y, NEW_GAME_BTN_W, NEW_GAME_BTN_H, 4, TFT_DARKGREEN);
tft->drawRoundRect(NEW_GAME_BTN_X, NEW_GAME_BTN_Y, NEW_GAME_BTN_W, NEW_GAME_BTN_H, 4, TFT_GREEN);
tft->setTextSize(1);
tft->setTextColor(TFT_WHITE, TFT_DARKGREEN);
// Centred-ish text — TFT_eSPI default font is ~6 pixels per character.
tft->setCursor(NEW_GAME_BTN_X + (NEW_GAME_BTN_W - 8 * 6) / 2, NEW_GAME_BTN_Y + (NEW_GAME_BTN_H - 8) / 2);
tft->print("NEW GAME");
}
bool isTouchOnNewGameButton() {
if (!touch.touched()) return false;
TS_Point p = touch.getPoint();
int tx = map(p.x, TOUCH_X_MIN, TOUCH_X_MAX, 0, 240);
int ty = map(p.y, TOUCH_Y_MIN, TOUCH_Y_MAX, 0, 320);
return (tx >= NEW_GAME_BTN_X && tx < NEW_GAME_BTN_X + NEW_GAME_BTN_W &&
ty >= NEW_GAME_BTN_Y && ty < NEW_GAME_BTN_Y + NEW_GAME_BTN_H);
}
void resetGame() {
initBoard(gs);
pieceSelected = false;
selectedRow = -1;
selectedCol = -1;
legalMoveCount = 0;
gameOver = false;
tft->fillScreen(COLOR_BG);
drawBoard(gs);
drawNewGameButton();
drawStatus("Your turn (White)");
}
void drawBoard(GameState &gs) {
tft->fillRect(BOARD_OFFSET_X, BOARD_OFFSET_Y, 8 * SQUARE_SIZE, 8 * SQUARE_SIZE, COLOR_LIGHT_SQ);
// Determine if any square is selected and build move dots
bool dotSquare[8][8];
memset(dotSquare, false, sizeof(dotSquare));
if (pieceSelected) {
for (int i = 0; i < legalMoveCount; i++) {
dotSquare[legalMoves[i].toRow][legalMoves[i].toCol] = true;
}
}
for (int r = 0; r < 8; r++) {
for (int c = 0; c < 8; c++) {
bool highlight = (pieceSelected && r == selectedRow && c == selectedCol);
drawSquare(r, c, highlight, dotSquare[r][c], gs);
}
}
// Draw coordinates
tft->setTextSize(1);
tft->setTextColor(TFT_YELLOW, COLOR_BG);
for (int c = 0; c < 8; c++) {
tft->setCursor(BOARD_OFFSET_X + c * SQUARE_SIZE + 12, BOARD_OFFSET_Y + 8 * SQUARE_SIZE + 2);
tft->print((char)('a' + c));
}
for (int r = 0; r < 8; r++) {
tft->setCursor(BOARD_OFFSET_X + 8 * SQUARE_SIZE + 2, BOARD_OFFSET_Y + r * SQUARE_SIZE + 10);
tft->print(8 - r);
}
}
// ─── Move Validation Helpers ───────────────────────────────────────────────
bool inBounds(int r, int c) {
return r >= 0 && r < 8 && c >= 0 && c < 8;
}
bool squareAttacked(GameState &gs, int row, int col, int byPlayer) {
// Check if (row,col) is attacked by 'byPlayer'
// Pawn attacks
int pawnDir = (byPlayer == WHITE_PIECE) ? 1 : -1;
int pawnRow = row + pawnDir;
if (inBounds(pawnRow, col - 1) && gs.board[pawnRow][col - 1] == PAWN && gs.color[pawnRow][col - 1] == byPlayer) return true;
if (inBounds(pawnRow, col + 1) && gs.board[pawnRow][col + 1] == PAWN && gs.color[pawnRow][col + 1] == byPlayer) return true;
// Knight attacks
int kd[8][2] = {{-2,-1},{-2,1},{-1,-2},{-1,2},{1,-2},{1,2},{2,-1},{2,1}};
for (int i = 0; i < 8; i++) {
int nr = row + kd[i][0], nc = col + kd[i][1];
if (inBounds(nr, nc) && gs.board[nr][nc] == KNIGHT && gs.color[nr][nc] == byPlayer) return true;
}
// Sliding pieces (Bishop/Queen diagonals)
int diagD[4][2] = {{1,1},{1,-1},{-1,1},{-1,-1}};
for (int d = 0; d < 4; d++) {
int nr = row + diagD[d][0], nc = col + diagD[d][1];
while (inBounds(nr, nc)) {
if (gs.board[nr][nc] != EMPTY) {
if (gs.color[nr][nc] == byPlayer && (gs.board[nr][nc] == BISHOP || gs.board[nr][nc] == QUEEN)) return true;
break;
}
nr += diagD[d][0]; nc += diagD[d][1];
}
}
// Sliding pieces (Rook/Queen straight)
int straightD[4][2] = {{1,0},{-1,0},{0,1},{0,-1}};
for (int d = 0; d < 4; d++) {
int nr = row + straightD[d][0], nc = col + straightD[d][1];
while (inBounds(nr, nc)) {
if (gs.board[nr][nc] != EMPTY) {
if (gs.color[nr][nc] == byPlayer && (gs.board[nr][nc] == ROOK || gs.board[nr][nc] == QUEEN)) return true;
break;
}
nr += straightD[d][0]; nc += straightD[d][1];
}
}
// King
for (int dr = -1; dr <= 1; dr++) {
for (int dc = -1; dc <= 1; dc++) {
if (dr == 0 && dc == 0) continue;
int nr = row + dr, nc = col + dc;
if (inBounds(nr, nc) && gs.board[nr][nc] == KING && gs.color[nr][nc] == byPlayer) return true;
}
}
return false;
}
bool isInCheck(GameState &gs, int player) {
int kingRow = -1, kingCol = -1;
for (int r = 0; r < 8 && kingRow == -1; r++) {
for (int c = 0; c < 8 && kingRow == -1; c++) {
if (gs.board[r][c] == KING && gs.color[r][c] == player) {
kingRow = r; kingCol = c;
}
}
}
if (kingRow == -1) return false;
int opponent = -player;
return squareAttacked(gs, kingRow, kingCol, opponent);
}
// ─── Move Generation ───────────────────────────────────────────────────────
void addMove(Move *moves, int &count, int fr, int fc, int tr, int tc, GameState &gs,
bool enPassant, bool castling, int rookFromCol, int rookToCol) {
if (count >= 255) return;
Move m;
m.fromRow = fr; m.fromCol = fc;
m.toRow = tr; m.toCol = tc;
m.capturedPiece = gs.board[tr][tc];
m.capturedColor = gs.color[tr][tc];
m.promotion = false;
m.promotedFrom = gs.board[fr][fc];
m.enPassant = enPassant;
m.castling = castling;
m.rookFromCol = rookFromCol;
m.rookToCol = rookToCol;
// Pawn promotion
if (gs.board[fr][fc] == PAWN) {
if ((gs.color[fr][fc] == WHITE_PIECE && tr == 0) ||
(gs.color[fr][fc] == BLACK_PIECE && tr == 7)) {
m.promotion = true;
}
}
moves[count++] = m;
}
void generatePseudoMoves(GameState &gs, int player, Move *moves, int &count) {
int dir = (player == WHITE_PIECE) ? -1 : 1;
for (int r = 0; r < 8; r++) {
for (int c = 0; c < 8; c++) {
if (gs.board[r][c] == EMPTY || gs.color[r][c] != player) continue;
int piece = gs.board[r][c];
if (piece == PAWN) {
int nr = r + dir;
// Forward
if (inBounds(nr, c) && gs.board[nr][c] == EMPTY) {
addMove(moves, count, r, c, nr, c, gs);
// Double push from start
int startRow = (player == WHITE_PIECE) ? 6 : 1;
if (r == startRow && gs.board[nr + dir][c] == EMPTY) {
addMove(moves, count, r, c, nr + dir, c, gs);
}
}
// Captures
for (int dc = -1; dc <= 1; dc += 2) {
int nc = c + dc;
if (!inBounds(nr, nc)) continue;
if (gs.board[nr][nc] != EMPTY && gs.color[nr][nc] != player) {
addMove(moves, count, r, c, nr, nc, gs);
}
// En passant
if (gs.enPassantCol == nc && gs.enPassantRow == nr) {
addMove(moves, count, r, c, nr, nc, gs, true);
}
}
}
else if (piece == KNIGHT) {
int kd[8][2] = {{-2,-1},{-2,1},{-1,-2},{-1,2},{1,-2},{1,2},{2,-1},{2,1}};
for (int i = 0; i < 8; i++) {
int nr = r + kd[i][0], nc = c + kd[i][1];
if (!inBounds(nr, nc)) continue;
if (gs.board[nr][nc] == EMPTY || gs.color[nr][nc] != player) {
addMove(moves, count, r, c, nr, nc, gs);
}
}
}
else if (piece == BISHOP || piece == QUEEN) {
int diagD[4][2] = {{1,1},{1,-1},{-1,1},{-1,-1}};
for (int d = 0; d < 4; d++) {
int nr = r + diagD[d][0], nc = c + diagD[d][1];
while (inBounds(nr, nc)) {
if (gs.board[nr][nc] != EMPTY) {
if (gs.color[nr][nc] != player) addMove(moves, count, r, c, nr, nc, gs);
break;
}
addMove(moves, count, r, c, nr, nc, gs);
nr += diagD[d][0]; nc += diagD[d][1];
}
}
}
if (piece == ROOK || piece == QUEEN) {
int straightD[4][2] = {{1,0},{-1,0},{0,1},{0,-1}};
for (int d = 0; d < 4; d++) {
int nr = r + straightD[d][0], nc = c + straightD[d][1];
while (inBounds(nr, nc)) {
if (gs.board[nr][nc] != EMPTY) {
if (gs.color[nr][nc] != player) addMove(moves, count, r, c, nr, nc, gs);
break;
}
addMove(moves, count, r, c, nr, nc, gs);
nr += straightD[d][0]; nc += straightD[d][1];
}
}
}
else if (piece == KING) {
for (int dr = -1; dr <= 1; dr++) {
for (int dc = -1; dc <= 1; dc++) {
if (dr == 0 && dc == 0) continue;
int nr = r + dr, nc = c + dc;
if (!inBounds(nr, nc)) continue;
if (gs.board[nr][nc] == EMPTY || gs.color[nr][nc] != player) {
addMove(moves, count, r, c, nr, nc, gs);
}
}
}
// Castling
int backRow = (player == WHITE_PIECE) ? 7 : 0;
int opponent = -player;
if (r == backRow && c == 4 && !isInCheck(gs, player)) {
// Kingside
bool canK = (player == WHITE_PIECE) ? gs.whiteCastleK : gs.blackCastleK;
if (canK && gs.board[backRow][5] == EMPTY && gs.board[backRow][6] == EMPTY &&
!squareAttacked(gs, backRow, 5, opponent) && !squareAttacked(gs, backRow, 6, opponent)) {
addMove(moves, count, r, c, backRow, 6, gs, false, true, 7, 5);
}
// Queenside
bool canQ = (player == WHITE_PIECE) ? gs.whiteCastleQ : gs.blackCastleQ;
if (canQ && gs.board[backRow][3] == EMPTY && gs.board[backRow][2] == EMPTY && gs.board[backRow][1] == EMPTY &&
!squareAttacked(gs, backRow, 3, opponent) && !squareAttacked(gs, backRow, 2, opponent)) {
addMove(moves, count, r, c, backRow, 2, gs, false, true, 0, 3);
}
}
}
}
}
}
void applyMove(GameState &gs, Move &m) {
int piece = gs.board[m.fromRow][m.fromCol];
int pieceColor = gs.color[m.fromRow][m.fromCol];
// Handle en passant capture
if (m.enPassant) {
int captureRow = m.fromRow;
gs.board[captureRow][m.toCol] = EMPTY;
gs.color[captureRow][m.toCol] = 0;
}
// Set new en passant target
gs.enPassantCol = -1;
gs.enPassantRow = -1;
if (piece == PAWN && abs(m.toRow - m.fromRow) == 2) {
gs.enPassantRow = (m.fromRow + m.toRow) / 2;
gs.enPassantCol = m.fromCol;
}
// Move piece
gs.board[m.toRow][m.toCol] = piece;
gs.color[m.toRow][m.toCol] = pieceColor;
gs.board[m.fromRow][m.fromCol] = EMPTY;
gs.color[m.fromRow][m.fromCol] = 0;
// Promotion
if (m.promotion) {
gs.board[m.toRow][m.toCol] = QUEEN;
}
// Castling: move rook
if (m.castling) {
int backRow = m.fromRow;
gs.board[backRow][m.rookToCol] = ROOK;
gs.color[backRow][m.rookToCol] = pieceColor;
gs.board[backRow][m.rookFromCol] = EMPTY;
gs.color[backRow][m.rookFromCol] = 0;
}
// Update castling rights
if (piece == KING) {
if (pieceColor == WHITE_PIECE) { gs.whiteCastleK = false; gs.whiteCastleQ = false; }
else { gs.blackCastleK = false; gs.blackCastleQ = false; }
}
if (piece == ROOK) {
if (pieceColor == WHITE_PIECE) {
if (m.fromCol == 7) gs.whiteCastleK = false;
if (m.fromCol == 0) gs.whiteCastleQ = false;
} else {
if (m.fromCol == 7) gs.blackCastleK = false;
if (m.fromCol == 0) gs.blackCastleQ = false;
}
}
gs.currentPlayer = -gs.currentPlayer;
}
void undoMove(GameState &gs, Move &m) {
int piece = gs.board[m.toRow][m.toCol];
int pieceColor = gs.color[m.toRow][m.toCol];
// Undo promotion
if (m.promotion) {
piece = PAWN;
}
gs.board[m.fromRow][m.fromCol] = piece;
gs.color[m.fromRow][m.fromCol] = pieceColor;
gs.board[m.toRow][m.toCol] = m.capturedPiece;
gs.color[m.toRow][m.toCol] = m.capturedColor;
// Undo en passant
if (m.enPassant) {
int captureRow = m.fromRow;
int captureColor = -pieceColor;
gs.board[captureRow][m.toCol] = PAWN;
gs.color[captureRow][m.toCol] = captureColor;
}
// Undo castling: move rook back
if (m.castling) {
int backRow = m.fromRow;
gs.board[backRow][m.rookFromCol] = ROOK;
gs.color[backRow][m.rookFromCol] = pieceColor;
gs.board[backRow][m.rookToCol] = EMPTY;
gs.color[backRow][m.rookToCol] = 0;
}
gs.currentPlayer = -gs.currentPlayer;
}
void generateMoves(GameState &gs, int player, Move *moves, int &count) {
count = 0;
Move pseudoMoves[256];
int pseudoCount = 0;
generatePseudoMoves(gs, player, pseudoMoves, pseudoCount);
// Snapshot/restore the full GameState rather than relying on undoMove —
// undoMove only restores board[][] / color[][], leaving enPassant target,
// castling rights, and currentPlayer corrupted. With those leaking across
// pseudoMove iterations, the AI sees positions that can't actually arise
// and emits illegal moves (e.g. rook capturing its own pawn). Full copy
// costs ~520 bytes per iteration but eliminates the whole class of bugs.
for (int i = 0; i < pseudoCount; i++) {
GameState saved = gs;
applyMove(gs, pseudoMoves[i]);
if (!isInCheck(gs, player)) {
moves[count++] = pseudoMoves[i];
}
gs = saved;
}
}
bool isCheckmate(GameState &gs, int player) {
Move moves[256];
int count = 0;
generateMoves(gs, player, moves, count);
return (count == 0 && isInCheck(gs, player));
}
bool isStalemate(GameState &gs, int player) {
Move moves[256];
int count = 0;
generateMoves(gs, player, moves, count);
return (count == 0 && !isInCheck(gs, player));
}
// ─── Evaluation ───────────────────────────────────────────────────────────
int pieceValue(int piece) {
switch (piece) {
case PAWN: return 100;
case KNIGHT: return 320;
case BISHOP: return 330;
case ROOK: return 500;
case QUEEN: return 900;
case KING: return 20000;
default: return 0;
}
}
// Piece-square tables (from white's perspective)
const int pawnTable[8][8] = {
{ 0, 0, 0, 0, 0, 0, 0, 0},
{50, 50, 50, 50, 50, 50, 50, 50},
{10, 10, 20, 30, 30, 20, 10, 10},
{ 5, 5, 10, 25, 25, 10, 5, 5},
{ 0, 0, 0, 20, 20, 0, 0, 0},
{ 5, -5,-10, 0, 0,-10, -5, 5},
{ 5, 10, 10,-20,-20, 10, 10, 5},
{ 0, 0, 0, 0, 0, 0, 0, 0}
};
const int knightTable[8][8] = {
{-50,-40,-30,-30,-30,-30,-40,-50},
{-40,-20, 0, 0, 0, 0,-20,-40},
{-30, 0, 10, 15, 15, 10, 0,-30},
{-30, 5, 15, 20, 20, 15, 5,-30},
{-30, 0, 15, 20, 20, 15, 0,-30},
{-30, 5, 10, 15, 15, 10, 5,-30},
{-40,-20, 0, 5, 5, 0,-20,-40},
{-50,-40,-30,-30,-30,-30,-40,-50}
};
const int bishopTable[8][8] = {
{-20,-10,-10,-10,-10,-10,-10,-20},
{-10, 0, 0, 0, 0, 0, 0,-10},
{-10, 0, 5, 10, 10, 5, 0,-10},
{-10, 5, 5, 10, 10, 5, 5,-10},
{-10, 0, 10, 10, 10, 10, 0,-10},
{-10, 10, 10, 10, 10, 10, 10,-10},
{-10, 5, 0, 0, 0, 0, 5,-10},
{-20,-10,-10,-10,-10,-10,-10,-20}
};
int getPST(int piece, int row, int col, int color) {
int r = (color == WHITE_PIECE) ? row : (7 - row);
switch (piece) {
case PAWN: return pawnTable[r][col];
case KNIGHT: return knightTable[r][col];
case BISHOP: return bishopTable[r][col];
default: return 0;
}
}
int evaluateBoard(GameState &gs) {
int score = 0;
for (int r = 0; r < 8; r++) {
for (int c = 0; c < 8; c++) {
if (gs.board[r][c] == EMPTY) continue;
int v = pieceValue(gs.board[r][c]) + getPST(gs.board[r][c], r, c, gs.color[r][c]);
if (gs.color[r][c] == BLACK_PIECE) score += v;
else score -= v;
}
}
return score;
}
// ─── Minimax with Alpha-Beta ───────────────────────────────────────────────
// Stack-overflow fix: a per-frame `Move moves[256]` (~10KB) Ă— recursion depth
// blows past the loopTask stack (even at 32KB) when combined with the
// setup→loop→handleTouch→getBestMove call chain. Loop task is single-threaded
// so a per-depth global pool is safe — each recursion level reads/writes its
// own slot.
#define MINIMAX_MAX_DEPTH 6
static Move minimaxMoveBuf[MINIMAX_MAX_DEPTH + 1][256];
int minimax(GameState &gs, int depth, int alpha, int beta, bool maximizing) {
if (depth == 0) return evaluateBoard(gs);
int player = maximizing ? BLACK_PIECE : WHITE_PIECE;
Move *moves = (depth >= 0 && depth <= MINIMAX_MAX_DEPTH) ? minimaxMoveBuf[depth] : minimaxMoveBuf[0];
int count = 0;
generateMoves(gs, player, moves, count);
if (count == 0) {
if (isInCheck(gs, player)) {
return maximizing ? -30000 - depth : 30000 + depth;
}
return 0; // stalemate
}
// vTaskDelay(1) (= 1 tick) actually lets the IDLE task run, unlike yield()
// which only schedules equal-or-higher priority. Calling every node would
// be too slow (~1ms Ă— thousands of nodes); call every 16th instead.
static uint32_t s_minimaxNodeCounter = 0;
if (maximizing) {
int maxEval = -32767;
for (int i = 0; i < count; i++) {
GameState saved = gs;
applyMove(gs, moves[i]);
int eval = minimax(gs, depth - 1, alpha, beta, false);
gs = saved;
if ((++s_minimaxNodeCounter & 0x0F) == 0) vTaskDelay(1);
if (eval > maxEval) maxEval = eval;
if (eval > alpha) alpha = eval;
if (beta <= alpha) break;
}
return maxEval;
} else {
int minEval = 32767;
for (int i = 0; i < count; i++) {
GameState saved = gs;
applyMove(gs, moves[i]);
int eval = minimax(gs, depth - 1, alpha, beta, true);
gs = saved;
if ((++s_minimaxNodeCounter & 0x0F) == 0) vTaskDelay(1);
if (eval < minEval) minEval = eval;
if (eval < beta) beta = eval;
if (beta <= alpha) break;
}
return minEval;
}
}
Move getBestMove(GameState &gs) {
Move moves[256];
int count = 0;
generateMoves(gs, BLACK_PIECE, moves, count);
Move bestMove = moves[0];
int bestVal = -32767;
int depth = 2; // Search depth — reduced from 3 to keep AI move time
// around 1s on ESP32 240MHz. Still a credible opponent.
for (int i = 0; i < count; i++) {
GameState saved = gs;
applyMove(gs, moves[i]);
int val = minimax(gs, depth - 1, -32767, 32767, false);
gs = saved;
if (val > bestVal) {
bestVal = val;
bestMove = moves[i];
}
}
return bestMove;
}
// ─── Touch Input ───────────────────────────────────────────────────────────
void getTouchSquare(int &row, int &col) {
row = -1; col = -1;
if (!touch.touched()) return;
TS_Point p = touch.getPoint();
// Map raw touch to screen coordinates
// The display is 240x320, touch is 240 wide x 320 tall
// Raw values need mapping based on calibration
int tx = map(p.x, TOUCH_X_MIN, TOUCH_X_MAX, 0, 240);
int ty = map(p.y, TOUCH_Y_MIN, TOUCH_Y_MAX, 0, 320);
tx = constrain(tx, 0, 239);
ty = constrain(ty, 0, 319);
// Convert to board square
int bx = tx - BOARD_OFFSET_X;
int by = ty - BOARD_OFFSET_Y;
if (bx < 0 || bx >= 8 * SQUARE_SIZE) return;
if (by < 0 || by >= 8 * SQUARE_SIZE) return;
col = bx / SQUARE_SIZE;
row = by / SQUARE_SIZE;
}
// ─── Setup & Loop ──────────────────────────────────────────────────────────
void setup() {
Serial.begin(115200);
// Backlight
ledcAttach(TFT_BL_PIN, 5000, 8);
ledcWrite(TFT_BL_PIN, 200);
// TFT
tft->begin();
tft->setRotation(0); // Portrait
tft->fillScreen(COLOR_BG);
// Capacitive touchscreen on its built-in I2C connection.
touch.begin();
touch.setRotation(0);
// Init game
initBoard(gs);
tft->fillScreen(COLOR_BG);
drawBoard(gs);
drawNewGameButton();
drawStatus("Your turn (White)");
// Diagnostic: how much loopTask stack is left at the end of setup()?
// Reports the minimum free stack (high-water mark). If this is small,
// setup() ate most of the stack and we need to bump getArduinoLoopTaskStackSize.
Serial.printf("[diag] loopTask stack high-water mark: %u bytes free\n",
(unsigned)uxTaskGetStackHighWaterMark(NULL));
Serial.println("Chess game started");
}
void loop() {
// ESP32 task watchdog feed: arduino-esp32 v2.x's loopTask doesn't yield
// between iterations, so a busy-waiting `loop()` (no touch → return early)
// starves Core 1 IDLE within ~5s and trips TG0WDT_SYS_RESET. delay(1)
// sleeps the loopTask for 1 tick, which is enough for IDLE to run.
// (yield() does NOT fix this — it only schedules equal-or-higher priority.)
delay(1);
// New Game button: works in any game state (mid-game, game over, AI's
// turn). Check before all other touch handling so a tap on the button
// always wins. Wait for finger release with the same 2s timeout used
// elsewhere so a stuck touch sensor can't freeze the reset.
if (isTouchOnNewGameButton()) {
delay(50); // debounce
if (isTouchOnNewGameButton()) {
unsigned long _waitStart = millis();
while (touch.touched() && millis() - _waitStart < 2000) { delay(10); }
resetGame();
return;
}
}
if (gameOver) {
// Wait for touch to restart
if (touch.touched()) {
delay(300);
// Wait for release — bail after 2s in case the sensor glitches and
// never reports !touched(). Without the timeout, a stuck touch event
// would freeze the game (loop() would never return, so we'd stop
// processing input even though the WDT stays fed by delay(10)).
unsigned long _waitStart = millis();
while (touch.touched() && millis() - _waitStart < 2000) { delay(10); }
gameOver = false;
pieceSelected = false;
selectedRow = -1;
selectedCol = -1;
legalMoveCount = 0;
initBoard(gs);
tft->fillScreen(COLOR_BG);
drawBoard(gs);
drawStatus("Your turn (White)");
}
return;
}
if (gs.currentPlayer == WHITE_PIECE) {
// Human's turn
if (!touch.touched()) return;
delay(50); // debounce
if (!touch.touched()) return;
int tRow, tCol;
getTouchSquare(tRow, tCol);
// Wait for release — same 2s timeout as the gameOver branch above.
unsigned long _waitStart = millis();
while (touch.touched() && millis() - _waitStart < 2000) { delay(10); }
if (tRow < 0 || tRow >= 8 || tCol < 0 || tCol >= 8) return;
if (!pieceSelected) {
// Select a piece
if (gs.board[tRow][tCol] != EMPTY && gs.color[tRow][tCol] == WHITE_PIECE) {
selectedRow = tRow;
selectedCol = tCol;
pieceSelected = true;
// Generate legal moves for this piece
Move allMoves[256];
int allCount = 0;
generateMoves(gs, WHITE_PIECE, allMoves, allCount);
legalMoveCount = 0;
for (int i = 0; i < allCount; i++) {
if (allMoves[i].fromRow == selectedRow && allMoves[i].fromCol == selectedCol) {
legalMoves[legalMoveCount++] = allMoves[i];
}
}
drawBoard(gs);
drawStatus("Select destination");
}
} else {
// Deselect if tapping same square
if (tRow == selectedRow && tCol == selectedCol) {
pieceSelected = false;
selectedRow = -1;
selectedCol = -1;
legalMoveCount = 0;
drawBoard(gs);
drawStatus("Your turn (White)");
return;
}
// Re-select another white piece
if (gs.board[tRow][tCol] != EMPTY && gs.color[tRow][tCol] == WHITE_PIECE) {
selectedRow = tRow;
selectedCol = tCol;
Move allMoves[256];
int allCount = 0;
generateMoves(gs, WHITE_PIECE, allMoves, allCount);
legalMoveCount = 0;
for (int i = 0; i < allCount; i++) {
if (allMoves[i].fromRow == selectedRow && allMoves[i].fromCol == selectedCol) {
legalMoves[legalMoveCount++] = allMoves[i];
}
}
drawBoard(gs);
drawStatus("Select destination");
return;
}
// Try to make a move
bool moveMade = false;
for (int i = 0; i < legalMoveCount; i++) {
if (legalMoves[i].toRow == tRow && legalMoves[i].toCol == tCol) {
applyMove(gs, legalMoves[i]);
moveMade = true;
break;
}
}
pieceSelected = false;
selectedRow = -1;
selectedCol = -1;
legalMoveCount = 0;
if (!moveMade) {
drawBoard(gs);
drawStatus("Invalid move!");
delay(800);
drawStatus("Your turn (White)");
return;
}
// Check game end conditions after player move
drawBoard(gs);
if (isCheckmate(gs, BLACK_PIECE)) {
drawStatus("Checkmate! You win!");
drawGameOver("You Win!");
gameOver = true;
return;
}
if (isStalemate(gs, BLACK_PIECE)) {
drawStatus("Stalemate!");
drawGameOver("Stalemate!");
gameOver = true;
return;
}
if (isInCheck(gs, BLACK_PIECE)) {
drawStatus("Check! AI thinking...");
} else {
drawStatus("AI thinking...");
}
}
} else {
// AI's turn (Black)
delay(100);
Move best = getBestMove(gs);
applyMove(gs, best);
drawBoard(gs);
if (isCheckmate(gs, WHITE_PIECE)) {
drawStatus("Checkmate! AI wins!");
drawGameOver("AI Wins!");
gameOver = true;
return;
}
if (isStalemate(gs, WHITE_PIECE)) {
drawStatus("Stalemate!");
drawGameOver("Stalemate!");
gameOver = true;
return;
}
if (isInCheck(gs, WHITE_PIECE)) {
drawStatus("You're in Check!");
} else {
drawStatus("Your turn (White)");
}
}
}Remix this project
Make it yours in one click
Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.




