Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Base Types

There are several types that occur again and again in a chess engine. Some of these types are Bitboards, Squares and Pieces, and they require some sort of representation within the program. Rustic calls these types “Base Types”: these are the fundamental types the engine is based upon.

It seems easy and logical to just use numbers for the squares (1-64, or 0-63), and numbers for the pieces as well (0 = king, 1 = queen .. 6 = none, and so on). This works, and Rustic Alpha 1 up to and including Alpha 3 did exactly that. Handling types this way has one huge drawback though. Let’s assume you have a function like this:

pub fn put(piece: u8, square: u8) {
    //...
}

This function puts a piece onto a square, but they are both u8s. This means these values can be swapped. If you do this, the engine will, at minimum, put the wrong piece on the wrong square if the square number is low enough to fit into the number of pieces, or it will panic while trying to put piece type 34 onto square 2. (Obviously, a chess game doesn’t have 34 different pieces; it only has 6.) Either way, the program’s board state may be corrupted, which makes the engine either play illegal moves or panic.

It looks like this can be resolved easily with the type keyword (and that is what Rustic Alpha 1-3 did):

type Piece = u8;
type Square = u8;

Now we can write:

pub fn put(piece: Piece, square: Square) {
    //...
}

However, this doesn’t solve anything. In this case, Piece and Square are just two different names for the u8 type, and they can still be mixed up. The solution to this problem is either the newtype pattern, using a struct, or creating a dedicated type using enums.

In Rust, a newtype is a small but important design pattern used to give a value a more precise meaning without changing how that value is stored in memory. Instead of using a primitive type such as u8 or u64 directly, the type is wrapped inside a new struct containing exactly one field. The result is a completely distinct type in the eyes of the compiler — literally a “new type”, which is where the pattern gets its name.

As mentioned earlier, using primitive types everywhere is dangerous because unrelated values are easy to mix up, and the compiler cannot help prevent these mistakes. You make a mistake, your program goes boom. A newtype solves this by introducing a dedicated wrapper. For example, for a bitboard we can define this:

pub struct Bitboard(u64);

Now, it is impossible to mix up a bitboard with any other u64s in the program. The Square and Piece types can also be wrapped in a dedicated wrapper. For these, Rustic uses enums. These are not newtypes in the strict Rust sense, because they do not wrap an existing primitive type. However, they serve the same purpose of creating distinct, type-safe domain types. Square and Piece are defined like this:

pub enum Square {
    A1 = 0, B1, C1, D1, E1, F1, G1, H1,
    A2, B2, C2, D2, E2, F2, G2, H2,
    A3, B3, C3, D3, E3, F3, G3, H3,
    A4, B4, C4, D4, E4, F4, G4, H4,
    A5, B5, C5, D5, E5, F5, G5, H5,
    A6, B6, C6, D6, E6, F6, G6, H6,
    A7, B7, C7, D7, E7, F7, G7, H7,
    A8, B8, C8, D8, E8, F8, G8, H8,
}

pub enum Piece {
    King = 0,
    Queen = 1,
    Rook = 2,
    Bishop = 3,
    Knight = 4,
    Pawn = 5,
    None = 6,
}

A square and a piece are now a distinct type that cannot be mixed up anymore, and because the compiler knows all the values that the type can have, it is also impossible to use a value that does not exist for the type. This makes programming a lot safer, at the expense of having to write the boiler plate for these types. (Uh… hello, AI. That is one of the few spots where AI was used, purely to save on typing work.)

As of Rustic 4.0.0, the engine includes the following base types:

  • Bitboard : This is the normal 64-bit bitboard
  • Square : Enum of all 64 squares
  • EverySquare : Array of length 64
  • Piece : Enum of 6 piece types + empty
  • EveryPiece : Array of length 6
  • Side : Enum for White and Black
  • EverySide : Array of length 2
  • File : Enum of File A-H
  • Rank : Enum of Rank R1-R8
  • Castling : Struct with Bitboards for castling permissions
  • EvalBase : Struct with the basis of Rustic’s evaluation
  • Error : Just a list of errors
  • NrOf : Just a list of numbers: NrOf::Squares, for example.

This gives us some very nice ergonomics. We can now write:

    let x : EverySquare<Bitboard>  = ...

It is clear now that the variable x holds a bitboard for each square. Obviously it is still possible to write nonsense:

    let x : EverySquare<EveryPiece<Rank>>  = ...

In this case, x holds a list of squares, where each square has a list of pieces, and each piece holds a Rank value from the board. I have no idea why this would be useful, but defining distinct types does not save you from defining weird combinations.

Some of these types require a large amount of implementation; especially Bitboard. Because a newtype is wrapped into a struct, you can’t do what you would normally be able to do. Taking Bitboard as an example:

// This works fine
let a: u64 = 15;
let b: u64 = 2;
let c: u64 = a & b;

struct Bitboard(pub u64);

let a: Bitboard = Bitboard(15);
let b: Bitboard = Bitboard(2);

// This doesn't work
let c: Bitboard = a & b;

The reason why it doesn’t work is because the Bitboard struct doesn’t know how to handle the “&” operator. Therefore you’ll have to implement this, along with “&=”, “|”, “|=” and so on. That is a perfect task to dedicate to an AI because after you implement one or two operators, you can implement them all; it just becomes a lot of typing work.

It is not really useful to show the exact implementation of the base types here. To review this, you are invited to take a look at the Codeberg repository. Also, if you use a different programming language, creation of new types will probably work differently, but I still urge you to create these types. They can help to catch a lot of mistakes before the program is even compiled.

Sidenote Some of this stuff could be dedicated to macros, but I decided to not do this. That way, the code is not made more abstract than absolutely necessary, and it shows what is actually required to write a chess engine in Rust.