Introduction

Chess has fascinated minds for centuries, offering a battlefield where at any time players are looking at both the problem and the solution. If you're like me, you love to play this game. Since I love computer science I always wanted to write a program that could defeat me at the game. This has been one of the most rewarding journeys of my life. There is so much beauty and elegancy in chess programming.

When a friend asks for new project ideas, I always say: if you haven't done it yet, build a chess engine.

Now, fair warning—making a bot that can actually hold its ground (or beat a decent player) is no joke. You’ll go through some serious trial and error before getting it right, which is probably why a lot of people either never try or give up halfway. But here's the thing: with the right setup, you don’t have to struggle for weeks to get good results. That’s why I made this guide—to help you build a chess engine that might actually take you down… in a weekend. Sounds exciting, right? Just two days from now, you’ll have created a program that might beat you at your own game!

Since we’re on a weekend time crunch, though, we’ll get a bit of help from a library called shakmaty for move generation. No need to stress about setup either—I’ve put together a GitHub repo with all the starting files you’ll need. And, at any point, you can test your bot by playing against it!

Alright, enough introduction. Next up, I'll cover a few things I'll assume you know before getting our hands dirty. See you there!

Who is this book for

Hello again, in this section I'll cover a few things I'll take from granted from now on. In case you're new to any of these or you need a quick refresher, I'll leave links to some solid free resources on the internet.

First up, you should be a decent programmer: this means concepts like linked lists, vectors or hashmaps should sound familiar to you. If they don't I recommend watching CS50: Introduction to Computer Science, it's an excellent resource to learn programming.

Since the examples in the book (and the starter code) are written in Rust, you should be familiar with its syntax. No need to be an expert but you should be comfortable with everything in the Rust Book. If you don't know what Rust is, go there and take a look, it's a modern alternative to C++ that eliminates a lot of memory management related bugs. I'll promise it's worth learning. Also you can take a look at Rust by Example.

Last but not least, you should know how to play chess. But since you're here I guess you already do.

What is a Chess Engine?

A chess engine is a computer program made to play chess. It looks at a position on the board and figures out the best move.

A chess engine has three main parts:

  1. Board Representation
  2. Search
  3. Evaluation

Board Representation

To work, the chess engine needs a way to represent the game state. This is what allows it to generate all the legal moves for a position. The goal here is to use as little memory as possible while keeping things efficient, since memory usage can be a bottleneck. On top of that, generating moves quickly is important too.

There are a ton of ways to represent the game state and generate moves, but to keep things simple, we'll use the shakmaty crate to take care of both for us.

If you're curious and want to dive deeper into board representation or move generation algorithms, here's a helpful resource:

Humans play chess based on intuition and memory, but a chess engine uses raw computing power to analyze as many possible positions as it can. This process is called search. The search algorithm looks something like this:

function search(position){
    legal_moves = generate_moves(position)
    for(move in legal_moves){
        new_position = play(position, move)
        search(new_position)
    }
}

But obviously, this can go on forever, because there are way more possible chess positions than there are atoms in the universe. So, we need a way to stop the search at some point, which we'll talk about in the next sections.

Evaluation

Once the engine has analyzed a position, it needs to figure out how good or bad it is for the player. This is where evaluation comes in: it helps the engine rank moves based on their strength so it can pick the best one. It's not an easy task, though. Since we'll often stop the search in the middle of a game, we need to find a way to evaluate all kinds of positions, not just in endgames.

Getting started

Let's get started. First we are going to need to install the rust compiler. To do that just go to the Rust website and follow the steps.

Now we can download the GitHub repository with the starter code. To do that just open a terminal and:

git clone https://github.com/TheCaptainCraken/chess-bot-maker
cd chess-bot-maker

Now let's try the example bot, run:

cargo run

After the compilation is complete, open your favorite browser and go to http://localhost:8080. You should see something like this:

image of a chessboard

You play as white, the bot plays as black. To move a piece, drag it to the desired square.

The bot is literally playing random moves. The logic for now goes something like:

moves = get_legal_moves()
next_move = get_random_element(moves)

return next_move

You can se the code for this bot in src/bot.rs. Open it with your favorite text editor:

#![allow(unused)]
fn main() {
use rand::seq::SliceRandom;
use shakmaty::{Chess, Move, Position};

pub fn next_move(position: &Chess) -> Move {
    let legal_moves = position.legal_moves();

    let bot_move = legal_moves.choose(&mut rand::thread_rng()).unwrap();

    bot_move.clone()
}
}

For us, creating a bot is as simple as returning a valid move from next_move. Here we are asking shakmaty to generate all the legal moves from our current position. Then we are using the rand crate to choose a random move and we return it.

This bot is not the brightest but it can at least play a full game of chess. In the next chapters we'll work on choosing better and better moves.

What is going on with the rest of the code?

If you' re curious about how everything works behind the scenes, here's a quick tour.

The entry point for our application is src/main.rs, here we create a web server using actix web:

use actix_files as fs;
use actix_web::{get, web, App, HttpServer, Result};
use shakmaty::{fen::Fen, CastlingMode, Chess, Position};

mod bot;

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    println!("Starting test game @ http://localhost:8080");
    HttpServer::new(|| {
        App::new()
            .service(bot_move)
            .service(fs::Files::new("/", "./static/").index_file("index.html"))
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

This new app has two services. The first is called bot_move, the code for it is:

#![allow(unused)]
fn main() {
#[get("/bot-move/{fen}")]
async fn bot_move(path: web::Path<String>) -> Result<String> {
    let data = path.into_inner();

    let fen: Fen = data
        .replace("%2F", "/")
        .replace("%20", " ")
        .parse()
        .unwrap();

    let position: Chess = fen.into_position(CastlingMode::Standard).unwrap();

    let bot_move = bot::next_move(&position);

    let new_position = position.play(&bot_move).unwrap();

    let fen_string = format!(
        "{}",
        Fen::from_position(new_position, shakmaty::EnPassantMode::Always)
    );
    Ok(fen_string)
}
}

This is an API endpoint which receives a FEN formatted string. It then loads it into shakmaty to create a Chess struct. Then it calls next_move (our code from earlier), plays the move and prints the resulting position to a string in FEN notation. This is sent back to the user of the API.

The second service is a simple static file web server, it serves all the files in the static folder. It contains:

static/
├── pieces/
├── index.html
├── index.js
└── style.css
  • The pieces folder contains custom pieces made by Paula Bellesi Torralba.
  • index.html just renders a div and the title you see on the page.
  • index.js uses chessboard.js to create the chess board and controls it. It also calls our api from earlier to get the moves for the bot.
  • style.css just imports a font for the title and aligns everything on the page.

Contributing

I started this project to help other people find joy in chess programming. If you want to help me you are very welcome!

Please report any mistakes you find in the book or example code by either opening an issue on GitHub sending an email to thecaptaincraken@gmail.com.

Any feature request or suggestion is welcome and if you're an experienced developer you can help the project by submitting a PR!

Together we can make anything possible.

Basic evaluation

Alright, let's improve this evaluation function!

Currently we are:

  1. Generating all the legal moves
  2. Picking one at random

Not really Grandmaster worthy. We can do better. We need a way to find the best move we have.

One way is to evaluate each move by checking if it leads to a better or worse position for us. This means assessing if we're winning or losing in any given game state. Unfortunately, there is no way to do it perfectly, since chess is a very complex game.

Piece counting

A dead simple, easy-peasy method to evaluate a position is counting the number of pieces:

  • We have a lot of pieces = good
  • Enemy has few pieces = good

We can bring this further. Not all pieces are created equal: a queen is way stronger (and more valuable) than a pawn! We can assign each piece a score and then calculate the total for both players by counting pieces and multiplying by scores.

These are the most common scores assigned to pieces:

piecescore
pawn1
bishop3
knight3
rook5
queen9
king0

Let's look at an example:

example chessboard

Let's say we are white here, we have:

  • 1 king
  • 3 pawns
  • 1 bishop
  • 2 queens

This makes our score: \(1 \cdot 0 + 3 \cdot 1 + 1 \cdot 3 + 2 \cdot 9 = 24\).

Black has:

  • 1 king
  • 2 pawns
  • 1 knight

This makes their score: \( 1 \cdot 0 + 2 \cdot 1 + 1 \cdot 3 = 5 \). The total score for this position is \( 24 - 5 = 19 \) we are winning by \(19\) points! Yay!

Side note for the curious: the king has a score of \(0\), this is because we are only going to analyze legal positions so there will always be a king thus no score needed.

Implementation

Let's start with the piece counting function:

function count_pieces(position){
    scores = {
        pawn = 1
        bishop = knight = 3
        rook = 5
        queen = 9
        king = 0
    }

    white_score = count(position.board.white, king) * scores.king + count(position.board.white, pawn) * scores.pawn ...

    black_score = ...

    return white_score - black_score
}

Then we use it to calculate the best possible move:

function next_move(position){
    legal_moves = get_legal_moves(position)
    best_move = null
    best_score = -infinity

    for(move in legal_moves){
        new_position = position.play(move)
        score = count_pieces(new_position)
        if(score > best_score){
            best_score = score
            best_move = move
        }
    }
}

Now, let's check out some Rust code. We'll start with some helper functions:

#![allow(unused)]
fn main() {
fn is_opponent(piece_color: Color, our_color: Color) -> i64 {
    if piece_color == our_color {
        1
    } else {
        -1
    }
}
}

This function returns \(1\) if the piece is ours and \(-1\) if it isn't. We'll use this to know if we need to add or subtract a piece's score based on its color.

#![allow(unused)]
fn main() {
fn get_score(role: Role) -> i64 {
    match role {
        Role::Pawn => 1,
        Role::Knight => 3,
        Role::Bishop => 3,
        Role::Rook => 5,
        Role::Queen => 9,
        Role::King => 0,
    }
}
}

This function just returns for each piece, the score associated with it, the scores are the same as before.

#![allow(unused)]
fn main() {
fn evaluate(position: &Chess) -> i64 {
    /*
        This is using iterative folding to calculate the evaluation of the position.
        The evaluation is calculated by iterating over the pieces on the board and summing up the score of each piece.
        The score of each piece is calculated by multiplying the count of the piece by the score of the piece and then taken positively or negatively based on the color of the piece.
    */
    let score = position
        .board()
        .material()
        .zip_color()
        .iter()
        .fold(0, |acc, (color, pieces)| {
            acc + pieces.zip_role().iter().fold(0, |acc, (role, count)| {
                let score = get_score(*role)
                    * (*count as i64)
                    * is_opponent((*color).other(), position.turn()); // we have to invert the color because by playing the move we are changing the turn.
                acc + score
            })
        });

    score
}
}

Now, we're doing something interesting: this function takes the board, iterates over every piece, calculates its value and adds everything together. Our pieces are added while enemy pieces are subtracted. Then, we return the score.

#![allow(unused)]
fn main() {
pub fn next_move(position: &Chess) -> Move {
    let legal_moves = position.legal_moves(); // Get all legal moves

    // Find the move that maximizes the evaluation (piece count)
    let best_move = legal_moves
        .iter()
        .max_by_key(|legal_move| {
            let new_position = position.clone().play(legal_move).expect("Move is legal");
            let evaluation = evaluate(&new_position);
            evaluation
        })
        .expect("No legal moves found");

    best_move.clone()
}
}

Here we use evaluate() to find the move leading to the most favorable state. Then we return that move. If there is more than one move with the best value, we just return the first.

Let's try it

Run the program and see how the engine is doing.

  • Is it capturing pieces as soon as it can?
  • Is it choosing the highest value piece if it has a choice?

If it isn't, check out my code. You can find it in src/examples/basic_evaluation.rs. The most common mistake here is to add what should be subtracted and viceversa.

Conclusion

Now our little engine is finally choosing moves on its own and it tries go get ahead! Yay! Slight problem, the bot is now playing like an angry goose. It tries to kill everything it can, regardless of what is going to happen in the next moves. This isn't the tactical wisdom we are looking for.

In the next section we'll be looking at how to make our little duck think before acting using one of my favorites algorithms ever: Minimax.