Tired of moving all the pieces yourself or just looking for a way to take your chess game to the next level? What if I told you your chessboard could move its own pieces, making every match feel like magic? ♟️
With this automated chessboard, powered by magnets, electromagnets, and a computer brain, the game comes alive — literally! Forget boring, static boards. This project takes the classic chess experience and gives it a modern twist. Each piece moves on its own, letting you focus on your next move without ever lifting a finger.
Whether you’re a chess enthusiast, tech lover, or just a DIY fan, this automated chessboard blends creativity and technology into a fun, game-changing project.💡
- Overall dimensions: 600mm x 470mm x 115mm
- Chess Field size: 340mm x 340mm
- Chess pieces size: base diameter 21mm
- Powering: Stepper Motors: 12V / 0,7A, Electromagnet: 9V / 0,5A
To have a better understanding of the setup of this project, we will break it down into 6 steps:
Step 1: Wooden HousingWe ordered wooden planks with specific dimensions for my project, including:
2x pieces measuring: 115mm x 470mm x 9mm
2x pieces measuring: 115mm x 600mm x 9mm
1x piece measuring: 470mm x 600mm x 9mm
Additionally, we designed 3D-printed joints and legs to complete the assembly, as shown in the pictures below.
We also added holes to the wooden planks according to my specific needs. To enhance the overall appearance, we 3D-designed custom caps that fit over the holes, giving the final structure a cleaner and more polished looker
In this step, we 3D-printed a Y-axis mechanism designed to move along linear rods using a stepper motor. This setup ensures smooth and accurate motion along the Y direction.
Additionally, the Y-axis itself houses a second stepper motor, which powers the X-axis movement. The X-axis is constructed with two linear rods and a belt system, allowing a trolley to move seamlessly along the axis. For a cleaner look, we designed a cap to cover the stepper motor on the wood. (pictures below).
Our trolley is 3D-printed and operates using a second stepper motor, which drives its motion along the X-axis via timing belts and linear rods. An electromagnet is securely mounted on top of the trolley as seen in the pictures below.
The chess field was 3D-printed and designed with clearly defined squares on a thin acrylic glass plate. Each captured piece is automatically transported to a designated parking space located at the side of the board (in black). This station serves as a neat and organized area where eliminated pieces are collected and displayed.
Additionally, we incorporated LEDs on the sheet of acrylic glass under the 3D printed squares to light the squares that are called to move and the destination squares.
A small magnet is embedded in the center of each of the 3D-printed chess pieces to make it possible to be moved with the electromagnet that is underneath the field.
The design of the chess set can be found in attribution.
To improve movement accuracy and define the limits of the chessboard, we added Hall effect switches along both the X and Y axes. We also placed small magnets on the moving parts of each axis so they could be detected by the sensors (see pictures below).
When the magnets align with the Hall switches, the system recognizes the edge positions, allowing for precise calibration and stops the motorized parts from moving beyond their intended range.
The automated chessboard software operates on a distributed architecture, with the Raspberry Pi and the XMC4700 microcontroller working together:
- Raspberry Pi: Handles game logic, tracks moves using the Python Chess Library, runs the Stockfish AI for computer opponents, and sends commands to the XMC via I²C.
- XMC4700: Controls motors for moving pieces, LEDs for lighting squares, and the electromagnet. It also uses sensors to ensure precise movement.
The Raspberry Pi calculates moves, while the XMC4700 executes them, ensuring smooth coordination. Let’s move on to the setup!
Environment SetupSetting up Python Chess Library:
We will make use of the ready Python chess library, which offers all the functionalities of a chess game with minimal development effort on our part. This chess game library will run on a Raspberry Pi as a virtual chess board to mirror the actions happening on the real-life board. We will use it to keep track of all the chess board functionalities on our behalf, for example, whose turn it is right now, if the moves are legal or not, etc.
In order to run this library on Raspberry Pi we first need to create a virtual environment as the pip installer is not directly supported by Python on Raspbian.
To create a virtual environment (for Python3) we need to follow the commands below:
- Install virtual environment module venv:
- Create virtual environment: python3 -m venv [environment name]
- Activate the virtual environment: source [environment name]/bin/activate
You will only need to create the venv once, but will be required to activate it for every use.
You can read more on virtual environments here.
Once the virtual environment is activated, we can begin setting up the chess library using the following command:
pip install chess
You can read more on Python chess library and its features here.
Setting up Stockfish:
Stockfish is a chess engine that will be used to simulate Player 2, a computer. To set up Stockfish on Raspberry Pi use the following commands in the terminal
- git clone https://github.com/official-stockfish/Stockfish.git
- cd Stockfish/src
- make -j profile-build
This will compile the Stockfish library directly from the source code. Now we have the stockfish engine available on our Raspberry Pi. Navigate to the src file within the Stockfish folder and locate the Stockfish application file. Copy this file into your virtual environment, inside the bin folder so it is visible to the virtual environment python machine. We will use this path later in our code.
The next step is to make use of this engine within the Python code. In order to do so, we use the Python library for Stockfish.
pip install stockfish
You can read more on Stockfish and its supported functions here.
Now that the building blocks of our system are set up, let's dive into the game implementation!
Game FlowBefore diving deep into the code, let's have an overview of how the game works. The game starts with the user input of the move they want to play through a terminal command. The move needs to follow the algebraic notation format. which means that if I want to move the white team's first pawn on the left one step forward, my input should be: a2a3
where a2
is the source square and a3
is the destination square.
After getting the player's input, the game will evaluate if this move is valid or not before continuing. In case the move is valid, the game checks if this move will result in a capture of one of the other team's pieces. If yes, the game follows a capture logic.
The capture logic handles the captured piece first. It moves the motor to the location of the captured piece, identifies what type of piece it is, and therefore where it should go in the docking station for its team. Next, the motor moves the piece to this docking location. Finally, it goes back to the source square of the player's move to execute it.
Alternatively, the move may not result in a capture. In this case, the motor moves from its last known location to the source square of the player's move and drags the piece to its destination square.
Finally, we need to execute the moves implemented on the board onto the virtual board by the Chess Python Library, so we are still able to keep track of the board and its functionalities after each move.
After each move, the board checks if it's in checkmate. If yes, the game stops, and the winning team is printed. Otherwise, the game continues with the readingof the next player's input.
RaspberryPi → XMC4700
For this communication, we use the I2C protocol. On the RaspberryPi side, it needs to have I2C activated, which can be easily done by following the instructions here.
The Raspberry Pi will calculate the movements the motor should make in terms of x & y axes and transmit them to the XMC4700 via Python code, using the SMBus library. Once this transmission is successful, the XMC4700 will send a flag signal, indicating the motors are now in motion to implement this move, and therefore the RPi should not send any new moves until the flag is cleared.
CodeNow let's move on to the code! We will start with communication code running on RaspberryPi and XMC4700.
Communication
As explained above, the Python code running on RaspberryPi uses the SMBus library to implement I2C communication. You will need to install the SMBus library into your virtual Python environment as we did above with the chess library.
pip install smbus
After SMBus is ready to use on the RaspberyyPi, let's begin writing our I2C script. We start by importing the libraries we will need.
from smbus import SMBus
import time
import struct
Next, we define the slave address for the XMC4700 with respect to the RaspberryPi and we open the I2C channel on the SMBus
addr = 0x08
bus = SMBus(1)
Now, we define the functions we will call in our game logic. Firstly, we define the function to send the x and y square values to the XMC4700. We will use the write_byte()
function predefined in the SMBus library to send a byte for each axis.
def send_i2c(x,y):
if(x==0 and y==0):
print("No movement required, skipping I2C send.")
return
bus.write_byte(addr,x)
print(f"Sending data: {x}") #Debug Message
bus.write_byte(addr,y)
print(f"Sending data: {y}") #Debug Message
Finally, we define the function we will call to check the status of the motor motion to see if the XMC4700 is ready to receive the new squares. We will use the read_byte
function predefined in the SMBus library to receive a byte for the motor motion flag.
def receive_i2c():
data = bus.read_byte(addr)
print(f"Received data: {data}")
return data #Return the received byte directly
Arduino partNow we move on to the code on the XMC4700 side, written in Arduino programming language. At the beginning of the program, we include all required libraries: Wire.h for I2C communication, IFX9201_XMC1300_StepperMotor.h to control the motors, and a custom header LEDs.hpp for the LED matrix system that lights up squares on the chessboard:
#include <IFX9201_XMC1300_StepperMotor.h>
#include <Wire.h>
#include "LEDs.hpp"
We define the slave I2C address used to communicate with the Raspberry Pi
#define SLAVE_ADDRESS 0x08
We then define the pins used for the Hall effect switches and motor driver
#define Hall_switch_pin_X_left 0
#define Hall_switch_pin_Y_bottom 1
#define Hall_switch_pin_X_right 2
#define Hall_switch_pin_Y_top 3
#define DIR_PIN_1 6
#define STP_PIN_1 12
#define DIS_PIN_1 5
#define DIR_PIN_2 IFX9201_STEPPERMOTOR_STD_DIR
#define STP_PIN_2 IFX9201_STEPPERMOTOR_STD_STP
#define DIS_PIN_2 IFX9201_STEPPERMOTOR_STD_DIS
#define ELECTROMAGNET_PIN 8
#define POS_0_X_define 100
#define POS_0_Y_define 100
Next, we set basic parameters like how many steps make up a full motor revolution, and how many steps are needed to move one chess square (based on the physical board layout):
const int stepsPerRevolution = 200;
const int stepsPerSquare = 225;
We then create motor objects and initialize key flags and buffers for I2C data and motor control
Stepper_motor motor1(stepsPerRevolution, DIR_PIN_1, STP_PIN_1, DIS_PIN_1);
Stepper_motor motor2(stepsPerRevolution, DIR_PIN_2, STP_PIN_2, DIS_PIN_2);
bool newDataAvailable = false;
bool motor_moving = false;
bool magnet_active = true;
int x = 0, y = 0;
char receivedP1[3] = {0, 0, 0};
char receivedP2[3] = {0, 0, 0};
The setup()
function initializes I2C communication, assigns callback functions, configures the motors and electromagnet, sets the Hall sensor pins to input mode, and finally calls init_pos()
to home the motors using Hall sensors:
void setup() {
Wire.begin(SLAVE_ADDRESS);
Wire.onRequest(sendData);
Wire.onReceive(receiveData);
motor1.begin();
motor2.begin();
motor1.setSpeed(60);
motor2.setSpeed(60);
pixels.begin();
pixels.clear();
pixels.show();
pinMode(ELECTROMAGNET_PIN, OUTPUT);
digitalWrite(ELECTROMAGNET_PIN, LOW);
Serial.begin(9600);
delay(6000);
pinMode(LED_BUILTIN, OUTPUT);
pinMode(Hall_switch_pin_X_left, INPUT_PULLUP);
pinMode(Hall_switch_pin_Y_bottom, INPUT_PULLUP);
init_pos(); // Home to starting position
}
The homing logic is inside init_pos()
. Each axis moves until its corresponding Hall effect switch is triggered by a magnet fixed to the motor platform. Once the switches are triggered, the motors move forward by a fixed offset to align with the a1 square:
void init_pos()
{
do
{
curValue = digitalRead(Hall_switch_pin_X_left);
if(curValue != value)
{
value = curValue;
if(value == LOW)
{
digitalWrite(LED_BUILTIN, HIGH);
}
else if(value == HIGH)
{
digitalWrite(LED_BUILTIN, LOW);
}
}
motor1.step(-1);
}
while(value == HIGH);
do
{
curValue = digitalRead(Hall_switch_pin_Y_bottom);
if(curValue != value)
{
value = curValue;
if(value == LOW)
{
digitalWrite(LED_BUILTIN, HIGH);
}
else if(value == HIGH)
{
digitalWrite(LED_BUILTIN, LOW);
}
}
motor2.step(-1);
}
while(value == HIGH);
// Check if the Hall switch is triggered
//motor1.step(-1); // Move motor1 backward until the switch is triggered
motor1.step(POS_0_X_define);
motor2.step(POS_0_Y_define+300);
Serial.println("Motors initialized to zero position.");
}
Once setup is complete, the loop()
function listens for new I2C data and calls processMove()
if a new command has arrived. It also includes logic to highlight source and destination squares using the LED matrix:
void loop() {
if (newDataAvailable) {
...
move_pending = true;
processMove();
motor_moving = false;
}
}
The function processMove()
determines the movement type based on the received x and y values and executes it accordingly. It supports straight-line motion, diagonal motion, and special handling for knight moves. Before each move, the electromagnet is activated, and it is turned off afterward.
void processMove() {
// Only process if move_pending is true
if (!move_pending) return;
//motor_moving = true;
move_pending = false;
Serial.print("Received X = ");
Serial.print(x);
Serial.print(" , Received y = ");
Serial.println(y);
Serial.print("Motor moving: ");
Serial.println(motor_moving);
// Diagonal movement
if(abs(x)==abs(y)){
if(magnet_active==true)
{
activateElectromagnet();
}
motor_moving = true;
Serial.println("Moving diagonally");
moveDiagonal(x*stepsPerSquare, y*stepsPerSquare);
motor_moving = false;
deactivateElectromagnet();
}
else if(x == 0 && y != 0){ //Vertical Movement
if(magnet_active==true)
{
activateElectromagnet();
}
motor_moving = true;
moveMotor(motor2,y*stepsPerSquare);
motor_moving = false;
deactivateElectromagnet();
}
else if(x != 0 && y == 0){ //horizontal Movement
if(magnet_active==true)
{
activateElectromagnet();
}
motor_moving = true;
moveMotor(motor1,x*stepsPerSquare);
motor_moving = false;
deactivateElectromagnet();
}
// Knight's L-shaped movement
else if ((abs(x) == 2 && abs(y) == 1) || (abs(x) == 1 && abs(y) == 2)) {
if(magnet_active==true)
{
activateElectromagnet();
}
motor_moving = true;
Serial.println("Performing knight move");
moveKnight(x,y);
motor_moving = false;
deactivateElectromagnet();
}
else {
// Für alle anderen Kombinationen: erst X, dann Y bewegen
Serial.println("Ungültige Kombination, führe X- und dann Y-Bewegung aus.");
//activateElectromagnet();
motor_moving = true;
if (x != 0) moveMotor(motor1, x*stepsPerSquare);
if (y != 0) moveMotor(motor2, y*stepsPerSquare);
motor_moving = false;
}
//activateElectromagnet(); // Turn ON the electromagnet
}
Each motion function checks if it’s safe to move using isSafeToMove()
, which reads all four Hall effect switches. If a boundary is reached, the motor is backed up slightly and the move is canceled
bool isSafeToMove() {
int xLeft = digitalRead(Hall_switch_pin_X_left);
int xRight = digitalRead(Hall_switch_pin_X_right);
int yBottom = digitalRead(Hall_switch_pin_Y_bottom);
int yTop = digitalRead(Hall_switch_pin_Y_top);
if (xLeft == LOW || xRight == LOW || yBottom == LOW || yTop == LOW) return false;
return true;
}
The electromagnet is controlled by simple digital writes:
void activateElectromagnet() {
digitalWrite(ELECTROMAGNET_PIN, HIGH);
}
void deactivateElectromagnet() {
digitalWrite(ELECTROMAGNET_PIN, LOW);
}
For the knight movement, the function moves in two segments: a half-step followed by a full step in the other direction:
void moveKnight(int x, int y) {
int dirX = (x > 0) ? 1 : -1;
int dirY = (y > 0) ? 1 : -1;
if (abs(x) == 1 && abs(y) == 2) {
motor1.step(dirX * stepsPerSquare / 2);
motor2.step(dirY * stepsPerSquare * 1.5);
motor1.step(dirX * stepsPerSquare / 2);
motor2.step(dirY * stepsPerSquare / 2);
} else if (abs(x) == 2 && abs(y) == 1) {
motor2.step(dirY * stepsPerSquare / 2);
motor1.step(dirX * stepsPerSquare * 1.5);
motor2.step(dirY * stepsPerSquare / 2);
motor1.step(dirX * stepsPerSquare / 2);
}
}
Communication over I2C is handled through receiveData()
and sendData()
. The receiveData()
function stores x, y, and square name data from the master (Raspberry Pi):
void receiveData(int bytecount) {
if (Wire.available() >= 7) {
Wire.read(); // Ignore command
x = (int8_t)Wire.read();
y = (int8_t)Wire.read();
receivedP1[0] = (char)Wire.read();
receivedP1[1] = (char)Wire.read();
receivedP1[2] = '\0';
receivedP2[0] = (char)Wire.read();
receivedP2[1] = (char)Wire.read();
receivedP2[2] = '\0';
motor_moving = true;
newDataAvailable = true;
magnet_active = !magnet_active;
digitalWrite(22, HIGH);
}
}
When the master requests a response, the sendData()
function simply transmits the motor_moving
status.
void sendData() {
Wire.write(motor_moving);
}
The LED logic for lighting the squares under the acrylic glass is implemented separately. This module determines the brightness and color of LEDs based on the current move, providing intuitive visual feedback for both players.
LED controlTo enhance the interactivity and visual feedback of the smart chessboard, an LED system is embedded underneath the acrylic glass layer. This system uses a strip of 390 individually addressable RGB LEDs, controlled via the Adafruit_NeoPixel
library. The LED strip is initialized on pin 7, with the correct pixel format for GRB encoding and 800kHz signaling:
#include <Adafruit_NeoPixel.h>
#define PIN 7
#define NUMPIXELS 390
Adafruit_NeoPixel pixels(NUMPIXELS, PIN, NEO_GRB + NEO_KHZ800);
Each square on the 8x8 chessboard corresponds to a group of 4 LEDs, meaning a total of 64 squares × 4 LEDs = 256 LEDs are actively used (with extra reserved LEDs available for expansion or testing). The physical mapping of LED indices to each square is stored in a 2D array squareLEDs[NUMSQUARES][4]
, where each row represents a square (from a1 to h8) and each column contains the index of one LED that belongs to that square:
int squareLEDs[NUMSQUARES][4] = {
{310, 311, 351, 350}, // a1
{266, 267, 306, 307}, // a2
...
{20, 21, 25, 24} // h8
};
To make referencing squares by name easier, a matching list of strings squareNames[]
is created, indexing all square identifiers from "a1" to "h8". This allows simple mapping between chess notation and LED positioning.
const char* squareNames[NUMSQUARES] = {
"a1", "a2", "a3", "a4", ..., "h8"
};
To further enhance the visual realism of the board, the LED lighting is adjusted based on whether the square is light or dark. A separate array dimSquares[]
contains the names of all dark-colored squares. This allows the lighting effect to be dimmer on dark squares and brighter on light squares, imitating the look of a traditional chessboard even when lit. The check is performed by a utility function isDimSquare()
:
const char* dimSquares[] = {"a2", "a4", "a6", "a8", "b1", ..., "h7"};
const int numDimSquares = sizeof(dimSquares) / sizeof(dimSquares[0]);
bool isDimSquare(const char* square) {
for (int i = 0; i < numDimSquares; i++) {
if (strcasecmp(square, dimSquares[i]) == 0) {
return true;
}
}
return false;
}
When a move is received by the microcontroller, the LED module is called to illuminate the source square (e.g., "a2") and destination square (e.g., "a4") by parsing their names, locating their index in the squareNames array, and then lighting up the corresponding 4 LEDs from squareLEDs. The brightness of the green color used depends on whether the square is found in the dim square list or not:
bool dim = isDimSquare(squareNames[squareIndex]);
if (dim) {
pixels.setPixelColor(..., pixels.Color(0, 20, 0)); // Dim green
} else {
pixels.setPixelColor(..., pixels.Color(0, 225, 0)); // Bright green
}
After updating the colors for all LEDs corresponding to the square, pixels.show()
is called to update the physical LEDs and reflect the new state visually on the board.
Initialization
First things first, we import the libraries we need into our main script and initialize the virtual chess board and stockfish module. To initialize the stockfish module we need to specify the path where it is downloaded. We also initialize the Python chess board as a variable board. We will also import all functions implemented in the Python script i2c_master which we created and will explain
import chess
from stockfish import Stockfish
from i2c_master import *
#Initialization
board = chess.Board()
stock = Stockfish(path="/bin/stockfish",depth=18, parameters={"Threads": 2, "Minimum Thinking Time": 30})
After initialization, you can set the depth and skill level of the Stockfish AI. The skill level ranges from 1-20.
stock.set_depth(20)
stock.set_skill_level(1)
Next, we move on to initializing the global variables we will use in our functions that define the motor movements. We must initialize the motor_location
variable correctly with the square at which the motor starts the game, as this will affect all the future calculations in the game.
#Global Variables
motor_location = "a1" #bottom right corner
to_src_move = "a1" #Updated with algebraic move from motor to source square
to_dest_move = "a1" #Updated with algebraic move from motor to destination square
We also need specific variables for the docking station calculations. We need to define the distance between the original location of the piece at the start of the game and its location in the docking station. It is recommended to make this a variable in the code instead of hardcoding it, so you can have the flexibility while working on your hardware design. For the pieces to be moved to their correct locations, we need to have counters and flags for the filled locations in the docking station of each team so far. Therefore, we define counters for the pawn to know how many we have returned to docking so far, and flags for the 2 time piece types: rook, knight, and bishop.
#Docking Station Location Variables
sq_to_docking = 3 #Go to original location of piece, then move back by x squares
in_capture = False #True: Capture logic, False: Normal move logic
pawn_rl_counter_w = "a" #Pawn right-left counter-White
pawn_rl_counter_b = "a" #Pawn right-left counter-Black
rook_left_empty_w = True #T: Left position to be filled, F: Right position to be filled-White
knight_left_empty_w = True #T: Left position to be filled, F: Right position to be filled-White
bishop_left_empty_w = True #T: Left position to be filled, F: Right position to be filled-White
rook_left_empty_b = True #T Left position to be filled, F: Right position to be filled-Black
knight_left_empty_b = True #T: Left position to be filled, F: Right position to be filled-Black
bishop_left_empty_b = True #T: Left position to be filled, F: Right position to be filled-Black
Functions
Since the game is made up of various calculation tasks and updates for the motor to execute, dividing tasks into functions is the easiest way to keep track. Let's look into the functions we defined.
motor_to_src
: This function reads an algebraic move as input when it is called, and creates a new move by combining the motor's last known location to the source square of this input move. To get the source square, we read the first 2 characters of the string of the input move. The function then updates the motor location to be the source square. By doing this, we can calculate the square movements required to move the motor from its last known location (from previous plays or moving a captured piece to docking) to the source square on the board itself.
def motor_to_src(move):
global to_src_move, motor_location
to_src_move = motor_location + (move[0]+ move[1])
print(f"[Motor] Motor last location: {motor_location}") #Debug message
motor_location = (move[0]+ move[1])
print(f"[Motor] Moving to source: {to_src_move}") #Debug message
src_to_dest
: This function reads an algebraic move as input when it is called, and creates a new move by combining the motor's last known location to the destination square of this input move. To get the destination square, we read the last 2 characters of the string of the input move. The function then updates the motor location to the destination square. By doing this, we can calculate the square movements required to move the motor from its last known location (source of the input move) to the destination square on the board itself.
def src_to_dest(move):
global to_dest_move, motor_location
to_dest_move = motor_location + (move[2]+move[3])
motor_location = (move[2]+move[3])
print(f"[Motor] Moving source to destination: {to_dest_move}") #Debug message
calculate_squares
:
This function takes in the algebraic move and calculates the distance in squares for the x and y axes.
def calculate_squares(move):
x = ord(move[2]) - ord(move[0])
y = ord(move[3]) - ord(move[1])
return x , y
execute_move
:
This function prepares the data to be sent to the XMC4700 to be executed by the motors. It takes as input the algebraic move and a variable turn
. If the variable is true, it is the white team's turn, if false it is the black team's turn.
First, the function calls the calculate_squares()
function to get the x and y values that should be transmitted to the motor. Then it checks if we are executing a move that is part of a capture logic. If we are executing a capture logic move we check the turn to know which docking station we will move the piece to. Since the docking station for each team is at the back of the board, as mentioned in the hardware section, we decide if we will add or subtract the squares to docking from the y value based on the piece color. Next, we reset the capture variable in_capture
as we consider the capture logic completed. Otherwise, we are not executing a capture logic and the x and y values will be transmitted as is.
Finally, we use I2C functions we imported at the beginning of our code. We call function receive_i2c()
to see what the XMC4700 is moving the motor at this time. If it transmits a 1, then the motor is moving right now and not ready to receive a new move so we wait. Otherwise, it is sending a 0 and therefore is ready to receive a new set of coordinates, so we call send_i2c(x,y)
to transmit the x and y values we calculated.
def execute_move(move, turn):
x , y = calculate_squares(move)
global in_capture
if(in_capture):
if(turn): #White team turn, docking a black piece
y += sq_to_docking #Add squares to the end of the board for black pieces
else: #Black team turn, docking a white piece
y -= sq_to_docking #Subtract squares to the start of the board for white
in_capture = False
motor_moving = receive_i2c() #Check if motor is moving
while i2c_receive(): #Wait if motor is moving
print("[I2C] Motor is moving, waiting...")
send_i2c(x, y) #Send coordinates to motor
print(f"[I2C] Coordinates sent: x={x}, y={y}")
docking_station_calculator
:
This function gives the position of the captured piece in the docking station as an algebraic move so it can be called by the other functions to create the motor moves. The function takes as input the type of piece being captured and the inverse of the turn. We send the inverse of the turn since the piece sent to the docking station is the opposite of the team whose turn it is. This will simplify the logic within the function.
We use case match for the piece type, then based on the piece type we load the relevant counter from the global variables to see how far it's been filled then define the position of the piece as a normal reset of the game, as it will be modified to the location in the docking station in the execute_move
function. In the cases of the queen and king, there is no need for counters as there is one piece of this type per color. Therefore, if the piece type is king or queen we only check the color and set the algebraic square.
Let's start with the pawn case. For any pawn we know it will be in the second row of either team's starting setup, so either row 2 or row 7. The question is which pawn cell is free, therefore we depend on the pawn right to left counter variables: pawn_rl_counter_w
or pawn_rl_counter_b
to track the columns. Both counters are initialized with the character 'a' as the first column in the board to be filled. Therefore, the first pawn to be sent to docking will take the column a + 2 or 7
depending on the color of the piece.
Next, we need to update the counter for the next time a pawn is captured. Since the columns are characters, not numbers, the counter is converted to its ASCII code using the ord(string)
string function so we can easily increment it. Then we use the chr()
string function to convert it back to a character.
def docking_station_calculator(piece_type, turn):
global pawn_rl_counter_w, rook_left_empty_w, knight_left_empty_w, bishop_left_empty_w
global pawn_rl_counter_b, rook_left_empty_b, knight_left_empty_b, bishop_left_empty_b
global in_capture = True #Capture logic
match piece_type:
case 1: #PAWN
if(turn): #White team captured
docking_dest = pawn_rl_counter_w + "2" #White team
pawn_rl_counter_w = chr(ord(pawn_rl_counter_w)+1) #Update pawn empty position white
else: #Black team captured
docking_dest = pawn_rl_counter_b + "7" #Black team
pawn_rl_counter_b = chr(ord(pawn_rl_counter_b)+1) #Update pawn empty position black
A different case would be a 2 time piece such as knight or rook or bishop, where we only have 2 pieces for each team. Therefore, we know its exact location, we only need a flag to tell us which side is empty.
In the case of a knight, it will always be b1 or g1 for the white team, docking or b8 and g8 for the black team. Hence, we use a flag to indicate if the left position (column b) is empty or not for each team color: knight_left_empty_w
and knight_left_empty_b.
If the variable is true, then the left square for the team is empty; otherwise, it is filled, and we should update the docking location to be the right one, i.e. column g.
case 2: #KNIGHT
if(turn): #White team captured
if(knight_left_empty_w): #Is left side empty for white team?
docking_dest = "b1"
knight_left_empty_w = False
else:
docking_dest = "g1"
else: #Black team captured
if(knight_left_empty_b): #Is left side empty for black team?
docking_dest = "b8"
knight_left_empty_b = False
else:
docking_dest = "g8"
Similarly for the bishop and rook, we will follow the same convention.
case 3: #BISHOP
if(turn): #White team turn
if(bishop_left_empty_w):
docking_dest = "c1"
bishop_left_empty_w = False
else:
docking_dest = "f1"
else: #Black team turn
if(bishop_left_empty_b):
docking_dest = "c8"
bishop_left_empty_b = False
else:
docking_dest = "f8"
case 4: #ROOK
if(turn): #White team turn
if(rook_left_empty_w):
docking_dest = "a1"
rook_left_empty_w = False
else:
docking_dest = "h1"
else: #Black team turn
if(rook_left_empty_b):
docking_dest = "a8"
rook_left_empty_b = False
else:
docking_dest = "h8"
Finally, the king and queen as single pieces have known locations only depending on the color of the captured piece. Therefore, we only check the turn
variable to see which team is captured.
case 5: #QUEEN
if(turn):
docking_dest = "d1"
else:
docking_dest = "d8"
case 6: #KING
if(turn):
docking_dest = "e1"
else:
docking_dest = "e8"
case _:
print("error")
return docking_dest
Game Loop
Now we will follow the flow chart we examined in the game overview section. We take the input move from the player and check if it's a legal move. The chess library provides a list of legal moves available on the board for each turn, so we look for the player move string within this list. The list is generated in "uci" chess move form, so we need to convert the string provided by the player to this form using chess.Move.from_uci()
function. If the move provided by the player is not legal we use the continue
keyword to skip everything and go back to the beginning of the loop to get new input from the player.
while(not checkmate):
#Read player move
player_move = input("What is your move? Please provide the move in algebraic chess form: 'a1a1' in lower case.\n")
#Check valid move
is_legal_move = chess.Move.from_uci(player_move) in board.legal_moves
if(not is_legal_move):
print("[Error] Illegal move, try again!")
continue
Next, we check if the move results in a capture. We check for this first since the capture logic requires extra moves first before execution of the move itself, as the captured piece needs to be moved to the docking station first. Therefore, we use the board variable to invoke the is_capture()
function. It takes as input the move in terms of UCI chess move. If true, then we trigger the capture logic as mentioned in the flowchart above.
We aim in this sequence to create an algebraic move that transports that captured piece from its current location to its location from the docking station. Since the location of the captured piece is the destination square of the move provided by the player, we can simply switch it to be the first 2 characters of the move instead of the last 2 characters and then call the function motor_to_src()
which we defined in the functions section. Then we execute this move by calling execute_move()
if(board.is_capture(chess.Move.from_uci(player_move))):
print("[Capture] Capture logic triggered.")
#Make destination of player move as source
capture_move = player_move[2]+player_move[3]+player_move[0]+player_move[1]
#Create move: Motor previous location to Captured piece location
motor_to_src(capture_move)
#Motor goes to captured piece
execute_move(to_src_move, board.turn)
Our next step is to know the location of the piece in the docking station in order for the motor to move the piece to the location. To get the piece type, we invoke the predefined function of the chess library board.piece_type_at()
which takes as input a square
data type. To generate a square from the string we have, we call the chess.parse_square()
function and give it the location of the captured piece as a string to parse it into the square
type. The square where the captured piece is standing is still the last 2 characters of the player_move
variable.
captured_piece_type = board.piece_type_at(chess.parse_square(player_move[2] + player_move[3]))
Next, we call the function we defined before to calculate the location in docking station: docking_station_calculator()
. We provide the captured piece type we just got from the line of code above, and the inverse of the color whose turn it is right now on the board, in other words, the color of the captured piece. We save this calculated location into theintermediate variable docking_dest
docking_dest = docking_station_calculator(captured_piece_type, not board.turn)
Then, we call the src_to_dest
function we defined earlier where the source of our move is the current motor location, i.e. the location of captured piece, and the destination is the docking station location we generated so we can generate a full algebraic move to be executed on the board. Finally, we call execute_move
function to reflect this capture in real life.
#Create move: Motor previous location (captured) to Captured piece location in docking
src_to_dest(motor_location + docking_dest)
#Motor moves captured piece to its docking location
execute_move(to_dest_move, board.turn)
After implementing the capture logic sequence, the move provided by the player will follow the normal play sequence of moving a piece from source to destination. Therefore, we add the code for normal play logic outside of the capture if condition, as something that will be implemented by default for the player moves whether it resulted in thecapture or not.
The play sequence calls the motor_to_src
function which creates a move from the last known location of the motor to the source of the player move, followed by a call to execute_move
to implement this motor move on the board. Next, we call src_to_dest
to create the algebraic move between to motor location and the destination square of the move, followed by another call to the execute_move
function.
#Updates to_src_move with move of motor to source square
motor_to_src(player_move)
#Motor goes to source square
execute_move(to_src_move, board.turn)
#Updates to_dest_move with move of motor to destination square
src_to_dest(player_move)
#Motor goes to destination square
execute_move(to_dest_move, board.turn)
We need to implement the move provided by the player in the virtual chess board as well so it's up to date with the real board. It will be an input to the Stockfish AI engine as well so it can make a decision about its own move as the computer player. To make a move in the Python chess board, we use the board.push_san
library function.
#Mimic motor motion in virtual board
board.push_san(player_move)
Finally, we must check the board status after the player move is played, to make sure if the board is in checkmate. If yes, we break out of the loop and end the game by declaring the winner. Otherwise, we skip the if condition and move to the other team, i.e. the Stockfish AI.
if(board.is_checkmate()):
print("[Game] WINNER IS TEAM WHITE!")
break
Now we move on to the computer move. It will follow the exact same sequence of events for executing the capture logic or a normal move. The only difference lies in how we obtain the move to be played. Now, the move is generated by the functions of the stockfish library we compiled, based on the state of the Python chess board. Stockfish library provides a function we can invoke on the Stockfish object we created stock.set_fen_position
. It takes as input the status of the board in terms of a special string called a fen string. This string describes the full state of the board, therefore the Stockfish engine is able to make a decision about the best moves to make. Luckily, there is a function provided by the Python chess library that can provide this string for us without any effort on our part: board.fen()
.
stock.set_fen_position(board.fen())
We can get the AI decision now using another predefined function stock.get_best_move()
. We need to convert this move into a list of characters so we use the str
and list
functions. Finally, we save this into the variable computer_move
before going through the motor motion sequences, same as above.
best_move=list(str(stock.get_best_move()))
computer_move=best_move[0]+best_move[1]+best_move[2]+best_move[3]
The capture sequence in this case will call the computer_move
variable.
if(board.is_capture(chess.Move.from_uci(computer_move))):
print("[Capture] Capture logic triggered.")
#Make destination of player move as source
capture_move = computer_move[2]+computer_move[3]+computer_move[0]+computer_move[1]
#Create move: Motor previous location to Captured piece location
motor_to_src(capture_move)
#Motor goes to captured piece
execute_move(to_src_move, board.turn)
captured_piece_type = board.piece_type_at(chess.parse_square(computer_move[2] + computer_move[3]))
docking_dest = docking_station_calculator(captured_piece_type, not board.turn)
#Create move: Motor previous location (captured) to Captured piece location in docking
src_to_dest(motor_location + docking_dest)
#Motor moves captured piece to its docking location
execute_move(to_dest_move, board.turn)
Similarly, we move to the normal sequence of moves and play the computer moves on the virtual board.
#Updates to_src_move with move of motor to source square
motor_to_src(computer_move)
#Motor goes to source square
execute_move(to_src_move, board.turn)
#Updates to_dest_move with move of motor to destination square
src_to_dest(computer_move)
#Motor goes to destination square
execute_move(to_dest_move, board.turn)
#Mimic motor motion in virtual board
board.push_san(computer_move)
Finally, we check if the board is in checkmate after the computer moves. If so, we break out of the loop and end the game, otherwise, we print the board into the terminal and loop back to get the first player's move.
checkmate = board.is_checkmate()
if(checkmate):
print("[Game] WINNER IS TEAM BLACK")
break
print(f"[Board]\n{board}")
FarewellAnd there you have it—the journey of building an automated chessboard! From crafting the wooden board to programming the pieces, it’s been a labor of love.
- How to Use?
- Power on the chessboard and the Raspberry Pi.
- Start the Python script on the Raspberry Pi to initialize the game.
- Input your moves through the terminal, and watch the board come to life as it executes them.
- Continue playing until checkmate or let the AI opponent challenge you!
Comments