Community project

Thai Checkers AI Game

ESP32
Photo of Thai Checkers AI Game
Generated with AI

Suparp Munngam

Published September 22, 2026

This project brings Thai Checkers to life on an ESP32 with a touchscreen display and AI opponent. The game features a fully playable board with piece movement, capture mechanics, and flying king promotion—all controlled through capacitive touch input on a 3.2-inch ILI9341 display.

Builders will receive a complete wiring diagram connecting the ESP32 to the display and touch controller, a full parts list, Arduino firmware with game logic and AI decision-making, and step-by-step assembly instructions. The guide covers board verification, optional speaker integration for audio feedback, USB power setup, and gameplay testing to get the AI checkers game running.

Wiring diagram

Assemble it in 4 steps

1. ตรวจสอบบอร์ดก่อนเปิดไฟ

ใช้บอร์ด ES3C28P รุ่นที่มีตัวอักษร P และจอสัมผัสติดอยู่แล้ว จอนี้และแผงสัมผัสต่อจากโรงงาน จึงไม่ต้องเสียบสายจอหรือสายปุ่มเพิ่ม

  • หน้าจอควรเป็นจอสี 2.8 นิ้วที่ติดกับแผงวงจรเดียวกัน
  • รุ่น ES3N28P ไม่มีแผงสัมผัส จึงใช้เกมเวอร์ชันแตะหน้าจอนี้ไม่ได้
  • อย่าต่อไฟ 5V เข้าที่ขา 3V3 ของบอร์ด เพราะไฟเกินอาจทำให้บอร์ดเสียหาย

2. เสียบลำโพงเมื่อพร้อมเพิ่มเสียง

ลำโพงที่มากับชุดเป็นอุปกรณ์เสริมสำหรับบอร์ดนี้ หากต้องการต่อ ให้เสียบหัวปลั๊กลำโพงเข้าช่องเสียบลำโพงเล็ก ๆ บนบอร์ดอย่างนุ่มนวลโดยไม่ฝืนขั้ว

  • เกมเวอร์ชันนี้เล่นได้แม้ยังไม่เสียบลำโพง เพราะใช้การแตะหน้าจอและแสดงผลบนจอเป็นหลัก
  • อย่าเสียบหัวลำโพงเข้าช่องแบตเตอรี่หรือช่อง USB เพราะอาจทำให้วงจรเสียหาย

3. จ่ายไฟด้วย USB

เสียบสาย USB-C เข้าบอร์ด แล้วเสียบปลายอีกด้านเข้าคอมพิวเตอร์ สายเส้นนี้จ่ายไฟให้บอร์ดและใช้ส่งโปรแกรมเกมลงบอร์ด

  • วางบอร์ดบนโต๊ะที่แห้งและไม่นำไฟฟ้าขณะใช้งาน
  • อย่าวางบอร์ดบนโลหะหรือบนของเปียกขณะเสียบ USB เพราะอาจลัดวงจรได้

4. ทดสอบการเล่นบนหน้าจอ

หลังแฟลชโปรแกรมแล้ว แตะ EASY, MEDIUM หรือ HARD เพื่อเลือกระดับ จากนั้นแตะ START GAME ในเกม ตัวหมากสีแดงเป็นของคุณ: แตะตัวหมากสีแดง แล้วแตะช่องที่ต้องการเดินไป

  • เมื่อมีการกินหมากที่ทำได้ เกมจะให้กินก่อนตามกติกา
  • หมากที่ไปถึงแถวบนสุดจะมีวงสีเหลือง แปลว่าเดินไกลตามเส้นทแยงได้
  • อย่ากดหรือเคาะจอแรง เพราะแรงกระแทกอาจทำให้จอสัมผัสเสียหาย

Deploy the firmware

#include <Arduino.h>
#include <SPI.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>

// ES3C28P built-in hardware: ILI9341V display and FT6336G capacitive touch.

// Hoisted type definitions
enum Page { HOME, GAME, RESULT };

struct Move { int fx, fy, tx, ty, cx, cy; bool capture; };


// Forward declarations
bool dark(int x, int y);
bool inside(int x, int y);
bool own(int p, int side);
bool foe(int p, int side);
bool touch(int &x, int &y);
int movesFor(int side, Move out[], int maxN, bool capturesOnly, int onlyX, int onlyY);
bool mustCapture(int side, int x, int y);
void apply(const Move &m);
void title(const char *line);
void drawPiece(int x, int y, int p);
void drawGame();
void drawHome();
void drawResult();
void checkEnd();
void aiMove();
void newGame();
void playTap(int px, int py);

constexpr int TFT_CS = 10;
constexpr int TFT_DC = 46;
constexpr int TFT_BL = 45;
constexpr int TFT_SCK = 12;
constexpr int TFT_MOSI = 11;
constexpr int TFT_MISO = 13;
constexpr int TOUCH_SDA = 16;
constexpr int TOUCH_SCL = 15;
constexpr uint8_t TOUCH_ADDR = 0x38;
constexpr int W = 320, H = 240;
constexpr int CELL = 25, BOARD_X = 60, BOARD_Y = 31;




Adafruit_ILI9341 tft(&SPI, TFT_CS, TFT_DC, -1);
int8_t pieces[8][8]; // 1/-1 men, 2/-2 flying kings; player is positive.
Page page = HOME;
int difficulty = 1;
int selectedX = -1, selectedY = -1;
bool playerTurn = true;
String outcome;

bool dark(int x, int y) { return ((x + y) & 1) != 0; }
bool inside(int x, int y) { return x >= 0 && x < 8 && y >= 0 && y < 8; }
bool own(int p, int side) { return side > 0 ? p > 0 : p < 0; }
bool foe(int p, int side) { return side > 0 ? p < 0 : p > 0; }

bool touch(int &x, int &y) {
  Wire.beginTransmission(TOUCH_ADDR); Wire.write(0x02);
  if (Wire.endTransmission(false) != 0 || Wire.requestFrom((int)TOUCH_ADDR, 5) != 5) return false;
  uint8_t count = Wire.read() & 0x0F;
  uint8_t xh = Wire.read(), xl = Wire.read(), yh = Wire.read(), yl = Wire.read();
  if (!count) return false;
  int rawX = ((xh & 0x0F) << 8) | xl;
  int rawY = ((yh & 0x0F) << 8) | yl;
  // The panel's portrait coordinates are converted to the landscape display.
  x = constrain(rawY, 0, W - 1);
  y = constrain(239 - rawX, 0, H - 1);
  return true;
}

int movesFor(int side, Move out[], int maxN, bool capturesOnly, int onlyX = -1, int onlyY = -1) {
  int n = 0;
  const int dirs[4][2] = {{-1,-1},{1,-1},{-1,1},{1,1}};
  for (int y = 0; y < 8; ++y) for (int x = 0; x < 8; ++x) {
    if (!own(pieces[y][x], side) || (onlyX >= 0 && (x != onlyX || y != onlyY))) continue;
    bool king = abs(pieces[y][x]) == 2;
    for (const auto &d : dirs) {
      int dx = d[0], dy = d[1];
      if (!king) {
        int nx = x + dx, ny = y + dy;
        if (!capturesOnly && dy == -side && inside(nx, ny) && pieces[ny][nx] == 0 && n < maxN)
          out[n++] = {x,y,nx,ny,-1,-1,false};
        int lx = x + 2 * dx, ly = y + 2 * dy;
        if (inside(lx, ly) && inside(nx, ny) && foe(pieces[ny][nx], side) && pieces[ly][lx] == 0 && n < maxN)
          out[n++] = {x,y,lx,ly,nx,ny,true};
      } else {
        int nx = x + dx, ny = y + dy, ex = -1, ey = -1;
        bool crossedEnemy = false;
        while (inside(nx, ny)) {
          int q = pieces[ny][nx];
          if (q == 0) {
            if (!crossedEnemy && !capturesOnly && n < maxN) out[n++] = {x,y,nx,ny,-1,-1,false};
            if (crossedEnemy && n < maxN) out[n++] = {x,y,nx,ny,ex,ey,true};
          } else if (own(q, side) || crossedEnemy) break;
          else { crossedEnemy = true; ex = nx; ey = ny; }
          nx += dx; ny += dy;
        }
      }
    }
  }
  return n;
}

bool mustCapture(int side, int x = -1, int y = -1) { Move list[80]; return movesFor(side, list, 80, true, x, y) > 0; }
void apply(const Move &m) {
  int8_t p = pieces[m.fy][m.fx]; pieces[m.fy][m.fx] = 0; pieces[m.ty][m.tx] = p;
  if (m.capture) pieces[m.cy][m.cx] = 0;
  if (p == 1 && m.ty == 0) pieces[m.ty][m.tx] = 2;
  if (p == -1 && m.ty == 7) pieces[m.ty][m.tx] = -2;
}

void title(const char *line) {
  tft.fillRect(0, 0, W, 27, ILI9341_NAVY);
  tft.setTextColor(ILI9341_WHITE); tft.setTextSize(2); tft.setCursor(7, 6); tft.print(line);
}
void drawPiece(int x, int y, int p) {
  int px = BOARD_X + x * CELL + CELL / 2, py = BOARD_Y + y * CELL + CELL / 2;
  uint16_t c = p > 0 ? ILI9341_RED : ILI9341_BLACK;
  tft.fillCircle(px, py, 9, c); tft.drawCircle(px, py, 9, ILI9341_WHITE);
  if (abs(p) == 2) { tft.drawCircle(px, py, 4, ILI9341_YELLOW); tft.fillCircle(px, py, 2, ILI9341_YELLOW); }
}
void drawGame() {
  tft.fillScreen(ILI9341_DARKGREY); title("THAI CHECKERS");
  tft.setTextSize(1); tft.setTextColor(ILI9341_WHITE); tft.setCursor(7, 35);
  tft.print(playerTurn ? "YOUR TURN" : "AI TURN");
  tft.setCursor(266, 35); tft.print(difficulty == 0 ? "EASY" : difficulty == 1 ? "MID" : "HARD");
  for (int y = 0; y < 8; ++y) for (int x = 0; x < 8; ++x) {
    int px = BOARD_X + x * CELL, py = BOARD_Y + y * CELL;
    tft.fillRect(px, py, CELL, CELL, dark(x,y) ? ILI9341_DARKGREEN : ILI9341_LIGHTGREY);
    if (x == selectedX && y == selectedY) tft.drawRect(px + 2, py + 2, CELL - 4, CELL - 4, ILI9341_CYAN);
    if (pieces[y][x]) drawPiece(x, y, pieces[y][x]);
  }
  tft.setTextColor(ILI9341_WHITE); tft.setCursor(8, 221); tft.print("Tap a red piece, then tap its destination");
}
void drawHome() {
  tft.fillScreen(ILI9341_BLACK); title("THAI CHECKERS");
  tft.setTextColor(ILI9341_YELLOW); tft.setTextSize(3); tft.setCursor(55, 48); tft.print("TRAINING");
  tft.setTextColor(ILI9341_WHITE); tft.setTextSize(1); tft.setCursor(76, 83); tft.print("Play a tournament match vs AI");
  const char *names[] = {"EASY", "MEDIUM", "HARD"};
  for (int i = 0; i < 3; ++i) {
    uint16_t c = i == difficulty ? ILI9341_GREEN : ILI9341_DARKGREY;
    tft.fillRoundRect(18 + i * 102, 108, 80, 35, 6, c);
    tft.setTextColor(ILI9341_WHITE); tft.setCursor(29 + i * 102, 121); tft.print(names[i]);
  }
  tft.fillRoundRect(74, 168, 172, 40, 8, ILI9341_BLUE);
  tft.setTextColor(ILI9341_WHITE); tft.setTextSize(2); tft.setCursor(101, 181); tft.print("START GAME");
  tft.setTextSize(1); tft.setCursor(35, 222); tft.print("Red is yours. Captures are compulsory.");
}
void drawResult() {
  tft.fillScreen(outcome == "YOU WIN!" ? ILI9341_DARKGREEN : ILI9341_MAROON); title("MATCH RESULT");
  tft.setTextColor(ILI9341_WHITE); tft.setTextSize(3); tft.setCursor(65, 75); tft.print(outcome);
  tft.setTextSize(2); tft.setCursor(55, 120); tft.print(outcome == "YOU WIN!" ? "CHAMPION!" : "KEEP TRAINING!");
  tft.fillRoundRect(81, 171, 158, 36, 8, ILI9341_BLUE); tft.setTextSize(1); tft.setCursor(103, 184); tft.print("BACK TO MENU");
}
void checkEnd() {
  Move list[80];
  if (!movesFor(1, list, 80, false)) { outcome = "AI WINS"; page = RESULT; drawResult(); }
  else if (!movesFor(-1, list, 80, false)) { outcome = "YOU WIN!"; page = RESULT; drawResult(); }
}
void aiMove() {
  Move list[80]; int n = movesFor(-1, list, 80, true); if (!n) n = movesFor(-1, list, 80, false);
  if (!n) { outcome = "YOU WIN!"; page = RESULT; drawResult(); return; }
  int pick = random(n);
  if (difficulty > 0) {
    int best = -9999;
    for (int i = 0; i < n; ++i) {
      int score = list[i].capture ? 100 : 0;
      if (pieces[list[i].fy][list[i].fx] == -1 && list[i].ty == 7) score += 40;
      score += random(difficulty == 2 ? 2 : 12);
      if (score > best) { best = score; pick = i; }
    }
  }
  apply(list[pick]); int ax = list[pick].tx, ay = list[pick].ty;
  while (list[pick].capture && mustCapture(-1, ax, ay)) {
    int k = movesFor(-1, list, 80, true, ax, ay); if (!k) break;
    pick = random(k); apply(list[pick]); ax = list[pick].tx; ay = list[pick].ty;
  }
  playerTurn = true; drawGame(); checkEnd();
}
void newGame() {
  memset(pieces, 0, sizeof(pieces));
  for (int y = 0; y < 2; ++y) for (int x = 0; x < 8; ++x) if (dark(x,y)) pieces[y][x] = -1;
  for (int y = 6; y < 8; ++y) for (int x = 0; x < 8; ++x) if (dark(x,y)) pieces[y][x] = 1;
  selectedX = selectedY = -1; playerTurn = true; page = GAME; drawGame();
}
void playTap(int px, int py) {
  if (!playerTurn || px < BOARD_X || px >= BOARD_X + 8 * CELL || py < BOARD_Y || py >= BOARD_Y + 8 * CELL) return;
  int x = (px - BOARD_X) / CELL, y = (py - BOARD_Y) / CELL;
  if (selectedX < 0) {
    if (!own(pieces[y][x], 1) || (mustCapture(1) && !mustCapture(1, x, y))) return;
    selectedX = x; selectedY = y; drawGame(); return;
  }
  Move list[80]; int n = movesFor(1, list, 80, mustCapture(1, selectedX, selectedY), selectedX, selectedY);
  for (int i = 0; i < n; ++i) if (list[i].tx == x && list[i].ty == y) {
    bool captured = list[i].capture; apply(list[i]); selectedX = selectedY = -1;
    if (captured && mustCapture(1, x, y)) { selectedX = x; selectedY = y; drawGame(); return; }
    playerTurn = false; drawGame(); delay(250); aiMove(); return;
  }
  selectedX = selectedY = -1; drawGame();
}
void setup() {
  pinMode(TFT_BL, OUTPUT); digitalWrite(TFT_BL, HIGH);
  SPI.begin(TFT_SCK, TFT_MISO, TFT_MOSI, TFT_CS); Wire.begin(TOUCH_SDA, TOUCH_SCL);
  tft.begin(); tft.setRotation(1); randomSeed(esp_random()); drawHome();
}
void loop() {
  static bool held = false; int x, y; bool down = touch(x, y);
  if (down && !held) {
    if (page == HOME) { if (y >= 108 && y < 143) { difficulty = constrain(x / 102, 0, 2); drawHome(); } else if (y >= 168 && y < 211) newGame(); }
    else if (page == GAME) playTap(x, y);
    else if (page == RESULT && y >= 165) { page = HOME; drawHome(); }
  }
  held = down;
  delay(18);
}

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.

Open in Schematik