How to Build a Touchscreen Chess Game on a Cheap Yellow Display

ESP32-2432S028R, ILI9341 graphics, XPT2046 touch input, and a small local chess opponent

ESP32GamingAdvanced45 minutes1 component

Updated

How to Build a Touchscreen Chess Game on a Cheap Yellow Display
For illustrative purposes only
On this page

What you'll build

This guide takes you through building a playable touchscreen chess game on the ESP32-2432S028R, usually known as the Cheap Yellow Display. The board combines an ESP32, a 240×320 ILI9341 TFT, and an XPT2046 resistive touchscreen, so the hardware is mostly self-contained. You power it over USB, deploy the firmware, and play directly on the screen with the included stylus.

The firmware draws an 8×8 chess board, lets you select pieces by touch, highlights legal moves, and gives Black a small local opponent. It is not trying to be Stockfish. It is a compact embedded chess prototype that shows how much you can do when the display, touch controller, code, wiring notes, and deploy path are generated together.

By the end of the build you will have a working handheld chess prototype running on a single ESP32 display board. It is still a first version: the next obvious upgrades are battery power, a case, and maybe a lichess or chess.com connection. This project came from Sam's Schematik workshop build, which you can watch on YouTube or read as a build diary.

Wiring diagram

Wiring diagram

Interactive wiring diagram

Components needed

ComponentTypeQtyBuy
Built-in 240×320 ILI9341 TFTdisplay1€4.65

Prices and availability are indicative and may have been updated by the supplier. Schematik may earn a commission from purchases made through affiliate links.

Assembly

1

Review the hardware and gather your tools

This project uses a single ESP32 development board with a built-in ILI9341 240x320 TFT display hard-wired onboard. Because all display connections are already made on the PCB, no external wiring is required for the display itself. Before you begin, confirm you have the correct board in hand — it should have a visible TFT screen mounted directly on the board. Gather a USB cable compatible with your board (micro-USB or USB-C depending on the variant) and a computer running Chrome or Edge for Web Serial support via Schematik Deploy. No breadboard, jumper wires, or additional components are needed for this build.

2

Understand the onboard display wiring

The built-in ILI9341 TFT is connected internally to the ESP32 HSPI bus. The fixed pin assignments are as follows: MOSI is on GPIO13, MISO is on GPIO12, SCK (clock) is on GPIO14, CS (chip select) is on GPIO15, DC (data/command select) is on GPIO2, and BL (backlight, PWM-controlled) is on GPIO21. These connections exist entirely on the PCB — there is nothing for you to wire. Understanding these assignments is important because you will need to configure the TFT_eSPI library to match them exactly. Note that GPIO21 is also the default I2C SDA pin on ESP32; if you add I2C peripherals in a future revision, you will need to remap I2C or manage GPIO21 carefully.

3

Power the board via USB

Connect your ESP32 board to your computer using the appropriate USB cable. The onboard voltage regulator converts the USB 5V supply to 3.3V for the ESP32 and the TFT display. The estimated total current draw for this project is approximately 300mA, which is well within USB 2.0 port limits. You do not need an external power supply. Once connected, the board should power on and you may see the display backlight illuminate if default firmware is present. Make sure the USB cable supports data transfer (not a charge-only cable), as you will need the data lines for firmware upload.

4

Configure the TFT_eSPI library for the onboard display

Before deploying firmware, the TFT_eSPI library must be configured to match the onboard pin assignments. In your Arduino or PlatformIO environment, locate the TFT_eSPI library folder and open the User_Setup.h file. Uncomment or set the ILI9341_DRIVER define. Then set the following pin definitions: TFT_MOSI to 13, TFT_MISO to 12, TFT_SCLK to 14, TFT_CS to 15, TFT_DC to 2, and TFT_BL to 21. Set TFT_WIDTH to 240 and TFT_HEIGHT to 320. Enable the HSPI bus by setting USE_HSPI_PORT. If backlight control is desired, define TFT_BACKLIGHT_ON as HIGH. Save the file before proceeding to the Schematik Deploy step.

5

Deploy the firmware using Schematik Deploy

With your board connected via USB and the browser open in Chrome or Edge, navigate to the Schematik Deploy panel. Select your ESP32 board model from the board selector dropdown to ensure the correct flash settings and partition scheme are used. Click the serial port selector and choose the COM port (Windows) or /dev/ttyUSB0 or /dev/cu.usbserial (Mac/Linux) that corresponds to your connected board. If no port appears, check that your USB cable is data-capable and that the board's USB-to-serial driver is installed (CP210x or CH340 depending on your board variant). Once the port is selected, click the Deploy button. Schematik will compile the sketch with the TFT_eSPI library and flash it to the board over Web Serial. Watch the progress bar and output log for any errors.

6

Verify the display and confirm successful operation

After flashing completes, the board will reset automatically. Within a few seconds, the TFT display should illuminate and show the application interface rendered by your sketch. The backlight on GPIO21 should be active. If your sketch includes a splash screen, UI elements, or game board, these should appear on the 240x320 pixel display. To further confirm correct operation, open the Schematik serial monitor (or your IDE's serial monitor) at 115200 baud. Look for initialization messages from the TFT_eSPI library such as 'TFT initialized' or any debug output your sketch produces. If the display remains blank but the backlight is on, re-check the TFT_eSPI pin configuration from the previous step and re-deploy. If the board does not boot at all, check that GPIO12 is not being pulled high externally.

Pin assignments

PinConnectionType
GPIO 13onboard-tft_0 MOSISPI
GPIO 12onboard-tft_0 MISOSPI
GPIO 14onboard-tft_0 SCKSPI
GPIO 15onboard-tft_0 CSSPI
GPIO 2onboard-tft_0 DCDIGITAL
GPIO 21onboard-tft_0 BLPWM

Code

// 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 <TFT_eSPI.h>
#include <XPT2046_Touchscreen.h>

// 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    21
#define TOUCH_CS_PIN  33
#define TOUCH_IRQ_PIN 36
#define TOUCH_MOSI    32
#define TOUCH_MISO    39
#define TOUCH_SCK     25

// ─── 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);

TFT_eSPI tft = TFT_eSPI();
SPIClass touchSPI(VSPI);
XPT2046_Touchscreen touch(TOUCH_CS_PIN, TOUCH_IRQ_PIN);

// ─── 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)";

// Touch calibration values (may need tuning)
#define TOUCH_X_MIN 200
#define TOUCH_X_MAX 3800
#define TOUCH_Y_MIN 300
#define TOUCH_Y_MAX 3700

// ─── 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
  ledcSetup(0, 5000, 8);
  ledcAttachPin(TFT_BL_PIN, 0);
  ledcWrite(0, 200);

  // TFT
  tft.init();
  tft.setRotation(0); // Portrait
  tft.fillScreen(COLOR_BG);

  // Touch SPI
  touchSPI.begin(TOUCH_SCK, TOUCH_MISO, TOUCH_MOSI, TOUCH_CS_PIN);
  touch.begin(touchSPI);
  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)");
    }
  }
}

// Run this and build other cool things at schematik.io
Libraries: TFT_eSPI, XPT2046_Touchscreen