CODE WITH MARTIN

How To Code A Chess Game In Python

Contents


Introduction

This article will take you through a fully working game of chess written in Python using the PyGame package developed by myself. It's not a step-by-step tutorial, instead I'm going to show you how the completed source hangs together and how it began it's life and the strategies used to break down the problem of developing the whole game.

I was inspired to work on this during Jan 2023 when chess.com was suffering from a database overload due to how popular it had become. I wanted to both explore PyGame and challenge myself to see if I could make my own chess game. It seemed possible and fun because I sold myself on it being "just a 2D array of pieces".

Python and PyGame were perfect for the idea. PyGame would enable me to simply draw squares (the board), import and draw images (the pieces), present the game in a game window, detect inputs from the mouse (dragging pieces) and finally, play sounds (check!).

It definitely helps if you know how the game of chess is played, if you don't, a lot of what is mentioned in the code might confuse you.

As for Python language skills, we don't do anything fancy at all. Everything you will see can be easily translated to any language. I use lists, classes, loops, conditionals, functions and a lot of variables of course.

Install and Run

To get started, you'll need the PyGame package which you can install with 'pip3 install pygame' in a terminal.

Next, grab the source code from GitHub located here: https://github.com/MBlore/chess-python

Load the folder in VSCode or your editor of choice and run the 'chess.py' file.

You should see a game window appear - white moves first. Just click and drag pieces to a valid square to move.

PyGame Introduction

I first wanted to get to the point where I could draw things on a screen. Everything for PyGame was a google search away (or ChatGPT?). It took just a few minutes to write a few lines of code to initialize the PyGame screen, and create a game loop.

What is a game loop you ask? Well, games typically are built where the program is constantly drawing an image which is then flipped to the screen. We do this many times per second. This image drawing process is called a 'Frame'. You may have heard the term FPS or Frames-Per-Second, well, that's the game code managing to figure out what to draw, drawing it, and then presenting that drawn image to the screen, 60 times per second for example.

Our chess game is not graphically demanding at all, which means it can run at many hundreds of frames per second. For chess, we really don't need that much real-time CPU time, so I cap the frame rate to 120 frames per second using PyGames clock function.

Just to show what this loop looks like in its simplest form, here's some code:

import pygame
import sys

# Initialize PyGame.
pygame.display.init()

# Create a screen display window with size 800x600.
screen = pygame.display.set_mode((800, 600), pygame.RESIZABLE)

# Set the title of the window.
pygame.display.set_caption("Chess")

# Create a clock that helps limit the frame rate.
clock = pygame.time.Clock()

# Start the infinite game loop.
while 1:
    # Check events in PyGame to see if the operating system wants us
    # to quit (user closed the window).
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()
            sys.exit()

    # Ensure that this loop is only running at 120 frames per second.
    clock.tick(120)

    # Fill the frame surface with the color black (RGB values).
    screen.fill((0,0,0))

    # Flip the frame surface to the display.
    pygame.display.flip()

You can put this example code in a .py file and run it if you like. You'll just see an empty window filled with black. This is a game's blank canvas and this is exactly where I began the chess game development.

You'll find most of the PyGame setup code in the 'chess.py' file, inside the class 'Chess', and the function named 'init'.

The game loop we just talked about can also be found in this class, inside the function 'run'. Our run function calls two other functions. One named 'update' and the other 'render'. The update function handles things like detecting the mouse input and triggering mouse events like mouse button down and up for drag logic. When we have detected the mouse may have clicked somewhere on the board, we later trigger logic which I'll explain in a short while.

The 'render' function is what clears the screen ready for a new frame of drawing then draws all the elements of the game on screen.

The next thing I needed to know was how to draw things on this blank canvas. With a bit more googling, PyGame has built in functions for drawing squares and images. With that alone, we can draw lots of squares to make up an 8x8 chess board, and we can load the chess piece images and draw them on top of the board.

The file 'images.py' contains code for loading the image files for the chess pieces.

The file 'board_render.py' contains code for drawing the chess piece images and drawing the board squares.

Development Strategy

So we've had a bit of an introduction to PyGame, but then how do we then develop the logic of chess and make the board come alive with moves when clicking with the mouse.

In my mind, my development road map looked a little like this:

Phew, that's a big list of things to do now that I look back. I know that in my head, it only started with the first few things on the list. Later on, I just started growing the things to do with what seemed logical to do at the time, which was always what the most simple next step was, and just keep going.

Let's talk a little about how the chess board logic itself hangs together in code.

The Chess Board

The entire chess board can be represented in a nested list of integers, like the following:

board_state = [
    [0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0]
]

The number in a given square will represent what piece occupies the square. 0 represents an empty square, but other numbers would be assigned to all the black and white pieces. I decided to create a list of constant variables representing the pieces. You can find this list in the file 'piece.py'. Here's the list:

class Piece(Enum):
    NONE = 0
    WHITE_PAWN = 1
    WHITE_KNIGHT = 2
    WHITE_BISHOP = 3
    WHITE_ROOK = 4
    WHITE_QUEEN = 5
    WHITE_KING = 6
    BLACK_PAWN = 7
    BLACK_KNIGHT = 8
    BLACK_BISHOP = 9
    BLACK_ROOK = 10
    BLACK_QUEEN = 11
    BLACK_KING = 12

We also created some helper functions in the 'piece.py' file that helps later for working out logic on the board. This helper code wasn't realized up-front. I just created this as I found I needed it when working on the piece movement logic.

The board state is contained in the class 'Board' which lives in the file 'board.py'.

You'll also find some helpful code in 'board_setup.py' which helps us initialize the nested list to represent the starting position of a new chess game. I also planned on putting other starting board types here to test features of the game, such as a stalemate situation.

Movement Logic

The heart of the chess movement and logic happens in a function called 'perform_move' which also lives in the Board class in the 'board.py' file.

The essence of this function is:

Figuring out the pieces allowed squares was where all the fun began. You'll find in the file 'piece_moves.py' a set of functions that align to each kind of chess piece.

For example, there is a function named 'moves_for_white_pawn'. This function will inspect the state of a white pawn on the board and figure out what squares it has available to move to depending on where it currently is placed and what other pieces are around it. For example, on the starting rank for a pawn, it's allowed to move two squares, but only one square once it's past its start position.

Pawns can also attack one square on the diagonals in front of them if there is an enemy piece on those squares.

All this determination logic is contained in these move functions. The list of legal squares the functions produce can then be used to check if the piece the user just dragged is in the allowed list of moves.

Everything happening in these functions is simply a matter of knowing what piece you're dealing with and checking the state of the squares in the nested list representing the board.

It was a little clunky working with the nested list, but I stuck it out knowing that it wouldn't be that painful as the code really wasn't too complicated to warrant a different method.

Check and Check-Mate

The final challenges of the piece movement were the check and check-mate states of the game. Once I knew how all the pieces could move on the board, then checking for check and check-mate would simply be a matter of checking if the kings were in sight of the enemy pieces.

After every move, I would loop through all the enemy pieces, and ask the question "Can you capture the king?", meaning, does a piece have the kings square as one of its legal movement squares, indicating a capture. If any piece did, this means the king is in check - perfect!

Check-mate was a bit more tricky, ok, so we know the king is in check. But what about check-mate? Getting a king out of checkmate could mean moving the king out of the way of a threat. It could also mean another piece moving to block the threat or capturing the piece that is threatening the king.

My idea for this was a little wild. I figured that if I took every single move that the player could possibly make with every single one if its pieces, would ANY of these moves take the king out of check?

It seemed the idea would work in theory, but that sounds like a lot of work to perform for every piece move, but I couldn't see another way around this, so I went with it. If there was a performance problem doing it this way, then I'll deal with it when I hit it.

All this logic is performed in the functions '_is_player_in_check' and '_is_player_in_check_mate' inside the 'board.py' file. These functions are invoked by the 'perform_move' function.

It turned out that the idea worked perfectly, without any performance problems, awesome!

En Passant / Castling / Stalemate

After the check-mate logic, I felt that the remaining challenges weren't going to be as challenging, as these moves were just about adding to the existing legal moves for a piece.

For En Passant, we needed to track the last move of the game to detect if a pawn could take a pawn that moved two squares on the last turn. That was an easy enough thing to add as the 'perform_move' function could just make a recording of the last move, which we store in the 'last_moved_piece' and 'last_moved_piece_from' class variables in the 'Board' class.

Castling was a little more interesting. We had to check if the squares the king would pass over were clear from attacks. So for this, we used the logic similar to the check-mate logic, where we loop over pieces from the opponent and check if they could attack the squares the king moves through. Further to this, checking if the rooks or king had moved meant we just track that in a few more state variables which we detect in the 'perform_move' function. You'll see the castling state variables to assist with this logic at the top of the 'Board' class.

Move Results

A final mention about the 'perform_move' function is that all of the results of the move are communicated back to the caller using a 'MoveResult' object that you will find in 'move_result.py'. These indicators help the game logic know when to display labels about the state of who is in check and why a move was denied for example.

You'll find logic in the 'on_mouse_up' function in the 'chess.py' file just after the 'perform_move' is called. You'll spot how each state triggers more label conditions to display on the UI and even sounds. Which brings us on to the last element of this game.

Sounds

Sounds were really easy in PyGame. You can load a '.wav' file in one line and play that sound with just one more line of code.

We kept the loading and playing of sounds in the 'Sounds' class located in the 'sounds.py' file. This is mainly used by the 'chess.py' logic after performing a move in the 'on_mouse_up' function.

Improvements

With the game allowing a complete manual play through between the white and black pieces, it would be nice to see multiplayer support using sockets. One player could host a game, wait for another player to connect to them, and the turns could be exchanged using packets of data containing the moves through TCP.

Scaling of the window does not currently scale the board or the pieces. This is something that could be implemented if you feel like a challenge.

There is also no UI indication of why a move may have been denied. Also, when in check, you can't spot what piece might have you in check sometimes. Both of these could use some piece highlighting or square indications showing this state.

And finally, highlighting squares and drawing arrows on the board to assist your thinking about the game is also another nice feature found on other popular chess games.

I hope you've found this helpful and somewhat interesting enough to explore making your own chess game, or other types of board games.

Feel free to make improvements or further the development of the game. Let me know if you do!