Analyzing Chess Positions in Python - Building a Chess Analysis App (Part 1)
Tags: python, tutorial, programming, showdev
Published: 2021-11-14 03:12:51

Computers have been significantly better than humans at chess for a long time now. So much so that in the official macOS chess app, the computer has a short delay before it makes a move because "Users tend to get frustrated once they realize how little time their Mac really spends to crush them."

![Comment from the mac chess source code](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/l41674y6ezy9d6z7aqhb.png)

We can, however, use these powerful chess engines to get a better understanding of the game. In this series, we'll build a production-ready chess analysis application. Our users will submit chess positions for deep analysis with an engine.

This first post will focus on how to do the analysis itself.

## How are chess positions represented?

Luckily for us, there already exists a standard format called [FEN](https://en.wikipedia.org/wiki/Forsyth%E2%80%93Edwards_Notation) (or Forsyth–Edwards Notation). It describes the entire board state in one simple string.

```text
The starting position:
rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1
```

This includes where all the pieces are, who's turn it is, where the kings are allowed to castle, and more. Our users will submit positions in this format.

You can use the [Lichess board editor](https://lichess.org/editor) to set up a chess board and convert it to FEN.

## How do chess engines represent who's winning and by how much?

There are a lot of books about advantages in chess. Looking at a simple example:

![Chess board without queen](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/33x7k9qxik6x254xy45z.png)

This example is impossible, but it illustrates an important point. White has an obvious advantage here because Black doesn't have a queen. This is called a material advantage. However, there are many other factors to consider, 
such as whose turn it is, how safe each player's king is, etc.

Engines take a ton of information into account and output a `score` for a position. These scores are measured in [centipawns](https://chess.fandom.com/wiki/Centipawn), which is 1/100th of a pawn.

If an engine says that Black is winning by 100 centipawns, this is similar to saying, "Black's advantage is about the same as if everything is equal, but they are up one pawn."

In some cases, one player can checkmate the other, regardless of what the other player does. These are called "forced mates" or "mate in X moves." They are usually represented with a `#`. `#1` means that White has a single move that will checkmate Black. `#-3` means that Black can checkmate White within the next three moves, no matter what White does.

![Mate in one](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5e80fm2kysmtodju4brl.png)

This is a "mate in 1" because if white moves the queen to G7, it's checkmate.

## Analyzing a chess position with Stockfish and python-chess

[Stockfish](https://stockfishchess.org/) is an open-source chess engine. It is often regarded as the strongest chess engine that exists today. We'll use the python library `python-chess` to interact with it.

First, download/install Stockfish from the instructions [here](https://stockfishchess.org/download/). Make note of where it's installed. If you installed it via homebrew on a Mac, you can use `which` to find it:

```shell
$ which stockfish
/usr/local/bin/stockfish
```

Next, we'll create a Python [virtual environment](https://docs.python.org/3/tutorial/venv.html) and install our
dependencies.

```shell
$ mkdir chess-api
$ python3 -m venv venv
$ source venv/bin/activate
(venv) $ pip install python-chess
```

Let's create a small test file to analyze our mate in one position from before:

```py
import chess
import chess.engine

# Change this if stockfish is somewhere else
engine = chess.engine.SimpleEngine.popen_uci("/usr/local/bin/stockfish")

# The position represented in FEN
board = chess.Board("5Q2/5K1k/8/8/8/8/8/8 w - - 0 1")

# Limit our search so it doesn't run forever
info = engine.analyse(board, chess.engine.Limit(depth=20))
```

info contains a lot of information, but let's just look at two important fields:

```py
{
  'score': PovScore(Mate(+1), WHITE),
  'pv': [Move.from_uci('f8g7')],
}
```

The `score` tells us what Stockfish thinks of the position, which is a mate in one.

The `pv` (short for [principal variation](https://www.chessprogramming.org/Principal_Variation)) tells us the sequence of moves that the engine expects to be played. In this case, it's saying that it expects White to move their queen on f8 to g7, which is checkmate.

## Searching for more than one move

The `analyse` function has a few more options we haven't used, and one important one is `multipv`. `multipv=3` tells the engine to return the top 3 best moves, instead of just the best move.

```py
# ...same as before
board = chess.Board("5Q2/5K1k/8/8/8/8/8/8 w - - 0 1")

# Get the 3 best moves
info = engine.analyse(board, chess.engine.Limit(depth=20), multipv=3)

# Info is now an array with at most 3 elements
# If there aren't 3 valid moves, the array would have less than 3 elements
print(info[0])
print(info[1])
print(info[2])
```

## Putting it all together

With everything we learned, we can now write a wrapper function that we'll use in our app.

```py
import chess
import chess.engine

# Change this if stockfish is somewhere else
engine = chess.engine.SimpleEngine.popen_uci("/usr/local/bin/stockfish")

def analyze_position(fen, num_moves_to_return=1, depth_limit=None, time_limit=None):
    search_limit = chess.engine.Limit(depth=depth_limit, time=time_limit)
    board = chess.Board(fen)
    infos = engine.analyse(board, search_limit, multipv=num_moves_to_return)
    return [format_info(info) for info in infos]
   
def format_info(info):
    # Normalize by always looking from White's perspective
    score = info["score"].white()
    
    # Split up the score into a mate score and a centipawn score
    mate_score = score.mate()
    centipawn_score = score.score()
    return {
        "mate_score": mate_score,
        "centipawn_score": centipawn_score,
        "pv": format_moves(info["pv"]),
    }

# Convert the move class to a standard string 
def format_moves(pv):
    return [move.uci() for move in pv]
```

Let's use it to analyze this position (Black's turn to move):

![A different position](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rg2b37poln44v0zgw18x.png)

```py
print(analyze_position("8/8/6P1/4R3/8/6k1/2r5/6K1 b - - 0 1", num_moves_to_return=3, depth_limit=20))
```

```py
[
  {
    'mate_score': -2,
    'centipawn_score': None,
    'pv': ['c2c1', 'e5e1', 'c1e1']
  },
  {
    'mate_score': None,
    'centipawn_score': 0,
    'pv': ['c2c8', 'e5e3', 'g3h4', 'e3e7', 'c8c6', ...]
  },
  {
    'mate_score': None,
    'centipawn_score': 0,
    'pv': ['g3f4', 'e5e7', 'c2c8', 'g6g7', 'c8g8', ...]
  }
]
```

This is a position where Black has a forced checkmate in 2 moves, but there is only one move that forces this checkmate. If Black plays any other move, White can equalize (`centipawn_score` of 0). The engine then shows a long sequence of moves that it considers equal.

## Analyzing with other engines

Under the hood, python-chess communicates with Stockfish using a standard protocol called [UCI (Universal Chess Interface)](https://en.wikipedia.org/wiki/Universal_Chess_Interface). You can replace the path to Stockfish with the path to any engine that implements UCI and it'll work just the same.

## What next?

Given relatively little code, we are able to analyze chess positions using the most powerful chess engine around.

In the next post, we'll create a backend service using Flask where users can submit positions to be analyzed. We'll create a queue of positions that we analyze so our system doesn't get overloaded. See you then!