Prince
Posted on November 21, 2022
Hey, I'm Prince and I'm going to be walking you through my process of creating a chess game with Python (this is my first project with pygame).
DISCLAIMER: This article is not for beginners but I will make an effort to make it accessible to those with just a little bit of Python knowledge. Some concepts involved here include; OOP and data structures.
The objectives of this program are to create a chess game that can be played with 2 players or against an AI.
Here's a link to the project on Github, feel free to play around with it or contribute.
I relied heavily on the techniques covered in this article
So firstly create a new folder (for the purposes of this article we will call it chess-game) where you want to store the code and in that folder create a virtual environment (if you are not familiar with virtual environments take a look at this ), activate the virtual environment and install the following packages:
- chess
- pygame
We need the chess module to handle the chess rules and validations and pygame to make the actual game.
Ok, we are going to split this walkthrough into 3 sections:
- The pieces, squares and the boards
- Displaying the board and pieces on the pygame window and
- Creating an AI player
The pieces, squares and the board
We will create a new package in our code, gui_components. To create a package just create a new folder (in this case gui_components) and in that new folder create a new file __init__.py
)
We will also create a new folder in our project directory (chess-game) called skins. This is where we will store the images for our pieces. Feel free to copy the skins directory from the repository
The project should have the following structure:
chess-game/
---|gui_components/
---|skins/
- The pieces
We will create a
pieces.py
file in our gui_components folder. In this file we will create a Piece class. For now the objects of this class will simply be used to display the image and get the value of the piece based on its notation (in chess the different pieces have notations k for King, q for Queen, r for Rook, b for bishop, n for Knight and p for Pawn) and whether or not it has been captured.
import os
import pygame
class Piece:
colors_notations_and_values = {
"w": {
"p": 1,
"n": 3,
"b": 3,
"r": 5,
"q": 9,
"k": 90
},
"b": {
"p": -1,
"n": -3,
"b": -3,
"r": -5,
"q": -9,
"k": -90
}
}
def __init__(self, name, notation, color, skin_directory="skins/default", is_captured=False) -> None:
self.name = name
self.__notation = notation
self.color = color
self.skin_directory = skin_directory
self.set_is_captured(is_captured)
self.value = self.get_piece_value()
def get_piece_value(self):
return Piece.colors_notations_and_values[self.color][self.__notation.lower()]
def get_piece_color_based_on_notation(notation) -> str:
"""
The chess module displays black pieces' notations in lowercase and white in uppercase, so we can get the color based on this
"""
return "w" if notation.isupper() else "b"
def get_value_from_notation(notation: str, color: str) -> int:
"""
A class method that gets the corresponding value for a particular notation and color
"""
return Piece.colors_notations_and_values[color][notation.lower()]
def set_is_captured(self, is_captured: bool):
self.__is_captured = bool(is_captured)
def get_image_path(self):
"""
Gets the path to the image of the piece based on its notation and
whether or not it has been captured
"""
if not self.__is_captured:
path = os.path.join(self.skin_directory, self.color, f"{self.__notation.lower()}.png")
else:
path = os.path.join(self.skin_directory, self.color, "captured", f"{self.__notation.lower()}.png")
return path
def get_image(self):
"""
Returns a pygame image object from the piece's corresponding image path
"""
image_path = self.get_image_path()
if os.path.exists(image_path):
return pygame.image.load(image_path)
else:
raise FileNotFoundError(f"The image was not found in the {image_path}")
def __str__(self):
return f"{self.__notation} {self.color}"
def get_notation(self) -> str:
"""
Returns the notation of the piece, (pawns' notations are empty strings)
"""
if self.__notation != 'p':
return self.__notation.upper()
return ''
def __set_notation(self, notation):
self.__notation = notation
def promote(self, notation: str):
"""
Promotes this piece to a piece with the notation notation.
It is important to note that promotion does not increase the piece's value,
just its capabilities
"""
if self.__notation.lower() != "p":
raise ValueError("Cannot promote a piece other than a pawn")
if notation not in ["q", "r", "n", "b"]:
raise ValueError("Can only promote to queen, rook, bishop or knight pieces")
self.__set_notation(notation)
- The squares and board
When creating this game I thought about being able to have a checkers game with it, so the classes in this section kind of reflect that vision. First and foremost, create a new file
boards.py
. In this file create a Square class (a generic class for squares checkers or chess)
import chess
import pygame
from gui_components.pieces import Piece
class Square(pygame.Rect):
def __init__(self, left: float, top: float, width: float, height: float, background_color: str, border_color: str, piece: Piece = None) -> None:
super().__init__(left, top, width, height)
self.background_color = background_color
self.border_color = border_color
self.piece = piece
self.is_possible_move = False
def toggle_is_possible_move(self):
self.is_possible_move = not self.is_possible_move
return self
def empty(self):
self.piece = None
return self
def set_is_possible_move(self, value: bool):
self.is_possible_move = bool(value)
return self
Now a square for chess pieces
class ChessSquare(Square):
def __init__(self, left: float, top: float, width: float, height: float, background_color: str, border_color: str, file_number, rank_number, piece: Piece = None) -> None:
super().__init__(left, top, width, height, background_color, border_color, piece)
self.file_number = file_number
self.rank_number = rank_number
self.ranks = list( str(i) for i in range(1, 9) )
self.files = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
def get_chess_square(self) -> chess.Square:
"""
Returns a chess.Square object that corresponds to this one
"""
return chess.square(self.file_number, self.rank_number)
def is_identical_to_chess_square(self, square: chess.Square) -> bool:
"""
Checks if this object corresponds to a chess.Square object
"""
return (
self.file_number == chess.square_file(square) and
self.rank_number == chess.square_rank(square)
)
def get_rank(self) -> str:
"""
Gets the rank of the object. Ranks are the rows of the board and they range from 1 to 8
"""
return self.ranks[ self.rank_number ]
def get_file(self) -> str:
"""
Gets the file of the object. Files are the columns of the board and range from A to H
"""
return self.files[ self.file_number ]
def get_notation(self) -> str:
"""
Gets the notation of the square object. A squares notation is simply its file and rank
"""
return f'{self.get_file()}{self.get_rank()}'
Now for the board. Same as the square we will create 2 board classes although the parent board class doesn't do much for now. This class will help us keep track of the pieces on our squares, highlight a move made, display the possible moves, get a square that corresponds to particular coordinates and make a move.
class Board(pygame.sprite.Sprite):
RANKS = [ i+1 for i in range(0, 8) ]
FILES = [ chr(i) for i in range(65, 65+9) ]
def __init__(self, number_of_rows, number_of_columns, left, top, width, height, horizontal_padding, vertical_padding, **kwargs) -> None:
self.left = left
self.top = top
self.number_of_rows = number_of_rows
self.number_of_columns = number_of_columns
self.width = width
self.height = height
self.horizontal_padding = horizontal_padding
self.vertical_padding = vertical_padding
self.squares = []
def create_squares(self):
pass
class ChessBoard(Board):
def __init__(
self, left, top, width, height,
horizontal_padding=None, vertical_padding=None,
light_square_color: str=(245, 245, 245), dark_square_color: str=(100, 100, 100),
previous_square_highlight_color=(186, 202, 43),
current_square_highlight_color=(246, 246, 105),
board: chess.Board=None, move_hints=True, **kwargs
) -> None:
super().__init__(
8, 8, left, top, width, height,
horizontal_padding, vertical_padding, **kwargs
)
self.__set_square_size()
self.light_square_color = light_square_color
self.dark_square_color = dark_square_color
self.board = board
self.move_hints = move_hints
print('The current board is')
print(self.board)
self.rect = pygame.Rect(left, top, width, height)
self.create_squares()
self.captured_pieces = {
"w": [],
"b": []
}
# the square the piece that made the latest move came from
self.previous_move_square = None
self.current_move_square = None
self.previous_square_highlight_color = previous_square_highlight_color
self.current_square_highlight_color = current_square_highlight_color
self.is_flipped = bool(kwargs["flipped"]) if "flipped" in kwargs else False
# set to True if a pawn has the right to promote and has to choose which piece it wants to promote to
self.awaiting_promotion = False
# self.flip()
def __set_square_size(self):
self.__square_size = self.height // 8
@property
def square_size(self) -> int:
return self.__square_size
def get_piece_from_notation(self, notation):
"""
Returns a piece object based on a particular notation
"""
if notation != '.':
piece_color = "b" if notation.islower() else "w"
notation = notation.lower()
piece = Piece(name=notation, notation=notation, color=piece_color)
return piece
return None
def get_square_from_chess_square(self, square: chess.Square) -> ChessSquare:
"""
Returns a Square object that corresponds to a particular chess.Square object
"""
square_file = chess.square_file(square)
square_rank = chess.square_rank(square)
rank = self.squares[ 7 - square_rank ]
return rank[ square_file ]
def create_squares(self):
"""
Creates the squares oon the board and places pieces on them based on the state of the chess.Board object
"""
string = self.board.__str__()
ranks_inverted = string.split('\n')#[::-1]
for i in range(self.number_of_rows):
self.squares.append( [] )
rank = ranks_inverted[i].split(' ')
for j in range(self.number_of_columns):
square = rank[j]
piece = self.get_piece_from_notation(square)
color = self.light_square_color if (i+j) % 2 == 0 else self.dark_square_color
board_square = ChessSquare(
self.left + (j*self.square_size), self.top + (i*self.square_size), self.square_size,
self.square_size, color, self.dark_square_color, j, 7 - i, piece=piece
)
self.squares[i].append( board_square )
def flip(self):
"""
Changes the coordinates of the squares in essence flipping them
"""
board_rect = pygame.Rect(self.left, self.top, self.width, self.height)
for (i, rank) in enumerate(self.squares):
print(f"Flipping the squares on rank: {8 - i}")
for (j, square) in enumerate(rank):
square: ChessSquare = square
_old = square.__repr__()
square.x += (7 - j) * self.square_size
square.y += (7 - i) * self.square_size
if not square.colliderect(board_rect):
print("Square is out of bounds of the board")
print(f"The board rectangle is: {board_rect}. The square rectangle is: {square}")
else:
print(f"Square was flipped successfully. Old coordinates: {_old}, new: {square}")
self.is_flipped = not self.is_flipped
def place_pieces(self):
"""
places pieces on the board based on the progress of the board attribute
different from create_squares in that it doesn't create squares it instead
clears all the squares of existing pieces and positions the pieces on the board
"""
string = self.board.__str__()
ranks_inverted = string.split('\n')#[::-1]
for i in range( self.number_of_rows ):
rank = ranks_inverted[i].split(' ')
for j in range( self.number_of_columns ):
self.squares[i][j].empty()
board_square = rank[j]
piece = self.get_piece_from_notation(board_square)
self.squares[i][j].piece = piece
def get_possible_moves(self, source_coordinates, remove_hints=False):
"""
Gets the possible moves from some coordinates and marks the squares as possible moves if move_hints are enabled
"""
# source_square = [ square.get_chess_square() for square in self.iter_squares() if square.collidepoint(source_coordinates) ]
source_square = self.get_square_from_coordinates(source_coordinates)
if source_square:
destination_chess_squares = [ move.to_square for move in self.board.legal_moves if move.from_square == source_square ]
destination_squares = [ square.set_is_possible_move(not remove_hints) for square in self.iter_squares() if square.get_chess_square() in destination_chess_squares ]
return destination_squares
return []
def get_possible_moves_without_hint(self, source_coordinates):
"""
Gets the possible moves from some coordinates
"""
source_square = self.get_square_from_coordinates(source_coordinates)
if source_square:
destination_chess_squares = [ move.to_square for move in self.board.legal_moves if move.from_square == source_square ]
destination_squares = [ square for square in self.iter_squares() if square.get_chess_square() in destination_chess_squares ]
return destination_squares
return []
def hide_hints(self):
"""
Hides the hints on the squares
"""
[square.set_is_possible_move(False) for square in self.iter_squares()]
def get_square_from_coordinates(self, coordinates, return_chess_square=True) -> ChessSquare:
"""
Returns a square that corresponds to the coordinates passed
"""
square = [ (square.get_chess_square() if return_chess_square else square) for square in self.iter_squares() if square.collidepoint(coordinates) ]
if len(square) > 0:
square = square[0]
return square
print(f"There is no square at the {coordinates} coordinates")
return None
def get_move_notation(self, source_square: ChessSquare, destination_square: ChessSquare):
"""
Gets the notation for a particular move made from source_square to destination_square
"""
move = ''
if source_square.piece:
other_pieces_of_the_same_type_that_can_make_move = self.get_pieces_that_can_make_move( [source_square.piece.get_notation()], source_square.piece.color, destination_square, [source_square] )
same_rank = False
same_file = False
if source_square.piece.get_notation() != '':
for square in other_pieces_of_the_same_type_that_can_make_move:
if square.rank_number == source_square.rank_number:
same_rank = True
if square.file_number == source_square.file_number:
same_file = True
move = move + source_square.piece.get_notation()
if same_file or same_rank:
if not same_file:
move = move + f"{source_square.get_file()}"
elif same_file and not same_rank:
move = move + f"{source_square.get_rank()}"
else:
move = move + f"{source_square.get_notation()}"
if destination_square.piece:
move = move + 'x'
if source_square.piece and source_square.piece.get_notation() == '':
move = source_square.get_file() + move
move = move + f'{destination_square.get_notation()}'
if source_square.piece.get_notation() == 'K' and source_square.get_file() == 'e' and destination_square.get_file() in [ 'c', 'g' ]:
# castling
if destination_square.get_file() == 'c':
return '0-0-0'
else:
return '0-0'
move = chess.Move(
from_square=source_square.get_chess_square(), to_square=destination_square.get_chess_square()
)
return move
def get_pieces_that_can_make_move(self, piece_notations: list, color, square: ChessSquare, squares_to_exclude: list):
"""
Returns the pieces with notations in <piece_notations> list and of color <color> that can make a move the <square> square
while excluding the pieces on the <squares_to_exclude> list
"""
squares_with_pieces_of_specified_types = [ _square for _square in self.iter_squares() if _square.piece and _square.piece.get_notation() in piece_notations and _square.piece.color == color and _square not in squares_to_exclude ]
squares_that_can_make_move = [ _square for _square in squares_with_pieces_of_specified_types if square in self.get_possible_moves_without_hint(_square.center) ]
return squares_that_can_make_move
def play(self, source_coordinates, destination_coordinates):
"""
Makes a move from source_coordinates to destination_coordinates
"""
source_square = self.get_square_from_coordinates(source_coordinates, return_chess_square=False)
destination_square = self.get_square_from_coordinates(destination_coordinates, return_chess_square=False)
self._play(source_square, destination_square)
def _play(self, source_square: ChessSquare=None, destination_square: ChessSquare=None,
source_chess_square: chess.Square=None, destination_chess_square: chess.Square=None,
move: chess.Move=None
):
"""
Makes a move based on the arguments.
"""
if move:
self.make_move(move)
self.previous_move_square = self.get_square_from_chess_square(move.from_square)
self.current_move_square = self.get_square_from_chess_square(move.to_square)
elif source_square and destination_square:
move = self.get_move_notation(source_square, destination_square)
self.make_move(move)
self.previous_move_square = source_square
self.current_move_square = destination_square
elif source_chess_square and destination_chess_square:
move = chess.Move(from_square=source_chess_square, to_square=destination_chess_square)
self.make_move(move)
self.previous_move_square = self.get_square_from_chess_square(source_chess_square)
self.current_move_square = self.get_square_from_chess_square(destination_chess_square)
else:
print("None of the conditions were fulfilled. No move is currently being made")
self.place_pieces()
print('The current board is')
print(self.board)
def make_move(self, move):
"""
Makes a move either with an str object or a chess.Move object
"""
if isinstance(move, str):
self.board.push_san(move)
elif isinstance(move, chess.Move):
if self.board.is_capture(move):
destination_square: ChessSquare = self.get_square_from_chess_square(move.to_square)
piece: Piece = destination_square.piece
print("The move was a capture")
if piece is not None:
piece.set_is_captured(True)
color = piece.color
self.captured_pieces[color].append(piece)
self.board.push(move)
def iter_squares(self):
"""
A generator that returns the different squares on the board
"""
for rank in self.squares:
for square in rank:
yield square
Displaying the board in a pygame window
Before we move forward with this, let's firstly create some classes we will use in this file. In our gui_components folder we will create a new file components.py
. Put this code inside that file
import pygame
class BorderedRectangle():
"""
An object that contains 2 pygame.Rect object, one put inside the other
"""
def __init__(
self, left: float, top: float, width: float, height: float,
background_color: str, border_color: str, border_width: int,
outer_rectangle_border_width=2, inner_rectangle_border_width=2
) -> None:
self.background_color = background_color
self.border_color = border_color
self.is_possible_move = False
self.outer_rectangle_border_width = outer_rectangle_border_width
self.inner_rectangle_border_width = inner_rectangle_border_width
self.outer_rectangle = pygame.Rect(left, top, width, height)
self.inner_rectangle = pygame.Rect(
left+(border_width / 2), top+(border_width/2),
width - border_width, height - border_width
)
Now in our root directory (chess-game), create a new file main.py
. In this file we will write the code to display our board in a pygame window and even to play the game without AI and board flips.
import chess
import pygame
from pygame import mixer
mixer.init()
from gui_components.board import ChessBoard
from gui_components.components import BorderedRectangle
from ai import players as ai_players
pygame.init()
screen = pygame.display.set_mode([500, 500])
board = chess.Board()
# A dictionary of the different players in the game. True corresponds to white and
# False to black
players = {
True: "user",
False: "user"
}
turns_taken = {
True: False, # set to True if white has already started playing
False: False # set to True if black has already started playing
}
# the different sounds for the moves
move_sound = mixer.Sound("sound_effects/piece_move.mp3")
check_sound = mixer.Sound("sound_effects/check.mp3")
checkmate_sound = mixer.Sound("sound_effects/checkmate.mp3")
SOURCE_POSITION = None
DESTINATION_POSITION = None
PREVIOUSLY_CLICKED_POSITION = None
POSSIBLE_MOVES = []
TURN = True
IS_FIRST_MOVE = True
running = True
LIGHT_COLOR = (245, 245, 245) # color of the light squares
DARK_COLOR = ( 100, 100, 100 ) # color of the dark squares
WHITE_COLOR = (255, 255, 255) # white
BLACK_COLOR = (0, 0, 0) # black
chess_board = ChessBoard( # creating a new ChessBoard object
50, 50, 400, 400, 0, 0, board=board
)
def draw_bordered_rectangle(rectangle: BorderedRectangle, screen):
pygame.draw.rect( screen, rectangle.border_color, rectangle.outer_rectangle, width=rectangle.outer_rectangle_border_width )
pygame.draw.rect( screen, rectangle.background_color, rectangle.inner_rectangle, width=rectangle.inner_rectangle_border_width )
def draw_chessboard(board: ChessBoard):
"""
Draw the chess board on the pygame window
"""
ranks = board.squares # get the rows of the board
# a rectangle enclosing the board and the files and ranks labels
board_bordered_rectangle = BorderedRectangle(25, 25, 450, 450, WHITE_COLOR, DARK_COLOR, 48)
draw_bordered_rectangle(board_bordered_rectangle, screen)
# draw the inner rectangle of the bordered rectangle with the same color
# as that of the dark squares
pygame.draw.rect(
screen, board_bordered_rectangle.border_color, board_bordered_rectangle.inner_rectangle,
width=1
)
board_top_left = board.rect.topleft
board_top_right = board.rect.topright
board_bottom_left = board.rect.bottomleft
for i, rank in enumerate(ranks):
rank_number = ChessBoard.RANKS[ 7 - i ]
file_letter = ChessBoard.RANKS[i]
font_size = 15 # font size for the ranks and files
# add the text rectangle on the left and right of the board
font = pygame.font.SysFont('helvetica', font_size)
# render the ranks (1-8)
for _i in range(1):
if _i == 0:
_rect = pygame.Rect(
board_top_left[0] - font_size, board_top_left[1] + (i*board.square_size),
font_size, board.square_size
)
else:
_rect = pygame.Rect(
board_top_right[0], board_top_right[1] + (i*board.square_size),
font_size, board.square_size
)
text = font.render(f"{rank_number}", True, DARK_COLOR)
text_rect = text.get_rect()
text_rect.center = _rect.center
screen.blit(text, text_rect)
# render the files A-H
for _i in range(1):
if _i == 0:
_rect = pygame.Rect(
board_top_left[0] + (i*board.square_size), board_top_left[1] - font_size,
board.square_size, font_size
)
else:
_rect = pygame.Rect(
board_top_left[0] + (i*board.square_size), board_bottom_left[1],
board.square_size, font_size
)
text = font.render(f"{file_letter}", True, DARK_COLOR)
text_rect = text.get_rect()
text_rect.center = _rect.center
screen.blit(text, text_rect)
for j, square in enumerate(rank):
if square is board.previous_move_square:
# highlight source square of the latest move
pygame.draw.rect( screen, board.previous_square_highlight_color, square )
elif square is board.current_move_square:
# highlight the destination square of the latest move
pygame.draw.rect( screen, board.current_square_highlight_color, square )
else:
pygame.draw.rect( screen, square.background_color, square )
if square.piece:
# draw the piece on the square
try:
image = square.piece.get_image()
image_rect = image.get_rect()
image_rect.center = square.center
screen.blit( image, image_rect )
except TypeError as e:
raise e
except FileNotFoundError as e:
print(f"Error on the square on the {i}th rank and the {j}th rank")
raise e
if square.is_possible_move and board.move_hints:
# draw a circle in the center of the square to highlight is as a possible move
pygame.draw.circle(
screen, (50, 50, 50),
square.center,
board.square_size*0.25
)
def play_sound(board):
"""
Play sound after move based on move type
"""
if board.is_checkmate():
mixer.Sound.play(checkmate_sound)
elif board.is_check():
mixer.Sound.play(check_sound)
elif board.is_stalemate():
pass
else:
mixer.Sound.play(move_sound)
def play(source_coordinates: tuple=None, destination_coordinates: tuple=None):
"""
Make a move on the board based on the source and destination coordinates if a user is playing
"""
global board, TURN, IS_FIRST_MOVE, chess_board
turn = board.turn
player = players[turn]
turns_taken[turn] = not turns_taken[turn]
print(f"Setting {turns_taken[turn]} to {not turns_taken[turn]}")
if not isinstance(player, str):
# AI model to play
player.make_move(chess_board)
play_sound(board)
TURN = not TURN
if isinstance(players[TURN], ai_players.AIPlayer):
# if the next player is an AI, automatically play
print("Next player is AI, making a move for them automaically")
# sleep(5)
else:
if source_coordinates and destination_coordinates:
# user to play
print("User is making move")
chess_board.play(source_coordinates, destination_coordinates)
play_sound(board)
TURN = not TURN
if IS_FIRST_MOVE:
IS_FIRST_MOVE = False
turns_taken[turn] = not turns_taken[turn]
print(f"Setting {turns_taken[turn]} to {not turns_taken[turn]}")
def click_handler(position):
"""
Handle the click events of the game
"""
global SOURCE_POSITION, POSSIBLE_MOVES, TURN
if chess_board.rect.collidepoint(position): # if position is in the board
current_player = players[TURN]
if isinstance(current_player, str):
if SOURCE_POSITION is None:
POSSIBLE_MOVES = chess_board.get_possible_moves(position)
SOURCE_POSITION = position if POSSIBLE_MOVES else None
else:
# getting the squares in the possible destinations that correspond to the clicked point
destination_square = [ square for square in POSSIBLE_MOVES if square.collidepoint(position) ]
if not destination_square:
chess_board.get_possible_moves(SOURCE_POSITION, remove_hints=True)
SOURCE_POSITION = None
else:
destination_square = destination_square[0]
print(f"In main.py, about to play, the source and destination are {SOURCE_POSITION} and {position} respectively")
chess_board.get_possible_moves(SOURCE_POSITION, remove_hints=True)
# chess_board.play( SOURCE_POSITION, position )
play(SOURCE_POSITION, position)
SOURCE_POSITION = None
current_player = players[TURN]
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
if event.type == pygame.MOUSEBUTTONDOWN:
MOUSE_CLICKED_POSITION = pygame.mouse.get_pos()
click_handler(MOUSE_CLICKED_POSITION)
screen.fill( (255, 255, 255) )
draw_chessboard(chess_board, True)
pygame.display.flip()
pygame.quit()
Now if you activate your virtual environment and run the main.py file python main.py
a GUI chess game should be displayed:
Here's a gif of a game between two users
In the next article, we are going to look at the creation of an AI player and how to integrate that with our existing code.
Posted on November 21, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.