Build a Chess Engine in C++20
Build a bitboard chess engine with alpha-beta search and UCI protocol.
Introduction
30 Second Summary
Chess engines are one of the most iconic achievements in computer science. They combine elegant data structures, search algorithms, and evaluation heuristics into a single program that can outplay most humans.
In this project, you will build a complete chess engine called "Zenith" in C++20. Your engine will represent the board using bitboards, generate all legal moves, search for the best move using alpha-beta pruning, and communicate with chess GUIs through the UCI protocol.
What You'll Build
A command-line chess engine that plays legal chess, evaluates positions, and connects to any UCI-compatible chess GUI like Arena or CuteChess.
By the end of this project, you'll have:
- A bitboard-based board representation with full legal move generation including castling, en passant, and promotion.
- An alpha-beta search engine with iterative deepening and quiescence search that finds reasonable moves within seconds.
- A complete CMake project with unit tests, benchmarks, and a UCI protocol handler for GUI compatibility.
- Secret Mission: Add a transposition table with Zobrist hashing to avoid re-searching positions and speed up your engine.
Are there any prerequisites?
You should have intermediate C++ knowledge, including classes, templates, and STL containers. A basic understanding of chess rules (how pieces move, check, and checkmate) is also needed.
No prior experience with chess programming, bitwise operations, or search algorithms is required. The project teaches all of these from scratch.
Set Up the Project and Build System
Every serious C++ project needs a well-organized directory structure and a reliable build system. Without these, compiling dozens of interconnected source files quickly becomes unmanageable.
In this step, you will create the Zenith project from scratch. You will set up the folder layout, configure CMake to compile C++20 code with Ninja, pull in the Catch2 testing framework, and create all the initial source files needed for a successful build.
In this step, get ready to:
- Create the project directory structure with separate folders for headers, source, and tests.
- Write a CMakeLists.txt that configures C++20, fetches Catch2, and defines build targets.
- Create all initial source files and verify the project compiles successfully.
Create the project directory and CMakeLists.txt
A chess engine has many moving parts: board logic, move generation, search, evaluation, and a protocol handler. Keeping these in a clean directory layout makes the project navigable as it grows.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project directory structure by running these commands:
mkdir -p zenith/include/zenith zenith/src zenith/tests
cd zenith
What does this directory layout mean?
The include/zenith/ folder holds public headers that define your engine's types and interfaces.
The src/ folder holds implementation files (the actual logic). The tests/ folder holds unit tests that verify each component works correctly.
- Confirm the directories exist by running:
ls
You should see include, src, and tests listed in the output.
Now you need a CMakeLists.txt file. This is the build configuration that tells CMake how to compile your project, which C++ standard to use, and where to find dependencies.
- Create CMakeLists.txt in the root of your zenith folder with the following content:
cmake_minimum_required(VERSION 3.20)
project(Zenith VERSION 1.0.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
# Disable C++20 module scanning (not used in this project)
set(CMAKE_CXX_SCAN_FOR_MODULES OFF)
include(FetchContent)
FetchContent_Declare(
Catch2
GIT_REPOSITORY https://github.com/catchorg/Catch2.git
GIT_TAG v3.15.0
)
FetchContent_MakeAvailable(Catch2)
# Core chess library
add_library(zenith_core STATIC
src/bitboard.cpp
src/board.cpp
src/movegen.cpp
src/evaluation.cpp
src/search.cpp
)
target_include_directories(zenith_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
target_compile_features(zenith_core PUBLIC cxx_std_20)
# Main engine executable
add_executable(zenith src/main.cpp src/uci.cpp)
target_link_libraries(zenith PRIVATE zenith_core)
# Tests
enable_testing()
add_executable(zenith_tests
tests/test_bitboard.cpp
tests/test_board.cpp
tests/test_movegen.cpp
tests/test_search.cpp
)
target_link_libraries(zenith_tests PRIVATE zenith_core Catch2::Catch2WithMain)
list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras)
include(CTest)
include(Catch)
catch_discover_tests(zenith_tests)
What does this CMakeLists.txt do?
- cmake_minimum_required(VERSION 3.20) ensures a modern CMake version that fully supports C++20.
- CMAKE_CXX_STANDARD 20 and CMAKE_CXX_STANDARD_REQUIRED ON enforce the C++20 standard for all targets.
- FetchContent downloads Catch2 v3.15.0 at configure time so you do not need to install it manually.
- zenith_core is a static library containing the engine logic. The zenith executable links against it.
- catch_discover_tests auto-registers all TEST_CASE macros with CTest so you can run tests with a single command.
- Save CMakeLists.txt.
Not sure where to create the file?
The CMakeLists.txt must be in the root of the zenith folder (at ~/Desktop/zenith/CMakeLists.txt). If you are using VS Code, open the zenith folder, then create the file in the top-level directory.
help me figure out where my CMakeLists.txt should go
Create the initial source files
Your CMakeLists.txt references several source files. You need to create all of them before the project can compile.
Start with the header files that define the engine's core types. These live in include/zenith/.
- Create include/zenith/types.hpp with the following content:
#pragma once
#include <cstdint>
#include <string_view>
#include <array>
namespace zenith {
using Bitboard = std::uint64_t;
enum class Color : std::uint8_t {
White = 0,
Black = 1
};
[[nodiscard]] constexpr Color operator~(Color c) noexcept {
return static_cast<Color>(static_cast<std::uint8_t>(c) ^ 1);
}
enum class PieceType : std::uint8_t {
None = 0,
Pawn = 1,
Knight = 2,
Bishop = 3,
Rook = 4,
Queen = 5,
King = 6
};
enum class Piece : std::uint8_t {
None = 0,
WhitePawn = 1, WhiteKnight = 2, WhiteBishop = 3,
WhiteRook = 4, WhiteQueen = 5, WhiteKing = 6,
BlackPawn = 7, BlackKnight = 8, BlackBishop = 9,
BlackRook = 10, BlackQueen = 11, BlackKing = 12
};
[[nodiscard]] constexpr PieceType type_of(Piece p) noexcept {
auto val = static_cast<std::uint8_t>(p);
if (val == 0) return PieceType::None;
return static_cast<PieceType>((val - 1) % 6 + 1);
}
[[nodiscard]] constexpr Color color_of(Piece p) noexcept {
return static_cast<std::uint8_t>(p) <= 6 ? Color::White : Color::Black;
}
[[nodiscard]] constexpr Piece make_piece(Color c, PieceType pt) noexcept {
return static_cast<Piece>(
static_cast<std::uint8_t>(pt) + static_cast<std::uint8_t>(c) * 6
);
}
enum class Square : std::uint8_t {
A1, 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,
None = 64
};
[[nodiscard]] constexpr int square_index(Square sq) noexcept {
return static_cast<int>(sq);
}
[[nodiscard]] constexpr Square make_square(int file, int rank) noexcept {
return static_cast<Square>(rank * 8 + file);
}
[[nodiscard]] constexpr int file_of(Square sq) noexcept {
return square_index(sq) % 8;
}
[[nodiscard]] constexpr int rank_of(Square sq) noexcept {
return square_index(sq) / 8;
}
enum class CastlingRights : std::uint8_t {
None = 0,
WhiteKingSide = 1,
WhiteQueenSide = 2,
BlackKingSide = 4,
BlackQueenSide = 8,
WhiteAll = WhiteKingSide | WhiteQueenSide,
BlackAll = BlackKingSide | BlackQueenSide,
All = WhiteAll | BlackAll
};
[[nodiscard]] constexpr CastlingRights operator|(CastlingRights a, CastlingRights b) noexcept {
return static_cast<CastlingRights>(
static_cast<std::uint8_t>(a) | static_cast<std::uint8_t>(b)
);
}
[[nodiscard]] constexpr CastlingRights operator&(CastlingRights a, CastlingRights b) noexcept {
return static_cast<CastlingRights>(
static_cast<std::uint8_t>(a) & static_cast<std::uint8_t>(b)
);
}
constexpr CastlingRights& operator|=(CastlingRights& a, CastlingRights b) noexcept {
a = a | b;
return a;
}
constexpr CastlingRights& operator&=(CastlingRights& a, CastlingRights b) noexcept {
a = a & b;
return a;
}
[[nodiscard]] constexpr CastlingRights operator~(CastlingRights a) noexcept {
return static_cast<CastlingRights>(~static_cast<std::uint8_t>(a));
}
// Move encoding: 32-bit compact representation
// Bits 0-5: from square
// Bits 6-11: to square
// Bits 12-15: flags (promotion piece, castling, en passant, double push)
enum class MoveFlag : std::uint8_t {
Quiet = 0,
DoublePush = 1,
KingCastle = 2,
QueenCastle = 3,
Capture = 4,
EnPassant = 5,
PromoteKnight = 8,
PromoteBishop = 9,
PromoteRook = 10,
PromoteQueen = 11,
CapturePromoteKnight = 12,
CapturePromoteBishop = 13,
CapturePromoteRook = 14,
CapturePromoteQueen = 15
};
class Move {
public:
constexpr Move() noexcept : data_(0) {}
constexpr Move(Square from, Square to, MoveFlag flag) noexcept
: data_(static_cast<std::uint16_t>(
static_cast<std::uint16_t>(square_index(from)) |
(static_cast<std::uint16_t>(square_index(to)) << 6) |
(static_cast<std::uint16_t>(flag) << 12)
)) {}
[[nodiscard]] constexpr Square from() const noexcept {
return static_cast<Square>(data_ & 0x3F);
}
[[nodiscard]] constexpr Square to() const noexcept {
return static_cast<Square>((data_ >> 6) & 0x3F);
}
[[nodiscard]] constexpr MoveFlag flag() const noexcept {
return static_cast<MoveFlag>((data_ >> 12) & 0xF);
}
[[nodiscard]] constexpr bool is_capture() const noexcept {
return (static_cast<std::uint8_t>(flag()) & 4) != 0;
}
[[nodiscard]] constexpr bool is_promotion() const noexcept {
return (static_cast<std::uint8_t>(flag()) & 8) != 0;
}
[[nodiscard]] constexpr bool is_castling() const noexcept {
auto f = flag();
return f == MoveFlag::KingCastle || f == MoveFlag::QueenCastle;
}
[[nodiscard]] constexpr bool is_null() const noexcept {
return data_ == 0;
}
[[nodiscard]] constexpr std::uint16_t raw() const noexcept {
return data_;
}
constexpr bool operator==(const Move& other) const noexcept = default;
// Convert to UCI long algebraic notation (e.g., "e2e4", "e7e8q")
[[nodiscard]] std::string to_uci() const;
private:
std::uint16_t data_;
};
// Constants
constexpr Bitboard FileA = 0x0101010101010101ULL;
constexpr Bitboard FileB = FileA << 1;
constexpr Bitboard FileG = FileA << 6;
constexpr Bitboard FileH = FileA << 7;
constexpr Bitboard Rank1 = 0xFFULL;
constexpr Bitboard Rank2 = Rank1 << 8;
constexpr Bitboard Rank7 = Rank1 << 48;
constexpr Bitboard Rank8 = Rank1 << 56;
constexpr std::string_view StartFEN =
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1";
} // namespace zenith
What is in types.hpp?
This header defines every fundamental type the engine uses. Color, PieceType, Piece, and Square are enum class types that prevent accidental conversions between unrelated values.
The Move class packs a chess move into 16 bits: 6 for the source square, 6 for the destination, and 4 for flags like captures, promotions, or castling. This compact encoding keeps move lists cache-friendly during search.
The Bitboard type alias (uint64_t) represents any set of squares as bits in a 64-bit integer, one bit per square on the chessboard.
- Create include/zenith/bitboard.hpp with the following content:
#pragma once
#include "zenith/types.hpp"
#include <bit>
#include <string>
namespace zenith {
// Population count (number of set bits)
[[nodiscard]] constexpr int popcount(Bitboard bb) noexcept {
return std::popcount(bb);
}
// Index of least significant bit
[[nodiscard]] constexpr int lsb(Bitboard bb) noexcept {
return std::countr_zero(bb);
}
// Pop least significant bit and return its index
[[nodiscard]] constexpr int pop_lsb(Bitboard& bb) noexcept {
int idx = lsb(bb);
bb &= bb - 1;
return idx;
}
// Set a bit at the given square
constexpr void set_bit(Bitboard& bb, Square sq) noexcept {
bb |= (1ULL << square_index(sq));
}
// Clear a bit at the given square
constexpr void clear_bit(Bitboard& bb, Square sq) noexcept {
bb &= ~(1ULL << square_index(sq));
}
// Test if a bit is set at the given square
[[nodiscard]] constexpr bool test_bit(Bitboard bb, Square sq) noexcept {
return (bb >> square_index(sq)) & 1;
}
// Print bitboard as 8x8 grid (for debugging)
void print_bitboard(Bitboard bb);
} // namespace zenith
Why use std::popcount and std::countr_zero?
These are C++20 <bit> header functions that compile down to single CPU instructions on modern processors. They replace older compiler-specific intrinsics like __builtin_popcountll with portable, standards-compliant code.
- Create include/zenith/board.hpp with the following content:
#pragma once
#include "zenith/types.hpp"
#include <array>
#include <string>
#include <string_view>
#include <vector>
namespace zenith {
struct BoardState {
CastlingRights castling = CastlingRights::None;
Square en_passant = Square::None;
int halfmove_clock = 0;
Piece captured = Piece::None;
};
class Board {
public:
Board();
// Setup
void set_from_fen(std::string_view fen);
void set_startpos();
// Accessors
[[nodiscard]] Bitboard pieces(Piece p) const noexcept;
[[nodiscard]] Bitboard pieces(Color c) const noexcept;
[[nodiscard]] Bitboard pieces(Color c, PieceType pt) const noexcept;
[[nodiscard]] Bitboard all_pieces() const noexcept;
[[nodiscard]] Piece piece_on(Square sq) const noexcept;
[[nodiscard]] Color side_to_move() const noexcept;
[[nodiscard]] CastlingRights castling_rights() const noexcept;
[[nodiscard]] Square en_passant_square() const noexcept;
[[nodiscard]] int halfmove_clock() const noexcept;
[[nodiscard]] int fullmove_number() const noexcept;
// Move execution
void make_move(Move move);
void unmake_move(Move move);
// Check detection
[[nodiscard]] bool is_in_check(Color c) const;
[[nodiscard]] bool is_in_check() const;
// Display
[[nodiscard]] std::string to_string() const;
[[nodiscard]] std::string to_fen() const;
private:
std::array<Bitboard, 13> piece_bbs_{}; // Index by Piece enum
std::array<Bitboard, 2> color_bbs_{}; // White, Black occupancy
std::array<Piece, 64> mailbox_{}; // Redundant mailbox for fast piece_on()
Color side_to_move_ = Color::White;
CastlingRights castling_ = CastlingRights::All;
Square en_passant_ = Square::None;
int halfmove_clock_ = 0;
int fullmove_number_ = 1;
std::vector<BoardState> state_stack_;
void place_piece(Piece p, Square sq);
void remove_piece(Square sq);
void move_piece(Square from, Square to);
};
} // namespace zenith
Now create the implementation files in src/. Several of these are placeholders that you will fill in during later steps.
- Create src/bitboard.cpp with the following content:
#include "zenith/bitboard.hpp"
#include <iostream>
#include <format>
namespace zenith {
void print_bitboard(Bitboard bb) {
std::cout << "\n";
for (int rank = 7; rank >= 0; --rank) {
std::cout << " " << (rank + 1) << " ";
for (int file = 0; file < 8; ++file) {
int sq = rank * 8 + file;
std::cout << ((bb >> sq) & 1 ? " 1" : " .");
}
std::cout << "\n";
}
std::cout << "\n a b c d e f g h\n";
std::cout << "\n Value: " << bb << "\n\n";
}
} // namespace zenith
- Create src/board.cpp with the following content:
#include "zenith/board.hpp"
#include "zenith/bitboard.hpp"
#include <sstream>
#include <stdexcept>
namespace zenith {
Board::Board() {
piece_bbs_.fill(0);
color_bbs_.fill(0);
mailbox_.fill(Piece::None);
}
void Board::set_startpos() {
set_from_fen(StartFEN);
}
void Board::set_from_fen(std::string_view fen) {
// Reset
piece_bbs_.fill(0);
color_bbs_.fill(0);
mailbox_.fill(Piece::None);
state_stack_.clear();
std::string fen_str(fen);
std::istringstream ss(fen_str);
std::string token;
// 1. Piece placement
ss >> token;
int rank = 7, file = 0;
for (char c : token) {
if (c == '/') {
rank--;
file = 0;
} else if (c >= '1' && c <= '8') {
file += (c - '0');
} else {
Piece piece = Piece::None;
switch (c) {
case 'P': piece = Piece::WhitePawn; break;
case 'N': piece = Piece::WhiteKnight; break;
case 'B': piece = Piece::WhiteBishop; break;
case 'R': piece = Piece::WhiteRook; break;
case 'Q': piece = Piece::WhiteQueen; break;
case 'K': piece = Piece::WhiteKing; break;
case 'p': piece = Piece::BlackPawn; break;
case 'n': piece = Piece::BlackKnight; break;
case 'b': piece = Piece::BlackBishop; break;
case 'r': piece = Piece::BlackRook; break;
case 'q': piece = Piece::BlackQueen; break;
case 'k': piece = Piece::BlackKing; break;
default: break;
}
if (piece != Piece::None) {
auto sq = make_square(file, rank);
place_piece(piece, sq);
}
file++;
}
}
// 2. Side to move
ss >> token;
side_to_move_ = (token == "w") ? Color::White : Color::Black;
// 3. Castling rights
ss >> token;
castling_ = CastlingRights::None;
for (char c : token) {
switch (c) {
case 'K': castling_ |= CastlingRights::WhiteKingSide; break;
case 'Q': castling_ |= CastlingRights::WhiteQueenSide; break;
case 'k': castling_ |= CastlingRights::BlackKingSide; break;
case 'q': castling_ |= CastlingRights::BlackQueenSide; break;
default: break;
}
}
// 4. En passant square
ss >> token;
if (token != "-" && token.size() == 2) {
int ep_file = token[0] - 'a';
int ep_rank = token[1] - '1';
en_passant_ = make_square(ep_file, ep_rank);
} else {
en_passant_ = Square::None;
}
// 5. Halfmove clock
if (ss >> token) {
halfmove_clock_ = std::stoi(token);
}
// 6. Fullmove number
if (ss >> token) {
fullmove_number_ = std::stoi(token);
}
}
Bitboard Board::pieces(Piece p) const noexcept {
return piece_bbs_[static_cast<std::size_t>(p)];
}
Bitboard Board::pieces(Color c) const noexcept {
return color_bbs_[static_cast<std::size_t>(c)];
}
Bitboard Board::pieces(Color c, PieceType pt) const noexcept {
return pieces(make_piece(c, pt));
}
Bitboard Board::all_pieces() const noexcept {
return color_bbs_[0] | color_bbs_[1];
}
Piece Board::piece_on(Square sq) const noexcept {
return mailbox_[square_index(sq)];
}
Color Board::side_to_move() const noexcept { return side_to_move_; }
CastlingRights Board::castling_rights() const noexcept { return castling_; }
Square Board::en_passant_square() const noexcept { return en_passant_; }
int Board::halfmove_clock() const noexcept { return halfmove_clock_; }
int Board::fullmove_number() const noexcept { return fullmove_number_; }
void Board::place_piece(Piece p, Square sq) {
set_bit(piece_bbs_[static_cast<std::size_t>(p)], sq);
set_bit(color_bbs_[static_cast<std::size_t>(color_of(p))], sq);
mailbox_[square_index(sq)] = p;
}
void Board::remove_piece(Square sq) {
Piece p = mailbox_[square_index(sq)];
if (p != Piece::None) {
clear_bit(piece_bbs_[static_cast<std::size_t>(p)], sq);
clear_bit(color_bbs_[static_cast<std::size_t>(color_of(p))], sq);
mailbox_[square_index(sq)] = Piece::None;
}
}
void Board::move_piece(Square from, Square to) {
Piece p = mailbox_[square_index(from)];
remove_piece(from);
place_piece(p, to);
}
void Board::make_move([[maybe_unused]] Move move) {
// TODO: Implement in Step 6
}
void Board::unmake_move([[maybe_unused]] Move move) {
// TODO: Implement in Step 6
}
bool Board::is_in_check([[maybe_unused]] Color c) const {
// TODO: Implement in Step 5
return false;
}
bool Board::is_in_check() const {
return is_in_check(side_to_move_);
}
std::string Board::to_string() const {
std::string result = "\n";
for (int rank = 7; rank >= 0; --rank) {
result += " " + std::to_string(rank + 1) + " ";
for (int file = 0; file < 8; ++file) {
Piece p = mailbox_[rank * 8 + file];
char c = '.';
constexpr std::array<char, 13> symbols = {
'.', 'P', 'N', 'B', 'R', 'Q', 'K',
'p', 'n', 'b', 'r', 'q', 'k'
};
c = symbols[static_cast<std::size_t>(p)];
result += ' ';
result += c;
}
result += '\n';
}
result += "\n a b c d e f g h\n\n";
result += " Side to move: ";
result += (side_to_move_ == Color::White ? "White" : "Black");
result += "\n";
return result;
}
std::string Board::to_fen() const {
std::string fen;
constexpr std::array<char, 13> symbols = {
'.', 'P', 'N', 'B', 'R', 'Q', 'K',
'p', 'n', 'b', 'r', 'q', 'k'
};
for (int rank = 7; rank >= 0; --rank) {
int empty = 0;
for (int file = 0; file < 8; ++file) {
Piece p = mailbox_[rank * 8 + file];
if (p == Piece::None) {
empty++;
} else {
if (empty > 0) {
fen += std::to_string(empty);
empty = 0;
}
fen += symbols[static_cast<std::size_t>(p)];
}
}
if (empty > 0) fen += std::to_string(empty);
if (rank > 0) fen += '/';
}
fen += (side_to_move_ == Color::White) ? " w " : " b ";
// Castling
std::string castling;
if ((castling_ & CastlingRights::WhiteKingSide) != CastlingRights::None) castling += 'K';
if ((castling_ & CastlingRights::WhiteQueenSide) != CastlingRights::None) castling += 'Q';
if ((castling_ & CastlingRights::BlackKingSide) != CastlingRights::None) castling += 'k';
if ((castling_ & CastlingRights::BlackQueenSide) != CastlingRights::None) castling += 'q';
fen += castling.empty() ? "-" : castling;
// En passant
if (en_passant_ != Square::None) {
fen += ' ';
fen += static_cast<char>('a' + file_of(en_passant_));
fen += static_cast<char>('1' + rank_of(en_passant_));
} else {
fen += " -";
}
fen += " " + std::to_string(halfmove_clock_);
fen += " " + std::to_string(fullmove_number_);
return fen;
}
} // namespace zenith
- Create the three placeholder source files. First, src/movegen.cpp:
#include "zenith/types.hpp"
namespace zenith {
// Placeholder - will be implemented in Step 5
// Move generation logic goes here
} // namespace zenith
- Create src/evaluation.cpp:
#include "zenith/types.hpp"
namespace zenith {
// Placeholder - will be implemented in Step 7
// Evaluation logic goes here
} // namespace zenith
- Create src/search.cpp:
#include "zenith/types.hpp"
namespace zenith {
// Placeholder - will be implemented in Step 8
// Search logic goes here
} // namespace zenith
- Create src/uci.cpp with the UCI protocol handler:
#include "zenith/board.hpp"
#include <iostream>
#include <sstream>
#include <string>
namespace zenith {
void uci_loop() {
Board board;
board.set_startpos();
std::string line;
while (std::getline(std::cin, line)) {
std::istringstream ss(line);
std::string command;
ss >> command;
if (command == "uci") {
std::cout << "id name Zenith 1.0.0" << std::endl;
std::cout << "id author Developer" << std::endl;
std::cout << "uciok" << std::endl;
} else if (command == "isready") {
std::cout << "readyok" << std::endl;
} else if (command == "ucinewgame") {
board.set_startpos();
} else if (command == "position") {
std::string type;
ss >> type;
if (type == "startpos") {
board.set_startpos();
} else if (type == "fen") {
std::string fen;
std::string part;
for (int i = 0; i < 6 && ss >> part; ++i) {
if (i > 0) fen += ' ';
fen += part;
if (part == "moves") { break; }
}
if (fen.find("moves") == std::string::npos) {
board.set_from_fen(fen);
}
}
// TODO: Parse "moves" and apply them
} else if (command == "go") {
// TODO: Implement search and return bestmove
std::cout << "bestmove e2e4" << std::endl;
} else if (command == "quit") {
break;
} else if (command == "d") {
std::cout << board.to_string() << std::endl;
std::cout << "FEN: " << board.to_fen() << std::endl;
}
}
}
} // namespace zenith
What is the UCI protocol?
UCI (Universal Chess Interface) is the standard protocol that lets chess engines communicate with graphical interfaces. The engine reads text commands from stdin and writes responses to stdout.
For now, the handler responds to basic commands like uci, isready, and position. It returns a hardcoded bestmove e2e4 until you implement the search in a later step.
- Create src/main.cpp with the program entry point:
#include "zenith/types.hpp"
#include "zenith/board.hpp"
#include <iostream>
#include <string>
// Forward declaration - implemented in uci.cpp
namespace zenith {
void uci_loop();
}
int main(int argc, char* argv[]) {
std::cout << "Zenith Chess Engine v1.0.0" << std::endl;
// Initialize attack tables
// zenith::attacks::init(); // Uncomment when attack module is ready
if (argc > 1 && std::string(argv[1]) == "bench") {
// Run benchmark mode
std::cout << "Benchmark mode not yet implemented." << std::endl;
return 0;
}
// Default: UCI mode
zenith::uci_loop();
return 0;
}
Finally, create the test files in tests/.
- Create tests/test_bitboard.cpp:
#include <catch2/catch_test_macros.hpp>
#include "zenith/bitboard.hpp"
using namespace zenith;
TEST_CASE("Bitboard popcount", "[bitboard]") {
REQUIRE(popcount(0) == 0);
REQUIRE(popcount(1) == 1);
REQUIRE(popcount(0xFF) == 8);
REQUIRE(popcount(0xFFFFFFFFFFFFFFFFULL) == 64);
}
TEST_CASE("Bitboard lsb", "[bitboard]") {
REQUIRE(lsb(1ULL) == 0);
REQUIRE(lsb(2ULL) == 1);
REQUIRE(lsb(8ULL) == 3);
REQUIRE(lsb(0x8000000000000000ULL) == 63);
}
TEST_CASE("Bitboard set and test", "[bitboard]") {
Bitboard bb = 0;
set_bit(bb, Square::E4);
REQUIRE(test_bit(bb, Square::E4));
REQUIRE_FALSE(test_bit(bb, Square::E5));
clear_bit(bb, Square::E4);
REQUIRE_FALSE(test_bit(bb, Square::E4));
}
TEST_CASE("Bitboard pop_lsb", "[bitboard]") {
Bitboard bb = 0;
set_bit(bb, Square::A1);
set_bit(bb, Square::H8);
int first = pop_lsb(bb);
REQUIRE(first == 0); // A1
REQUIRE(popcount(bb) == 1);
int second = pop_lsb(bb);
REQUIRE(second == 63); // H8
REQUIRE(bb == 0);
}
- Create tests/test_board.cpp:
#include <catch2/catch_test_macros.hpp>
#include "zenith/board.hpp"
using namespace zenith;
TEST_CASE("Board startpos FEN", "[board]") {
Board board;
board.set_startpos();
REQUIRE(board.side_to_move() == Color::White);
REQUIRE(board.castling_rights() == CastlingRights::All);
REQUIRE(board.en_passant_square() == Square::None);
REQUIRE(board.halfmove_clock() == 0);
REQUIRE(board.fullmove_number() == 1);
}
TEST_CASE("Board piece placement from startpos", "[board]") {
Board board;
board.set_startpos();
REQUIRE(board.piece_on(Square::E1) == Piece::WhiteKing);
REQUIRE(board.piece_on(Square::D8) == Piece::BlackQueen);
REQUIRE(board.piece_on(Square::A2) == Piece::WhitePawn);
REQUIRE(board.piece_on(Square::E4) == Piece::None);
}
TEST_CASE("Board FEN round-trip", "[board]") {
Board board;
std::string_view fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1";
board.set_from_fen(fen);
REQUIRE(board.to_fen() == fen);
REQUIRE(board.side_to_move() == Color::Black);
REQUIRE(board.en_passant_square() == Square::E3);
}
TEST_CASE("Board piece bitboards", "[board]") {
Board board;
board.set_startpos();
REQUIRE(popcount(board.pieces(Color::White, PieceType::Pawn)) == 8);
REQUIRE(popcount(board.pieces(Color::Black, PieceType::Pawn)) == 8);
REQUIRE(popcount(board.pieces(Color::White)) == 16);
REQUIRE(popcount(board.pieces(Color::Black)) == 16);
REQUIRE(popcount(board.all_pieces()) == 32);
}
- Create tests/test_movegen.cpp:
#include <catch2/catch_test_macros.hpp>
#include "zenith/types.hpp"
using namespace zenith;
TEST_CASE("Move encoding", "[move]") {
Move move(Square::E2, Square::E4, MoveFlag::DoublePush);
REQUIRE(move.from() == Square::E2);
REQUIRE(move.to() == Square::E4);
REQUIRE(move.flag() == MoveFlag::DoublePush);
REQUIRE_FALSE(move.is_capture());
REQUIRE_FALSE(move.is_promotion());
}
TEST_CASE("Move capture flag", "[move]") {
Move move(Square::E4, Square::D5, MoveFlag::Capture);
REQUIRE(move.is_capture());
REQUIRE_FALSE(move.is_promotion());
}
TEST_CASE("Move promotion", "[move]") {
Move move(Square::E7, Square::E8, MoveFlag::PromoteQueen);
REQUIRE(move.is_promotion());
REQUIRE_FALSE(move.is_capture());
}
TEST_CASE("Move null check", "[move]") {
Move null_move;
REQUIRE(null_move.is_null());
Move real_move(Square::E2, Square::E4, MoveFlag::Quiet);
REQUIRE_FALSE(real_move.is_null());
}
- Create tests/test_search.cpp:
#include <catch2/catch_test_macros.hpp>
#include "zenith/types.hpp"
using namespace zenith;
// Placeholder tests - will be expanded in Step 8
TEST_CASE("Search placeholder", "[search]") {
REQUIRE(true); // Placeholder until search is implemented
}
- Save all files.
Missing a file or not sure about placement?
Double check your directory layout matches this structure:
zenith/
CMakeLists.txt
include/zenith/types.hpp
include/zenith/bitboard.hpp
include/zenith/board.hpp
src/bitboard.cpp
src/board.cpp
src/movegen.cpp
src/evaluation.cpp
src/search.cpp
src/uci.cpp
src/main.cpp
tests/test_bitboard.cpp
tests/test_board.cpp
tests/test_movegen.cpp
tests/test_search.cpp
help me verify my Zenith project file structure is correct
Configure and build the project
With all source files in place, you can now run CMake to generate the build system and compile everything. The first configure step will also download Catch2 from GitHub, which may take a moment.
- Make sure your terminal is in the zenith root directory (~/Desktop/zenith).
- Configure the project with CMake and Ninja by running:
cmake -S . -B build -G Ninja
What does this command do?
- -S . tells CMake the source directory is the current folder (where CMakeLists.txt lives).
- -B build creates a separate build directory for all generated build files, keeping your source tree clean.
- -G Ninja selects the Ninja build system generator, which is faster than Make for incremental builds.
CMake will download Catch2 and generate the Ninja build files. You should see output ending with lines like -- Build files have been written to: followed by the path to your build directory.
- Build the project by running:
cmake --build build
You should see the compiler process each source file and link the zenith executable successfully with no errors.
- Verify the engine runs by executing:
./build/zenith
You should see Zenith Chess Engine v1.0.0 printed to the terminal. The engine is now waiting for UCI commands on stdin.
- Type quit and press Enter to exit the engine.
Build failed with errors?
If you see errors about missing headers, verify your include/zenith/ directory contains all three .hpp files. CMake looks for headers in the path set by target_include_directories.
If you see errors about C++20 features not being supported, confirm your compiler supports C++20 by checking its version. GCC 10+ and Clang 10+ both provide C++20 support.
help me debug my Zenith CMake build errors
✔️ Awesome, I've got everything!
Great. Double check you have saved all files, then move on to the next step.
ⓧ I'd like to double check the full code
Here is the complete CMakeLists.txt for reference:
cmake_minimum_required(VERSION 3.20)
project(Zenith VERSION 1.0.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
# Disable C++20 module scanning (not used in this project)
set(CMAKE_CXX_SCAN_FOR_MODULES OFF)
include(FetchContent)
FetchContent_Declare(
Catch2
GIT_REPOSITORY https://github.com/catchorg/Catch2.git
GIT_TAG v3.15.0
)
FetchContent_MakeAvailable(Catch2)
# Core chess library
add_library(zenith_core STATIC
src/bitboard.cpp
src/board.cpp
src/movegen.cpp
src/evaluation.cpp
src/search.cpp
)
target_include_directories(zenith_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
target_compile_features(zenith_core PUBLIC cxx_std_20)
# Main engine executable
add_executable(zenith src/main.cpp src/uci.cpp)
target_link_libraries(zenith PRIVATE zenith_core)
# Tests
enable_testing()
add_executable(zenith_tests
tests/test_bitboard.cpp
tests/test_board.cpp
tests/test_movegen.cpp
tests/test_search.cpp
)
target_link_libraries(zenith_tests PRIVATE zenith_core Catch2::Catch2WithMain)
list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras)
include(CTest)
include(Catch)
catch_discover_tests(zenith_tests)
Here is src/main.cpp:
#include "zenith/types.hpp"
#include "zenith/board.hpp"
#include <iostream>
#include <string>
// Forward declaration - implemented in uci.cpp
namespace zenith {
void uci_loop();
}
int main(int argc, char* argv[]) {
std::cout << "Zenith Chess Engine v1.0.0" << std::endl;
// Initialize attack tables
// zenith::attacks::init(); // Uncomment when attack module is ready
if (argc > 1 && std::string(argv[1]) == "bench") {
// Run benchmark mode
std::cout << "Benchmark mode not yet implemented." << std::endl;
return 0;
}
// Default: UCI mode
zenith::uci_loop();
return 0;
}
Here is src/uci.cpp:
#include "zenith/board.hpp"
#include <iostream>
#include <sstream>
#include <string>
namespace zenith {
void uci_loop() {
Board board;
board.set_startpos();
std::string line;
while (std::getline(std::cin, line)) {
std::istringstream ss(line);
std::string command;
ss >> command;
if (command == "uci") {
std::cout << "id name Zenith 1.0.0" << std::endl;
std::cout << "id author Developer" << std::endl;
std::cout << "uciok" << std::endl;
} else if (command == "isready") {
std::cout << "readyok" << std::endl;
} else if (command == "ucinewgame") {
board.set_startpos();
} else if (command == "position") {
std::string type;
ss >> type;
if (type == "startpos") {
board.set_startpos();
} else if (type == "fen") {
std::string fen;
std::string part;
for (int i = 0; i < 6 && ss >> part; ++i) {
if (i > 0) fen += ' ';
fen += part;
if (part == "moves") { break; }
}
if (fen.find("moves") == std::string::npos) {
board.set_from_fen(fen);
}
}
// TODO: Parse "moves" and apply them
} else if (command == "go") {
// TODO: Implement search and return bestmove
std::cout << "bestmove e2e4" << std::endl;
} else if (command == "quit") {
break;
} else if (command == "d") {
std::cout << board.to_string() << std::endl;
std::cout << "FEN: " << board.to_fen() << std::endl;
}
}
}
} // namespace zenith
Your chess engine project is scaffolded and compiling. Next, you will implement the bitboard utilities and board representation that give the engine its speed advantage.
Build the Board Representation with Bitboards
In the previous step, you set up the Zenith project skeleton with CMake, placeholder files, and a successful build. Now it is time to fill those placeholders with the core data structures that power every chess engine.
A chess engine needs to answer the question "what is happening on this board?" millions of times per second. The data structure you choose for the board determines how fast the engine thinks. You will use bitboards to represent piece positions as 64-bit integers, where each bit maps to one square on the 8x8 board.
In this step, get ready to:
- Define all chess types (pieces, squares, colors, moves) in a shared header.
- Build bitboard utility functions using C++20's <bit> header.
- Create the Board class with dual representation (bitboards plus mailbox array).
Implement the types header
Every module in the engine needs shared vocabulary: what is a square, a piece, a color, a move? The types.hpp header defines all of these as strongly-typed enum class values, preventing accidental mixing of unrelated integers.
- Create the file include/zenith/types.hpp in VS Code.
- Copy in the first section of the types header, covering the Bitboard type alias, Color, PieceType, and Piece enums with helper functions:
#pragma once
#include <cstdint>
#include <string_view>
#include <array>
namespace zenith {
// A bitboard is a 64-bit integer where each bit represents one square
using Bitboard = std::uint64_t;
// White = 0, Black = 1 for array indexing
enum class Color : std::uint8_t {
White = 0,
Black = 1
};
// Flip color: ~White == Black and vice versa
[[nodiscard]] constexpr Color operator~(Color c) noexcept {
return static_cast<Color>(static_cast<std::uint8_t>(c) ^ 1);
}
// Piece types without color information
enum class PieceType : std::uint8_t {
None = 0,
Pawn = 1,
Knight = 2,
Bishop = 3,
Rook = 4,
Queen = 5,
King = 6
};
// Full piece identifier: color + type encoded in one byte
enum class Piece : std::uint8_t {
None = 0,
WhitePawn = 1, WhiteKnight = 2, WhiteBishop = 3,
WhiteRook = 4, WhiteQueen = 5, WhiteKing = 6,
BlackPawn = 7, BlackKnight = 8, BlackBishop = 9,
BlackRook = 10, BlackQueen = 11, BlackKing = 12
};
// Extract the piece type from a full Piece value
[[nodiscard]] constexpr PieceType type_of(Piece p) noexcept {
auto val = static_cast<std::uint8_t>(p);
if (val == 0) return PieceType::None;
return static_cast<PieceType>((val - 1) % 6 + 1);
}
// Extract the color from a full Piece value
[[nodiscard]] constexpr Color color_of(Piece p) noexcept {
return static_cast<std::uint8_t>(p) <= 6 ? Color::White : Color::Black;
}
// Combine color and piece type into a full Piece
[[nodiscard]] constexpr Piece make_piece(Color c, PieceType pt) noexcept {
return static_cast<Piece>(
static_cast<std::uint8_t>(pt) + static_cast<std::uint8_t>(c) * 6
);
}
What does this code do?
The Bitboard type alias maps a 64-bit unsigned integer to the 64 squares of a chessboard. Each bit being set (1) means "a piece is here."
The Piece enum encodes all 12 piece/color combinations (6 white, 6 black) as consecutive integers 1-12. The helper functions type_of, color_of, and make_piece let you convert between the combined and separated representations without casting manually.
- Continue adding the Square enum and CastlingRights enum directly below the previous code in the same file:
// Squares enumerated A1=0 through H8=63, left-to-right, bottom-to-top
enum class Square : std::uint8_t {
A1, 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,
None = 64
};
// Convert Square to integer index (0-63)
[[nodiscard]] constexpr int square_index(Square sq) noexcept {
return static_cast<int>(sq);
}
// Build a Square from file (0-7) and rank (0-7)
[[nodiscard]] constexpr Square make_square(int file, int rank) noexcept {
return static_cast<Square>(rank * 8 + file);
}
// Extract file (column, 0=a through 7=h) from a Square
[[nodiscard]] constexpr int file_of(Square sq) noexcept {
return square_index(sq) % 8;
}
// Extract rank (row, 0=rank1 through 7=rank8) from a Square
[[nodiscard]] constexpr int rank_of(Square sq) noexcept {
return square_index(sq) / 8;
}
// Castling rights stored as bit flags
enum class CastlingRights : std::uint8_t {
None = 0,
WhiteKingSide = 1,
WhiteQueenSide = 2,
BlackKingSide = 4,
BlackQueenSide = 8,
WhiteAll = WhiteKingSide | WhiteQueenSide,
BlackAll = BlackKingSide | BlackQueenSide,
All = WhiteAll | BlackAll
};
// Bitwise operators for combining and masking castling rights
[[nodiscard]] constexpr CastlingRights operator|(CastlingRights a, CastlingRights b) noexcept {
return static_cast<CastlingRights>(
static_cast<std::uint8_t>(a) | static_cast<std::uint8_t>(b)
);
}
[[nodiscard]] constexpr CastlingRights operator&(CastlingRights a, CastlingRights b) noexcept {
return static_cast<CastlingRights>(
static_cast<std::uint8_t>(a) & static_cast<std::uint8_t>(b)
);
}
constexpr CastlingRights& operator|=(CastlingRights& a, CastlingRights b) noexcept {
a = a | b;
return a;
}
constexpr CastlingRights& operator&=(CastlingRights& a, CastlingRights b) noexcept {
a = a & b;
return a;
}
[[nodiscard]] constexpr CastlingRights operator~(CastlingRights a) noexcept {
return static_cast<CastlingRights>(~static_cast<std::uint8_t>(a));
}
What does this code do?
The Square enum assigns a number 0-63 to every square on the board. A1 is bit 0 (bottom-left), H8 is bit 63 (top-right). This mapping matches the bit positions in a Bitboard directly.
The CastlingRights enum uses bit flags so you can combine and test multiple rights with bitwise OR/AND. The operator overloads let you write natural expressions like rights |= CastlingRights::WhiteKingSide instead of manual casts.
- Add the final section of types.hpp below the castling operators. This defines the Move class, move flags, and board constants:
// Flags encoded in the top 4 bits of a Move
enum class MoveFlag : std::uint8_t {
Quiet = 0,
DoublePush = 1,
KingCastle = 2,
QueenCastle = 3,
Capture = 4,
EnPassant = 5,
PromoteKnight = 8,
PromoteBishop = 9,
PromoteRook = 10,
PromoteQueen = 11,
CapturePromoteKnight = 12,
CapturePromoteBishop = 13,
CapturePromoteRook = 14,
CapturePromoteQueen = 15
};
// Compact 16-bit move: 6 bits from, 6 bits to, 4 bits flags
class Move {
public:
constexpr Move() noexcept : data_(0) {}
constexpr Move(Square from, Square to, MoveFlag flag) noexcept
: data_(static_cast<std::uint16_t>(
static_cast<std::uint16_t>(square_index(from)) |
(static_cast<std::uint16_t>(square_index(to)) << 6) |
(static_cast<std::uint16_t>(flag) << 12)
)) {}
[[nodiscard]] constexpr Square from() const noexcept {
return static_cast<Square>(data_ & 0x3F);
}
[[nodiscard]] constexpr Square to() const noexcept {
return static_cast<Square>((data_ >> 6) & 0x3F);
}
[[nodiscard]] constexpr MoveFlag flag() const noexcept {
return static_cast<MoveFlag>((data_ >> 12) & 0xF);
}
[[nodiscard]] constexpr bool is_capture() const noexcept {
return (static_cast<std::uint8_t>(flag()) & 4) != 0;
}
[[nodiscard]] constexpr bool is_promotion() const noexcept {
return (static_cast<std::uint8_t>(flag()) & 8) != 0;
}
[[nodiscard]] constexpr bool is_castling() const noexcept {
auto f = flag();
return f == MoveFlag::KingCastle || f == MoveFlag::QueenCastle;
}
[[nodiscard]] constexpr bool is_null() const noexcept {
return data_ == 0;
}
[[nodiscard]] constexpr std::uint16_t raw() const noexcept {
return data_;
}
constexpr bool operator==(const Move& other) const noexcept = default;
// Convert to UCI long algebraic notation (e.g., "e2e4", "e7e8q")
[[nodiscard]] std::string to_uci() const;
private:
std::uint16_t data_;
};
// File and rank bitmasks for filtering bitboard operations
constexpr Bitboard FileA = 0x0101010101010101ULL;
constexpr Bitboard FileB = FileA << 1;
constexpr Bitboard FileG = FileA << 6;
constexpr Bitboard FileH = FileA << 7;
constexpr Bitboard Rank1 = 0xFFULL;
constexpr Bitboard Rank2 = Rank1 << 8;
constexpr Bitboard Rank7 = Rank1 << 48;
constexpr Bitboard Rank8 = Rank1 << 56;
// Standard starting position in FEN notation
constexpr std::string_view StartFEN =
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1";
} // namespace zenith
Why a 16-bit Move encoding?
During search, the engine stores millions of moves in memory. A compact 16-bit encoding (6 bits source square + 6 bits destination + 4 bits flags) keeps move lists cache-friendly. The flag bits cleverly encode whether a move is a capture (bit 2 set), a promotion (bit 3 set), or both.
The file/rank constants (FileA, Rank1, etc.) are precomputed bitmasks. You will use them later to prevent pawns from wrapping around the board edges during move generation.
- Save include/zenith/types.hpp.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
#pragma once
#include <cstdint>
#include <string_view>
#include <array>
namespace zenith {
using Bitboard = std::uint64_t;
enum class Color : std::uint8_t {
White = 0,
Black = 1
};
[[nodiscard]] constexpr Color operator~(Color c) noexcept {
return static_cast<Color>(static_cast<std::uint8_t>(c) ^ 1);
}
enum class PieceType : std::uint8_t {
None = 0,
Pawn = 1,
Knight = 2,
Bishop = 3,
Rook = 4,
Queen = 5,
King = 6
};
enum class Piece : std::uint8_t {
None = 0,
WhitePawn = 1, WhiteKnight = 2, WhiteBishop = 3,
WhiteRook = 4, WhiteQueen = 5, WhiteKing = 6,
BlackPawn = 7, BlackKnight = 8, BlackBishop = 9,
BlackRook = 10, BlackQueen = 11, BlackKing = 12
};
[[nodiscard]] constexpr PieceType type_of(Piece p) noexcept {
auto val = static_cast<std::uint8_t>(p);
if (val == 0) return PieceType::None;
return static_cast<PieceType>((val - 1) % 6 + 1);
}
[[nodiscard]] constexpr Color color_of(Piece p) noexcept {
return static_cast<std::uint8_t>(p) <= 6 ? Color::White : Color::Black;
}
[[nodiscard]] constexpr Piece make_piece(Color c, PieceType pt) noexcept {
return static_cast<Piece>(
static_cast<std::uint8_t>(pt) + static_cast<std::uint8_t>(c) * 6
);
}
enum class Square : std::uint8_t {
A1, 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,
None = 64
};
[[nodiscard]] constexpr int square_index(Square sq) noexcept {
return static_cast<int>(sq);
}
[[nodiscard]] constexpr Square make_square(int file, int rank) noexcept {
return static_cast<Square>(rank * 8 + file);
}
[[nodiscard]] constexpr int file_of(Square sq) noexcept {
return square_index(sq) % 8;
}
[[nodiscard]] constexpr int rank_of(Square sq) noexcept {
return square_index(sq) / 8;
}
enum class CastlingRights : std::uint8_t {
None = 0,
WhiteKingSide = 1,
WhiteQueenSide = 2,
BlackKingSide = 4,
BlackQueenSide = 8,
WhiteAll = WhiteKingSide | WhiteQueenSide,
BlackAll = BlackKingSide | BlackQueenSide,
All = WhiteAll | BlackAll
};
[[nodiscard]] constexpr CastlingRights operator|(CastlingRights a, CastlingRights b) noexcept {
return static_cast<CastlingRights>(
static_cast<std::uint8_t>(a) | static_cast<std::uint8_t>(b)
);
}
[[nodiscard]] constexpr CastlingRights operator&(CastlingRights a, CastlingRights b) noexcept {
return static_cast<CastlingRights>(
static_cast<std::uint8_t>(a) & static_cast<std::uint8_t>(b)
);
}
constexpr CastlingRights& operator|=(CastlingRights& a, CastlingRights b) noexcept {
a = a | b;
return a;
}
constexpr CastlingRights& operator&=(CastlingRights& a, CastlingRights b) noexcept {
a = a & b;
return a;
}
[[nodiscard]] constexpr CastlingRights operator~(CastlingRights a) noexcept {
return static_cast<CastlingRights>(~static_cast<std::uint8_t>(a));
}
enum class MoveFlag : std::uint8_t {
Quiet = 0,
DoublePush = 1,
KingCastle = 2,
QueenCastle = 3,
Capture = 4,
EnPassant = 5,
PromoteKnight = 8,
PromoteBishop = 9,
PromoteRook = 10,
PromoteQueen = 11,
CapturePromoteKnight = 12,
CapturePromoteBishop = 13,
CapturePromoteRook = 14,
CapturePromoteQueen = 15
};
class Move {
public:
constexpr Move() noexcept : data_(0) {}
constexpr Move(Square from, Square to, MoveFlag flag) noexcept
: data_(static_cast<std::uint16_t>(
static_cast<std::uint16_t>(square_index(from)) |
(static_cast<std::uint16_t>(square_index(to)) << 6) |
(static_cast<std::uint16_t>(flag) << 12)
)) {}
[[nodiscard]] constexpr Square from() const noexcept {
return static_cast<Square>(data_ & 0x3F);
}
[[nodiscard]] constexpr Square to() const noexcept {
return static_cast<Square>((data_ >> 6) & 0x3F);
}
[[nodiscard]] constexpr MoveFlag flag() const noexcept {
return static_cast<MoveFlag>((data_ >> 12) & 0xF);
}
[[nodiscard]] constexpr bool is_capture() const noexcept {
return (static_cast<std::uint8_t>(flag()) & 4) != 0;
}
[[nodiscard]] constexpr bool is_promotion() const noexcept {
return (static_cast<std::uint8_t>(flag()) & 8) != 0;
}
[[nodiscard]] constexpr bool is_castling() const noexcept {
auto f = flag();
return f == MoveFlag::KingCastle || f == MoveFlag::QueenCastle;
}
[[nodiscard]] constexpr bool is_null() const noexcept {
return data_ == 0;
}
[[nodiscard]] constexpr std::uint16_t raw() const noexcept {
return data_;
}
constexpr bool operator==(const Move& other) const noexcept = default;
[[nodiscard]] std::string to_uci() const;
private:
std::uint16_t data_;
};
constexpr Bitboard FileA = 0x0101010101010101ULL;
constexpr Bitboard FileB = FileA << 1;
constexpr Bitboard FileG = FileA << 6;
constexpr Bitboard FileH = FileA << 7;
constexpr Bitboard Rank1 = 0xFFULL;
constexpr Bitboard Rank2 = Rank1 << 8;
constexpr Bitboard Rank7 = Rank1 << 48;
constexpr Bitboard Rank8 = Rank1 << 56;
constexpr std::string_view StartFEN =
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1";
} // namespace zenith
Implement bitboard utility functions
C++20 introduced the <bit> header with portable, compiler-optimized bit operations. You will wrap std::popcount and std::countr_zero into chess-engine-friendly utility functions.
Why bitboards over a simple array?
An 8x8 array requires loops to answer "which squares have white pawns?" A bitboard answers the same question with a single 64-bit value. Operations like counting pieces (popcount), finding the next piece (countr_zero), or intersecting piece sets (bitwise AND) become single CPU instructions.
- Create the file include/zenith/bitboard.hpp and paste in the following code:
#pragma once
#include "zenith/types.hpp"
#include <bit>
#include <string>
namespace zenith {
// Population count (number of set bits)
[[nodiscard]] constexpr int popcount(Bitboard bb) noexcept {
return std::popcount(bb);
}
// Index of least significant bit
[[nodiscard]] constexpr int lsb(Bitboard bb) noexcept {
return std::countr_zero(bb);
}
// Pop least significant bit and return its index
[[nodiscard]] constexpr int pop_lsb(Bitboard& bb) noexcept {
int idx = lsb(bb);
bb &= bb - 1;
return idx;
}
// Set a bit at the given square
constexpr void set_bit(Bitboard& bb, Square sq) noexcept {
bb |= (1ULL << square_index(sq));
}
// Clear a bit at the given square
constexpr void clear_bit(Bitboard& bb, Square sq) noexcept {
bb &= ~(1ULL << square_index(sq));
}
// Test if a bit is set at the given square
[[nodiscard]] constexpr bool test_bit(Bitboard bb, Square sq) noexcept {
return (bb >> square_index(sq)) & 1;
}
// Print bitboard as 8x8 grid (for debugging)
void print_bitboard(Bitboard bb);
} // namespace zenith
What does this code do?
Each function wraps a fundamental bit operation the engine uses constantly. popcount counts how many pieces are on a bitboard. lsb finds the index of the lowest-numbered occupied square. pop_lsb does both in one call: it returns the index and clears that bit, letting you iterate through all pieces in a while loop.
Marking them constexpr means the compiler can evaluate these at compile time when the inputs are known. At runtime, std::popcount compiles down to a single POPCNT instruction on modern x86 CPUs.
- Now replace the placeholder content in src/bitboard.cpp with the print_bitboard implementation:
#include "zenith/bitboard.hpp"
#include <iostream>
#include <format>
namespace zenith {
void print_bitboard(Bitboard bb) {
std::cout << "\n";
for (int rank = 7; rank >= 0; --rank) {
std::cout << " " << (rank + 1) << " ";
for (int file = 0; file < 8; ++file) {
int sq = rank * 8 + file;
std::cout << ((bb >> sq) & 1 ? " 1" : " .");
}
std::cout << "\n";
}
std::cout << "\n a b c d e f g h\n";
std::cout << "\n Value: " << bb << "\n\n";
}
} // namespace zenith
What does this code do?
The print_bitboard function is a debugging tool. It prints any 64-bit bitboard as an 8x8 grid so you can visually verify which squares have their bits set. You will use this to sanity-check attack patterns and piece placements during development.
- Save both include/zenith/bitboard.hpp and src/bitboard.cpp.
Seeing a compile error about <bit>?
The <bit> header requires C++20. Make sure your CMakeLists.txt has set(CMAKE_CXX_STANDARD 20) and set(CMAKE_CXX_STANDARD_REQUIRED ON). If you are using GCC, you need version 10 or newer. For Clang, version 14 or newer.
Help me fix my C++20 <bit> header compile error.
Implement the Board class
The Board class ties everything together. It stores 12 piece bitboards (one per piece type and color), two occupancy bitboards (one per color), and a redundant mailbox array for fast "what piece is on square X?" lookups.
- Create the file include/zenith/board.hpp and paste in the Board class declaration:
#pragma once
#include "zenith/types.hpp"
#include <array>
#include <string>
#include <string_view>
#include <vector>
namespace zenith {
// Saved state for unmake_move - stores what changed
struct BoardState {
CastlingRights castling = CastlingRights::None;
Square en_passant = Square::None;
int halfmove_clock = 0;
Piece captured = Piece::None;
};
class Board {
public:
Board();
// Setup
void set_from_fen(std::string_view fen);
void set_startpos();
// Accessors
[[nodiscard]] Bitboard pieces(Piece p) const noexcept;
[[nodiscard]] Bitboard pieces(Color c) const noexcept;
[[nodiscard]] Bitboard pieces(Color c, PieceType pt) const noexcept;
[[nodiscard]] Bitboard all_pieces() const noexcept;
[[nodiscard]] Piece piece_on(Square sq) const noexcept;
[[nodiscard]] Color side_to_move() const noexcept;
[[nodiscard]] CastlingRights castling_rights() const noexcept;
[[nodiscard]] Square en_passant_square() const noexcept;
[[nodiscard]] int halfmove_clock() const noexcept;
[[nodiscard]] int fullmove_number() const noexcept;
// Move execution (stubs for now - implemented in Step 6)
void make_move(Move move);
void unmake_move(Move move);
// Check detection (stub - implemented in Step 5)
[[nodiscard]] bool is_in_check(Color c) const;
[[nodiscard]] bool is_in_check() const;
// Display
[[nodiscard]] std::string to_string() const;
[[nodiscard]] std::string to_fen() const;
private:
std::array<Bitboard, 13> piece_bbs_{}; // Index by Piece enum (0=None, 1-12=pieces)
std::array<Bitboard, 2> color_bbs_{}; // White, Black occupancy
std::array<Piece, 64> mailbox_{}; // Redundant mailbox for fast piece_on()
Color side_to_move_ = Color::White;
CastlingRights castling_ = CastlingRights::All;
Square en_passant_ = Square::None;
int halfmove_clock_ = 0;
int fullmove_number_ = 1;
std::vector<BoardState> state_stack_;
// Internal helpers
void place_piece(Piece p, Square sq);
void remove_piece(Square sq);
void move_piece(Square from, Square to);
};
} // namespace zenith
What does this code do?
The Board class uses a dual representation. The piece_bbs_ array has 13 entries (index 0 unused, indices 1-12 for each piece). The color_bbs_ array gives instant access to all white or black pieces combined.
The mailbox_ array stores which piece sits on each of the 64 squares. This seems redundant, but answering "what did I just capture?" is a constant-time array lookup instead of iterating 12 bitboards.
The state_stack_ vector stores the irreversible parts of board state before each move, so unmake_move can restore them.
- Now replace the placeholder content in src/board.cpp with the full implementation. This is the longest file in this step:
#include "zenith/board.hpp"
#include "zenith/bitboard.hpp"
#include <sstream>
#include <stdexcept>
namespace zenith {
Board::Board() {
piece_bbs_.fill(0);
color_bbs_.fill(0);
mailbox_.fill(Piece::None);
}
void Board::set_startpos() {
set_from_fen(StartFEN);
}
void Board::set_from_fen(std::string_view fen) {
// Reset all state
piece_bbs_.fill(0);
color_bbs_.fill(0);
mailbox_.fill(Piece::None);
state_stack_.clear();
std::string fen_str(fen);
std::istringstream ss(fen_str);
std::string token;
// 1. Parse piece placement (rank 8 to rank 1, left to right)
ss >> token;
int rank = 7, file = 0;
for (char c : token) {
if (c == '/') { rank--; file = 0; }
else if (c >= '1' && c <= '8') { file += (c - '0'); }
else {
Piece piece = Piece::None;
switch (c) {
case 'P': piece = Piece::WhitePawn; break;
case 'N': piece = Piece::WhiteKnight; break;
case 'B': piece = Piece::WhiteBishop; break;
case 'R': piece = Piece::WhiteRook; break;
case 'Q': piece = Piece::WhiteQueen; break;
case 'K': piece = Piece::WhiteKing; break;
case 'p': piece = Piece::BlackPawn; break;
case 'n': piece = Piece::BlackKnight; break;
case 'b': piece = Piece::BlackBishop; break;
case 'r': piece = Piece::BlackRook; break;
case 'q': piece = Piece::BlackQueen; break;
case 'k': piece = Piece::BlackKing; break;
default: break;
}
if (piece != Piece::None) { place_piece(piece, make_square(file, rank)); }
file++;
}
}
// 2. Side to move
ss >> token;
side_to_move_ = (token == "w") ? Color::White : Color::Black;
// 3. Castling availability
ss >> token;
castling_ = CastlingRights::None;
for (char c : token) {
switch (c) {
case 'K': castling_ |= CastlingRights::WhiteKingSide; break;
case 'Q': castling_ |= CastlingRights::WhiteQueenSide; break;
case 'k': castling_ |= CastlingRights::BlackKingSide; break;
case 'q': castling_ |= CastlingRights::BlackQueenSide; break;
default: break;
}
}
// 4. En passant target square
ss >> token;
if (token != "-" && token.size() == 2) {
int ep_file = token[0] - 'a';
int ep_rank = token[1] - '1';
en_passant_ = make_square(ep_file, ep_rank);
} else {
en_passant_ = Square::None;
}
// 5. Halfmove clock and 6. Fullmove number
if (ss >> token) { halfmove_clock_ = std::stoi(token); }
if (ss >> token) { fullmove_number_ = std::stoi(token); }
}
What does this code do?
FEN (Forsyth-Edwards Notation) is the standard way to describe any chess position as a single string. The parser splits the FEN into its 6 fields: piece placement, side to move, castling rights, en passant target, halfmove clock, and fullmove number.
The piece placement loop walks the FEN from rank 8 down to rank 1 (top of the board to bottom). Letters map to pieces, digits skip empty squares, and slashes separate ranks.
- Continue adding the accessor implementations, helper functions, and display methods to src/board.cpp directly below the set_from_fen function:
// Accessors
Bitboard Board::pieces(Piece p) const noexcept { return piece_bbs_[static_cast<std::size_t>(p)]; }
Bitboard Board::pieces(Color c) const noexcept { return color_bbs_[static_cast<std::size_t>(c)]; }
Bitboard Board::pieces(Color c, PieceType pt) const noexcept { return pieces(make_piece(c, pt)); }
Bitboard Board::all_pieces() const noexcept { return color_bbs_[0] | color_bbs_[1]; }
Piece Board::piece_on(Square sq) const noexcept { return mailbox_[square_index(sq)]; }
Color Board::side_to_move() const noexcept { return side_to_move_; }
CastlingRights Board::castling_rights() const noexcept { return castling_; }
Square Board::en_passant_square() const noexcept { return en_passant_; }
int Board::halfmove_clock() const noexcept { return halfmove_clock_; }
int Board::fullmove_number() const noexcept { return fullmove_number_; }
// Internal helpers to keep bitboards and mailbox in sync
void Board::place_piece(Piece p, Square sq) {
set_bit(piece_bbs_[static_cast<std::size_t>(p)], sq);
set_bit(color_bbs_[static_cast<std::size_t>(color_of(p))], sq);
mailbox_[square_index(sq)] = p;
}
void Board::remove_piece(Square sq) {
Piece p = mailbox_[square_index(sq)];
if (p != Piece::None) {
clear_bit(piece_bbs_[static_cast<std::size_t>(p)], sq);
clear_bit(color_bbs_[static_cast<std::size_t>(color_of(p))], sq);
mailbox_[square_index(sq)] = Piece::None;
}
}
void Board::move_piece(Square from, Square to) {
Piece p = mailbox_[square_index(from)];
remove_piece(from);
place_piece(p, to);
}
// Stubs for future steps
void Board::make_move([[maybe_unused]] Move move) { /* TODO: Step 6 */ }
void Board::unmake_move([[maybe_unused]] Move move) { /* TODO: Step 6 */ }
bool Board::is_in_check([[maybe_unused]] Color c) const { return false; /* TODO: Step 5 */ }
bool Board::is_in_check() const { return is_in_check(side_to_move_); }
// ASCII display of the board
std::string Board::to_string() const {
std::string result = "\n";
for (int rank = 7; rank >= 0; --rank) {
result += " " + std::to_string(rank + 1) + " ";
for (int file = 0; file < 8; ++file) {
Piece p = mailbox_[rank * 8 + file];
constexpr std::array<char, 13> symbols = { '.', 'P', 'N', 'B', 'R', 'Q', 'K', 'p', 'n', 'b', 'r', 'q', 'k' };
result += ' ';
result += symbols[static_cast<std::size_t>(p)];
}
result += '\n';
}
result += "\n a b c d e f g h\n\n";
result += " Side to move: ";
result += (side_to_move_ == Color::White ? "White" : "Black");
result += "\n";
return result;
}
// Export the board state back to FEN notation
std::string Board::to_fen() const {
std::string fen;
constexpr std::array<char, 13> symbols = { '.', 'P', 'N', 'B', 'R', 'Q', 'K', 'p', 'n', 'b', 'r', 'q', 'k' };
for (int rank = 7; rank >= 0; --rank) {
int empty = 0;
for (int file = 0; file < 8; ++file) {
Piece p = mailbox_[rank * 8 + file];
if (p == Piece::None) { empty++; }
else {
if (empty > 0) { fen += std::to_string(empty); empty = 0; }
fen += symbols[static_cast<std::size_t>(p)];
}
}
if (empty > 0) fen += std::to_string(empty);
if (rank > 0) fen += '/';
}
fen += (side_to_move_ == Color::White) ? " w " : " b ";
std::string castling;
if ((castling_ & CastlingRights::WhiteKingSide) != CastlingRights::None) castling += 'K';
if ((castling_ & CastlingRights::WhiteQueenSide) != CastlingRights::None) castling += 'Q';
if ((castling_ & CastlingRights::BlackKingSide) != CastlingRights::None) castling += 'k';
if ((castling_ & CastlingRights::BlackQueenSide) != CastlingRights::None) castling += 'q';
fen += castling.empty() ? "-" : castling;
if (en_passant_ != Square::None) {
fen += ' ';
fen += static_cast<char>('a' + file_of(en_passant_));
fen += static_cast<char>('1' + rank_of(en_passant_));
} else { fen += " -"; }
fen += " " + std::to_string(halfmove_clock_);
fen += " " + std::to_string(fullmove_number_);
return fen;
}
} // namespace zenith
What does this code do?
The three internal helpers (place_piece, remove_piece, move_piece) are the only functions that touch the raw bitboard and mailbox arrays directly. Every other operation goes through them, guaranteeing the two representations stay in sync.
The to_fen() method does the reverse of set_from_fen. It serializes the current board state back to a FEN string. You will use this for round-trip testing: parse a FEN, then export it, and confirm you get the same string back.
- Save include/zenith/board.hpp and src/board.cpp.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before writing the tests.
ⓧ I'd like to double check the full code
#pragma once
#include "zenith/types.hpp"
#include <array>
#include <string>
#include <string_view>
#include <vector>
namespace zenith {
struct BoardState {
CastlingRights castling = CastlingRights::None;
Square en_passant = Square::None;
int halfmove_clock = 0;
Piece captured = Piece::None;
};
class Board {
public:
Board();
void set_from_fen(std::string_view fen);
void set_startpos();
[[nodiscard]] Bitboard pieces(Piece p) const noexcept;
[[nodiscard]] Bitboard pieces(Color c) const noexcept;
[[nodiscard]] Bitboard pieces(Color c, PieceType pt) const noexcept;
[[nodiscard]] Bitboard all_pieces() const noexcept;
[[nodiscard]] Piece piece_on(Square sq) const noexcept;
[[nodiscard]] Color side_to_move() const noexcept;
[[nodiscard]] CastlingRights castling_rights() const noexcept;
[[nodiscard]] Square en_passant_square() const noexcept;
[[nodiscard]] int halfmove_clock() const noexcept;
[[nodiscard]] int fullmove_number() const noexcept;
void make_move(Move move);
void unmake_move(Move move);
[[nodiscard]] bool is_in_check(Color c) const;
[[nodiscard]] bool is_in_check() const;
[[nodiscard]] std::string to_string() const;
[[nodiscard]] std::string to_fen() const;
private:
std::array<Bitboard, 13> piece_bbs_{};
std::array<Bitboard, 2> color_bbs_{};
std::array<Piece, 64> mailbox_{};
Color side_to_move_ = Color::White;
CastlingRights castling_ = CastlingRights::All;
Square en_passant_ = Square::None;
int halfmove_clock_ = 0;
int fullmove_number_ = 1;
std::vector<BoardState> state_stack_;
void place_piece(Piece p, Square sq);
void remove_piece(Square sq);
void move_piece(Square from, Square to);
};
} // namespace zenith
#include "zenith/board.hpp"
#include "zenith/bitboard.hpp"
#include <sstream>
#include <stdexcept>
namespace zenith {
Board::Board() {
piece_bbs_.fill(0);
color_bbs_.fill(0);
mailbox_.fill(Piece::None);
}
void Board::set_startpos() {
set_from_fen(StartFEN);
}
void Board::set_from_fen(std::string_view fen) {
piece_bbs_.fill(0);
color_bbs_.fill(0);
mailbox_.fill(Piece::None);
state_stack_.clear();
std::string fen_str(fen);
std::istringstream ss(fen_str);
std::string token;
ss >> token;
int rank = 7, file = 0;
for (char c : token) {
if (c == '/') { rank--; file = 0; }
else if (c >= '1' && c <= '8') { file += (c - '0'); }
else {
Piece piece = Piece::None;
switch (c) {
case 'P': piece = Piece::WhitePawn; break;
case 'N': piece = Piece::WhiteKnight; break;
case 'B': piece = Piece::WhiteBishop; break;
case 'R': piece = Piece::WhiteRook; break;
case 'Q': piece = Piece::WhiteQueen; break;
case 'K': piece = Piece::WhiteKing; break;
case 'p': piece = Piece::BlackPawn; break;
case 'n': piece = Piece::BlackKnight; break;
case 'b': piece = Piece::BlackBishop; break;
case 'r': piece = Piece::BlackRook; break;
case 'q': piece = Piece::BlackQueen; break;
case 'k': piece = Piece::BlackKing; break;
default: break;
}
if (piece != Piece::None) { place_piece(piece, make_square(file, rank)); }
file++;
}
}
ss >> token;
side_to_move_ = (token == "w") ? Color::White : Color::Black;
ss >> token;
castling_ = CastlingRights::None;
for (char c : token) {
switch (c) {
case 'K': castling_ |= CastlingRights::WhiteKingSide; break;
case 'Q': castling_ |= CastlingRights::WhiteQueenSide; break;
case 'k': castling_ |= CastlingRights::BlackKingSide; break;
case 'q': castling_ |= CastlingRights::BlackQueenSide; break;
default: break;
}
}
ss >> token;
if (token != "-" && token.size() == 2) {
int ep_file = token[0] - 'a';
int ep_rank = token[1] - '1';
en_passant_ = make_square(ep_file, ep_rank);
} else {
en_passant_ = Square::None;
}
if (ss >> token) { halfmove_clock_ = std::stoi(token); }
if (ss >> token) { fullmove_number_ = std::stoi(token); }
}
Bitboard Board::pieces(Piece p) const noexcept { return piece_bbs_[static_cast<std::size_t>(p)]; }
Bitboard Board::pieces(Color c) const noexcept { return color_bbs_[static_cast<std::size_t>(c)]; }
Bitboard Board::pieces(Color c, PieceType pt) const noexcept { return pieces(make_piece(c, pt)); }
Bitboard Board::all_pieces() const noexcept { return color_bbs_[0] | color_bbs_[1]; }
Piece Board::piece_on(Square sq) const noexcept { return mailbox_[square_index(sq)]; }
Color Board::side_to_move() const noexcept { return side_to_move_; }
CastlingRights Board::castling_rights() const noexcept { return castling_; }
Square Board::en_passant_square() const noexcept { return en_passant_; }
int Board::halfmove_clock() const noexcept { return halfmove_clock_; }
int Board::fullmove_number() const noexcept { return fullmove_number_; }
void Board::place_piece(Piece p, Square sq) {
set_bit(piece_bbs_[static_cast<std::size_t>(p)], sq);
set_bit(color_bbs_[static_cast<std::size_t>(color_of(p))], sq);
mailbox_[square_index(sq)] = p;
}
void Board::remove_piece(Square sq) {
Piece p = mailbox_[square_index(sq)];
if (p != Piece::None) {
clear_bit(piece_bbs_[static_cast<std::size_t>(p)], sq);
clear_bit(color_bbs_[static_cast<std::size_t>(color_of(p))], sq);
mailbox_[square_index(sq)] = Piece::None;
}
}
void Board::move_piece(Square from, Square to) {
Piece p = mailbox_[square_index(from)];
remove_piece(from);
place_piece(p, to);
}
void Board::make_move([[maybe_unused]] Move move) { /* TODO: Step 6 */ }
void Board::unmake_move([[maybe_unused]] Move move) { /* TODO: Step 6 */ }
bool Board::is_in_check([[maybe_unused]] Color c) const { return false; /* TODO: Step 5 */ }
bool Board::is_in_check() const { return is_in_check(side_to_move_); }
std::string Board::to_string() const {
std::string result = "\n";
for (int rank = 7; rank >= 0; --rank) {
result += " " + std::to_string(rank + 1) + " ";
for (int file = 0; file < 8; ++file) {
Piece p = mailbox_[rank * 8 + file];
constexpr std::array<char, 13> symbols = { '.', 'P', 'N', 'B', 'R', 'Q', 'K', 'p', 'n', 'b', 'r', 'q', 'k' };
result += ' ';
result += symbols[static_cast<std::size_t>(p)];
}
result += '\n';
}
result += "\n a b c d e f g h\n\n";
result += " Side to move: ";
result += (side_to_move_ == Color::White ? "White" : "Black");
result += "\n";
return result;
}
std::string Board::to_fen() const {
std::string fen;
constexpr std::array<char, 13> symbols = { '.', 'P', 'N', 'B', 'R', 'Q', 'K', 'p', 'n', 'b', 'r', 'q', 'k' };
for (int rank = 7; rank >= 0; --rank) {
int empty = 0;
for (int file = 0; file < 8; ++file) {
Piece p = mailbox_[rank * 8 + file];
if (p == Piece::None) { empty++; }
else {
if (empty > 0) { fen += std::to_string(empty); empty = 0; }
fen += symbols[static_cast<std::size_t>(p)];
}
}
if (empty > 0) fen += std::to_string(empty);
if (rank > 0) fen += '/';
}
fen += (side_to_move_ == Color::White) ? " w " : " b ";
std::string castling;
if ((castling_ & CastlingRights::WhiteKingSide) != CastlingRights::None) castling += 'K';
if ((castling_ & CastlingRights::WhiteQueenSide) != CastlingRights::None) castling += 'Q';
if ((castling_ & CastlingRights::BlackKingSide) != CastlingRights::None) castling += 'k';
if ((castling_ & CastlingRights::BlackQueenSide) != CastlingRights::None) castling += 'q';
fen += castling.empty() ? "-" : castling;
if (en_passant_ != Square::None) {
fen += ' ';
fen += static_cast<char>('a' + file_of(en_passant_));
fen += static_cast<char>('1' + rank_of(en_passant_));
} else { fen += " -"; }
fen += " " + std::to_string(halfmove_clock_);
fen += " " + std::to_string(fullmove_number_);
return fen;
}
} // namespace zenith
Now let's write tests to verify everything works correctly.
- Replace the placeholder content in tests/test_bitboard.cpp with these unit tests:
#include <catch2/catch_test_macros.hpp>
#include "zenith/bitboard.hpp"
using namespace zenith;
TEST_CASE("Bitboard popcount", "[bitboard]") {
REQUIRE(popcount(0) == 0);
REQUIRE(popcount(1) == 1);
REQUIRE(popcount(0xFF) == 8);
REQUIRE(popcount(0xFFFFFFFFFFFFFFFFULL) == 64);
}
TEST_CASE("Bitboard lsb", "[bitboard]") {
REQUIRE(lsb(1ULL) == 0);
REQUIRE(lsb(2ULL) == 1);
REQUIRE(lsb(8ULL) == 3);
REQUIRE(lsb(0x8000000000000000ULL) == 63);
}
TEST_CASE("Bitboard set and test", "[bitboard]") {
Bitboard bb = 0;
set_bit(bb, Square::E4);
REQUIRE(test_bit(bb, Square::E4));
REQUIRE_FALSE(test_bit(bb, Square::E5));
clear_bit(bb, Square::E4);
REQUIRE_FALSE(test_bit(bb, Square::E4));
}
TEST_CASE("Bitboard pop_lsb", "[bitboard]") {
Bitboard bb = 0;
set_bit(bb, Square::A1);
set_bit(bb, Square::H8);
int first = pop_lsb(bb);
REQUIRE(first == 0);
REQUIRE(popcount(bb) == 1);
int second = pop_lsb(bb);
REQUIRE(second == 63);
REQUIRE(bb == 0);
}
- Replace the placeholder content in tests/test_board.cpp with these unit tests:
#include <catch2/catch_test_macros.hpp>
#include "zenith/board.hpp"
using namespace zenith;
TEST_CASE("Board startpos FEN", "[board]") {
Board board;
board.set_startpos();
REQUIRE(board.side_to_move() == Color::White);
REQUIRE(board.castling_rights() == CastlingRights::All);
REQUIRE(board.en_passant_square() == Square::None);
REQUIRE(board.halfmove_clock() == 0);
REQUIRE(board.fullmove_number() == 1);
}
TEST_CASE("Board piece placement from startpos", "[board]") {
Board board;
board.set_startpos();
REQUIRE(board.piece_on(Square::E1) == Piece::WhiteKing);
REQUIRE(board.piece_on(Square::D8) == Piece::BlackQueen);
REQUIRE(board.piece_on(Square::A2) == Piece::WhitePawn);
REQUIRE(board.piece_on(Square::E4) == Piece::None);
}
TEST_CASE("Board FEN round-trip", "[board]") {
Board board;
std::string_view fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1";
board.set_from_fen(fen);
REQUIRE(board.to_fen() == fen);
REQUIRE(board.side_to_move() == Color::Black);
REQUIRE(board.en_passant_square() == Square::E3);
}
TEST_CASE("Board piece bitboards", "[board]") {
Board board;
board.set_startpos();
REQUIRE(popcount(board.pieces(Color::White, PieceType::Pawn)) == 8);
REQUIRE(popcount(board.pieces(Color::Black, PieceType::Pawn)) == 8);
REQUIRE(popcount(board.pieces(Color::White)) == 16);
REQUIRE(popcount(board.pieces(Color::Black)) == 16);
REQUIRE(popcount(board.all_pieces()) == 32);
}
What do these tests verify?
The bitboard tests confirm that the C++20 <bit> wrappers work correctly: counting bits, finding the lowest set bit, and setting/clearing individual squares.
The board tests verify that FEN parsing produces the correct board state (right pieces on the right squares, correct side to move, castling rights). The round-trip test is the strongest: if parse-then-export produces the original FEN, both functions are correct.
- Save both test files.
- Rebuild and run the tests by running these commands in the terminal:
cmake --build build
ctest --test-dir build --output-on-failure
- You should see all tests passing, including the new bitboard and board tests.
Tests failing or build errors?
If you see linker errors about undefined references, make sure all your .cpp files are listed in the CMakeLists.txt under add_library(zenith_core STATIC ...). The files src/bitboard.cpp and src/board.cpp should already be there from Step 1.
If the FEN round-trip test fails, check that your to_fen() output matches the exact spacing in the test FEN string. FEN fields are separated by single spaces.
Help me debug my Zenith chess engine build or test failures.
Your board representation is solid. You have type-safe enums, fast bit manipulation utilities, and a Board class that can parse and export any chess position. Next up, you will teach the board to understand FEN positions and display itself for debugging.
Implement Attack Tables and Move Generation
Your board can store positions and display them, but it has no idea which moves are legal.
To generate moves, a chess engine needs attack tables that answer the question "which squares can this piece reach?" at blazing speed. In this step, you will pre-compute those tables for every piece type, then build the full move generator on top of them.
In this step, get ready to:
- Create pre-computed attack tables for pawns, knights, kings, and sliding pieces.
- Implement a MoveGenerator that produces all pseudo-legal moves for every piece type.
- Add legality filtering with a working is_in_check and validate correctness via perft tests.
Create the attack tables module
Attack tables are look-up arrays populated once at startup. For "leaper" pieces (pawns, knights, kings) every possible attack pattern from every square is stored directly. For sliding pieces (bishops, rooks, queens) you compute ray attacks on the fly using a blocker-aware loop.
- Create a new file include/zenith/attacks.hpp and add the following header that declares the global tables and sliding-piece functions:
#pragma once
#include "zenith/types.hpp"
#include <array>
namespace zenith::attacks {
// Pre-computed leaper attack tables (populated by init())
extern std::array<std::array<Bitboard, 64>, 2> pawn_attacks;
extern std::array<Bitboard, 64> knight_attacks;
extern std::array<Bitboard, 64> king_attacks;
// Call once at startup to fill all leaper tables
void init();
// Sliding piece attacks computed on the fly with blocker awareness
[[nodiscard]] Bitboard bishop_attacks(Square sq, Bitboard occupied);
[[nodiscard]] Bitboard rook_attacks(Square sq, Bitboard occupied);
[[nodiscard]] Bitboard queen_attacks(Square sq, Bitboard occupied);
} // namespace zenith::attacks
What does this header declare?
The three extern arrays are global look-up tables. pawn_attacks[2][64] stores pawn attack patterns for both colors on all 64 squares. knight_attacks[64] and king_attacks[64] do the same for knights and kings.
Sliding pieces need to know what is blocking their path, so bishop_attacks, rook_attacks, and queen_attacks take an occupancy bitboard and compute attacks at call time using ray casting.
- Create src/attacks.cpp with the full implementation of table initialization and sliding-piece ray logic:
#include "zenith/attacks.hpp"
#include "zenith/bitboard.hpp"
namespace zenith::attacks {
// Global attack table definitions
std::array<std::array<Bitboard, 64>, 2> pawn_attacks;
std::array<Bitboard, 64> knight_attacks;
std::array<Bitboard, 64> king_attacks;
namespace {
// Compute pawn attacks for a single square and color
Bitboard compute_pawn_attacks(Color color, int sq) {
Bitboard attacks = 0;
Bitboard bb = 1ULL << sq;
if (color == Color::White) {
if (bb & ~FileA) attacks |= bb << 7; // capture up-left
if (bb & ~FileH) attacks |= bb << 9; // capture up-right
} else {
if (bb & ~FileH) attacks |= bb >> 7; // capture down-right
if (bb & ~FileA) attacks |= bb >> 9; // capture down-left
}
return attacks;
}
// Compute knight attacks for a single square
Bitboard compute_knight_attacks(int sq) {
Bitboard attacks = 0;
Bitboard bb = 1ULL << sq;
// All 8 L-shaped jumps, guarded against file wrapping
if (bb & ~(FileA | FileB)) { attacks |= bb << 6; attacks |= bb >> 10; }
if (bb & ~FileA) { attacks |= bb << 15; attacks |= bb >> 17; }
if (bb & ~FileH) { attacks |= bb << 17; attacks |= bb >> 15; }
if (bb & ~(FileG | FileH)) { attacks |= bb << 10; attacks |= bb >> 6; }
return attacks;
}
// Compute king attacks for a single square
Bitboard compute_king_attacks(int sq) {
Bitboard attacks = 0;
Bitboard bb = 1ULL << sq;
attacks |= (bb << 8); // up
attacks |= (bb >> 8); // down
if (bb & ~FileA) { attacks |= (bb << 7); attacks |= (bb >> 1); attacks |= (bb >> 9); }
if (bb & ~FileH) { attacks |= (bb << 9); attacks |= (bb << 1); attacks |= (bb >> 7); }
return attacks;
}
// Cast a ray in one direction until hitting the board edge or a blocker
Bitboard ray_attack(int sq, int dir_file, int dir_rank, Bitboard occupied) {
Bitboard attacks = 0;
int file = sq % 8;
int rank = sq / 8;
while (true) {
file += dir_file;
rank += dir_rank;
if (file < 0 || file > 7 || rank < 0 || rank > 7) break;
Bitboard bit = 1ULL << (rank * 8 + file);
attacks |= bit;
// Stop at the first occupied square (blocker)
if (occupied & bit) break;
}
return attacks;
}
} // anonymous namespace
void init() {
for (int sq = 0; sq < 64; ++sq) {
pawn_attacks[0][sq] = compute_pawn_attacks(Color::White, sq);
pawn_attacks[1][sq] = compute_pawn_attacks(Color::Black, sq);
knight_attacks[sq] = compute_knight_attacks(sq);
king_attacks[sq] = compute_king_attacks(sq);
}
}
Bitboard bishop_attacks(Square sq, Bitboard occupied) {
int s = square_index(sq);
return ray_attack(s, 1, 1, occupied)
| ray_attack(s, 1, -1, occupied)
| ray_attack(s, -1, 1, occupied)
| ray_attack(s, -1, -1, occupied);
}
Bitboard rook_attacks(Square sq, Bitboard occupied) {
int s = square_index(sq);
return ray_attack(s, 1, 0, occupied)
| ray_attack(s, -1, 0, occupied)
| ray_attack(s, 0, 1, occupied)
| ray_attack(s, 0, -1, occupied);
}
Bitboard queen_attacks(Square sq, Bitboard occupied) {
return bishop_attacks(sq, occupied) | rook_attacks(sq, occupied);
}
} // namespace zenith::attacks
What does this code do?
The init() function loops over all 64 squares and pre-computes where pawns, knights, and kings can attack from each position. These are pure look-ups after initialization.
Sliding pieces use ray_attack() which walks in a direction (e.g. diagonal for bishops, straight for rooks) and stops when it hits either the board edge or a blocker in the occupied bitboard. The blocker square itself is included in the attack set (you can capture blockers).
- Now register src/attacks.cpp in your build. In CMakeLists.txt, find the add_library(zenith_core STATIC block and add src/attacks.cpp after src/board.cpp:
add_library(zenith_core STATIC
src/bitboard.cpp
src/board.cpp
src/attacks.cpp
src/movegen.cpp
src/evaluation.cpp
src/search.cpp
)
- Open src/main.cpp and activate the attack table initialization. Replace the contents with:
#include "zenith/types.hpp"
#include "zenith/board.hpp"
#include "zenith/attacks.hpp"
#include <iostream>
#include <string>
namespace zenith {
void uci_loop();
}
int main(int argc, char* argv[]) {
std::cout << "Zenith Chess Engine v1.0.0" << std::endl;
// Initialize attack tables at startup
zenith::attacks::init();
if (argc > 1 && std::string(argv[1]) == "bench") {
std::cout << "Benchmark mode not yet implemented." << std::endl;
return 0;
}
zenith::uci_loop();
return 0;
}
Why call init() before anything else?
The leaper tables (pawn_attacks, knight_attacks, king_attacks) are zero-initialized until init() runs. Calling any move generation or check detection before init() would produce empty results.
- Save include/zenith/attacks.hpp, src/attacks.cpp, CMakeLists.txt, and src/main.cpp.
- Rebuild the project by running:
cmake --build build
- The build should succeed with no errors. You now have a working attack-table module linked into your engine.
Seeing linker errors about undefined references?
Make sure src/attacks.cpp is listed in the add_library(zenith_core STATIC ...) block in CMakeLists.txt. If you forgot to add it, CMake does not know to compile the file.
Also confirm your #include "zenith/attacks.hpp" paths match the actual file location inside include/zenith/.
Help me fix linker errors in my Zenith chess engine build
Implement the MoveGenerator class
With attack tables in place, you can now generate all pseudo-legal moves. A pseudo-legal move is one that follows piece movement rules but might leave the king in check. You will filter those out in the next substep.
- Create include/zenith/movegen.hpp with the MoveGenerator class interface and the perft utility function:
#pragma once
#include "zenith/types.hpp"
#include "zenith/board.hpp"
#include <vector>
namespace zenith {
class MoveGenerator {
public:
explicit MoveGenerator(Board& board);
// Generate only legal moves (pseudo-legal filtered by is_legal)
[[nodiscard]] std::vector<Move> generate_legal_moves();
// Generate all pseudo-legal moves (may leave king in check)
[[nodiscard]] std::vector<Move> generate_pseudo_legal_moves();
// Check if a pseudo-legal move is actually legal
[[nodiscard]] bool is_legal(Move move);
private:
Board& board_;
void generate_pawn_moves(std::vector<Move>& moves);
void generate_knight_moves(std::vector<Move>& moves);
void generate_king_moves(std::vector<Move>& moves);
void generate_sliding_moves(std::vector<Move>& moves);
};
// Perft: count leaf nodes at a given depth (for correctness testing)
[[nodiscard]] std::uint64_t perft(Board& board, int depth);
} // namespace zenith
What does this code do?
The MoveGenerator takes a mutable reference to a Board because legality checking temporarily applies a move. The private helper methods split generation by piece type so each can focus on its unique movement rules.
The standalone perft() function recursively counts leaf nodes in the move tree. If your perft values match published results, your move generator is correct.
- Now replace the placeholder in src/movegen.cpp with the full move generation implementation:
#include "zenith/movegen.hpp"
#include "zenith/attacks.hpp"
#include "zenith/bitboard.hpp"
namespace zenith {
MoveGenerator::MoveGenerator(Board& board) : board_(board) {}
std::vector<Move> MoveGenerator::generate_pseudo_legal_moves() {
std::vector<Move> moves;
moves.reserve(256);
generate_pawn_moves(moves);
generate_knight_moves(moves);
generate_sliding_moves(moves);
generate_king_moves(moves);
return moves;
}
std::vector<Move> MoveGenerator::generate_legal_moves() {
auto moves = generate_pseudo_legal_moves();
std::vector<Move> legal;
legal.reserve(moves.size());
for (auto& move : moves) {
if (is_legal(move)) {
legal.push_back(move);
}
}
return legal;
}
bool MoveGenerator::is_legal(Move move) {
board_.make_move(move);
// After making the move, side_to_move has flipped.
// Check if the side that JUST moved left its own king in check.
bool legal = !board_.is_in_check(~board_.side_to_move());
board_.unmake_move(move);
return legal;
}
void MoveGenerator::generate_pawn_moves(std::vector<Move>& moves) {
Color us = board_.side_to_move();
Color them = ~us;
Bitboard our_pawns = board_.pieces(us, PieceType::Pawn);
Bitboard occupied = board_.all_pieces();
Bitboard enemies = board_.pieces(them);
int direction = (us == Color::White) ? 8 : -8;
Bitboard promo_rank = (us == Color::White) ? Rank8 : Rank1;
Bitboard start_rank = (us == Color::White) ? Rank2 : Rank7;
// Single push forward
Bitboard single = (us == Color::White)
? (our_pawns << 8) & ~occupied
: (our_pawns >> 8) & ~occupied;
Bitboard promo_pushes = single & promo_rank;
Bitboard quiet_pushes = single & ~promo_rank;
while (quiet_pushes) {
int to = pop_lsb(quiet_pushes);
moves.emplace_back(static_cast<Square>(to - direction),
static_cast<Square>(to), MoveFlag::Quiet);
}
// Promotion by push
while (promo_pushes) {
int to = pop_lsb(promo_pushes);
auto from = static_cast<Square>(to - direction);
auto to_sq = static_cast<Square>(to);
moves.emplace_back(from, to_sq, MoveFlag::PromoteQueen);
moves.emplace_back(from, to_sq, MoveFlag::PromoteRook);
moves.emplace_back(from, to_sq, MoveFlag::PromoteBishop);
moves.emplace_back(from, to_sq, MoveFlag::PromoteKnight);
}
// Double push from starting rank
Bitboard double_push = (us == Color::White)
? ((our_pawns & start_rank) << 8) & ~occupied
: ((our_pawns & start_rank) >> 8) & ~occupied;
double_push = (us == Color::White)
? (double_push << 8) & ~occupied
: (double_push >> 8) & ~occupied;
while (double_push) {
int to = pop_lsb(double_push);
moves.emplace_back(static_cast<Square>(to - direction * 2),
static_cast<Square>(to), MoveFlag::DoublePush);
}
// Pawn captures (including capture-promotions)
int color_idx = static_cast<int>(us);
Bitboard pawns_copy = our_pawns;
while (pawns_copy) {
int from = pop_lsb(pawns_copy);
Bitboard captures = attacks::pawn_attacks[color_idx][from] & enemies;
while (captures) {
int to = pop_lsb(captures);
auto from_sq = static_cast<Square>(from);
auto to_sq = static_cast<Square>(to);
if ((1ULL << to) & promo_rank) {
moves.emplace_back(from_sq, to_sq, MoveFlag::CapturePromoteQueen);
moves.emplace_back(from_sq, to_sq, MoveFlag::CapturePromoteRook);
moves.emplace_back(from_sq, to_sq, MoveFlag::CapturePromoteBishop);
moves.emplace_back(from_sq, to_sq, MoveFlag::CapturePromoteKnight);
} else {
moves.emplace_back(from_sq, to_sq, MoveFlag::Capture);
}
}
}
// En passant
Square ep = board_.en_passant_square();
if (ep != Square::None) {
// Find our pawns that can reach the en passant square
Bitboard ep_pawns = attacks::pawn_attacks[static_cast<int>(them)][square_index(ep)] & our_pawns;
while (ep_pawns) {
int from = pop_lsb(ep_pawns);
moves.emplace_back(static_cast<Square>(from), ep, MoveFlag::EnPassant);
}
}
}
void MoveGenerator::generate_knight_moves(std::vector<Move>& moves) {
Color us = board_.side_to_move();
Bitboard our_knights = board_.pieces(us, PieceType::Knight);
Bitboard our_pieces = board_.pieces(us);
Bitboard enemies = board_.pieces(~us);
while (our_knights) {
int from = pop_lsb(our_knights);
Bitboard targets = attacks::knight_attacks[from] & ~our_pieces;
while (targets) {
int to = pop_lsb(targets);
MoveFlag flag = ((1ULL << to) & enemies)
? MoveFlag::Capture : MoveFlag::Quiet;
moves.emplace_back(static_cast<Square>(from),
static_cast<Square>(to), flag);
}
}
}
void MoveGenerator::generate_king_moves(std::vector<Move>& moves) {
Color us = board_.side_to_move();
Bitboard our_king = board_.pieces(us, PieceType::King);
Bitboard our_pieces = board_.pieces(us);
Bitboard enemies = board_.pieces(~us);
if (!our_king) return;
int from = lsb(our_king);
Bitboard targets = attacks::king_attacks[from] & ~our_pieces;
while (targets) {
int to = pop_lsb(targets);
MoveFlag flag = ((1ULL << to) & enemies)
? MoveFlag::Capture : MoveFlag::Quiet;
moves.emplace_back(static_cast<Square>(from),
static_cast<Square>(to), flag);
}
// Castling moves
CastlingRights rights = board_.castling_rights();
Bitboard occupied = board_.all_pieces();
if (us == Color::White) {
// King-side: f1 and g1 must be empty, king not in check
if ((rights & CastlingRights::WhiteKingSide) != CastlingRights::None) {
if (!(occupied & ((1ULL << 5) | (1ULL << 6)))) {
if (!board_.is_in_check(Color::White)) {
moves.emplace_back(Square::E1, Square::G1, MoveFlag::KingCastle);
}
}
}
// Queen-side: b1, c1, d1 must be empty
if ((rights & CastlingRights::WhiteQueenSide) != CastlingRights::None) {
if (!(occupied & ((1ULL << 1) | (1ULL << 2) | (1ULL << 3)))) {
if (!board_.is_in_check(Color::White)) {
moves.emplace_back(Square::E1, Square::C1, MoveFlag::QueenCastle);
}
}
}
} else {
// King-side: f8 and g8 must be empty
if ((rights & CastlingRights::BlackKingSide) != CastlingRights::None) {
if (!(occupied & ((1ULL << 61) | (1ULL << 62)))) {
if (!board_.is_in_check(Color::Black)) {
moves.emplace_back(Square::E8, Square::G8, MoveFlag::KingCastle);
}
}
}
// Queen-side: b8, c8, d8 must be empty
if ((rights & CastlingRights::BlackQueenSide) != CastlingRights::None) {
if (!(occupied & ((1ULL << 57) | (1ULL << 58) | (1ULL << 59)))) {
if (!board_.is_in_check(Color::Black)) {
moves.emplace_back(Square::E8, Square::C8, MoveFlag::QueenCastle);
}
}
}
}
}
void MoveGenerator::generate_sliding_moves(std::vector<Move>& moves) {
Color us = board_.side_to_move();
Bitboard our_pieces = board_.pieces(us);
Bitboard enemies = board_.pieces(~us);
Bitboard occupied = board_.all_pieces();
// Bishops and queens (diagonal movement)
Bitboard diagonal_sliders =
board_.pieces(us, PieceType::Bishop) | board_.pieces(us, PieceType::Queen);
while (diagonal_sliders) {
int from = pop_lsb(diagonal_sliders);
Bitboard targets = attacks::bishop_attacks(
static_cast<Square>(from), occupied) & ~our_pieces;
while (targets) {
int to = pop_lsb(targets);
MoveFlag flag = ((1ULL << to) & enemies)
? MoveFlag::Capture : MoveFlag::Quiet;
moves.emplace_back(static_cast<Square>(from),
static_cast<Square>(to), flag);
}
}
// Rooks and queens (orthogonal movement)
Bitboard orthogonal_sliders =
board_.pieces(us, PieceType::Rook) | board_.pieces(us, PieceType::Queen);
while (orthogonal_sliders) {
int from = pop_lsb(orthogonal_sliders);
Bitboard targets = attacks::rook_attacks(
static_cast<Square>(from), occupied) & ~our_pieces;
while (targets) {
int to = pop_lsb(targets);
MoveFlag flag = ((1ULL << to) & enemies)
? MoveFlag::Capture : MoveFlag::Quiet;
moves.emplace_back(static_cast<Square>(from),
static_cast<Square>(to), flag);
}
}
}
std::uint64_t perft(Board& board, int depth) {
if (depth == 0) return 1;
MoveGenerator gen(board);
auto moves = gen.generate_legal_moves();
// At depth 1, just count moves without recursing
if (depth == 1) return moves.size();
std::uint64_t nodes = 0;
for (auto& move : moves) {
board.make_move(move);
nodes += perft(board, depth - 1);
board.unmake_move(move);
}
return nodes;
}
How does pawn move generation work?
Pawns are the most complex piece to generate moves for because they have five distinct move types: single push, double push from the starting rank, diagonal captures, en passant, and promotion (which combines with both pushes and captures).
The code shifts the entire pawn bitboard forward by 8 bits (one rank) and masks out occupied squares to find valid pushes. For captures, it uses the pre-computed pawn_attacks table to find which enemy squares each pawn can reach diagonally.
Why does castling check is_in_check() here?
Chess rules forbid castling when the king is currently in check. By calling is_in_check() before adding a castling move, we avoid generating an illegal castle. The requirement that the king does not pass through an attacked square is handled later by the legality filter.
- Save include/zenith/movegen.hpp and src/movegen.cpp.
Getting compile errors in movegen.cpp?
Verify that your #include paths at the top of the file match exactly: "zenith/movegen.hpp", "zenith/attacks.hpp", and "zenith/bitboard.hpp".
If you see errors about pop_lsb or lsb not being found, make sure the bitboard.hpp include is present. These functions live in the zenith namespace which is already active inside movegen.cpp.
Help me fix compile errors in my move generator
Wire up is_in_check and verify with perft
The is_in_check() function currently returns false as a stub. Now that you have attack tables, you can implement it properly by checking whether any enemy piece attacks the king square.
- In src/board.cpp, add the attacks include at the top of the file. Find this line:
#include "zenith/board.hpp"
#include "zenith/bitboard.hpp"
- Add the attacks header after bitboard.hpp, so it looks like this:
#include "zenith/board.hpp"
#include "zenith/bitboard.hpp"
#include "zenith/attacks.hpp"
- Now find the stub is_in_check method:
bool Board::is_in_check([[maybe_unused]] Color c) const { return false; /* TODO: Step 5 */ }
- Replace it with the full implementation that queries attack tables:
bool Board::is_in_check(Color c) const {
// Find the king of the given color
Bitboard king_bb = pieces(c, PieceType::King);
if (!king_bb) return false;
int king_sq = lsb(king_bb);
auto sq = static_cast<Square>(king_sq);
Color them = ~c;
Bitboard occupied = all_pieces();
// Is the king attacked by an enemy pawn?
if (attacks::pawn_attacks[static_cast<int>(c)][king_sq] &
pieces(them, PieceType::Pawn))
return true;
// Is the king attacked by an enemy knight?
if (attacks::knight_attacks[king_sq] & pieces(them, PieceType::Knight))
return true;
// Is the king attacked diagonally (bishop or queen)?
if (attacks::bishop_attacks(sq, occupied) &
(pieces(them, PieceType::Bishop) | pieces(them, PieceType::Queen)))
return true;
// Is the king attacked orthogonally (rook or queen)?
if (attacks::rook_attacks(sq, occupied) &
(pieces(them, PieceType::Rook) | pieces(them, PieceType::Queen)))
return true;
// Is the king attacked by the enemy king? (for legality checks)
if (attacks::king_attacks[king_sq] & pieces(them, PieceType::King))
return true;
return false;
}
Why use pawn_attacks[c] instead of pawn_attacks[them]?
This is a common source of confusion. To check if an enemy pawn attacks the king, you look at where the king could attack AS a pawn of its own color. A white king on e4 is attacked by black pawns on d5 or f5. Those are exactly the squares a white pawn on e4 would attack. So you use the king's own color index into the pawn table.
- Save src/board.cpp.
- Now expand your tests. Replace the contents of tests/test_movegen.cpp with the following, which adds perft tests alongside the existing move encoding tests:
#include <catch2/catch_test_macros.hpp>
#include "zenith/types.hpp"
#include "zenith/board.hpp"
#include "zenith/movegen.hpp"
#include "zenith/attacks.hpp"
using namespace zenith;
TEST_CASE("Move encoding", "[move]") {
Move move(Square::E2, Square::E4, MoveFlag::DoublePush);
REQUIRE(move.from() == Square::E2);
REQUIRE(move.to() == Square::E4);
REQUIRE(move.flag() == MoveFlag::DoublePush);
REQUIRE_FALSE(move.is_capture());
REQUIRE_FALSE(move.is_promotion());
}
TEST_CASE("Move capture flag", "[move]") {
Move move(Square::E4, Square::D5, MoveFlag::Capture);
REQUIRE(move.is_capture());
REQUIRE_FALSE(move.is_promotion());
}
TEST_CASE("Move promotion", "[move]") {
Move move(Square::E7, Square::E8, MoveFlag::PromoteQueen);
REQUIRE(move.is_promotion());
REQUIRE_FALSE(move.is_capture());
}
TEST_CASE("Move null check", "[move]") {
Move null_move;
REQUIRE(null_move.is_null());
Move real_move(Square::E2, Square::E4, MoveFlag::Quiet);
REQUIRE_FALSE(real_move.is_null());
}
TEST_CASE("Perft starting position depth 1", "[movegen][perft]") {
attacks::init();
Board board;
board.set_startpos();
REQUIRE(perft(board, 1) == 20);
}
TEST_CASE("Perft starting position depth 2", "[movegen][perft]") {
attacks::init();
Board board;
board.set_startpos();
REQUIRE(perft(board, 2) == 400);
}
What do the perft values mean?
From the standard chess starting position, there are exactly 20 legal moves at depth 1 (16 pawn moves and 4 knight moves). At depth 2, each of those 20 moves leads to a position with 20 legal responses, giving 400 total leaf nodes. These are well-known published values used by every chess engine to validate move generation correctness.
- Save tests/test_movegen.cpp.
- Rebuild and run the test suite:
cmake --build build && ctest --test-dir build --output-on-failure
- You should see all tests passing, including the two new perft tests confirming depth 1 = 20 and depth 2 = 400.
Perft values don't match?
If depth 1 returns fewer than 20, your pawn or knight generation is missing cases. Double-check that double pushes use the correct start rank (Rank2 for White, Rank7 for Black) and that your knight attack table computes all 8 L-shaped jumps.
If the build fails with undefined references to attacks::init or perft, confirm src/attacks.cpp is in your CMakeLists.txt source list and that the test file includes both "zenith/movegen.hpp" and "zenith/attacks.hpp".
Help me debug my failing perft tests
✔️ Awesome, I've got everything!
Great. Double check you have saved all files before moving on.
ⓧ I'd like to double check the full code
Here is the full content of each file you created or changed in this step.
cmake_minimum_required(VERSION 3.20)
project(Zenith VERSION 1.0.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
set(CMAKE_CXX_SCAN_FOR_MODULES OFF)
include(FetchContent)
FetchContent_Declare(
Catch2
GIT_REPOSITORY https://github.com/catchorg/Catch2.git
GIT_TAG v3.15.0
)
FetchContent_MakeAvailable(Catch2)
add_library(zenith_core STATIC
src/bitboard.cpp
src/board.cpp
src/attacks.cpp
src/movegen.cpp
src/evaluation.cpp
src/search.cpp
)
target_include_directories(zenith_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
target_compile_features(zenith_core PUBLIC cxx_std_20)
add_executable(zenith src/main.cpp src/uci.cpp)
target_link_libraries(zenith PRIVATE zenith_core)
enable_testing()
add_executable(zenith_tests
tests/test_bitboard.cpp
tests/test_board.cpp
tests/test_movegen.cpp
tests/test_search.cpp
)
target_link_libraries(zenith_tests PRIVATE zenith_core Catch2::Catch2WithMain)
list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras)
include(CTest)
include(Catch)
catch_discover_tests(zenith_tests)
#pragma once
#include "zenith/types.hpp"
#include <array>
namespace zenith::attacks {
extern std::array<std::array<Bitboard, 64>, 2> pawn_attacks;
extern std::array<Bitboard, 64> knight_attacks;
extern std::array<Bitboard, 64> king_attacks;
void init();
[[nodiscard]] Bitboard bishop_attacks(Square sq, Bitboard occupied);
[[nodiscard]] Bitboard rook_attacks(Square sq, Bitboard occupied);
[[nodiscard]] Bitboard queen_attacks(Square sq, Bitboard occupied);
} // namespace zenith::attacks
#pragma once
#include "zenith/types.hpp"
#include "zenith/board.hpp"
#include <vector>
namespace zenith {
class MoveGenerator {
public:
explicit MoveGenerator(Board& board);
[[nodiscard]] std::vector<Move> generate_legal_moves();
[[nodiscard]] std::vector<Move> generate_pseudo_legal_moves();
[[nodiscard]] bool is_legal(Move move);
private:
Board& board_;
void generate_pawn_moves(std::vector<Move>& moves);
void generate_knight_moves(std::vector<Move>& moves);
void generate_king_moves(std::vector<Move>& moves);
void generate_sliding_moves(std::vector<Move>& moves);
};
[[nodiscard]] std::uint64_t perft(Board& board, int depth);
} // namespace zenith
#include "zenith/attacks.hpp"
#include "zenith/bitboard.hpp"
namespace zenith::attacks {
std::array<std::array<Bitboard, 64>, 2> pawn_attacks;
std::array<Bitboard, 64> knight_attacks;
std::array<Bitboard, 64> king_attacks;
namespace {
Bitboard compute_pawn_attacks(Color color, int sq) {
Bitboard attacks = 0;
Bitboard bb = 1ULL << sq;
if (color == Color::White) {
if (bb & ~FileA) attacks |= bb << 7;
if (bb & ~FileH) attacks |= bb << 9;
} else {
if (bb & ~FileH) attacks |= bb >> 7;
if (bb & ~FileA) attacks |= bb >> 9;
}
return attacks;
}
Bitboard compute_knight_attacks(int sq) {
Bitboard attacks = 0;
Bitboard bb = 1ULL << sq;
if (bb & ~(FileA | FileB)) { attacks |= bb << 6; attacks |= bb >> 10; }
if (bb & ~FileA) { attacks |= bb << 15; attacks |= bb >> 17; }
if (bb & ~FileH) { attacks |= bb << 17; attacks |= bb >> 15; }
if (bb & ~(FileG | FileH)) { attacks |= bb << 10; attacks |= bb >> 6; }
return attacks;
}
Bitboard compute_king_attacks(int sq) {
Bitboard attacks = 0;
Bitboard bb = 1ULL << sq;
attacks |= (bb << 8);
attacks |= (bb >> 8);
if (bb & ~FileA) { attacks |= (bb << 7); attacks |= (bb >> 1); attacks |= (bb >> 9); }
if (bb & ~FileH) { attacks |= (bb << 9); attacks |= (bb << 1); attacks |= (bb >> 7); }
return attacks;
}
Bitboard ray_attack(int sq, int dir_file, int dir_rank, Bitboard occupied) {
Bitboard attacks = 0;
int file = sq % 8;
int rank = sq / 8;
while (true) {
file += dir_file;
rank += dir_rank;
if (file < 0 || file > 7 || rank < 0 || rank > 7) break;
Bitboard bit = 1ULL << (rank * 8 + file);
attacks |= bit;
if (occupied & bit) break;
}
return attacks;
}
} // anonymous namespace
void init() {
for (int sq = 0; sq < 64; ++sq) {
pawn_attacks[0][sq] = compute_pawn_attacks(Color::White, sq);
pawn_attacks[1][sq] = compute_pawn_attacks(Color::Black, sq);
knight_attacks[sq] = compute_knight_attacks(sq);
king_attacks[sq] = compute_king_attacks(sq);
}
}
Bitboard bishop_attacks(Square sq, Bitboard occupied) {
int s = square_index(sq);
return ray_attack(s, 1, 1, occupied)
| ray_attack(s, 1, -1, occupied)
| ray_attack(s, -1, 1, occupied)
| ray_attack(s, -1, -1, occupied);
}
Bitboard rook_attacks(Square sq, Bitboard occupied) {
int s = square_index(sq);
return ray_attack(s, 1, 0, occupied)
| ray_attack(s, -1, 0, occupied)
| ray_attack(s, 0, 1, occupied)
| ray_attack(s, 0, -1, occupied);
}
Bitboard queen_attacks(Square sq, Bitboard occupied) {
return bishop_attacks(sq, occupied) | rook_attacks(sq, occupied);
}
} // namespace zenith::attacks
#include "zenith/movegen.hpp"
#include "zenith/attacks.hpp"
#include "zenith/bitboard.hpp"
namespace zenith {
MoveGenerator::MoveGenerator(Board& board) : board_(board) {}
std::vector<Move> MoveGenerator::generate_pseudo_legal_moves() {
std::vector<Move> moves;
moves.reserve(256);
generate_pawn_moves(moves);
generate_knight_moves(moves);
generate_sliding_moves(moves);
generate_king_moves(moves);
return moves;
}
std::vector<Move> MoveGenerator::generate_legal_moves() {
auto moves = generate_pseudo_legal_moves();
std::vector<Move> legal;
legal.reserve(moves.size());
for (auto& move : moves) {
if (is_legal(move)) {
legal.push_back(move);
}
}
return legal;
}
bool MoveGenerator::is_legal(Move move) {
board_.make_move(move);
bool legal = !board_.is_in_check(~board_.side_to_move());
board_.unmake_move(move);
return legal;
}
void MoveGenerator::generate_pawn_moves(std::vector<Move>& moves) {
Color us = board_.side_to_move();
Color them = ~us;
Bitboard our_pawns = board_.pieces(us, PieceType::Pawn);
Bitboard occupied = board_.all_pieces();
Bitboard enemies = board_.pieces(them);
int direction = (us == Color::White) ? 8 : -8;
Bitboard promo_rank = (us == Color::White) ? Rank8 : Rank1;
Bitboard start_rank = (us == Color::White) ? Rank2 : Rank7;
Bitboard single = (us == Color::White)
? (our_pawns << 8) & ~occupied
: (our_pawns >> 8) & ~occupied;
Bitboard promo_pushes = single & promo_rank;
Bitboard quiet_pushes = single & ~promo_rank;
while (quiet_pushes) {
int to = pop_lsb(quiet_pushes);
moves.emplace_back(static_cast<Square>(to - direction),
static_cast<Square>(to), MoveFlag::Quiet);
}
while (promo_pushes) {
int to = pop_lsb(promo_pushes);
auto from = static_cast<Square>(to - direction);
auto to_sq = static_cast<Square>(to);
moves.emplace_back(from, to_sq, MoveFlag::PromoteQueen);
moves.emplace_back(from, to_sq, MoveFlag::PromoteRook);
moves.emplace_back(from, to_sq, MoveFlag::PromoteBishop);
moves.emplace_back(from, to_sq, MoveFlag::PromoteKnight);
}
Bitboard double_push = (us == Color::White)
? ((our_pawns & start_rank) << 8) & ~occupied
: ((our_pawns & start_rank) >> 8) & ~occupied;
double_push = (us == Color::White)
? (double_push << 8) & ~occupied
: (double_push >> 8) & ~occupied;
while (double_push) {
int to = pop_lsb(double_push);
moves.emplace_back(static_cast<Square>(to - direction * 2),
static_cast<Square>(to), MoveFlag::DoublePush);
}
int color_idx = static_cast<int>(us);
Bitboard pawns_copy = our_pawns;
while (pawns_copy) {
int from = pop_lsb(pawns_copy);
Bitboard captures = attacks::pawn_attacks[color_idx][from] & enemies;
while (captures) {
int to = pop_lsb(captures);
auto from_sq = static_cast<Square>(from);
auto to_sq = static_cast<Square>(to);
if ((1ULL << to) & promo_rank) {
moves.emplace_back(from_sq, to_sq, MoveFlag::CapturePromoteQueen);
moves.emplace_back(from_sq, to_sq, MoveFlag::CapturePromoteRook);
moves.emplace_back(from_sq, to_sq, MoveFlag::CapturePromoteBishop);
moves.emplace_back(from_sq, to_sq, MoveFlag::CapturePromoteKnight);
} else {
moves.emplace_back(from_sq, to_sq, MoveFlag::Capture);
}
}
}
Square ep = board_.en_passant_square();
if (ep != Square::None) {
Bitboard ep_pawns = attacks::pawn_attacks[static_cast<int>(them)][square_index(ep)] & our_pawns;
while (ep_pawns) {
int from = pop_lsb(ep_pawns);
moves.emplace_back(static_cast<Square>(from), ep, MoveFlag::EnPassant);
}
}
}
void MoveGenerator::generate_knight_moves(std::vector<Move>& moves) {
Color us = board_.side_to_move();
Bitboard our_knights = board_.pieces(us, PieceType::Knight);
Bitboard our_pieces = board_.pieces(us);
Bitboard enemies = board_.pieces(~us);
while (our_knights) {
int from = pop_lsb(our_knights);
Bitboard targets = attacks::knight_attacks[from] & ~our_pieces;
while (targets) {
int to = pop_lsb(targets);
MoveFlag flag = ((1ULL << to) & enemies)
? MoveFlag::Capture : MoveFlag::Quiet;
moves.emplace_back(static_cast<Square>(from),
static_cast<Square>(to), flag);
}
}
}
void MoveGenerator::generate_king_moves(std::vector<Move>& moves) {
Color us = board_.side_to_move();
Bitboard our_king = board_.pieces(us, PieceType::King);
Bitboard our_pieces = board_.pieces(us);
Bitboard enemies = board_.pieces(~us);
if (!our_king) return;
int from = lsb(our_king);
Bitboard targets = attacks::king_attacks[from] & ~our_pieces;
while (targets) {
int to = pop_lsb(targets);
MoveFlag flag = ((1ULL << to) & enemies)
? MoveFlag::Capture : MoveFlag::Quiet;
moves.emplace_back(static_cast<Square>(from),
static_cast<Square>(to), flag);
}
CastlingRights rights = board_.castling_rights();
Bitboard occupied = board_.all_pieces();
if (us == Color::White) {
if ((rights & CastlingRights::WhiteKingSide) != CastlingRights::None) {
if (!(occupied & ((1ULL << 5) | (1ULL << 6)))) {
if (!board_.is_in_check(Color::White)) {
moves.emplace_back(Square::E1, Square::G1, MoveFlag::KingCastle);
}
}
}
if ((rights & CastlingRights::WhiteQueenSide) != CastlingRights::None) {
if (!(occupied & ((1ULL << 1) | (1ULL << 2) | (1ULL << 3)))) {
if (!board_.is_in_check(Color::White)) {
moves.emplace_back(Square::E1, Square::C1, MoveFlag::QueenCastle);
}
}
}
} else {
if ((rights & CastlingRights::BlackKingSide) != CastlingRights::None) {
if (!(occupied & ((1ULL << 61) | (1ULL << 62)))) {
if (!board_.is_in_check(Color::Black)) {
moves.emplace_back(Square::E8, Square::G8, MoveFlag::KingCastle);
}
}
}
if ((rights & CastlingRights::BlackQueenSide) != CastlingRights::None) {
if (!(occupied & ((1ULL << 57) | (1ULL << 58) | (1ULL << 59)))) {
if (!board_.is_in_check(Color::Black)) {
moves.emplace_back(Square::E8, Square::C8, MoveFlag::QueenCastle);
}
}
}
}
}
void MoveGenerator::generate_sliding_moves(std::vector<Move>& moves) {
Color us = board_.side_to_move();
Bitboard our_pieces = board_.pieces(us);
Bitboard enemies = board_.pieces(~us);
Bitboard occupied = board_.all_pieces();
Bitboard diagonal_sliders =
board_.pieces(us, PieceType::Bishop) | board_.pieces(us, PieceType::Queen);
while (diagonal_sliders) {
int from = pop_lsb(diagonal_sliders);
Bitboard targets = attacks::bishop_attacks(
static_cast<Square>(from), occupied) & ~our_pieces;
while (targets) {
int to = pop_lsb(targets);
MoveFlag flag = ((1ULL << to) & enemies)
? MoveFlag::Capture : MoveFlag::Quiet;
moves.emplace_back(static_cast<Square>(from),
static_cast<Square>(to), flag);
}
}
Bitboard orthogonal_sliders =
board_.pieces(us, PieceType::Rook) | board_.pieces(us, PieceType::Queen);
while (orthogonal_sliders) {
int from = pop_lsb(orthogonal_sliders);
Bitboard targets = attacks::rook_attacks(
static_cast<Square>(from), occupied) & ~our_pieces;
while (targets) {
int to = pop_lsb(targets);
MoveFlag flag = ((1ULL << to) & enemies)
? MoveFlag::Capture : MoveFlag::Quiet;
moves.emplace_back(static_cast<Square>(from),
static_cast<Square>(to), flag);
}
}
}
std::uint64_t perft(Board& board, int depth) {
if (depth == 0) return 1;
MoveGenerator gen(board);
auto moves = gen.generate_legal_moves();
if (depth == 1) return moves.size();
std::uint64_t nodes = 0;
for (auto& move : moves) {
board.make_move(move);
nodes += perft(board, depth - 1);
board.unmake_move(move);
}
return nodes;
}
#include "zenith/types.hpp"
#include "zenith/board.hpp"
#include "zenith/attacks.hpp"
#include <iostream>
#include <string>
namespace zenith {
void uci_loop();
}
int main(int argc, char* argv[]) {
std::cout << "Zenith Chess Engine v1.0.0" << std::endl;
// Initialize attack tables at startup
zenith::attacks::init();
if (argc > 1 && std::string(argv[1]) == "bench") {
std::cout << "Benchmark mode not yet implemented." << std::endl;
return 0;
}
zenith::uci_loop();
return 0;
}
#include <catch2/catch_test_macros.hpp>
#include "zenith/types.hpp"
#include "zenith/board.hpp"
#include "zenith/movegen.hpp"
#include "zenith/attacks.hpp"
using namespace zenith;
TEST_CASE("Move encoding", "[move]") {
Move move(Square::E2, Square::E4, MoveFlag::DoublePush);
REQUIRE(move.from() == Square::E2);
REQUIRE(move.to() == Square::E4);
REQUIRE(move.flag() == MoveFlag::DoublePush);
REQUIRE_FALSE(move.is_capture());
REQUIRE_FALSE(move.is_promotion());
}
TEST_CASE("Move capture flag", "[move]") {
Move move(Square::E4, Square::D5, MoveFlag::Capture);
REQUIRE(move.is_capture());
REQUIRE_FALSE(move.is_promotion());
}
TEST_CASE("Move promotion", "[move]") {
Move move(Square::E7, Square::E8, MoveFlag::PromoteQueen);
REQUIRE(move.is_promotion());
REQUIRE_FALSE(move.is_capture());
}
TEST_CASE("Move null check", "[move]") {
Move null_move;
REQUIRE(null_move.is_null());
Move real_move(Square::E2, Square::E4, MoveFlag::Quiet);
REQUIRE_FALSE(real_move.is_null());
}
TEST_CASE("Perft starting position depth 1", "[movegen][perft]") {
attacks::init();
Board board;
board.set_startpos();
REQUIRE(perft(board, 1) == 20);
}
TEST_CASE("Perft starting position depth 2", "[movegen][perft]") {
attacks::init();
Board board;
board.set_startpos();
REQUIRE(perft(board, 2) == 400);
}
Your engine now has a fully working attack-table system and move generator. It can enumerate all pseudo-legal moves from any position and filter them for legality. Next up, you will implement make/unmake move so that the engine can actually play through sequences and enable deeper perft testing.
Implement Make/Unmake Move and Perft Validation
Your engine can generate all legal moves, but it cannot actually play them yet. To search the game tree, the engine needs to execute a move on the board, explore what happens next, then undo the move and try a different line.
This make/unmake cycle is the heartbeat of every chess engine's search. In this step, you will implement Board::make_move() and Board::unmake_move(), then prove they work by running perft (performance test) against published node counts for the starting position.
In this step, get ready to:
- Implement Board::make_move() to execute any legal move on the board.
- Implement Board::unmake_move() to perfectly restore the previous board state.
- Write a perft function and validate move counts against known correct values up to depth 5.
Implement Board::make_move()
The make_move() method must handle every possible move type: quiet moves, captures, en passant, castling, double pawn pushes, and promotions. Before modifying anything, it saves the current board state onto the state stack so unmake_move() can restore it later.
- In src/board.cpp, find the Board::make_move() stub that currently says // TODO: Implement in Step 6.
- Replace the entire function body with the following implementation:
void Board::make_move(Move move) {
// Save current state for undo
BoardState state;
state.castling = castling_;
state.en_passant = en_passant_;
state.halfmove_clock = halfmove_clock_;
state.captured = Piece::None;
Square from = move.from();
Square to = move.to();
MoveFlag flag = move.flag();
Piece moving = piece_on(from);
Color us = side_to_move_;
Color them = ~us;
// Handle captures - remove the captured piece first
if (move.is_capture()) {
if (flag == MoveFlag::EnPassant) {
// En passant captures a pawn on a different square
Square cap_sq = make_square(file_of(to), rank_of(from));
state.captured = piece_on(cap_sq);
remove_piece(cap_sq);
} else {
state.captured = piece_on(to);
remove_piece(to);
}
}
// Move the piece from source to destination
move_piece(from, to);
// Handle pawn promotion - replace pawn with promoted piece
if (move.is_promotion()) {
remove_piece(to);
PieceType promo_type = PieceType::Knight;
switch (flag) {
case MoveFlag::PromoteKnight:
case MoveFlag::CapturePromoteKnight:
promo_type = PieceType::Knight; break;
case MoveFlag::PromoteBishop:
case MoveFlag::CapturePromoteBishop:
promo_type = PieceType::Bishop; break;
case MoveFlag::PromoteRook:
case MoveFlag::CapturePromoteRook:
promo_type = PieceType::Rook; break;
case MoveFlag::PromoteQueen:
case MoveFlag::CapturePromoteQueen:
promo_type = PieceType::Queen; break;
default: break;
}
place_piece(make_piece(us, promo_type), to);
}
// Handle castling - move the rook to its new square
if (flag == MoveFlag::KingCastle) {
if (us == Color::White) {
move_piece(Square::H1, Square::F1);
} else {
move_piece(Square::H8, Square::F8);
}
} else if (flag == MoveFlag::QueenCastle) {
if (us == Color::White) {
move_piece(Square::A1, Square::D1);
} else {
move_piece(Square::A8, Square::D8);
}
}
// Update en passant square - only set on double pawn push
if (flag == MoveFlag::DoublePush) {
en_passant_ = make_square(file_of(from),
us == Color::White ? rank_of(from) + 1 : rank_of(from) - 1);
} else {
en_passant_ = Square::None;
}
// Update castling rights when king or rook moves/is captured
if (type_of(moving) == PieceType::King) {
if (us == Color::White) {
castling_ &= ~CastlingRights::WhiteAll;
} else {
castling_ &= ~CastlingRights::BlackAll;
}
}
if (from == Square::A1 || to == Square::A1)
castling_ &= ~CastlingRights::WhiteQueenSide;
if (from == Square::H1 || to == Square::H1)
castling_ &= ~CastlingRights::WhiteKingSide;
if (from == Square::A8 || to == Square::A8)
castling_ &= ~CastlingRights::BlackQueenSide;
if (from == Square::H8 || to == Square::H8)
castling_ &= ~CastlingRights::BlackKingSide;
// Update halfmove clock (resets on pawn move or capture)
if (type_of(moving) == PieceType::Pawn || move.is_capture()) {
halfmove_clock_ = 0;
} else {
halfmove_clock_++;
}
// Update fullmove number after Black's move
if (us == Color::Black) {
fullmove_number_++;
}
// Flip side to move
side_to_move_ = them;
// Push state onto the stack for unmake
state_stack_.push_back(state);
}
What does this code do?
The function handles six distinct move types in sequence. First it saves everything needed for undo into a BoardState struct.
- Captures remove the target piece before the moving piece lands. En passant is special because the captured pawn sits on a different square from the destination.
- Promotions replace the pawn after it moves. The code removes the pawn from the destination square and places the promoted piece type.
- Castling moves both the king and the rook. The king was already moved by move_piece(from, to), so only the rook needs explicit handling.
- Castling rights update whenever a king or rook leaves or arrives at a corner square. Checking both from and to handles the case where a rook is captured on its starting square.
- Save src/board.cpp.
- You will not see a visible change yet. The function is exercised in the next substep and fully validated by perft later.
Implement Board::unmake_move()
Undoing a move reverses every operation from make_move() in the opposite order. The function pops the saved state from the stack and uses it to restore castling rights, en passant, the halfmove clock, and any captured piece.
- In src/board.cpp, find the Board::unmake_move() stub that currently says // TODO: Implement in Step 6.
- Replace the entire function body with the following implementation:
void Board::unmake_move(Move move) {
// Pop the saved state
BoardState state = state_stack_.back();
state_stack_.pop_back();
// Flip side to move back
side_to_move_ = ~side_to_move_;
Color us = side_to_move_;
Square from = move.from();
Square to = move.to();
MoveFlag flag = move.flag();
// Undo promotion - replace promoted piece with pawn
if (move.is_promotion()) {
remove_piece(to);
place_piece(make_piece(us, PieceType::Pawn), to);
}
// Move the piece back to its original square
move_piece(to, from);
// Restore captured piece to its original position
if (move.is_capture()) {
if (flag == MoveFlag::EnPassant) {
// En passant captured pawn was beside the moving pawn
Square cap_sq = make_square(file_of(to), rank_of(from));
place_piece(state.captured, cap_sq);
} else {
place_piece(state.captured, to);
}
}
// Undo castling - move the rook back
if (flag == MoveFlag::KingCastle) {
if (us == Color::White) {
move_piece(Square::F1, Square::H1);
} else {
move_piece(Square::F8, Square::H8);
}
} else if (flag == MoveFlag::QueenCastle) {
if (us == Color::White) {
move_piece(Square::D1, Square::A1);
} else {
move_piece(Square::D8, Square::A8);
}
}
// Restore saved state values
castling_ = state.castling;
en_passant_ = state.en_passant;
halfmove_clock_ = state.halfmove_clock;
// Restore fullmove number
if (us == Color::Black) {
fullmove_number_--;
}
}
Why undo promotion before moving the piece back?
On the board right now, the destination square holds the promoted piece (a queen, rook, bishop, or knight). You need to turn it back into a pawn before calling move_piece(to, from). Otherwise the promoted piece would slide back to the origin square instead of a pawn.
The rest of the undo is straightforward: move the piece back, place any captured piece, slide the castling rook home, then overwrite the mutable state fields from the saved snapshot.
- Save src/board.cpp.
- You will not see a change yet. The perft test in the next substep will confirm both functions work correctly.
Common make/unmake bugs to watch for
- If you forget to handle en passant captures separately (removing the pawn from the adjacent square instead of the destination), perft at depth 3+ will be incorrect.
- If castling rights are not updated when a rook is captured on its starting square (the to check), certain positions will produce wrong node counts.
- If unmake_move() does not restore promotion back to a pawn before calling move_piece(), you will get corrupted bitboards.
help me debug my make_move or unmake_move implementation
Write the perft function and validate against known positions
Perft (performance test) recursively makes every legal move to a given depth and counts the leaf nodes. If your make/unmake and move generation are correct, the node counts will exactly match published values.
- In src/movegen.cpp, add the perft function implementation (the declaration already exists in include/zenith/movegen.hpp):
std::uint64_t perft(Board& board, int depth) {
// Base case: count this position as one node
if (depth == 0) return 1;
MoveGenerator gen(board);
auto moves = gen.generate_legal_moves();
// Bulk counting optimization at depth 1
if (depth == 1) return moves.size();
std::uint64_t nodes = 0;
for (const auto& move : moves) {
board.make_move(move);
nodes += perft(board, depth - 1);
board.unmake_move(move);
}
return nodes;
}
What does this code do?
- At depth 0, the function returns 1 because we have reached a leaf node.
- At depth 1, it returns the number of legal moves directly. This bulk counting optimization avoids making and unmaking moves at the last level.
- At deeper depths, it makes each legal move, recurses, then unmakes. The total across all branches is the perft value.
- Save src/movegen.cpp.
Now add tests that validate perft counts against the known correct values for the starting position.
- In tests/test_movegen.cpp, add the following perft test cases alongside your existing depth 1 and depth 2 tests:
TEST_CASE("Perft starting position depth 3", "[perft]") {
zenith::attacks::init();
Board board;
board.set_startpos();
REQUIRE(perft(board, 3) == 8902);
}
TEST_CASE("Perft starting position depth 4", "[perft]") {
zenith::attacks::init();
Board board;
board.set_startpos();
REQUIRE(perft(board, 4) == 197281);
}
TEST_CASE("Perft starting position depth 5", "[perft]") {
zenith::attacks::init();
Board board;
board.set_startpos();
REQUIRE(perft(board, 5) == 4865609);
}
Why these exact numbers?
These perft values are universally agreed upon in the chess programming community. Depth 1 = 20 (the starting 20 legal moves), depth 2 = 400 (20 responses to each of 20 moves), and deeper values account for all special moves.
If any single move is generated wrong or any make/unmake bug exists, the totals will not match. This makes perft the gold standard for validating chess engine correctness.
- Save tests/test_movegen.cpp.
- Build and run the test suite by running:
cd build && ninja && ctest --output-on-failure
- All perft tests should pass. Depth 5 may take a few seconds depending on your machine.
Perft counts don't match?
A mismatch at depth 3 but correct at depth 2 almost always means a make/unmake bug. The board is not being fully restored after recursion.
- Add a FEN round-trip assertion inside the perft loop: save the FEN before make_move, then compare it after unmake_move. Any difference reveals which move type is broken.
- If the count is off by a small amount, en passant or castling through check is the likely culprit.
- If depth 5 takes more than 30 seconds, confirm you have the bulk counting optimization (returning moves.size() at depth 1).
help me debug why my perft values don't match the expected counts
✔️ Awesome, I've got everything!
Great. Make sure you have saved both src/board.cpp and src/movegen.cpp, and that all perft tests pass.
ⓧ I'd like to double check the full code
Here is the complete make_move and unmake_move implementation in src/board.cpp. The rest of the file remains unchanged from previous steps.
void Board::make_move(Move move) {
// Save current state for undo
BoardState state;
state.castling = castling_;
state.en_passant = en_passant_;
state.halfmove_clock = halfmove_clock_;
state.captured = Piece::None;
Square from = move.from();
Square to = move.to();
MoveFlag flag = move.flag();
Piece moving = piece_on(from);
Color us = side_to_move_;
Color them = ~us;
// Handle captures - remove the captured piece first
if (move.is_capture()) {
if (flag == MoveFlag::EnPassant) {
// En passant captures a pawn on a different square
Square cap_sq = make_square(file_of(to), rank_of(from));
state.captured = piece_on(cap_sq);
remove_piece(cap_sq);
} else {
state.captured = piece_on(to);
remove_piece(to);
}
}
// Move the piece from source to destination
move_piece(from, to);
// Handle pawn promotion - replace pawn with promoted piece
if (move.is_promotion()) {
remove_piece(to);
PieceType promo_type = PieceType::Knight;
switch (flag) {
case MoveFlag::PromoteKnight:
case MoveFlag::CapturePromoteKnight:
promo_type = PieceType::Knight; break;
case MoveFlag::PromoteBishop:
case MoveFlag::CapturePromoteBishop:
promo_type = PieceType::Bishop; break;
case MoveFlag::PromoteRook:
case MoveFlag::CapturePromoteRook:
promo_type = PieceType::Rook; break;
case MoveFlag::PromoteQueen:
case MoveFlag::CapturePromoteQueen:
promo_type = PieceType::Queen; break;
default: break;
}
place_piece(make_piece(us, promo_type), to);
}
// Handle castling - move the rook to its new square
if (flag == MoveFlag::KingCastle) {
if (us == Color::White) {
move_piece(Square::H1, Square::F1);
} else {
move_piece(Square::H8, Square::F8);
}
} else if (flag == MoveFlag::QueenCastle) {
if (us == Color::White) {
move_piece(Square::A1, Square::D1);
} else {
move_piece(Square::A8, Square::D8);
}
}
// Update en passant square - only set on double pawn push
if (flag == MoveFlag::DoublePush) {
en_passant_ = make_square(file_of(from),
us == Color::White ? rank_of(from) + 1 : rank_of(from) - 1);
} else {
en_passant_ = Square::None;
}
// Update castling rights when king or rook moves/is captured
if (type_of(moving) == PieceType::King) {
if (us == Color::White) {
castling_ &= ~CastlingRights::WhiteAll;
} else {
castling_ &= ~CastlingRights::BlackAll;
}
}
if (from == Square::A1 || to == Square::A1)
castling_ &= ~CastlingRights::WhiteQueenSide;
if (from == Square::H1 || to == Square::H1)
castling_ &= ~CastlingRights::WhiteKingSide;
if (from == Square::A8 || to == Square::A8)
castling_ &= ~CastlingRights::BlackQueenSide;
if (from == Square::H8 || to == Square::H8)
castling_ &= ~CastlingRights::BlackKingSide;
// Update halfmove clock (resets on pawn move or capture)
if (type_of(moving) == PieceType::Pawn || move.is_capture()) {
halfmove_clock_ = 0;
} else {
halfmove_clock_++;
}
// Update fullmove number after Black's move
if (us == Color::Black) {
fullmove_number_++;
}
// Flip side to move
side_to_move_ = them;
// Push state onto the stack for unmake
state_stack_.push_back(state);
}
void Board::unmake_move(Move move) {
// Pop the saved state
BoardState state = state_stack_.back();
state_stack_.pop_back();
// Flip side to move back
side_to_move_ = ~side_to_move_;
Color us = side_to_move_;
Square from = move.from();
Square to = move.to();
MoveFlag flag = move.flag();
// Undo promotion - replace promoted piece with pawn
if (move.is_promotion()) {
remove_piece(to);
place_piece(make_piece(us, PieceType::Pawn), to);
}
// Move the piece back to its original square
move_piece(to, from);
// Restore captured piece to its original position
if (move.is_capture()) {
if (flag == MoveFlag::EnPassant) {
// En passant captured pawn was beside the moving pawn
Square cap_sq = make_square(file_of(to), rank_of(from));
place_piece(state.captured, cap_sq);
} else {
place_piece(state.captured, to);
}
}
// Undo castling - move the rook back
if (flag == MoveFlag::KingCastle) {
if (us == Color::White) {
move_piece(Square::F1, Square::H1);
} else {
move_piece(Square::F8, Square::H8);
}
} else if (flag == MoveFlag::QueenCastle) {
if (us == Color::White) {
move_piece(Square::D1, Square::A1);
} else {
move_piece(Square::D8, Square::A8);
}
}
// Restore saved state values
castling_ = state.castling;
en_passant_ = state.en_passant;
halfmove_clock_ = state.halfmove_clock;
// Restore fullmove number
if (us == Color::Black) {
fullmove_number_--;
}
}
Here is the perft function in src/movegen.cpp:
std::uint64_t perft(Board& board, int depth) {
// Base case: count this position as one node
if (depth == 0) return 1;
MoveGenerator gen(board);
auto moves = gen.generate_legal_moves();
// Bulk counting optimization at depth 1
if (depth == 1) return moves.size();
std::uint64_t nodes = 0;
for (const auto& move : moves) {
board.make_move(move);
nodes += perft(board, depth - 1);
board.unmake_move(move);
}
return nodes;
}
Here are the perft tests in tests/test_movegen.cpp (add these alongside your existing depth 1 and depth 2 tests):
TEST_CASE("Perft starting position depth 3", "[perft]") {
zenith::attacks::init();
Board board;
board.set_startpos();
REQUIRE(perft(board, 3) == 8902);
}
TEST_CASE("Perft starting position depth 4", "[perft]") {
zenith::attacks::init();
Board board;
board.set_startpos();
REQUIRE(perft(board, 4) == 197281);
}
TEST_CASE("Perft starting position depth 5", "[perft]") {
zenith::attacks::init();
Board board;
board.set_startpos();
REQUIRE(perft(board, 5) == 4865609);
}
Your engine can now play, undo, and prove its correctness against published values. Next up, you will give the engine the ability to evaluate who is winning in any position.
Implement Position Evaluation
Your engine can now make and unmake moves with perfect accuracy, verified by perft tests matching known node counts. The next challenge is teaching Zenith to understand which positions are good and which are bad.
A chess engine's evaluation function assigns a numerical score to any board position. In this step, you will build an evaluator that combines material counting, piece-square tables, and mobility scoring to produce a single centipawn score from the side-to-move's perspective.
In this step, get ready to:
- Implement material evaluation with standard piece values.
- Add piece-square tables that reward good piece placement.
- Combine material, placement, and mobility into one score.
Create the evaluation header and material scoring
The evaluation lives in its own module so the search can call it at leaf nodes without coupling to the board internals. You will start with the header that declares piece values and the Evaluator class, then implement material scoring.
- Create a new file include/zenith/evaluation.hpp and add the following code:
#pragma once
#include "zenith/board.hpp"
namespace zenith {
// Standard piece values in centipawns
constexpr int PawnValue = 100;
constexpr int KnightValue = 320;
constexpr int BishopValue = 330;
constexpr int RookValue = 500;
constexpr int QueenValue = 900;
class Evaluator {
public:
// Returns score in centipawns from side-to-move perspective
[[nodiscard]] static int evaluate(const Board& board);
private:
// Sum of piece values for one color
[[nodiscard]] static int material_score(const Board& board, Color c);
// Bonus/penalty based on piece placement
[[nodiscard]] static int piece_square_score(const Board& board, Color c);
// Squares reachable by pieces (attack-based mobility)
[[nodiscard]] static int mobility_score(Board& board, Color c);
};
} // namespace zenith
What does this header define?
Five constexpr piece values encode the standard material weights a chess engine uses for counting. The king has no value because losing it ends the game.
The Evaluator class exposes one public function (evaluate) and three private helpers for material, placement, and mobility. The mobility_score helper takes a non-const Board& because it needs to iterate bitboards using pop_lsb.
- Save include/zenith/evaluation.hpp.
Now implement the material scoring function. Material is the most important evaluation term in chess.
- Open src/evaluation.cpp and replace the placeholder content with the following code:
#include "zenith/evaluation.hpp"
#include "zenith/bitboard.hpp"
#include "zenith/attacks.hpp"
#include <array>
namespace zenith {
int Evaluator::material_score(const Board& board, Color c) {
int score = 0;
// Count each piece type and multiply by its value
score += popcount(board.pieces(c, PieceType::Pawn)) * PawnValue;
score += popcount(board.pieces(c, PieceType::Knight)) * KnightValue;
score += popcount(board.pieces(c, PieceType::Bishop)) * BishopValue;
score += popcount(board.pieces(c, PieceType::Rook)) * RookValue;
score += popcount(board.pieces(c, PieceType::Queen)) * QueenValue;
return score;
}
} // namespace zenith
What does this code do?
popcount counts the number of set bits in a bitboard, telling you how many pieces of that type exist for the given color. Multiplying the piece count by its value gives the total material score for one side.
- Save src/evaluation.cpp.
- The project will not compile yet because piece_square_score and mobility_score are declared but not defined. You will add those next.
Add piece-square tables for all piece types
Material tells you what pieces are on the board, but not where they are. A knight in the center controls up to 8 squares. A knight in the corner controls only 2.
Piece-square tables encode this positional knowledge as a 64-element array of bonuses and penalties for each piece type. Each table is written from White's perspective. For Black, the engine mirrors the square by flipping the rank.
- In src/evaluation.cpp, add the following piece-square table arrays inside the namespace zenith block, above the material_score function:
// Piece-square tables from White's perspective (A1 = index 0)
// Values in centipawns: positive = good square, negative = bad square
// Pawns: reward advancement and center control
constexpr std::array<int, 64> PawnPST = {
0, 0, 0, 0, 0, 0, 0, 0,
5, 10, 10,-20,-20, 10, 10, 5,
5, -5,-10, 0, 0,-10, -5, 5,
0, 0, 0, 20, 20, 0, 0, 0,
5, 5, 10, 25, 25, 10, 5, 5,
10, 10, 20, 30, 30, 20, 10, 10,
50, 50, 50, 50, 50, 50, 50, 50,
0, 0, 0, 0, 0, 0, 0, 0
};
// Knights: strongly prefer the center
constexpr std::array<int, 64> KnightPST = {
-50,-40,-30,-30,-30,-30,-40,-50,
-40,-20, 0, 5, 5, 0,-20,-40,
-30, 5, 10, 15, 15, 10, 5,-30,
-30, 0, 15, 20, 20, 15, 0,-30,
-30, 5, 15, 20, 20, 15, 5,-30,
-30, 0, 10, 15, 15, 10, 0,-30,
-40,-20, 0, 0, 0, 0,-20,-40,
-50,-40,-30,-30,-30,-30,-40,-50
};
// Bishops: prefer long diagonals and avoid edges
constexpr std::array<int, 64> BishopPST = {
-20,-10,-10,-10,-10,-10,-10,-20,
-10, 5, 0, 0, 0, 0, 5,-10,
-10, 10, 10, 10, 10, 10, 10,-10,
-10, 0, 10, 10, 10, 10, 0,-10,
-10, 5, 5, 10, 10, 5, 5,-10,
-10, 0, 5, 10, 10, 5, 0,-10,
-10, 0, 0, 0, 0, 0, 0,-10,
-20,-10,-10,-10,-10,-10,-10,-20
};
// Rooks: prefer open files and the seventh rank
constexpr std::array<int, 64> RookPST = {
0, 0, 0, 5, 5, 0, 0, 0,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
5, 10, 10, 10, 10, 10, 10, 5,
0, 0, 0, 0, 0, 0, 0, 0
};
// Queen: mild centralization preference
constexpr std::array<int, 64> QueenPST = {
-20,-10,-10, -5, -5,-10,-10,-20,
-10, 0, 0, 0, 0, 0, 0,-10,
-10, 0, 5, 5, 5, 5, 0,-10,
-5, 0, 5, 5, 5, 5, 0, -5,
0, 0, 5, 5, 5, 5, 0, -5,
-10, 5, 5, 5, 5, 5, 0,-10,
-10, 0, 5, 0, 0, 0, 0,-10,
-20,-10,-10, -5, -5,-10,-10,-20
};
// King: prioritize safety (stay castled, avoid center)
constexpr std::array<int, 64> KingPST = {
20, 30, 10, 0, 0, 10, 30, 20,
20, 20, 0, 0, 0, 0, 20, 20,
-10,-20,-20,-20,-20,-20,-20,-10,
-20,-30,-30,-40,-40,-30,-30,-20,
-30,-40,-40,-50,-50,-40,-40,-30,
-30,-40,-40,-50,-50,-40,-40,-30,
-30,-40,-40,-50,-50,-40,-40,-30,
-30,-40,-40,-50,-50,-40,-40,-30
};
// Mirror a square index for Black (flip the rank)
constexpr int mirror(int sq) noexcept {
return sq ^ 56;
}
How do piece-square tables work?
Each array has 64 entries corresponding to the 64 squares (index 0 = A1, index 63 = H8). Positive values reward a piece for being on that square. Negative values penalize it.
For Black pieces, the mirror() function flips the rank using XOR with 56 (A1 becomes A8, E4 becomes E5). This means Black's pawns are rewarded for advancing toward rank 1, not rank 8.
Now add the function that sums PST values across all pieces of a given color.
- Still in src/evaluation.cpp, add the piece_square_score function below material_score:
int Evaluator::piece_square_score(const Board& board, Color c) {
int score = 0;
// Lambda to iterate a bitboard and sum PST values
auto add_pst = [&](PieceType pt, const std::array<int, 64>& table) {
Bitboard bb = board.pieces(c, pt);
while (bb) {
int sq = pop_lsb(bb);
// White uses table directly, Black mirrors the square
score += (c == Color::White) ? table[sq] : table[mirror(sq)];
}
};
add_pst(PieceType::Pawn, PawnPST);
add_pst(PieceType::Knight, KnightPST);
add_pst(PieceType::Bishop, BishopPST);
add_pst(PieceType::Rook, RookPST);
add_pst(PieceType::Queen, QueenPST);
add_pst(PieceType::King, KingPST);
return score;
}
What does this code do?
The lambda add_pst iterates over every piece of a given type using pop_lsb to extract each piece's square from the bitboard. It looks up the bonus or penalty in the appropriate table.
Calling add_pst six times covers all piece types. The total is a sum of positional bonuses in centipawns.
- Save src/evaluation.cpp.
Seeing a compile error about pop_lsb?
Make sure you have #include "zenith/bitboard.hpp" at the top of evaluation.cpp. The pop_lsb and popcount utilities live in that header.
help me fix a compile error in evaluation.cpp
Implement mobility scoring and the evaluate function
Material and placement are static. Two positions with identical pieces on identical squares score the same regardless of whose turn it is. Mobility adds a dynamic element by counting how many squares each side's pieces can reach.
The mobility score uses the pre-computed attack tables from Step 4. It counts the number of squares each piece attacks (excluding friendly-occupied squares) as a proxy for piece activity.
- In src/evaluation.cpp, add the mobility_score function and the main evaluate function below piece_square_score:
int Evaluator::mobility_score(Board& board, Color c) {
int mobility = 0;
Bitboard occupied = board.all_pieces();
Bitboard friendly = board.pieces(c);
// Knight mobility: count non-friendly squares each knight attacks
Bitboard knights = board.pieces(c, PieceType::Knight);
while (knights) {
int sq = pop_lsb(knights);
Bitboard attacks = attacks::knight_attacks(static_cast<Square>(sq)) & ~friendly;
mobility += popcount(attacks);
}
// Bishop mobility: sliding attacks minus friendly squares
Bitboard bishops = board.pieces(c, PieceType::Bishop);
while (bishops) {
int sq = pop_lsb(bishops);
Bitboard attacks = attacks::bishop_attacks(static_cast<Square>(sq), occupied) & ~friendly;
mobility += popcount(attacks);
}
// Rook mobility: sliding attacks minus friendly squares
Bitboard rooks = board.pieces(c, PieceType::Rook);
while (rooks) {
int sq = pop_lsb(rooks);
Bitboard attacks = attacks::rook_attacks(static_cast<Square>(sq), occupied) & ~friendly;
mobility += popcount(attacks);
}
// Queen mobility: combines bishop and rook attacks
Bitboard queens = board.pieces(c, PieceType::Queen);
while (queens) {
int sq = pop_lsb(queens);
Bitboard attacks = (attacks::bishop_attacks(static_cast<Square>(sq), occupied) |
attacks::rook_attacks(static_cast<Square>(sq), occupied)) & ~friendly;
mobility += popcount(attacks);
}
return mobility;
}
int Evaluator::evaluate(const Board& board) {
int white_score = 0;
int black_score = 0;
// Material: the most important term
white_score += material_score(board, Color::White);
black_score += material_score(board, Color::Black);
// Piece-square tables: positional bonuses
white_score += piece_square_score(board, Color::White);
black_score += piece_square_score(board, Color::Black);
// Mobility: weighted at 5 centipawns per reachable square
Board copy = board;
white_score += mobility_score(copy, Color::White) * 5;
black_score += mobility_score(copy, Color::Black) * 5;
// Return score from side-to-move perspective
int score = white_score - black_score;
return (board.side_to_move() == Color::White) ? score : -score;
}
Why copy the board for mobility?
evaluate accepts a const Board& because callers (the search function) should not worry about their board being modified. However mobility_score takes a Board& because bitboard iteration uses pop_lsb on local copies. The copy ensures the original board is never touched.
The final score is negated for Black because in negamax search, each side wants a positive score from its own perspective.
- Save src/evaluation.cpp.
Now write unit tests to verify the evaluation behaves correctly. Three properties are easy to test: a symmetric position scores near zero, material advantages show up clearly, and flipping the side to move negates the score.
- Open tests/test_search.cpp and replace the placeholder content with the following tests:
#include <catch2/catch_test_macros.hpp>
#include "zenith/evaluation.hpp"
#include "zenith/board.hpp"
using namespace zenith;
TEST_CASE("Starting position evaluates near zero", "[evaluation]") {
Board board;
board.set_startpos();
int score = Evaluator::evaluate(board);
// Symmetric position: material, PST, and mobility are equal
REQUIRE(score >= -50);
REQUIRE(score <= 50);
}
TEST_CASE("Evaluation symmetry", "[evaluation]") {
// Same position, flip side to move: score should negate
Board white_to_move;
white_to_move.set_from_fen(
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1");
int score_w = Evaluator::evaluate(white_to_move);
Board black_to_move;
black_to_move.set_from_fen(
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR b KQkq - 0 1");
int score_b = Evaluator::evaluate(black_to_move);
// Both sides see the same magnitude from their own perspective
REQUIRE(score_w == -score_b);
}
TEST_CASE("Material advantage detected", "[evaluation]") {
// White is missing a queen (900 cp deficit)
Board board;
board.set_from_fen(
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNB1KBNR w KQkq - 0 1");
int score = Evaluator::evaluate(board);
// Score should reflect the missing queen (heavily negative for White)
REQUIRE(score < -800);
}
What do these tests verify?
- Starting position near zero confirms material, PST, and mobility are all symmetric.
- Symmetry test proves the same position with different side to move produces negated scores. This guarantees the engine treats both colors fairly.
- Material advantage test removes White's queen and checks the score drops below -800 centipawns, proving material counting works correctly.
- Save tests/test_search.cpp.
- Build and run the tests by running:
cmake --build build && ctest --test-dir build --output-on-failure
- All three new evaluation tests should pass alongside your existing bitboard, board, and movegen tests.
Evaluation symmetry test failing?
Check that your piece-square tables are symmetric across files (each row reads the same left-to-right as it would right-to-left for the mirrored rank). A common mistake is mirroring with sq ^ 7 (flips file) instead of sq ^ 56 (flips rank).
Also verify that mobility_score computes attacks for both colors independently using the attack tables, not the move generator.
help me debug my evaluation symmetry test failure
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files: evaluation.hpp, evaluation.cpp, and test_search.cpp.
ⓧ I'd like to double check the full code
Here is the complete include/zenith/evaluation.hpp:
#pragma once
#include "zenith/board.hpp"
namespace zenith {
constexpr int PawnValue = 100;
constexpr int KnightValue = 320;
constexpr int BishopValue = 330;
constexpr int RookValue = 500;
constexpr int QueenValue = 900;
class Evaluator {
public:
[[nodiscard]] static int evaluate(const Board& board);
private:
[[nodiscard]] static int material_score(const Board& board, Color c);
[[nodiscard]] static int piece_square_score(const Board& board, Color c);
[[nodiscard]] static int mobility_score(Board& board, Color c);
};
} // namespace zenith
Here is the complete src/evaluation.cpp:
#include "zenith/evaluation.hpp"
#include "zenith/bitboard.hpp"
#include "zenith/attacks.hpp"
#include <array>
namespace zenith {
// Piece-square tables from White's perspective (A1 = index 0)
// Pawns: reward advancement and center control
constexpr std::array<int, 64> PawnPST = {
0, 0, 0, 0, 0, 0, 0, 0,
5, 10, 10,-20,-20, 10, 10, 5,
5, -5,-10, 0, 0,-10, -5, 5,
0, 0, 0, 20, 20, 0, 0, 0,
5, 5, 10, 25, 25, 10, 5, 5,
10, 10, 20, 30, 30, 20, 10, 10,
50, 50, 50, 50, 50, 50, 50, 50,
0, 0, 0, 0, 0, 0, 0, 0
};
// Knights: strongly prefer the center
constexpr std::array<int, 64> KnightPST = {
-50,-40,-30,-30,-30,-30,-40,-50,
-40,-20, 0, 5, 5, 0,-20,-40,
-30, 5, 10, 15, 15, 10, 5,-30,
-30, 0, 15, 20, 20, 15, 0,-30,
-30, 5, 15, 20, 20, 15, 5,-30,
-30, 0, 10, 15, 15, 10, 0,-30,
-40,-20, 0, 0, 0, 0,-20,-40,
-50,-40,-30,-30,-30,-30,-40,-50
};
// Bishops: prefer long diagonals and avoid edges
constexpr std::array<int, 64> BishopPST = {
-20,-10,-10,-10,-10,-10,-10,-20,
-10, 5, 0, 0, 0, 0, 5,-10,
-10, 10, 10, 10, 10, 10, 10,-10,
-10, 0, 10, 10, 10, 10, 0,-10,
-10, 5, 5, 10, 10, 5, 5,-10,
-10, 0, 5, 10, 10, 5, 0,-10,
-10, 0, 0, 0, 0, 0, 0,-10,
-20,-10,-10,-10,-10,-10,-10,-20
};
// Rooks: prefer open files and the seventh rank
constexpr std::array<int, 64> RookPST = {
0, 0, 0, 5, 5, 0, 0, 0,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
5, 10, 10, 10, 10, 10, 10, 5,
0, 0, 0, 0, 0, 0, 0, 0
};
// Queen: mild centralization preference
constexpr std::array<int, 64> QueenPST = {
-20,-10,-10, -5, -5,-10,-10,-20,
-10, 0, 0, 0, 0, 0, 0,-10,
-10, 0, 5, 5, 5, 5, 0,-10,
-5, 0, 5, 5, 5, 5, 0, -5,
0, 0, 5, 5, 5, 5, 0, -5,
-10, 5, 5, 5, 5, 5, 0,-10,
-10, 0, 5, 0, 0, 0, 0,-10,
-20,-10,-10, -5, -5,-10,-10,-20
};
// King: prioritize safety (stay castled, avoid center)
constexpr std::array<int, 64> KingPST = {
20, 30, 10, 0, 0, 10, 30, 20,
20, 20, 0, 0, 0, 0, 20, 20,
-10,-20,-20,-20,-20,-20,-20,-10,
-20,-30,-30,-40,-40,-30,-30,-20,
-30,-40,-40,-50,-50,-40,-40,-30,
-30,-40,-40,-50,-50,-40,-40,-30,
-30,-40,-40,-50,-50,-40,-40,-30,
-30,-40,-40,-50,-50,-40,-40,-30
};
// Mirror a square index for Black (flip the rank)
constexpr int mirror(int sq) noexcept {
return sq ^ 56;
}
int Evaluator::material_score(const Board& board, Color c) {
int score = 0;
score += popcount(board.pieces(c, PieceType::Pawn)) * PawnValue;
score += popcount(board.pieces(c, PieceType::Knight)) * KnightValue;
score += popcount(board.pieces(c, PieceType::Bishop)) * BishopValue;
score += popcount(board.pieces(c, PieceType::Rook)) * RookValue;
score += popcount(board.pieces(c, PieceType::Queen)) * QueenValue;
return score;
}
int Evaluator::piece_square_score(const Board& board, Color c) {
int score = 0;
auto add_pst = [&](PieceType pt, const std::array<int, 64>& table) {
Bitboard bb = board.pieces(c, pt);
while (bb) {
int sq = pop_lsb(bb);
score += (c == Color::White) ? table[sq] : table[mirror(sq)];
}
};
add_pst(PieceType::Pawn, PawnPST);
add_pst(PieceType::Knight, KnightPST);
add_pst(PieceType::Bishop, BishopPST);
add_pst(PieceType::Rook, RookPST);
add_pst(PieceType::Queen, QueenPST);
add_pst(PieceType::King, KingPST);
return score;
}
int Evaluator::mobility_score(Board& board, Color c) {
int mobility = 0;
Bitboard occupied = board.all_pieces();
Bitboard friendly = board.pieces(c);
// Knight mobility
Bitboard knights = board.pieces(c, PieceType::Knight);
while (knights) {
int sq = pop_lsb(knights);
Bitboard attacks = attacks::knight_attacks(static_cast<Square>(sq)) & ~friendly;
mobility += popcount(attacks);
}
// Bishop mobility
Bitboard bishops = board.pieces(c, PieceType::Bishop);
while (bishops) {
int sq = pop_lsb(bishops);
Bitboard attacks = attacks::bishop_attacks(static_cast<Square>(sq), occupied) & ~friendly;
mobility += popcount(attacks);
}
// Rook mobility
Bitboard rooks = board.pieces(c, PieceType::Rook);
while (rooks) {
int sq = pop_lsb(rooks);
Bitboard attacks = attacks::rook_attacks(static_cast<Square>(sq), occupied) & ~friendly;
mobility += popcount(attacks);
}
// Queen mobility
Bitboard queens = board.pieces(c, PieceType::Queen);
while (queens) {
int sq = pop_lsb(queens);
Bitboard attacks = (attacks::bishop_attacks(static_cast<Square>(sq), occupied) |
attacks::rook_attacks(static_cast<Square>(sq), occupied)) & ~friendly;
mobility += popcount(attacks);
}
return mobility;
}
int Evaluator::evaluate(const Board& board) {
int white_score = 0;
int black_score = 0;
// Material
white_score += material_score(board, Color::White);
black_score += material_score(board, Color::Black);
// Piece-square tables
white_score += piece_square_score(board, Color::White);
black_score += piece_square_score(board, Color::Black);
// Mobility: weighted at 5 centipawns per reachable square
Board copy = board;
white_score += mobility_score(copy, Color::White) * 5;
black_score += mobility_score(copy, Color::Black) * 5;
// Return score from side-to-move perspective
int score = white_score - black_score;
return (board.side_to_move() == Color::White) ? score : -score;
}
} // namespace zenith
Here is the complete tests/test_search.cpp:
#include <catch2/catch_test_macros.hpp>
#include "zenith/evaluation.hpp"
#include "zenith/board.hpp"
using namespace zenith;
TEST_CASE("Starting position evaluates near zero", "[evaluation]") {
Board board;
board.set_startpos();
int score = Evaluator::evaluate(board);
REQUIRE(score >= -50);
REQUIRE(score <= 50);
}
TEST_CASE("Evaluation symmetry", "[evaluation]") {
Board white_to_move;
white_to_move.set_from_fen(
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1");
int score_w = Evaluator::evaluate(white_to_move);
Board black_to_move;
black_to_move.set_from_fen(
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR b KQkq - 0 1");
int score_b = Evaluator::evaluate(black_to_move);
REQUIRE(score_w == -score_b);
}
TEST_CASE("Material advantage detected", "[evaluation]") {
Board board;
board.set_from_fen(
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNB1KBNR w KQkq - 0 1");
int score = Evaluator::evaluate(board);
REQUIRE(score < -800);
}
Your engine now knows that a queen is worth more than a knight, that a knight in the center is better than one in the corner, and that pieces with more squares to move to are more active. Next up, you will build the search algorithm that uses this evaluation to pick the best move.
Implement the Search Algorithm
Your evaluation function can now score any position. The missing piece is a search algorithm that explores the game tree and finds the best move by looking ahead.
In this step, you will implement the core search using negamax with alpha-beta pruning. You will then wrap it in an iterative deepening framework that progressively searches deeper until time runs out, and add quiescence search to resolve tactical positions at leaf nodes.
In this step, get ready to:
- Implement negamax with alpha-beta pruning as the core search function.
- Add iterative deepening to search progressively deeper under a time limit.
- Add quiescence search and MVV-LVA move ordering for stronger tactical play.
Implement negamax with alpha-beta pruning
Negamax is a variant of minimax that exploits the zero-sum nature of chess. Instead of alternating between maximizing and minimizing, each side maximizes its own score (the opponent's score is negated). Alpha-beta pruning cuts branches that cannot possibly improve the result, dramatically reducing the number of positions evaluated.
- Create the file include/zenith/search.hpp by right-clicking the include/zenith folder in the Explorer sidebar and selecting New File.
- Add the Search class declaration by pasting this code:
#pragma once
#include "zenith/board.hpp"
#include <chrono>
#include <cstdint>
namespace zenith {
struct SearchResult {
Move best_move;
int score = 0;
int depth = 0;
std::uint64_t nodes = 0;
};
class Search {
public:
explicit Search(Board& board);
// Run iterative deepening search up to max_depth or time_limit
[[nodiscard]] SearchResult find_best_move(int max_depth, std::chrono::milliseconds time_limit);
// Stop the search externally (e.g., from UCI "stop" command)
void stop() { stopped_ = true; }
private:
Board& board_;
bool stopped_ = false;
std::uint64_t nodes_ = 0;
std::chrono::time_point<std::chrono::steady_clock> start_time_;
std::chrono::milliseconds time_limit_{};
// Core alpha-beta search
int negamax(int depth, int alpha, int beta);
// Quiescence search - resolve captures at leaf nodes
int qsearch(int alpha, int beta);
// MVV-LVA scoring for capture move ordering
[[nodiscard]] int mvv_lva_score(Move move) const;
// Sort moves: captures first (by MVV-LVA), then quiet moves
void order_moves(std::vector<Move>& moves);
};
} // namespace zenith
What does this header define?
The SearchResult struct bundles the output of a search: the best move found, its evaluation score, the depth reached, and total nodes searched.
The Search class holds a reference to the board and manages time control with stopped_ and time_limit_. The private methods implement the actual search logic.
- Save include/zenith/search.hpp.
- Now open src/search.cpp in the editor. Replace the placeholder content with the includes and constructor:
#include "zenith/search.hpp"
#include "zenith/evaluation.hpp"
#include "zenith/movegen.hpp"
#include <algorithm>
#include <limits>
namespace zenith {
// Mate score constant - offset by ply to prefer shorter mates
constexpr int MateScore = 100000;
Search::Search(Board& board)
: board_(board) {}
} // namespace zenith
Why a MateScore constant?
When the search detects checkmate, it returns a very large score. By subtracting the current ply from MateScore, the engine prefers shorter forced mates over longer ones.
- Below the constructor, add the negamax function:
int Search::negamax(int depth, int alpha, int beta) {
// Check time periodically
if ((nodes_ & 2047) == 0) {
auto elapsed = std::chrono::steady_clock::now() - start_time_;
if (elapsed >= time_limit_) {
stopped_ = true;
}
}
if (stopped_) return 0;
// Leaf node: drop into quiescence search
if (depth <= 0) {
return qsearch(alpha, beta);
}
nodes_++;
// Generate all legal moves
auto moves = MoveGenerator::generate_legal_moves(board_);
// No legal moves: checkmate or stalemate
if (moves.empty()) {
if (board_.is_in_check()) {
return -MateScore + static_cast<int>(nodes_ & 0); // Use ply offset
}
return 0; // Stalemate
}
// Order moves for better pruning
order_moves(moves);
int best_score = -std::numeric_limits<int>::max();
for (auto& move : moves) {
board_.make_move(move);
int score = -negamax(depth - 1, -beta, -alpha);
board_.unmake_move(move);
if (stopped_) return 0;
if (score > best_score) {
best_score = score;
}
if (score > alpha) {
alpha = score;
}
// Beta cutoff: opponent won't allow this line
if (alpha >= beta) {
break;
}
}
return best_score;
}
How does alpha-beta pruning work here?
Alpha represents the best score the current player can guarantee. Beta represents the best score the opponent can guarantee. When alpha meets or exceeds beta, the remaining moves are irrelevant because the opponent already has a better option elsewhere in the tree.
The time check on every 2048th node (using nodes_ & 2047) ensures the search respects its time budget without checking the clock on every single node.
- Save src/search.cpp.
You won't see a change yet. The next chunk adds iterative deepening, which makes the search callable.
Seeing a compile error?
Make sure you included zenith/movegen.hpp at the top of src/search.cpp. The MoveGenerator::generate_legal_moves function is defined there.
Also verify that search.hpp includes zenith/board.hpp (which provides the Move type).
help me debug my negamax compile error in Zenith
Add the iterative deepening framework
A fixed-depth search might take unpredictable time. Iterative deepening solves this by searching depth 1 first, then depth 2, and so on. If time runs out mid-search, the engine returns the best move from the deepest completed iteration.
- In src/search.cpp, add the find_best_move method below the negamax function:
SearchResult Search::find_best_move(int max_depth, std::chrono::milliseconds time_limit) {
// Reset search state
stopped_ = false;
nodes_ = 0;
start_time_ = std::chrono::steady_clock::now();
time_limit_ = time_limit;
SearchResult result{};
// Iterative deepening: search depth 1, 2, 3, ... up to max_depth
for (int depth = 1; depth <= max_depth; ++depth) {
int alpha = -std::numeric_limits<int>::max();
int beta = std::numeric_limits<int>::max();
auto moves = MoveGenerator::generate_legal_moves(board_);
if (moves.empty()) break;
order_moves(moves);
Move best_move_this_iteration;
int best_score = -std::numeric_limits<int>::max();
for (auto& move : moves) {
board_.make_move(move);
int score = -negamax(depth - 1, -beta, -alpha);
board_.unmake_move(move);
if (stopped_) break;
if (score > best_score) {
best_score = score;
best_move_this_iteration = move;
}
if (score > alpha) {
alpha = score;
}
}
// Only update result if this iteration completed
if (!stopped_) {
result.best_move = best_move_this_iteration;
result.score = best_score;
result.depth = depth;
result.nodes = nodes_;
}
// Stop early if we found a forced mate
if (best_score >= MateScore - 100) break;
}
return result;
}
Why only update the result when an iteration completes?
If the search is stopped mid-iteration (time ran out), the partial results from that iteration may be unreliable. The engine keeps the best move from the last fully completed depth instead. This guarantees the returned move is backed by a complete search.
The early exit on MateScore - 100 prevents wasting time searching deeper once a forced checkmate is already found.
- Save src/search.cpp.
You won't see a change yet. The quiescence search and move ordering are needed before the engine can build successfully.
Add quiescence search and MVV-LVA move ordering
Without quiescence search, the engine might stop evaluating right before a queen gets captured. This is called the "horizon effect." Quiescence search extends the search at leaf nodes by continuing to evaluate capture moves until the position is quiet.
Move ordering makes alpha-beta far more effective. By searching the best-looking captures first (high-value victim captured by low-value attacker), the engine finds cutoffs earlier and prunes more of the tree.
- In src/search.cpp, add the quiescence search below find_best_move:
int Search::qsearch(int alpha, int beta) {
// Check time periodically
if ((nodes_ & 2047) == 0) {
auto elapsed = std::chrono::steady_clock::now() - start_time_;
if (elapsed >= time_limit_) {
stopped_ = true;
}
}
if (stopped_) return 0;
nodes_++;
// Stand-pat: evaluate the current position
int stand_pat = Evaluator::evaluate(board_);
// Beta cutoff: position is already too good
if (stand_pat >= beta) {
return beta;
}
if (stand_pat > alpha) {
alpha = stand_pat;
}
// Generate all legal moves and filter to captures only
auto moves = MoveGenerator::generate_legal_moves(board_);
std::vector<Move> captures;
for (auto& m : moves) {
if (m.is_capture()) {
captures.push_back(m);
}
}
// Order captures by MVV-LVA
order_moves(captures);
for (auto& move : captures) {
board_.make_move(move);
int score = -qsearch(-beta, -alpha);
board_.unmake_move(move);
if (stopped_) return 0;
if (score >= beta) {
return beta;
}
if (score > alpha) {
alpha = score;
}
}
return alpha;
}
What is the stand-pat score?
The stand-pat score is the static evaluation of the current position without making any more moves. It represents the "do nothing" option. If this score already beats beta, we can prune immediately. If it's better than alpha, it raises the bar for captures to beat.
- Now add the MVV-LVA scoring and move ordering functions below qsearch:
int Search::mvv_lva_score(Move move) const {
// Score = victim value * 10 - attacker value
// Higher score means more valuable victim captured by less valuable attacker
constexpr std::array<int, 7> piece_values = {
0, 100, 320, 330, 500, 900, 20000 // None, Pawn, Knight, Bishop, Rook, Queen, King
};
Piece victim_piece = board_.piece_on(move.to());
Piece attacker_piece = board_.piece_on(move.from());
int victim_val = piece_values[static_cast<int>(type_of(victim_piece))];
int attacker_val = piece_values[static_cast<int>(type_of(attacker_piece))];
return victim_val * 10 - attacker_val;
}
void Search::order_moves(std::vector<Move>& moves) {
// Sort moves: captures first (highest MVV-LVA score), then quiet moves
std::sort(moves.begin(), moves.end(),
[this](const Move& a, const Move& b) {
int score_a = a.is_capture() ? mvv_lva_score(a) + 1000000 : 0;
int score_b = b.is_capture() ? mvv_lva_score(b) + 1000000 : 0;
return score_a > score_b;
}
);
}
How does MVV-LVA work?
MVV-LVA stands for Most Valuable Victim, Least Valuable Attacker. It scores captures by multiplying the victim's value by 10 and subtracting the attacker's value. A pawn capturing a queen scores very high (9000 - 100 = 8900). A queen capturing a pawn scores low (1000 - 900 = 100).
The + 1000000 offset ensures all captures are searched before any quiet moves, regardless of MVV-LVA score.
- Save src/search.cpp.
- Build the project to verify it compiles by running:
cmake --build build
You should see a clean build with no errors.
Build failing with undefined references?
Check that src/search.cpp is listed in the add_library(zenith_core ...) call in your CMakeLists.txt. It should already be there from the initial project setup.
Also verify you have the closing } // namespace zenith at the end of the file.
help me fix my Zenith build errors after adding the search module
- Now update tests/test_search.cpp to test that the search can find forced mates. Replace the file contents with:
#include <catch2/catch_test_macros.hpp>
#include "zenith/board.hpp"
#include "zenith/search.hpp"
#include <chrono>
using namespace zenith;
TEST_CASE("Search finds mate in 1", "[search]") {
Board board;
// White queen on h5, black king on h8, white king on g1
// Qh5-h7# is mate in 1
board.set_from_fen("7k/8/8/7Q/8/8/8/6K1 w - - 0 1");
Search search(board);
auto result = search.find_best_move(4, std::chrono::milliseconds(5000));
// The best move should deliver checkmate
REQUIRE_FALSE(result.best_move.is_null());
REQUIRE(result.score >= 90000); // Mate score threshold
}
TEST_CASE("Search finds mate in 2", "[search]") {
Board board;
// A known mate-in-2 position
board.set_from_fen("2bqkbn1/2pppp2/np2N3/r3P1p1/p2N2B1/5Q2/PPPPPP1P/RNB1K2R w KQ - 0 1");
Search search(board);
auto result = search.find_best_move(5, std::chrono::milliseconds(10000));
REQUIRE_FALSE(result.best_move.is_null());
REQUIRE(result.score >= 90000); // Mate score found
REQUIRE(result.depth >= 3); // Needed at least depth 3 to see mate in 2
}
TEST_CASE("Search nodes per second benchmark", "[search][!benchmark]") {
Board board;
board.set_startpos();
Search search(board);
auto start = std::chrono::steady_clock::now();
auto result = search.find_best_move(5, std::chrono::milliseconds(5000));
auto end = std::chrono::steady_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
if (ms > 0) {
auto nps = result.nodes * 1000 / static_cast<std::uint64_t>(ms);
WARN("Nodes/second: " << nps);
}
REQUIRE(result.nodes > 0);
REQUIRE_FALSE(result.best_move.is_null());
}
What do these tests verify?
- The mate-in-1 test confirms the engine finds a checkmate when one exists one move away.
- The mate-in-2 test uses a more complex position where the engine must look two moves ahead.
- The benchmark test measures how many nodes per second the search can process from the starting position, giving you a performance baseline.
- Save tests/test_search.cpp.
- Build and run the tests:
cmake --build build && ctest --test-dir build --output-on-failure -R search
You should see all search tests passing. The benchmark test will print a nodes-per-second value as a warning line in the output.
Mate-in-1 test failing?
Verify that your Board::is_in_check() correctly detects check from all directions. The mate-in-1 test uses a queen delivering checkmate on the back rank.
Also check that MoveGenerator::generate_legal_moves returns an empty vector when the position is checkmate (no legal moves available for the checkmated side).
help me debug why my Zenith search test is not finding mate in 1
✔️ Awesome, I've got everything!
Great. Double check you have saved both include/zenith/search.hpp and src/search.cpp.
ⓧ I'd like to double check the full code
Here is the complete include/zenith/search.hpp:
#pragma once
#include "zenith/board.hpp"
#include <chrono>
#include <cstdint>
namespace zenith {
struct SearchResult {
Move best_move;
int score = 0;
int depth = 0;
std::uint64_t nodes = 0;
};
class Search {
public:
explicit Search(Board& board);
[[nodiscard]] SearchResult find_best_move(int max_depth, std::chrono::milliseconds time_limit);
void stop() { stopped_ = true; }
private:
Board& board_;
bool stopped_ = false;
std::uint64_t nodes_ = 0;
std::chrono::time_point<std::chrono::steady_clock> start_time_;
std::chrono::milliseconds time_limit_{};
int negamax(int depth, int alpha, int beta);
int qsearch(int alpha, int beta);
[[nodiscard]] int mvv_lva_score(Move move) const;
void order_moves(std::vector<Move>& moves);
};
} // namespace zenith
Here is the complete src/search.cpp:
#include "zenith/search.hpp"
#include "zenith/evaluation.hpp"
#include "zenith/movegen.hpp"
#include <algorithm>
#include <limits>
namespace zenith {
constexpr int MateScore = 100000;
Search::Search(Board& board)
: board_(board) {}
int Search::negamax(int depth, int alpha, int beta) {
// Check time periodically
if ((nodes_ & 2047) == 0) {
auto elapsed = std::chrono::steady_clock::now() - start_time_;
if (elapsed >= time_limit_) {
stopped_ = true;
}
}
if (stopped_) return 0;
// Leaf node: drop into quiescence search
if (depth <= 0) {
return qsearch(alpha, beta);
}
nodes_++;
// Generate all legal moves
auto moves = MoveGenerator::generate_legal_moves(board_);
// No legal moves: checkmate or stalemate
if (moves.empty()) {
if (board_.is_in_check()) {
return -MateScore + static_cast<int>(nodes_ & 0);
}
return 0;
}
// Order moves for better pruning
order_moves(moves);
int best_score = -std::numeric_limits<int>::max();
for (auto& move : moves) {
board_.make_move(move);
int score = -negamax(depth - 1, -beta, -alpha);
board_.unmake_move(move);
if (stopped_) return 0;
if (score > best_score) {
best_score = score;
}
if (score > alpha) {
alpha = score;
}
if (alpha >= beta) {
break;
}
}
return best_score;
}
SearchResult Search::find_best_move(int max_depth, std::chrono::milliseconds time_limit) {
stopped_ = false;
nodes_ = 0;
start_time_ = std::chrono::steady_clock::now();
time_limit_ = time_limit;
SearchResult result{};
for (int depth = 1; depth <= max_depth; ++depth) {
int alpha = -std::numeric_limits<int>::max();
int beta = std::numeric_limits<int>::max();
auto moves = MoveGenerator::generate_legal_moves(board_);
if (moves.empty()) break;
order_moves(moves);
Move best_move_this_iteration;
int best_score = -std::numeric_limits<int>::max();
for (auto& move : moves) {
board_.make_move(move);
int score = -negamax(depth - 1, -beta, -alpha);
board_.unmake_move(move);
if (stopped_) break;
if (score > best_score) {
best_score = score;
best_move_this_iteration = move;
}
if (score > alpha) {
alpha = score;
}
}
if (!stopped_) {
result.best_move = best_move_this_iteration;
result.score = best_score;
result.depth = depth;
result.nodes = nodes_;
}
if (best_score >= MateScore - 100) break;
}
return result;
}
int Search::qsearch(int alpha, int beta) {
if ((nodes_ & 2047) == 0) {
auto elapsed = std::chrono::steady_clock::now() - start_time_;
if (elapsed >= time_limit_) {
stopped_ = true;
}
}
if (stopped_) return 0;
nodes_++;
int stand_pat = Evaluator::evaluate(board_);
if (stand_pat >= beta) {
return beta;
}
if (stand_pat > alpha) {
alpha = stand_pat;
}
auto moves = MoveGenerator::generate_legal_moves(board_);
std::vector<Move> captures;
for (auto& m : moves) {
if (m.is_capture()) {
captures.push_back(m);
}
}
order_moves(captures);
for (auto& move : captures) {
board_.make_move(move);
int score = -qsearch(-beta, -alpha);
board_.unmake_move(move);
if (stopped_) return 0;
if (score >= beta) {
return beta;
}
if (score > alpha) {
alpha = score;
}
}
return alpha;
}
int Search::mvv_lva_score(Move move) const {
constexpr std::array<int, 7> piece_values = {
0, 100, 320, 330, 500, 900, 20000
};
Piece victim_piece = board_.piece_on(move.to());
Piece attacker_piece = board_.piece_on(move.from());
int victim_val = piece_values[static_cast<int>(type_of(victim_piece))];
int attacker_val = piece_values[static_cast<int>(type_of(attacker_piece))];
return victim_val * 10 - attacker_val;
}
void Search::order_moves(std::vector<Move>& moves) {
std::sort(moves.begin(), moves.end(),
[this](const Move& a, const Move& b) {
int score_a = a.is_capture() ? mvv_lva_score(a) + 1000000 : 0;
int score_b = b.is_capture() ? mvv_lva_score(b) + 1000000 : 0;
return score_a > score_b;
}
);
}
} // namespace zenith
Here is the complete tests/test_search.cpp:
#include <catch2/catch_test_macros.hpp>
#include "zenith/board.hpp"
#include "zenith/search.hpp"
#include <chrono>
using namespace zenith;
TEST_CASE("Search finds mate in 1", "[search]") {
Board board;
board.set_from_fen("7k/8/8/7Q/8/8/8/6K1 w - - 0 1");
Search search(board);
auto result = search.find_best_move(4, std::chrono::milliseconds(5000));
REQUIRE_FALSE(result.best_move.is_null());
REQUIRE(result.score >= 90000);
}
TEST_CASE("Search finds mate in 2", "[search]") {
Board board;
board.set_from_fen("2bqkbn1/2pppp2/np2N3/r3P1p1/p2N2B1/5Q2/PPPPPP1P/RNB1K2R w KQ - 0 1");
Search search(board);
auto result = search.find_best_move(5, std::chrono::milliseconds(10000));
REQUIRE_FALSE(result.best_move.is_null());
REQUIRE(result.score >= 90000);
REQUIRE(result.depth >= 3);
}
TEST_CASE("Search nodes per second benchmark", "[search][!benchmark]") {
Board board;
board.set_startpos();
Search search(board);
auto start = std::chrono::steady_clock::now();
auto result = search.find_best_move(5, std::chrono::milliseconds(5000));
auto end = std::chrono::steady_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
if (ms > 0) {
auto nps = result.nodes * 1000 / static_cast<std::uint64_t>(ms);
WARN("Nodes/second: " << nps);
}
REQUIRE(result.nodes > 0);
REQUIRE_FALSE(result.best_move.is_null());
}
Your engine can now think. In the next step, you will wire it up to the UCI protocol so it can play against you or any chess GUI.
Implement the UCI Protocol and Complete the Engine
Your engine can search positions and find strong moves. Now it needs a way to communicate with the outside world.
The UCI protocol (Universal Chess Interface) is the standard text-based interface that lets chess engines talk to GUIs like Arena or CuteChess. In this step, you will implement the full UCI protocol so Zenith can receive positions, run searches with time controls, and report its best moves.
In this step, get ready to:
- Implement UCI move formatting and parsing.
- Build the complete UCI command loop with position, go, and time management.
- Test the engine interactively from the command line.
Implement Move::to_uci() for UCI notation output
UCI communicates moves in long algebraic notation. A move from e2 to e4 is written as e2e4. Promotions append the piece letter in lowercase, like e7e8q.
- In src/uci.cpp, add the implementation for Move::to_uci() at the top of the file (before the uci_loop function) by adding this code:
#include "zenith/board.hpp"
#include "zenith/search.hpp"
#include "zenith/movegen.hpp"
#include <iostream>
#include <sstream>
#include <string>
#include <chrono>
#include <algorithm>
namespace zenith {
// Convert a Move to UCI long algebraic notation (e.g., "e2e4", "e7e8q")
std::string Move::to_uci() const {
if (is_null()) return "0000";
std::string result;
// Encode the from-square as file letter + rank digit
result += static_cast<char>('a' + file_of(from()));
result += static_cast<char>('1' + rank_of(from()));
// Encode the to-square the same way
result += static_cast<char>('a' + file_of(to()));
result += static_cast<char>('1' + rank_of(to()));
// Append promotion piece in lowercase if this is a promotion
if (is_promotion()) {
auto f = flag();
if (f == MoveFlag::PromoteKnight || f == MoveFlag::CapturePromoteKnight) result += 'n';
else if (f == MoveFlag::PromoteBishop || f == MoveFlag::CapturePromoteBishop) result += 'b';
else if (f == MoveFlag::PromoteRook || f == MoveFlag::CapturePromoteRook) result += 'r';
else result += 'q';
}
return result;
}
What does this code do?
Move::to_uci() converts the engine's compact 16-bit move encoding into the human-readable UCI format. It maps from-square and to-square indices back to file letters (a-h) and rank digits (1-8).
A null move outputs 0000 per the UCI spec. Promotions append the target piece type in lowercase (q, r, b, n).
Implement UCI move parsing from string to Move
The engine also needs to understand moves sent by the GUI. Parsing a UCI string like e2e4 means finding the matching legal move from the current position.
- Below the Move::to_uci() implementation, add the parse_uci_move function:
// Parse a UCI move string (e.g., "e2e4", "e7e8q") into a Move
// by matching against all legal moves in the current position
Move parse_uci_move(Board& board, const std::string& str) {
if (str.size() < 4) return Move();
// Extract from-square and to-square from the string
int from_file = str[0] - 'a';
int from_rank = str[1] - '1';
int to_file = str[2] - 'a';
int to_rank = str[3] - '1';
Square from_sq = make_square(from_file, from_rank);
Square to_sq = make_square(to_file, to_rank);
// Generate all legal moves and find the one matching this UCI string
MoveGenerator gen(board);
auto moves = gen.generate_legal_moves();
for (const auto& move : moves) {
if (move.from() == from_sq && move.to() == to_sq) {
// For promotions, also match the promotion piece
if (move.is_promotion()) {
if (str.size() < 5) continue;
char promo = str[4];
auto f = move.flag();
bool match = false;
if (promo == 'q' && (f == MoveFlag::PromoteQueen || f == MoveFlag::CapturePromoteQueen)) match = true;
if (promo == 'r' && (f == MoveFlag::PromoteRook || f == MoveFlag::CapturePromoteRook)) match = true;
if (promo == 'b' && (f == MoveFlag::PromoteBishop || f == MoveFlag::CapturePromoteBishop)) match = true;
if (promo == 'n' && (f == MoveFlag::PromoteKnight || f == MoveFlag::CapturePromoteKnight)) match = true;
if (match) return move;
} else {
return move;
}
}
}
return Move(); // No matching legal move found
}
Why match against legal moves?
A UCI string like e2e4 only tells us the from-square and to-square. It does not encode the move flags (double push, castling, en passant). By generating all legal moves and finding the one that matches, the engine automatically resolves the correct flags.
This also guarantees the engine never accepts an illegal move. If no legal move matches the string, the function returns a null move.
Build the full UCI command loop
The UCI loop reads commands from stdin and responds on stdout. This is how every UCI-compatible GUI communicates with an engine.
- Replace the existing uci_loop() function in src/uci.cpp with this complete implementation:
void uci_loop() {
Board board;
board.set_startpos();
std::string line;
while (std::getline(std::cin, line)) {
std::istringstream ss(line);
std::string command;
ss >> command;
if (command == "uci") {
// Identify the engine and signal UCI readiness
std::cout << "id name Zenith 1.0.0" << std::endl;
std::cout << "id author Developer" << std::endl;
std::cout << "uciok" << std::endl;
} else if (command == "isready") {
// Always respond immediately to confirm the engine is alive
std::cout << "readyok" << std::endl;
} else if (command == "ucinewgame") {
// Reset the board for a new game
board.set_startpos();
} else if (command == "position") {
std::string type;
ss >> type;
if (type == "startpos") {
board.set_startpos();
} else if (type == "fen") {
// Read the 6 FEN fields
std::string fen;
std::string part;
int parts_read = 0;
while (parts_read < 6 && ss >> part) {
if (part == "moves") break;
if (parts_read > 0) fen += ' ';
fen += part;
parts_read++;
}
board.set_from_fen(fen);
// If we broke on "moves", fall through to move parsing below
if (part != "moves") continue;
}
// Look for the "moves" keyword and apply each move
std::string token;
if (type == "startpos") {
if (!(ss >> token) || token != "moves") continue;
}
// Parse and apply each move in the list
while (ss >> token) {
Move move = parse_uci_move(board, token);
if (!move.is_null()) {
board.make_move(move);
}
}
} else if (command == "go") {
int max_depth = 64;
std::chrono::milliseconds time_limit(0);
int wtime = 0, btime = 0, winc = 0, binc = 0;
bool use_time_control = false;
// Parse all go sub-commands
std::string token;
while (ss >> token) {
if (token == "depth") {
ss >> max_depth;
} else if (token == "movetime") {
int ms;
ss >> ms;
time_limit = std::chrono::milliseconds(ms);
} else if (token == "wtime") {
ss >> wtime;
use_time_control = true;
} else if (token == "btime") {
ss >> btime;
use_time_control = true;
} else if (token == "winc") {
ss >> winc;
} else if (token == "binc") {
ss >> binc;
}
}
// Allocate time from clock-based time controls
if (use_time_control && time_limit.count() == 0) {
int our_time = (board.side_to_move() == Color::White) ? wtime : btime;
int our_inc = (board.side_to_move() == Color::White) ? winc : binc;
// Simple allocation: 1/30 of remaining time plus half the increment
int allocated = our_time / 30 + our_inc / 2;
time_limit = std::chrono::milliseconds(std::max(allocated, 100));
}
// Default to 5 seconds if no time constraint was given
if (time_limit.count() == 0 && max_depth == 64) {
time_limit = std::chrono::milliseconds(5000);
}
// Run the search
auto search_start = std::chrono::steady_clock::now();
Search search(board);
SearchResult result = search.find_best_move(max_depth, time_limit);
auto search_end = std::chrono::steady_clock::now();
// Calculate elapsed time and nodes per second
auto elapsed_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
search_end - search_start
).count();
std::uint64_t nps = elapsed_ms > 0
? result.nodes * 1000 / static_cast<std::uint64_t>(elapsed_ms)
: result.nodes;
// Output final info string with search statistics
std::cout << "info depth " << result.depth
<< " score cp " << result.score
<< " nodes " << result.nodes
<< " nps " << nps
<< " pv " << result.best_move.to_uci()
<< std::endl;
// Output the best move
std::cout << "bestmove " << result.best_move.to_uci() << std::endl;
} else if (command == "stop") {
// In a single-threaded engine, search completes before we read this
// A multi-threaded version would signal the search to halt here
} else if (command == "quit") {
break;
} else if (command == "d") {
// Debug command: display the current board
std::cout << board.to_string() << std::endl;
std::cout << "FEN: " << board.to_fen() << std::endl;
}
}
}
} // namespace zenith
What does the go command do?
The go command tells the engine to start searching. It accepts several sub-commands: depth limits search depth, movetime sets a fixed time in milliseconds, and wtime/btime/winc/binc provide full clock-based time controls.
The time allocation formula (1/30 of remaining time plus half the increment) is a simple but effective heuristic. It ensures the engine uses its time budget across an entire game rather than spending it all on the opening.
After the search completes, the engine outputs an info line with depth, score (in centipawns), nodes searched, nodes per second, and the principal variation. Then it outputs bestmove with the chosen move.
Why parse moves by matching legal moves?
The UCI protocol sends moves as simple coordinate pairs (e2e4). These strings do not encode whether a move is castling, en passant, or a double push. By generating all legal moves and finding the match, the engine reconstructs the full internal Move object with correct flags.
- Save src/uci.cpp.
- Rebuild the project by running:
cmake --build build
- You should see a clean build with no errors.
Build errors about missing headers?
Make sure you included zenith/search.hpp and zenith/movegen.hpp at the top of src/uci.cpp. The UCI loop needs access to the Search class and MoveGenerator for move parsing.
If you see linker errors about Move::to_uci(), confirm that the method implementation is inside the namespace zenith block.
help me fix UCI build errors in my chess engine
Test the engine interactively from the command line
UCI engines communicate entirely through text on stdin/stdout. You can test Zenith by running it directly and typing commands.
- Run the engine by executing:
./build/zenith
- Type uci and press Enter. You should see:
id name Zenith 1.0.0
id author Developer
uciok
- Type isready and press Enter. You should see:
readyok
- Type position startpos and press Enter (no output expected).
- Type go depth 4 and press Enter. You should see an info line followed by a bestmove line, for example:
info depth 4 score cp 15 nodes 5432 nps 271600 pv e2e4
bestmove e2e4
What should I see?
The exact score, node count, and best move will vary depending on your evaluation and move ordering. What matters is that you see a legal move in UCI notation (like e2e4, d2d4, or g1f3) and that the engine responds without crashing.
- Test a position with moves by typing:
position startpos moves e2e4 e7e5
go depth 5
- The engine should respond with bestmove followed by a legal reply to 1.e4 e5.
- Type quit to exit the engine.
Engine hangs or produces no output?
If the engine produces no output after uci, make sure your std::cout lines end with std::endl (which flushes the buffer). Without flushing, output may never reach the GUI or terminal.
If the engine crashes on go, verify that your Search class is correctly linked. Run ctest --test-dir build to confirm all unit tests still pass.
help me debug my UCI chess engine that hangs or crashes
- Verify all tests still pass by running:
ctest --test-dir build
- You should see all test cases passing with no failures.
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/uci.cpp and that the build is clean.
ⓧ I'd like to double check the full code
Here is the complete src/uci.cpp file:
#include "zenith/board.hpp"
#include "zenith/search.hpp"
#include "zenith/movegen.hpp"
#include <iostream>
#include <sstream>
#include <string>
#include <chrono>
#include <algorithm>
namespace zenith {
// Convert a Move to UCI long algebraic notation (e.g., "e2e4", "e7e8q")
std::string Move::to_uci() const {
if (is_null()) return "0000";
std::string result;
// Encode the from-square as file letter + rank digit
result += static_cast<char>('a' + file_of(from()));
result += static_cast<char>('1' + rank_of(from()));
// Encode the to-square the same way
result += static_cast<char>('a' + file_of(to()));
result += static_cast<char>('1' + rank_of(to()));
// Append promotion piece in lowercase if this is a promotion
if (is_promotion()) {
auto f = flag();
if (f == MoveFlag::PromoteKnight || f == MoveFlag::CapturePromoteKnight) result += 'n';
else if (f == MoveFlag::PromoteBishop || f == MoveFlag::CapturePromoteBishop) result += 'b';
else if (f == MoveFlag::PromoteRook || f == MoveFlag::CapturePromoteRook) result += 'r';
else result += 'q';
}
return result;
}
// Parse a UCI move string (e.g., "e2e4", "e7e8q") into a Move
// by matching against all legal moves in the current position
Move parse_uci_move(Board& board, const std::string& str) {
if (str.size() < 4) return Move();
// Extract from-square and to-square from the string
int from_file = str[0] - 'a';
int from_rank = str[1] - '1';
int to_file = str[2] - 'a';
int to_rank = str[3] - '1';
Square from_sq = make_square(from_file, from_rank);
Square to_sq = make_square(to_file, to_rank);
// Generate all legal moves and find the one matching this UCI string
MoveGenerator gen(board);
auto moves = gen.generate_legal_moves();
for (const auto& move : moves) {
if (move.from() == from_sq && move.to() == to_sq) {
// For promotions, also match the promotion piece
if (move.is_promotion()) {
if (str.size() < 5) continue;
char promo = str[4];
auto f = move.flag();
bool match = false;
if (promo == 'q' && (f == MoveFlag::PromoteQueen || f == MoveFlag::CapturePromoteQueen)) match = true;
if (promo == 'r' && (f == MoveFlag::PromoteRook || f == MoveFlag::CapturePromoteRook)) match = true;
if (promo == 'b' && (f == MoveFlag::PromoteBishop || f == MoveFlag::CapturePromoteBishop)) match = true;
if (promo == 'n' && (f == MoveFlag::PromoteKnight || f == MoveFlag::CapturePromoteKnight)) match = true;
if (match) return move;
} else {
return move;
}
}
}
return Move(); // No matching legal move found
}
void uci_loop() {
Board board;
board.set_startpos();
std::string line;
while (std::getline(std::cin, line)) {
std::istringstream ss(line);
std::string command;
ss >> command;
if (command == "uci") {
// Identify the engine and signal UCI readiness
std::cout << "id name Zenith 1.0.0" << std::endl;
std::cout << "id author Developer" << std::endl;
std::cout << "uciok" << std::endl;
} else if (command == "isready") {
// Always respond immediately to confirm the engine is alive
std::cout << "readyok" << std::endl;
} else if (command == "ucinewgame") {
// Reset the board for a new game
board.set_startpos();
} else if (command == "position") {
std::string type;
ss >> type;
if (type == "startpos") {
board.set_startpos();
} else if (type == "fen") {
// Read the 6 FEN fields
std::string fen;
std::string part;
int parts_read = 0;
while (parts_read < 6 && ss >> part) {
if (part == "moves") break;
if (parts_read > 0) fen += ' ';
fen += part;
parts_read++;
}
board.set_from_fen(fen);
// If we broke on "moves", fall through to move parsing below
if (part != "moves") continue;
}
// Look for the "moves" keyword and apply each move
std::string token;
if (type == "startpos") {
if (!(ss >> token) || token != "moves") continue;
}
// Parse and apply each move in the list
while (ss >> token) {
Move move = parse_uci_move(board, token);
if (!move.is_null()) {
board.make_move(move);
}
}
} else if (command == "go") {
int max_depth = 64;
std::chrono::milliseconds time_limit(0);
int wtime = 0, btime = 0, winc = 0, binc = 0;
bool use_time_control = false;
// Parse all go sub-commands
std::string token;
while (ss >> token) {
if (token == "depth") {
ss >> max_depth;
} else if (token == "movetime") {
int ms;
ss >> ms;
time_limit = std::chrono::milliseconds(ms);
} else if (token == "wtime") {
ss >> wtime;
use_time_control = true;
} else if (token == "btime") {
ss >> btime;
use_time_control = true;
} else if (token == "winc") {
ss >> winc;
} else if (token == "binc") {
ss >> binc;
}
}
// Allocate time from clock-based time controls
if (use_time_control && time_limit.count() == 0) {
int our_time = (board.side_to_move() == Color::White) ? wtime : btime;
int our_inc = (board.side_to_move() == Color::White) ? winc : binc;
// Simple allocation: 1/30 of remaining time plus half the increment
int allocated = our_time / 30 + our_inc / 2;
time_limit = std::chrono::milliseconds(std::max(allocated, 100));
}
// Default to 5 seconds if no time constraint was given
if (time_limit.count() == 0 && max_depth == 64) {
time_limit = std::chrono::milliseconds(5000);
}
// Run the search
auto search_start = std::chrono::steady_clock::now();
Search search(board);
SearchResult result = search.find_best_move(max_depth, time_limit);
auto search_end = std::chrono::steady_clock::now();
// Calculate elapsed time and nodes per second
auto elapsed_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
search_end - search_start
).count();
std::uint64_t nps = elapsed_ms > 0
? result.nodes * 1000 / static_cast<std::uint64_t>(elapsed_ms)
: result.nodes;
// Output final info string with search statistics
std::cout << "info depth " << result.depth
<< " score cp " << result.score
<< " nodes " << result.nodes
<< " nps " << nps
<< " pv " << result.best_move.to_uci()
<< std::endl;
// Output the best move
std::cout << "bestmove " << result.best_move.to_uci() << std::endl;
} else if (command == "stop") {
// In a single-threaded engine, search completes before we read this
// A multi-threaded version would signal the search to halt here
} else if (command == "quit") {
break;
} else if (command == "d") {
// Debug command: display the current board
std::cout << board.to_string() << std::endl;
std::cout << "FEN: " << board.to_fen() << std::endl;
}
}
}
} // namespace zenith
Your engine is now a fully functional UCI chess engine. You can connect it to any UCI-compatible GUI to play games against it. Next, you will add comprehensive tests and benchmarks to validate correctness and measure performance.
Write Tests and Run Benchmarks
Your chess engine now plays legal chess, handles UCI commands, and finds moves through alpha-beta search. The final piece is proving that everything works correctly.
In this step, you will write comprehensive unit tests that verify every layer of the engine (from bitboard operations to search correctness), implement a benchmark mode that measures raw performance, and run everything through CTest to confirm your engine passes with flying colors.
In this step, get ready to:
- Write comprehensive tests for board, move generation, and search.
- Implement perft and search benchmarks in bench mode.
- Run the full test suite and verify performance targets.
Write comprehensive unit tests for all components
Robust tests are what separate a hobby engine from a production-quality one. You will expand your test files to cover FEN parsing, perft correctness, evaluation symmetry, and the search finding forced mates.
Start with your board tests. The existing tests in tests/test_board.cpp verify the starting position, but a strong test suite covers at least five different FEN strings to exercise castling rights, en passant, and various piece configurations.
- In tests/test_board.cpp, add new TEST_CASE blocks that parse and round-trip several mid-game positions:
TEST_CASE("Board FEN mid-game positions", "[board]") {
Board board;
// Sicilian Najdorf structure
SECTION("Sicilian Najdorf") {
std::string_view fen = "r1bqkb1r/1p2pppp/p1np4/6B1/3NP3/2N5/PPP2PPP/R2QKB1R b KQkq - 1 6";
board.set_from_fen(fen);
REQUIRE(board.to_fen() == fen);
REQUIRE(board.side_to_move() == Color::Black);
REQUIRE(board.castling_rights() == CastlingRights::All);
}
// Position with en passant
SECTION("En passant available") {
std::string_view fen = "rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3";
board.set_from_fen(fen);
REQUIRE(board.to_fen() == fen);
REQUIRE(board.en_passant_square() == Square::D6);
}
// Position with partial castling rights
SECTION("Partial castling rights") {
std::string_view fen = "r3k2r/pppppppp/8/8/8/8/PPPPPPPP/R3K2R w Kq - 0 1";
board.set_from_fen(fen);
REQUIRE(board.to_fen() == fen);
REQUIRE((board.castling_rights() & CastlingRights::WhiteKingSide) != CastlingRights::None);
REQUIRE((board.castling_rights() & CastlingRights::WhiteQueenSide) == CastlingRights::None);
}
// Position with no castling rights
SECTION("No castling rights") {
std::string_view fen = "r3k2r/pppppppp/8/8/8/8/PPPPPPPP/R3K2R w - - 0 1";
board.set_from_fen(fen);
REQUIRE(board.to_fen() == fen);
REQUIRE(board.castling_rights() == CastlingRights::None);
}
// Endgame position
SECTION("King and pawn endgame") {
std::string_view fen = "8/8/4k3/8/2p5/8/B2P2K1/8 w - - 0 1";
board.set_from_fen(fen);
REQUIRE(board.to_fen() == fen);
REQUIRE(popcount(board.all_pieces()) == 4);
}
}
What does this code do?
Each SECTION tests a different aspect of FEN parsing. The Sicilian Najdorf position verifies full castling rights parsing.
The en passant section confirms the board correctly identifies D6 as the target square. The partial castling rights section proves the engine distinguishes between individual castling flags (K, Q, k, q) rather than treating them as all-or-nothing.
Next, add perft tests to tests/test_movegen.cpp. Perft (performance test) counts all legal leaf nodes at a given depth. If your counts match the published values, your move generation is correct.
- In tests/test_movegen.cpp, add perft test cases for the starting position and the Kiwipete position:
#include <catch2/catch_test_macros.hpp>
#include "zenith/types.hpp"
#include "zenith/board.hpp"
#include "zenith/movegen.hpp"
using namespace zenith;
// Helper: count all legal leaf nodes at a given depth
static std::uint64_t perft(Board& board, int depth) {
if (depth == 0) return 1;
MoveGenerator gen(board);
auto moves = gen.generate_legal_moves();
std::uint64_t nodes = 0;
for (const auto& move : moves) {
board.make_move(move);
nodes += perft(board, depth - 1);
board.unmake_move(move);
}
return nodes;
}
TEST_CASE("Move encoding", "[move]") {
Move move(Square::E2, Square::E4, MoveFlag::DoublePush);
REQUIRE(move.from() == Square::E2);
REQUIRE(move.to() == Square::E4);
REQUIRE(move.flag() == MoveFlag::DoublePush);
REQUIRE_FALSE(move.is_capture());
REQUIRE_FALSE(move.is_promotion());
}
TEST_CASE("Move capture flag", "[move]") {
Move move(Square::E4, Square::D5, MoveFlag::Capture);
REQUIRE(move.is_capture());
REQUIRE_FALSE(move.is_promotion());
}
TEST_CASE("Move promotion", "[move]") {
Move move(Square::E7, Square::E8, MoveFlag::PromoteQueen);
REQUIRE(move.is_promotion());
REQUIRE_FALSE(move.is_capture());
}
TEST_CASE("Move null check", "[move]") {
Move null_move;
REQUIRE(null_move.is_null());
Move real_move(Square::E2, Square::E4, MoveFlag::Quiet);
REQUIRE_FALSE(real_move.is_null());
}
TEST_CASE("Perft starting position", "[perft]") {
Board board;
board.set_startpos();
// Published perft values for the starting position
REQUIRE(perft(board, 1) == 20);
REQUIRE(perft(board, 2) == 400);
REQUIRE(perft(board, 3) == 8902);
REQUIRE(perft(board, 4) == 197281);
REQUIRE(perft(board, 5) == 4865609);
}
TEST_CASE("Perft Kiwipete position", "[perft]") {
Board board;
// Kiwipete by Peter McKenzie - tests castling, en passant, promotions
board.set_from_fen(
"r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1"
);
REQUIRE(perft(board, 1) == 48);
REQUIRE(perft(board, 2) == 2039);
REQUIRE(perft(board, 3) == 97862);
REQUIRE(perft(board, 4) == 4085603);
}
What does this code do?
The perft helper recursively counts all legal leaf nodes. At depth 0, it returns 1 (counting this position). At each depth, it generates all legal moves, makes each one, recurses, then unmakes.
The starting position values (20, 400, 8902, 197281, 4865609) are universally agreed-upon reference values. If your engine matches these, your move generation handles all standard moves correctly.
The Kiwipete position stresses promotions, castling, and en passant simultaneously. Its depth 4 value of 4085603 is the definitive reference when under-promotions are included.
Perft depth 5 takes time
Perft depth 5 counts nearly 5 million nodes. This test may take 5-30 seconds depending on your hardware. If your CI is slow, consider tagging it with [.slow] so you can skip it during rapid iteration.
Now write the search tests. These verify that your alpha-beta search correctly finds forced checkmates and that your evaluation function is symmetric.
- Replace the placeholder in tests/test_search.cpp with these test cases:
#include <catch2/catch_test_macros.hpp>
#include "zenith/types.hpp"
#include "zenith/board.hpp"
#include "zenith/search.hpp"
#include "zenith/evaluation.hpp"
using namespace zenith;
TEST_CASE("Search finds mate in 1", "[search]") {
Board board;
// White queen on h5, black king on f7, white king on e1
// Qh5-f7 is not quite right - use a known mate-in-1 position
// Position: White to move, Qd1xd8# is mate
board.set_from_fen("3k4/8/3K4/8/8/8/8/3Q4 w - - 0 1");
Search search;
auto result = search.go(board, 2);
// The search must find a mating move
REQUIRE_FALSE(result.best_move.is_null());
// Score should indicate a mate
REQUIRE(result.score > 20000);
}
TEST_CASE("Search finds mate in 2", "[search]") {
Board board;
// Classic back-rank mate: Rook delivers mate in 2
board.set_from_fen("6k1/5ppp/8/8/8/8/8/R3K3 w Q - 0 1");
Search search;
auto result = search.go(board, 4);
// The search must find a winning line
REQUIRE_FALSE(result.best_move.is_null());
REQUIRE(result.score > 20000);
}
TEST_CASE("Evaluation symmetry", "[evaluation]") {
Board board;
// Symmetric starting position should evaluate to ~0
board.set_startpos();
int score = evaluate(board);
REQUIRE(score >= -20);
REQUIRE(score <= 20);
}
TEST_CASE("Search returns legal move from startpos", "[search]") {
Board board;
board.set_startpos();
Search search;
auto result = search.go(board, 4);
// Must return a valid move
REQUIRE_FALSE(result.best_move.is_null());
// Score should be reasonable (not a mate score)
REQUIRE(result.score > -10000);
REQUIRE(result.score < 10000);
}
What do these tests verify?
The mate-in-1 test sets up a position where White can deliver checkmate immediately. If the search returns a score above 20000 (a common mate threshold in centipawn-based engines), it found the forced win.
The evaluation symmetry test confirms the starting position evaluates near zero. This catches bugs where one side is accidentally favored before any moves are made.
The search correctness test ensures the engine returns a reasonable evaluation from the opening position and does not crash or return a null move.
- Save all three test files: tests/test_board.cpp, tests/test_movegen.cpp, and tests/test_search.cpp.
Compilation errors in the test files?
Make sure your #include paths match your project's header locations. If your search interface uses different method names than search.go(board, depth), adapt the test to match your implementation.
help me fix test compilation errors in my chess engine
Implement bench mode for perft and search benchmarks
A benchmark mode lets you measure raw engine performance: how many nodes per second your move generator and search can process. This is the standard way chess engine developers compare builds and spot regressions.
- In src/main.cpp, replace the placeholder bench mode with a working implementation:
#include "zenith/types.hpp"
#include "zenith/board.hpp"
#include "zenith/movegen.hpp"
#include "zenith/search.hpp"
#include "zenith/attacks.hpp"
#include <chrono>
#include <iostream>
#include <string>
namespace zenith {
void uci_loop();
}
// Perft helper for benchmarking
static std::uint64_t perft(zenith::Board& board, int depth) {
if (depth == 0) return 1;
zenith::MoveGenerator gen(board);
auto moves = gen.generate_legal_moves();
std::uint64_t nodes = 0;
for (const auto& move : moves) {
board.make_move(move);
nodes += perft(board, depth - 1);
board.unmake_move(move);
}
return nodes;
}
static void run_bench() {
using Clock = std::chrono::high_resolution_clock;
std::cout << "=== Perft Benchmark ===" << std::endl;
zenith::Board board;
board.set_startpos();
// Run perft depth 5 and measure time
auto start = Clock::now();
std::uint64_t nodes = perft(board, 5);
auto end = Clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::uint64_t nps = ms > 0 ? (nodes * 1000) / ms : 0;
std::cout << " Depth 5: " << nodes << " nodes in "
<< ms << " ms (" << nps << " nps)" << std::endl;
std::cout << "\n=== Search Benchmark ===" << std::endl;
// Benchmark search on several positions
const std::string positions[] = {
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
"r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1",
"r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10"
};
std::uint64_t total_nodes = 0;
auto search_start = Clock::now();
zenith::Search search;
for (const auto& fen : positions) {
board.set_from_fen(fen);
auto result = search.go(board, 6);
total_nodes += result.nodes;
std::cout << " Position: " << result.nodes << " nodes, best: "
<< result.best_move.to_uci() << std::endl;
}
auto search_end = Clock::now();
auto search_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
search_end - search_start).count();
std::uint64_t search_nps = search_ms > 0 ? (total_nodes * 1000) / search_ms : 0;
std::cout << "\n Total: " << total_nodes << " nodes in "
<< search_ms << " ms (" << search_nps << " nps)" << std::endl;
}
int main(int argc, char* argv[]) {
std::cout << "Zenith Chess Engine v1.0.0" << std::endl;
// Initialize attack tables
zenith::attacks::init();
if (argc > 1 && std::string(argv[1]) == "bench") {
run_bench();
return 0;
}
// Default: UCI mode
zenith::uci_loop();
return 0;
}
What does the bench mode do?
The perft benchmark runs depth 5 from the starting position and reports nodes per second. This measures your move generation throughput in isolation.
The search benchmark runs a depth-6 search on three standard positions (startpos, Kiwipete, and a mid-game position) and reports total nodes per second. This measures your search + evaluation throughput combined.
- Save src/main.cpp.
Linker errors about missing Search or MoveGenerator symbols?
Make sure src/search.cpp and src/movegen.cpp are listed in your CMakeLists.txt under the zenith_core library sources. The zenith executable links against zenith_core, so all implementation files must be part of that static library.
help me fix linker errors in my Zenith chess engine build
Run the full test suite and verify performance
With all tests written and bench mode implemented, it is time to build, test, and benchmark.
- Rebuild the project by running:
cmake --build build
- Run all tests through CTest by running:
cd build && ctest --output-on-failure
You should see every test case pass, including the perft tests that verify your move generation matches published values.
What should I see?
CTest outputs a line for each test case with PASSED or FAILED. A successful run shows 100% tests passed with zero failures. The perft depth 5 test may take several seconds to complete.
- Run the benchmark by executing:
./zenith bench
The benchmark reports nodes per second for both perft and search. A well-optimized engine on modern hardware should exceed 500,000 nodes per second in search mode.
Interpreting your benchmark numbers
Perft nps measures pure move generation speed. Values of 5-50 million nps are typical for bitboard engines.
Search nps measures how fast the engine evaluates positions during alpha-beta. Values above 500k nps indicate your evaluation and move ordering are efficient. Lower values suggest optimization opportunities in your evaluation function or move ordering.
Perft values do not match?
If depth 1 is wrong, check basic move generation (pawn pushes, knight jumps). If depth 1-3 are correct but depth 4+ diverges, the bug is likely in castling, en passant, or promotion handling.
Use perft divide (splitting node counts per first move) to isolate which move produces the wrong subtree count.
help me debug perft mismatches in my chess engine
✔️ Awesome, I've got everything!
Great. Make sure all test files and src/main.cpp are saved.
ⓧ I'd like to double check the full code
Here is the full tests/test_movegen.cpp for reference:
#include <catch2/catch_test_macros.hpp>
#include "zenith/types.hpp"
#include "zenith/board.hpp"
#include "zenith/movegen.hpp"
using namespace zenith;
// Helper: count all legal leaf nodes at a given depth
static std::uint64_t perft(Board& board, int depth) {
if (depth == 0) return 1;
MoveGenerator gen(board);
auto moves = gen.generate_legal_moves();
std::uint64_t nodes = 0;
for (const auto& move : moves) {
board.make_move(move);
nodes += perft(board, depth - 1);
board.unmake_move(move);
}
return nodes;
}
TEST_CASE("Move encoding", "[move]") {
Move move(Square::E2, Square::E4, MoveFlag::DoublePush);
REQUIRE(move.from() == Square::E2);
REQUIRE(move.to() == Square::E4);
REQUIRE(move.flag() == MoveFlag::DoublePush);
REQUIRE_FALSE(move.is_capture());
REQUIRE_FALSE(move.is_promotion());
}
TEST_CASE("Move capture flag", "[move]") {
Move move(Square::E4, Square::D5, MoveFlag::Capture);
REQUIRE(move.is_capture());
REQUIRE_FALSE(move.is_promotion());
}
TEST_CASE("Move promotion", "[move]") {
Move move(Square::E7, Square::E8, MoveFlag::PromoteQueen);
REQUIRE(move.is_promotion());
REQUIRE_FALSE(move.is_capture());
}
TEST_CASE("Move null check", "[move]") {
Move null_move;
REQUIRE(null_move.is_null());
Move real_move(Square::E2, Square::E4, MoveFlag::Quiet);
REQUIRE_FALSE(real_move.is_null());
}
TEST_CASE("Perft starting position", "[perft]") {
Board board;
board.set_startpos();
// Published perft values for the starting position
REQUIRE(perft(board, 1) == 20);
REQUIRE(perft(board, 2) == 400);
REQUIRE(perft(board, 3) == 8902);
REQUIRE(perft(board, 4) == 197281);
REQUIRE(perft(board, 5) == 4865609);
}
TEST_CASE("Perft Kiwipete position", "[perft]") {
Board board;
// Kiwipete by Peter McKenzie - tests castling, en passant, promotions
board.set_from_fen(
"r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1"
);
REQUIRE(perft(board, 1) == 48);
REQUIRE(perft(board, 2) == 2039);
REQUIRE(perft(board, 3) == 97862);
REQUIRE(perft(board, 4) == 4085603);
}
Your engine now has a complete test suite and benchmarks that prove correctness and measure performance. Every layer is verified: bitboard operations, FEN parsing, legal move generation via perft, evaluation symmetry, and search finding forced mates. This is the kind of engineering rigor that makes a chess engine portfolio-worthy.
Secret mission
Add a Transposition Table with Zobrist Hashing
Your engine already plays legal chess and searches millions of nodes. But it wastes time re-evaluating positions it has already seen. Add a transposition table with Zobrist hashing to cache search results and dramatically speed up Zenith's play.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your project files to continue developing Zenith, or delete them to free up disk space.
Resources you used:
- The zenith project folder on your Desktop (source code, headers, tests).
- The build/ directory inside the project (compiled binaries, CMake cache, fetched Catch2 source).
Keep everything running
No action needed. Choose this if you plan to keep developing Zenith, adding features like opening books, endgame tablebases, or multi-threaded search.
- Your project folder remains at ~/Desktop/zenith.
- CMake, Ninja, and your C++20 compiler remain installed on your system for future projects.
- VS Code and the CMake Tools extension remain available.
Pause - I'll come back to this later
Free up disk space from build artifacts while keeping your source code intact. You can rebuild anytime with a single command.
- Delete just the build directory by running:
rm -rf ~/Desktop/zenith/build
- When you return, regenerate the build directory by running:
cd ~/Desktop/zenith
cmake -B build -G Ninja
cmake --build build
This re-fetches Catch2, recompiles everything, and gets you back to where you left off.
Delete - I don't want to use this again
Remove the entire project from your system. This deletes all source code, build artifacts, and test files permanently.
- Delete the project folder by running the command for your operating system.
macOS
rm -rf ~/Desktop/zenith
Windows
rmdir /s /q %USERPROFILE%\Desktop\zenith
- Confirm the folder is gone by running:
ls ~/Desktop/zenith
You should see a "No such file or directory" message confirming the deletion.
Nice Work!
Nice Work!
You just built Zenith, a complete chess engine from scratch in C++20. This is a serious systems programming achievement that demonstrates mastery of algorithms, data structures, and modern software engineering.
You've learned how to:
- Build a bitboard-based board representation using C++20 features like std::popcount and std::countr_zero, with full legal move generation for all special moves including castling, en passant, and promotion.
- Implement an alpha-beta search with iterative deepening, quiescence search, and MVV-LVA move ordering that finds strong moves within seconds.
- Design a complete CMake project with a UCI protocol handler for GUI compatibility, position evaluation using piece-square tables, and a full Catch2 test suite with perft verification.
- Add a transposition table with Zobrist hashing to cache previously searched positions, avoid redundant work, and improve move ordering.
Ready to quiz yourself?