Creating a chess game with Python, pygame and chess (Pt. 2)
Tags: python, gamedev, ai, tutorial
Published: 2022-11-23 13:55:28

Hey there, I hope you're having a nice day so far. We will be continuing from where we left off in [part 1] (https://dev.to/nguhprince/creating-a-chess-game-with-python-pygame-and-chess-2451).

In this article we will be looking at how to create the AI part of the game and how we will plug that in with our existing code base.

We will break the article into the following sections: 
* Creating a parent AIPlayer class
* Plugging the AIPlayer class into our existing code
* Creating a player that selects the best move available
* Creating a player that looks ahead a couple of moves and selects the move that gives the best outcome using the minmax algorithm.

Before we get started, please create a folder called ai in your project's root directory and in that folder add the following files: `__init__.py` and `players.py`.
Your project should look similar to this now:
chess-game/
---|ai
------|`__init__.py`
------|`players.py`
---|gui_components
---|skins
`main.py`

### Creating a parent AIPlayer class
Seeing as we will be creating multiple ai players, it is important that we create a parent class, that way they all have some common behavior inherited from this class and can be used interchangeably in our application. We will see this later. 

We need our AIPlayer to be able to do the following: 
* Get the legal moves in the game
* Choose a move from the legal moves
* Make a move without affecting the board (in order to evaluate possible future states of the board)
* Make a move on a ChessBoard object.

In your players.py file type the following code
```
class AIPlayer:
    def __init__(self, board: chess.Board, color: str) -> None:
        self.board = board
        self.color = color

    def get_legal_moves(self, board: chess.Board=None) -> list:
        if not board:
            board = self.board

        return list(board.legal_moves)

    def choose_move(self, board: chess.Board=None):
        legal_moves = self.get_legal_moves()
        
        random.shuffle(legal_moves)

        chosen_move = None

        for move in legal_moves:
            evaluation_before = self.evaluate_board()
            fake_board = self.false_move(move)
            evaluation_after = self.evaluate_board(fake_board)

            if chosen_move is None:
                chosen_move = move
            else:
                # if the player is white and the move results in a higher material for white
                if evaluation_after > evaluation_before and self.color == "w":
                    chosen_move = move
                # if the player is black and the move results in higher material for black
                elif evaluation_before > evaluation_after and self.color == "b":
                    chosen_move = move
        
        return chosen_move


    def false_move(self, move: chess.Move=None, board: chess.Board=None) -> chess.Board:
        # make a move without affecting the game's current state

        # make a copy of the board for move testing
        if not board:
            board_copy = copy.deepcopy(self.board)
        else:
            board_copy = board

        if not move:
            move = self.play(board_copy)
        
        board_copy.push(move)

        return board_copy

    
    def make_move(self, chess_board: ChessBoard):
        # make a move an a ChessBoard object
        move = self.choose_move()
        chess_board._play(move=move)
```

This player doesn't implement any sophisticated techniques, it simply selects a random move from the list of available moves.

After writing this, we simply have to modify the main.py file so that our AI plays as black instead of the user. 
Now in your main.py file add the following line at the top with the other imports `from ai import players ai_players`. 
Where you have ```
players = {
    True: "user",
    False: "user"
}
```
Change it to
```
players = {
    True: "user",
    False: ai_players.AIPlayer(board, "b")
}
```
And towards the bottom of your main.py file in the while loop after the call to draw_chessboard function add the following code: 
```
if not isinstance(players[TURN], str) and IS_FIRST_MOVE:
    # the first move is an AI so it plays automatically
    play()
elif not isinstance(players[TURN], str) and not turns_taken[TURN]:
    play()
```

In the end your main.py should look like
```
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()

players = {
    True: "user",
    False: ai_players.AIPlayer(board, "b")
}

turns_taken = {
    True: False, # set 
    False: False
}

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)
DARK_COLOR = ( 100, 100, 100 )
WHITE_COLOR = (255, 255, 255)
BLACK_COLOR = (0, 0, 0)

chess_board = ChessBoard(
    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):
    ranks = board.squares

    bordered_rectangle = BorderedRectangle(10, 10, 480, 480, (255, 255, 255), DARK_COLOR, 10)

    # draw_bordered_rectangle(bordered_rectangle, screen)

    # board_border_rect = pygame.Rect( 40, 40, 400, 400 )
    # pygame.draw.rect(screen, DARK_COLOR, board_border_rect, width=1)

    board_bordered_rectangle = BorderedRectangle(25, 25, 450, 450, WHITE_COLOR, DARK_COLOR, 48)
    draw_bordered_rectangle(board_bordered_rectangle, screen)

    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:
                pygame.draw.rect( screen, board.previous_square_highlight_color, square )
            elif square is board.current_move_square:
                pygame.draw.rect( screen, board.current_square_highlight_color, square )
            else:
                pygame.draw.rect( screen, square.background_color, square )
            
            if square.piece:
                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
                pygame.draw.circle( 
                    screen, (50, 50, 50), 
                    square.center,
                    board.square_size*0.25
                )

def play_sound(board):
    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):
    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):
    global SOURCE_POSITION, POSSIBLE_MOVES, TURN

    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)

    if not isinstance(players[TURN], str) and IS_FIRST_MOVE:
        print("It is the first move and there is no human player")
        play()
    elif not isinstance(players[TURN], str) and not turns_taken[TURN]:
        play()

    pygame.display.flip()

pygame.quit()
```

Now if you run the `main.py` file you should be able to play with the AI that randomly selects moves. 

![User vs Random AI player](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/iy1uwu7e2a8p8dyuvcm9.gif)

### Creating a player that selects the best move available
The base AIPlayer class selected a random move from the list of possible moves. We will now create a player that can analyze all the possible moves on the board and select the best one, to do this we will need to use some algorithm that can evaluate the board, this algorithm will take into account the pieces on the board and the positions of those pieces. We will need to go and modify our pieces.py file. 
In your pieces.py file, add the following code in your Piece class after the colors_and_notations_and_values dictionary definition: 
```
    # Gives a score based on the position of the piece on the board 
    # this score is then added to the piece's value 
    # to give its value relative to its position
    piece_square_tables = {
        "k": [
            [-3.0, -4.0, -4.0, -5.0, -5.0, -4.0, -4.0, -3.0],
            [-3.0, -4.0, -4.0, -5.0, -5.0, -4.0, -4.0, -3.0],
            [-3.0, -4.0, -4.0, -5.0, -5.0, -4.0, -4.0, -3.0],
            [-3.0, -4.0, -4.0, -5.0, -5.0, -4.0, -4.0, -3.0],
            [-2.0, -3.0, -3.0, -4.0, -4.0, -3.0, -3.0, -2.0],
            [-1.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -1.0],
            [2.0, 2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 2.0, 2.0],
            [2.0, 3.0, 1.0, 0.0, 0.0, 1.0, 3.0, 2.0]
        ],
        "q": [
            [-2.0, -1.0, -1.0, -0.5, -0.5, -1.0, -1.0, -2.0],
            [-1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -1.0],
            [-1.0, 0.0, 0.5, 0.5, 0.5, 0.5, 0.0, -1.0],
            [-0.5, 0.0, 0.5, 0.5, 0.5, 0.5, 0.0, -0.5],
            [0.0, 0.0, 0.5, 0.5, 0.5, 0.5, 0.0, -0.5],
            [-1.0, 0.5, 0.5, 0.5, 0.5, 0.5, 0.0, -1.0],
            [-1.0, 0.0, 0.5, 0.0, 0.0, 0.0, 0.0, -1.0],
            [-2.0, -1.0, -1.0, -0.5, -0.5, -1.0, -1.0, -2.0]
        ],
        "r": [
            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
            [0.5, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.5],
            [-0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.5],
            [-0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.5],
            [-0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.5],
            [-0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.5],
            [-0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.5],
            [0.0, 0.0, 0.0, 0.5, 0.5, 0.0, 0.0, 0.0]
        ],
        "b": [
            [-2.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -2.0],
            [-1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -1.0],
            [-1.0, 0.0, 0.5, 1.0, 1.0, 1.0, 0.5, 0.0, -1.0],
            [-1.0, 0.5, 0.5, 1.0, 1.0, 0.5, 0.5, -1.0],
            [-1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0],
            [-1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0],
            [-1.0, 0.5, 0.0, 0.0, 0.0, 0.0, 0.5, -1.0],
            [-2.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -2.0]
        ],
        "n": [
            [-5.0, -4.0, -3.0, -3.0, -3.0, -3.0, -4.0, -5.0],
            [-4.0, -2.0, 0.0, 0.0, 0.0, 0.0, -2.0, -4.0],
            [-3.0, 0.0, 1.0, 1.5, 1.5, 1.0, 0.0, -3.0],
            [-3.0, 0.5, 1.5, 2.0, 2.0, 1.5, 0.5, -3.0],
            [-3.0, 0.0, 1.5, 2.0, 2.0, 1.5, 0.0, -3.0],
            [-3.0, 0.5, 1.0, 1.5, 1.5, 1.0, 0.5, -3.0],
            [-4.0, -2.0, 0.0, 0.5, 0.5, 0.0, -2.0, -4.0],
            [-5.0, -4.0, -3.0, -3.0, -3.0, -3.0, -4.0, -5.0]
        ],
        "p": [
            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
            [5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0],
            [1.0, 1.0, 2.0, 3.0, 3.0, 2.0, 1.0, 1.0],
            [0.5, 0.5, 1.0, 2.5, 2.5, 1.0, 0.5, 0.5],
            [0.0, 0.0, 0.0, 2.0, 2.0, 0.0, 0.0, 0.0],
            [0.5, -0.5, -1.0, 0.0, 0.0, -1.0, -0.5, 0.5],
            [0.5, 1.0, 1.0, -2.0, -2.0, 1.0, 1.0, 0.5],
            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
        ]
    }
```
This is a dictionary that contains the points to be added to a piece's value based on its position on the board. The positions start from the 8th rank (ranks are rows in chess, starting from white's side) to the 1st rank (i.e. `piece_square_tables['k'][0]` contains the positional value of the white king on the 8th rank and `piece_square_tables['k'][7]` contains those for the 1st rank). This dictionary shows only the positional values of white pieces, to get that for black we have to reverse the lists and negate the values of the elements.

Put this code beneath the one above to get the complete piece_square_tables.
```
    piece_square_tables = {
        "w": piece_square_tables,
        "b": { 
            key: value[::-1] # reversing the previous lists
            for key, value in piece_square_tables.items() 
        } 
    }

    # negating the values in black's list
    for key, value in piece_square_tables["b"].items():
        piece_square_tables["b"][key] = [ [ -j for j in rank ] for rank in value ]
```

Since we will be evaluating a chess.Board object it is important that we can easily get the different pieces on the board without having to loop through all the squares in our gui board. Let's take a look at a board, open a python shell and type in the following: 
```
import chess
board = chess.Board()
print(board)
```
It should give you an output similar to the following: 
```
r n b q k b n r
p p p p p p p p
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
P P P P P P P P
R N B Q K B N R
```

The black pieces are at the top and the whites down. It is important to note that the black pieces are written in lowercase and the white in upper. From this representation we can easily get the colors of the pieces and their positions on the board. 

In our Piece class we will add a static method that gets the piece's color based on a notation passed. Add the following to the Piece class
```
    def get_piece_color_based_on_notation(notation) -> str:
        return "w" if notation.isupper() else "b"
```

We also need to add a method that gets a piece's value when given its notation, color, rank_number and file_number

```
def get_piece_value_from_notation_and_position(notation: str, color: str, rank_number, file_number):
        """
        Gets a piece's value relative to its color, notation, rank_number and file_number 
        rank_number ranges from 0-7 with 0 => rank 8 and 7 => rank 1
        file_number ranges from 0-7 with 0 => file A and 7 => file H
        """
        position_value = Piece.piece_square_tables[color][notation.lower()][rank_number][file_number]
        
        # negating the value obtained from the piece squares table if the piece is black
        # position_value = -position_value if color == "b" else position_value

        piece_value = Piece.colors_notations_and_values[color][notation.lower()]

        return position_value + piece_value
```

After laying the groundwork in out Piece class, we will create an `evaluate_board` method in our new Player class that actually does the board evaluation. Open the ai/players.py file and type the following code under the AIPlayer class
```
class PlayerWithEvaluation(AIPlayer):
    def evaluate_board(self, board: chess.Board=None) -> int:
        if board is None: 
            board = self.board

        regex = re.compile("\w")
        string = board.__str__()
        
        material_sum = 0

        ranks = [ row.split(' ') for row in string.split('\n')]

        for i, rank in enumerate(ranks):
            for j, notation in enumerate(rank):
                if regex.search(notation):
                    piece_color = Piece.get_piece_color_based_on_notation(notation)
                    piece_positional_value = Piece.get_piece_value_from_notation_and_position(notation, piece_color, i, j)

                    material_sum += piece_positional_value

        return material_sum

    def choose_move(self, board: chess.Board=None):
        """
        Chooses the move that results in the highest material gain for the player
        """
        legal_moves = self.get_legal_moves()
        
        chosen_move = None
        minimum_evaluation = None
        maximum_evaluation = None

        for move in legal_moves:
            # make a move on the board without affecting it
            fake_board = self.false_move(move)
            evaluation_after = self.evaluate_board(fake_board)

            if chosen_move is None:
                chosen_move = move
            if minimum_evaluation is None:
                minimum_evaluation = evaluation_after
            if maximum_evaluation is None: 
                maximum_evaluation = evaluation_after
            
            else:
                # if the player is white and the move results in a more positive score
                if evaluation_after > maximum_evaluation and self.color == "w":
                    chosen_move = move
                # if the player is black and the move results in a more negative score
                elif evaluation_after < minimum_evaluation and self.color == "b":
                    chosen_move = move
        
        return chosen_move
```

It is important to note that a positive score is favorable for white whereas a negative score is favorable for black. So the player will always go for the move that tilts the evaluation in its favor.

Now to test out this new player, simply go to the main.py file and change the line that contains: 
```
players = {
    True: "user",
    False: ai_players.AIPlayer(board, "b")
}
```
To 
```
players = {
    True: "user",
    False: ai_players.PlayerWithEvaluation(board, "b")
}
```

You should be able to play with this new model after running the `main.py` file.

![Player with evaluation](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/lz4bwvd5e00jyilpomte.gif)

Although this player is an improvement to the previous one it still falls short in one very important aspect of the game of chess, anticipating your opponent's moves. Even from the short illustration we see that the player is losing pieces because it only plays the best move in front of it not taking into account the opponent's move. 

We will solve this problem in the next article, where we will build a player using the minmax algorithm.