Build a Chess Engine in C++
Build a UCI-compliant chess engine with bitboards and alpha-beta search.
Introduction
30 Second Summary
Chess is one of the oldest strategy games in the world. Building a program that plays it well is one of the most satisfying challenges in computer science, teaching you algorithms that power everything from game AI to decision-making systems in robotics and finance.
In this project, you will build a complete chess engine called "Nexus" in C++20 that uses bitboards for blazing-fast board representation, alpha-beta pruning to find strong moves, and the UCI protocol to communicate with any chess GUI.
What You'll Build
A standalone C++20 chess engine binary that plugs into any UCI-compatible GUI, plays legal chess with real tactical awareness, and demonstrates mastery of bitwise programming, tree search algorithms, and protocol design.
By the end of this project, you'll have:
- A bitboard-based board representation supporting all chess rules including castling, en passant, and promotion.
- A complete legal move generator validated with perft testing, plus a make/unmake move system enabling both search traversal and undo/redo.
- An alpha-beta search with iterative deepening, quiescence search, transposition tables, and advanced move ordering that plays at approximately 1500-1800 Elo.
- Secret Mission: Add null move pruning and late move reductions to boost search depth by 2-3 plies.
Are there any prerequisites?
This project requires solid C++ knowledge, including bitwise operations, classes, and templates. You should also understand the basic rules of chess (how pieces move, castling, en passant).
No prior experience with chess programming or game AI is needed. The project walks you through every algorithm from scratch.
Before We Start
Every chess engine needs a compiler to translate C++ code into a fast binary, a build system to manage compilation, and a GUI to test the engine visually.
In this step, you will install the compiler toolchain, set up the build system, create the project structure, and install a chess GUI for testing.
In this step, get ready to:
- Install Xcode Command Line Tools, CMake, and CuteChess.
- Create the Nexus project directory structure.
- Write the CMakeLists.txt build configuration.
Install Xcode Command Line Tools
Xcode Command Line Tools provides the Clang C++ compiler and Make build utility on macOS.
- Press Cmd+Space to open Spotlight, type Terminal, and press Enter to open it.
- Check if Xcode Command Line Tools are already installed by running this command:
xcode-select -p
✔️ I see a path like /Library/Developer/CommandLineTools
Xcode Command Line Tools are already installed. Skip ahead to the Homebrew check below.
ⓧ I see an error or nothing
- Install Xcode Command Line Tools by running this command:
xcode-select --install
What does this command do?
This triggers a macOS system dialog asking you to install the developer tools. Click Install in the popup and wait for the download to complete. This can take several minutes.
- Once the installation finishes, confirm it worked by running:
clang --version
You should see version information for Apple clang.
Install Homebrew and CMake
CMake is the build system that will compile your engine. You will install it through Homebrew, the macOS package manager.
- Check if Homebrew is already installed by running:
brew --version
✔️ I see a version number
Homebrew is ready. Move on to installing CMake below.
ⓧ Command not found
- Install Homebrew by running this command:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
Follow the prompts in your terminal. Once it finishes, close and reopen Terminal so the brew command is available.
- Install CMake by running:
brew install cmake
- Verify CMake installed correctly by running:
cmake --version
You should see a version number of 3.20 or higher.
Not seeing cmake?
If the command is not found, try closing and reopening your Terminal. Homebrew needs a fresh shell session to make newly installed commands available.
Help me troubleshoot my CMake installation.
Install CuteChess for engine testing
CuteChess is a chess GUI that speaks the UCI protocol. You will use it later to test your engine visually.
- Install CuteChess by running these commands:
brew tap lorenzosim/cutechess
brew install cutechess
What does this command do?
The brew tap command adds a third-party formula repository to Homebrew. The second command installs CuteChess from that repository.
🙋♀️ Having trouble installing CuteChess?
CuteChess is optional for completing the project. If the installation fails, you can still test your engine directly in the terminal using UCI commands. You can always install CuteChess later.
Help me fix my CuteChess installation.
Open your code editor
- Press Cmd+Space to open Spotlight.
- Type Cursor (or Visual Studio Code if you prefer VS Code) and press Enter to open it.
Create the project directory
Your chess engine needs a home. You will create a folder called nexus on your Desktop with a src subdirectory for source files.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir nexus
cd nexus
mkdir src
- Confirm the folders exist by running:
ls
You should see src in the output.
Create the CMakeLists.txt build file
CMake uses a file called CMakeLists.txt to know how to compile your project. This file declares the C++ standard, source files, and compiler optimizations.
- Open the nexus folder in your code editor. In Cursor, click File in the top menu bar.
- Click Open Folder.
- Select the nexus folder on your Desktop, then click Open.
- Create a new file in the root of the nexus folder called CMakeLists.txt.
- Paste the following code into CMakeLists.txt:
cmake_minimum_required(VERSION 3.20)
project(Nexus VERSION 1.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
add_executable(nexus
src/main.cpp
src/bitboard.cpp
src/position.cpp
src/movegen.cpp
src/search.cpp
src/evaluate.cpp
src/uci.cpp
src/tt.cpp
)
target_include_directories(nexus PRIVATE src)
target_compile_options(nexus PRIVATE
-O3 -march=native -flto
)
target_link_options(nexus PRIVATE -flto)
What does this file do?
This file tells CMake how to build your chess engine. It requires CMake 3.20 or higher and uses C++20 as the language standard.
The add_executable block lists every source file that will be compiled into the final nexus binary. The compiler flags -O3 (maximum optimization), -march=native (target your specific CPU), and -flto (link-time optimization) make the engine as fast as possible.
- Save CMakeLists.txt.
✔️ Awesome, I've got everything!
Great. Double check you have saved CMakeLists.txt in the root of your nexus folder.
ⓧ I'd like to double check the full code
Here is the complete CMakeLists.txt file:
cmake_minimum_required(VERSION 3.20)
project(Nexus VERSION 1.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
add_executable(nexus
src/main.cpp
src/bitboard.cpp
src/position.cpp
src/movegen.cpp
src/search.cpp
src/evaluate.cpp
src/uci.cpp
src/tt.cpp
)
target_include_directories(nexus PRIVATE src)
target_compile_options(nexus PRIVATE
-O3 -march=native -flto
)
target_link_options(nexus PRIVATE -flto)
File not saving in the right place?
Make sure CMakeLists.txt is at the top level of the nexus folder, not inside src. In the editor sidebar, it should appear next to the src folder, not inside it.
Help me get my CMakeLists.txt in the right location.
Your development environment is ready. Next up, you will define the core data types and build the bitboard foundation that gives your engine its speed.
Define Types and Bitboard Foundations
Your project structure is in place. Now you need to teach Nexus how to think about chess.
A chess engine needs a fast way to represent the board and answer questions like "where can this knight move?" in nanoseconds. The standard approach in serious engines is the bitboard. A single 64-bit integer maps one-to-one to the 64 squares on a chess board, where each bit being 1 or 0 means "piece here" or "empty." This lets you compute attacks, pins, and legal moves using blazing-fast bitwise operations instead of slow loops.
In this step, get ready to:
- Define core types (Square, Piece, Color, Move encoding) and constants.
- Implement bitboard utility functions (set/clear/test bit, population count, bit scan).
- Build pre-computed attack tables for knights, kings, and pawns.
Define core types and constants
Every chess concept needs a compact numeric representation. Squares, pieces, colors, and moves all get encoded as small integers so the engine can manipulate them with arithmetic instead of string comparisons.
- In Cursor, create a new file called types.h inside the src folder.
- Paste the following enum definitions into src/types.h:
#pragma once
#include <cstdint>
#include <string>
#include <array>
#include <cassert>
// 64-bit unsigned integer for bitboards
using U64 = uint64_t;
// Side to move
enum Color : int { WHITE, BLACK, COLOR_NB = 2 };
// Piece types without color
enum PieceType : int { PAWN, KNIGHT, BISHOP, ROOK, QUEEN, KING, PIECE_TYPE_NB = 6, NO_PIECE_TYPE = 6 };
// Pieces with color (white pieces 0-5, black pieces 6-11)
enum Piece : int {
W_PAWN, W_KNIGHT, W_BISHOP, W_ROOK, W_QUEEN, W_KING,
B_PAWN, B_KNIGHT, B_BISHOP, B_ROOK, B_QUEEN, B_KING,
NO_PIECE = 12, PIECE_NB = 12
};
// Board squares from A1=0 to H8=63 (rank-major order)
enum Square : int {
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,
NO_SQUARE = 64, SQUARE_NB = 64
};
enum File : int { FILE_A, FILE_B, FILE_C, FILE_D, FILE_E, FILE_F, FILE_G, FILE_H, FILE_NB = 8 };
enum Rank : int { RANK_1, RANK_2, RANK_3, RANK_4, RANK_5, RANK_6, RANK_7, RANK_8, RANK_NB = 8 };
// Castling encoded as bit flags
enum CastlingRight : int {
NO_CASTLING = 0,
WHITE_OO = 1,
WHITE_OOO = 2,
BLACK_OO = 4,
BLACK_OOO = 8,
ALL_CASTLING = 15
};
// Special move types
enum MoveFlag : int { NORMAL = 0, PROMOTION = 1, EN_PASSANT = 2, CASTLING = 3 };
What do these enums represent?
Each enum maps a chess concept to a small integer. Square assigns 0 to A1, 1 to B1, all the way up to 63 for H8. This rank-major layout means you can extract the file with s & 7 and the rank with s >> 3.
Castling rights use bit flags so you can combine them. WHITE_OO | BLACK_OOO means white can castle kingside and black can castle queenside.
- Below the MoveFlag enum, add the Move struct that packs an entire move into 16 bits:
// Move encoding: 16 bits
// bits 0-5: from square
// bits 6-11: to square
// bits 12-13: promotion piece type (0=knight, 1=bishop, 2=rook, 3=queen)
// bits 14-15: special flags (0=normal, 1=promotion, 2=en passant, 3=castling)
struct Move {
uint16_t data = 0;
Move() = default;
Move(Square from, Square to, MoveFlag flag = NORMAL, int promo = 0)
: data(from | (to << 6) | (promo << 12) | (flag << 14)) {}
Square from() const { return Square(data & 0x3F); }
Square to() const { return Square((data >> 6) & 0x3F); }
MoveFlag flag() const { return MoveFlag((data >> 14) & 0x3); }
PieceType promotion() const { return PieceType(((data >> 12) & 0x3) + KNIGHT); }
bool operator==(const Move& other) const { return data == other.data; }
bool operator!=(const Move& other) const { return data != other.data; }
explicit operator bool() const { return data != 0; }
};
Why pack a move into 16 bits?
During search, the engine generates millions of moves per second. A compact 16-bit encoding means moves fit in CPU cache and can be compared with a single integer operation. The bit layout stores from-square, to-square, promotion piece, and special flags all in one uint16_t.
- Below the Move struct, add the search constants and inline helper functions:
constexpr int MAX_MOVES = 256;
constexpr int MAX_PLY = 128;
constexpr int INFINITE = 30000;
constexpr int MATE_SCORE = 29000;
constexpr int VALUE_NONE = 30001;
// Flip color: ~WHITE == BLACK, ~BLACK == WHITE
inline constexpr Color operator~(Color c) { return Color(c ^ 1); }
// Square from file and rank
inline constexpr Square make_square(File f, Rank r) { return Square(r * 8 + f); }
inline constexpr File file_of(Square s) { return File(s & 7); }
inline constexpr Rank rank_of(Square s) { return Rank(s >> 3); }
// Piece construction and decomposition
inline constexpr Piece make_piece(Color c, PieceType pt) { return Piece(c * 6 + pt); }
inline constexpr Color color_of(Piece p) { return Color(p / 6); }
inline constexpr PieceType type_of(Piece p) { return PieceType(p % 6); }
// Square to/from algebraic notation (e.g. "e4")
inline std::string square_to_string(Square s) {
return std::string(1, 'a' + file_of(s)) + std::string(1, '1' + rank_of(s));
}
inline Square string_to_square(const std::string& str) {
return make_square(File(str[0] - 'a'), Rank(str[1] - '1'));
}
What do these helpers do?
The inline functions convert between different representations. file_of and rank_of extract file and rank from a square index using bitwise math. make_piece combines color and piece type into a single index.
The operator~ overload flips a color using XOR. This is used throughout the engine to switch between white and black perspectives.
- Save src/types.h.
Seeing red squiggles in Cursor?
The editor may show errors because the project cannot compile yet. Other source files referenced in CMakeLists.txt do not exist. These errors disappear as you add the remaining files in later steps.
help me understand why my editor shows errors even though my code looks correct
✔️ Awesome, I've got everything!
Great. Double check you saved src/types.h.
ⓧ I'd like to double check the full code
#pragma once
#include <cstdint>
#include <string>
#include <array>
#include <cassert>
using U64 = uint64_t;
enum Color : int { WHITE, BLACK, COLOR_NB = 2 };
enum PieceType : int { PAWN, KNIGHT, BISHOP, ROOK, QUEEN, KING, PIECE_TYPE_NB = 6, NO_PIECE_TYPE = 6 };
enum Piece : int {
W_PAWN, W_KNIGHT, W_BISHOP, W_ROOK, W_QUEEN, W_KING,
B_PAWN, B_KNIGHT, B_BISHOP, B_ROOK, B_QUEEN, B_KING,
NO_PIECE = 12, PIECE_NB = 12
};
enum Square : int {
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,
NO_SQUARE = 64, SQUARE_NB = 64
};
enum File : int { FILE_A, FILE_B, FILE_C, FILE_D, FILE_E, FILE_F, FILE_G, FILE_H, FILE_NB = 8 };
enum Rank : int { RANK_1, RANK_2, RANK_3, RANK_4, RANK_5, RANK_6, RANK_7, RANK_8, RANK_NB = 8 };
enum CastlingRight : int {
NO_CASTLING = 0,
WHITE_OO = 1,
WHITE_OOO = 2,
BLACK_OO = 4,
BLACK_OOO = 8,
ALL_CASTLING = 15
};
enum MoveFlag : int { NORMAL = 0, PROMOTION = 1, EN_PASSANT = 2, CASTLING = 3 };
struct Move {
uint16_t data = 0;
Move() = default;
Move(Square from, Square to, MoveFlag flag = NORMAL, int promo = 0)
: data(from | (to << 6) | (promo << 12) | (flag << 14)) {}
Square from() const { return Square(data & 0x3F); }
Square to() const { return Square((data >> 6) & 0x3F); }
MoveFlag flag() const { return MoveFlag((data >> 14) & 0x3); }
PieceType promotion() const { return PieceType(((data >> 12) & 0x3) + KNIGHT); }
bool operator==(const Move& other) const { return data == other.data; }
bool operator!=(const Move& other) const { return data != other.data; }
explicit operator bool() const { return data != 0; }
};
constexpr int MAX_MOVES = 256;
constexpr int MAX_PLY = 128;
constexpr int INFINITE = 30000;
constexpr int MATE_SCORE = 29000;
constexpr int VALUE_NONE = 30001;
inline constexpr Color operator~(Color c) { return Color(c ^ 1); }
inline constexpr Square make_square(File f, Rank r) { return Square(r * 8 + f); }
inline constexpr File file_of(Square s) { return File(s & 7); }
inline constexpr Rank rank_of(Square s) { return Rank(s >> 3); }
inline constexpr Piece make_piece(Color c, PieceType pt) { return Piece(c * 6 + pt); }
inline constexpr Color color_of(Piece p) { return Color(p / 6); }
inline constexpr PieceType type_of(Piece p) { return PieceType(p % 6); }
inline std::string square_to_string(Square s) {
return std::string(1, 'a' + file_of(s)) + std::string(1, '1' + rank_of(s));
}
inline Square string_to_square(const std::string& str) {
return make_square(File(str[0] - 'a'), Rank(str[1] - '1'));
}
Implement bitboard utility functions
With the type system in place, you need operations that manipulate 64-bit integers as chess boards. These include setting and clearing individual bits, counting how many bits are set, and shifting entire bitboards in the eight compass directions.
- Create a new file called bitboard.h inside the src folder.
- Paste the following declarations and utility functions into src/bitboard.h:
#pragma once
#include "types.h"
namespace Bitboards {
void init();
}
// Pre-computed attack tables (filled at startup)
extern U64 KnightAttacks[SQUARE_NB];
extern U64 KingAttacks[SQUARE_NB];
extern U64 PawnAttacks[COLOR_NB][SQUARE_NB];
extern U64 BetweenBB[SQUARE_NB][SQUARE_NB];
extern U64 LineBB[SQUARE_NB][SQUARE_NB];
// Magic bitboard structures for sliding pieces
struct Magic {
U64* attacks;
U64 mask;
U64 magic;
int shift;
unsigned index(U64 occupied) const {
return unsigned(((occupied & mask) * magic) >> shift);
}
};
extern Magic BishopMagics[SQUARE_NB];
extern Magic RookMagics[SQUARE_NB];
// Sliding piece attack lookups (O(1) via magic indexing)
inline U64 bishop_attacks(Square s, U64 occupied) {
return BishopMagics[s].attacks[BishopMagics[s].index(occupied)];
}
inline U64 rook_attacks(Square s, U64 occupied) {
return RookMagics[s].attacks[RookMagics[s].index(occupied)];
}
inline U64 queen_attacks(Square s, U64 occupied) {
return bishop_attacks(s, occupied) | rook_attacks(s, occupied);
}
// Bitboard utility functions
inline constexpr U64 square_bb(Square s) { return 1ULL << s; }
inline int popcount(U64 b) { return __builtin_popcountll(b); }
inline Square lsb(U64 b) { return Square(__builtin_ctzll(b)); }
inline Square pop_lsb(U64& b) {
Square s = lsb(b);
b &= b - 1;
return s;
}
What do these utilities do?
square_bb converts a square index to a bitboard with a single bit set. popcount counts how many pieces are on a bitboard. lsb finds the lowest square that has a piece. pop_lsb returns the lowest set square and removes it in one step.
These use compiler built-ins (__builtin_popcountll and __builtin_ctzll) that compile down to single CPU instructions on modern x86 hardware.
- Below the pop_lsb function, add the file/rank bitboard constants and directional shift functions:
// File (column) bitboards
constexpr U64 FileABB = 0x0101010101010101ULL;
constexpr U64 FileBBB = FileABB << 1;
constexpr U64 FileCBB = FileABB << 2;
constexpr U64 FileDBB = FileABB << 3;
constexpr U64 FileEBB = FileABB << 4;
constexpr U64 FileFBB = FileABB << 5;
constexpr U64 FileGBB = FileABB << 6;
constexpr U64 FileHBB = FileABB << 7;
// Rank (row) bitboards
constexpr U64 Rank1BB = 0xFFULL;
constexpr U64 Rank2BB = Rank1BB << 8;
constexpr U64 Rank3BB = Rank1BB << 16;
constexpr U64 Rank4BB = Rank1BB << 24;
constexpr U64 Rank5BB = Rank1BB << 32;
constexpr U64 Rank6BB = Rank1BB << 40;
constexpr U64 Rank7BB = Rank1BB << 48;
constexpr U64 Rank8BB = Rank1BB << 56;
// Shift bitboards in 8 compass directions (masking prevents wrap-around)
inline U64 shift_north(U64 b) { return b << 8; }
inline U64 shift_south(U64 b) { return b >> 8; }
inline U64 shift_east(U64 b) { return (b << 1) & ~FileABB; }
inline U64 shift_west(U64 b) { return (b >> 1) & ~FileHBB; }
inline U64 shift_north_east(U64 b) { return (b << 9) & ~FileABB; }
inline U64 shift_north_west(U64 b) { return (b << 7) & ~FileHBB; }
inline U64 shift_south_east(U64 b) { return (b >> 7) & ~FileABB; }
inline U64 shift_south_west(U64 b) { return (b >> 9) & ~FileHBB; }
Why mask the shift functions?
When you shift a bitboard east (left by 1 bit), a piece on the H-file would "wrap around" to the A-file of the next rank. The & ~FileABB mask clears any bits that wrapped, preventing illegal moves. The same logic applies to west shifts with & ~FileHBB.
- Save src/bitboard.h.
✔️ Awesome, I've got everything!
Great. Double check you saved src/bitboard.h.
ⓧ I'd like to double check the full code
#pragma once
#include "types.h"
namespace Bitboards {
void init();
}
extern U64 KnightAttacks[SQUARE_NB];
extern U64 KingAttacks[SQUARE_NB];
extern U64 PawnAttacks[COLOR_NB][SQUARE_NB];
extern U64 BetweenBB[SQUARE_NB][SQUARE_NB];
extern U64 LineBB[SQUARE_NB][SQUARE_NB];
struct Magic {
U64* attacks;
U64 mask;
U64 magic;
int shift;
unsigned index(U64 occupied) const {
return unsigned(((occupied & mask) * magic) >> shift);
}
};
extern Magic BishopMagics[SQUARE_NB];
extern Magic RookMagics[SQUARE_NB];
inline U64 bishop_attacks(Square s, U64 occupied) {
return BishopMagics[s].attacks[BishopMagics[s].index(occupied)];
}
inline U64 rook_attacks(Square s, U64 occupied) {
return RookMagics[s].attacks[RookMagics[s].index(occupied)];
}
inline U64 queen_attacks(Square s, U64 occupied) {
return bishop_attacks(s, occupied) | rook_attacks(s, occupied);
}
inline constexpr U64 square_bb(Square s) { return 1ULL << s; }
inline int popcount(U64 b) { return __builtin_popcountll(b); }
inline Square lsb(U64 b) { return Square(__builtin_ctzll(b)); }
inline Square pop_lsb(U64& b) {
Square s = lsb(b);
b &= b - 1;
return s;
}
constexpr U64 FileABB = 0x0101010101010101ULL;
constexpr U64 FileBBB = FileABB << 1;
constexpr U64 FileCBB = FileABB << 2;
constexpr U64 FileDBB = FileABB << 3;
constexpr U64 FileEBB = FileABB << 4;
constexpr U64 FileFBB = FileABB << 5;
constexpr U64 FileGBB = FileABB << 6;
constexpr U64 FileHBB = FileABB << 7;
constexpr U64 Rank1BB = 0xFFULL;
constexpr U64 Rank2BB = Rank1BB << 8;
constexpr U64 Rank3BB = Rank1BB << 16;
constexpr U64 Rank4BB = Rank1BB << 24;
constexpr U64 Rank5BB = Rank1BB << 32;
constexpr U64 Rank6BB = Rank1BB << 40;
constexpr U64 Rank7BB = Rank1BB << 48;
constexpr U64 Rank8BB = Rank1BB << 56;
inline U64 shift_north(U64 b) { return b << 8; }
inline U64 shift_south(U64 b) { return b >> 8; }
inline U64 shift_east(U64 b) { return (b << 1) & ~FileABB; }
inline U64 shift_west(U64 b) { return (b >> 1) & ~FileHBB; }
inline U64 shift_north_east(U64 b) { return (b << 9) & ~FileABB; }
inline U64 shift_north_west(U64 b) { return (b << 7) & ~FileHBB; }
inline U64 shift_south_east(U64 b) { return (b >> 7) & ~FileABB; }
inline U64 shift_south_west(U64 b) { return (b >> 9) & ~FileHBB; }
Build pre-computed attack tables for knights, kings, and pawns
Knights, kings, and pawns have fixed attack patterns that do not depend on which squares are blocked. You can pre-compute every possible attack for every square once at startup, then look them up in O(1) during search. This file also initializes the magic bitboard tables for sliding pieces (bishops and rooks) which you will build out in the next step.
- Create a new file called bitboard.cpp inside the src folder.
- Paste the global array definitions and the magic-number PRNG into src/bitboard.cpp:
#include "bitboard.h"
#include <cstring>
// Global attack tables
U64 KnightAttacks[SQUARE_NB];
U64 KingAttacks[SQUARE_NB];
U64 PawnAttacks[COLOR_NB][SQUARE_NB];
U64 BetweenBB[SQUARE_NB][SQUARE_NB];
U64 LineBB[SQUARE_NB][SQUARE_NB];
Magic BishopMagics[SQUARE_NB];
Magic RookMagics[SQUARE_NB];
// Internal storage for magic attack lookups
static U64 BishopTable[0x1480];
static U64 RookTable[0x19000];
// Xorshift64 PRNG for magic number generation
static U64 prng_state;
static U64 random_u64() {
prng_state ^= prng_state >> 12;
prng_state ^= prng_state << 25;
prng_state ^= prng_state >> 27;
return prng_state * 2685821657736338717ULL;
}
// AND three random values to produce a sparse number (few bits set)
static U64 random_sparse_u64() {
return random_u64() & random_u64() & random_u64();
}
What are magic bitboards?
Magic bitboards are a technique for looking up sliding piece attacks in O(1). For each square, a "magic number" is a specially chosen constant that, when multiplied by the relevant occupancy bits, maps them to a unique table index. The result is a direct lookup instead of a ray-tracing loop.
Rather than hardcoding magic numbers (which are error-prone), this engine finds them at startup via brute-force trial. The PRNG generates sparse random 64-bit numbers and the code verifies each candidate against all possible occupancy patterns. If a candidate produces a collision with a different attack set, it is rejected and a new one is tried. This self-verifying approach guarantees correctness.
- Below the PRNG functions, add the sliding attack computation and magic-finding logic:
// Compute sliding attacks by walking rays until hitting an occupied square
static U64 sliding_attack(Square sq, U64 occupied, const int deltas[4][2]) {
U64 attacks = 0;
for (int i = 0; i < 4; i++) {
int dr = deltas[i][0], df = deltas[i][1];
int r = rank_of(sq) + dr, f = file_of(sq) + df;
while (r >= 0 && r < 8 && f >= 0 && f < 8) {
Square s = make_square(File(f), Rank(r));
attacks |= square_bb(s);
if (occupied & square_bb(s)) break;
r += dr; f += df;
}
}
return attacks;
}
// Generate the relevant occupancy mask for bishop on a given square
static U64 bishop_mask(Square sq) {
static const int deltas[4][2] = {{1,1},{1,-1},{-1,1},{-1,-1}};
// For bishops, all edge squares are irrelevant (diagonals never stay on same file/rank)
U64 edges = Rank1BB | Rank8BB | FileABB | FileHBB;
return sliding_attack(sq, 0, deltas) & ~edges;
}
// Generate the relevant occupancy mask for rook on a given square
static U64 rook_mask(Square sq) {
static const int deltas[4][2] = {{1,0},{-1,0},{0,1},{0,-1}};
// For rooks, exclude edges except the rook's own rank/file
U64 edges = ((Rank1BB | Rank8BB) & ~(Rank1BB << (rank_of(sq) * 8)))
| ((FileABB | FileHBB) & ~(FileABB << file_of(sq)));
return sliding_attack(sq, 0, deltas) & ~edges;
}
// Find magic numbers at runtime and build lookup tables
// Uses epoch-based verification (like Stockfish) to avoid clearing arrays
static void init_magics(Magic magics[], U64 table[], bool bishop) {
static const int bishop_deltas[4][2] = {{1,1},{1,-1},{-1,1},{-1,-1}};
static const int rook_deltas[4][2] = {{1,0},{-1,0},{0,1},{0,-1}};
U64 occupancy[4096], reference[4096];
int epoch[4096] = {};
int cnt = 0;
U64* current = table;
// Seed PRNG differently for bishop vs rook to get different magic sequences
prng_state = bishop ? 234907523ULL : 981275349ULL;
for (int sq = 0; sq < 64; sq++) {
Magic& m = magics[sq];
m.mask = bishop ? bishop_mask(Square(sq)) : rook_mask(Square(sq));
m.shift = 64 - popcount(m.mask);
m.attacks = current;
// Enumerate all occupancy subsets using Carry-Rippler trick
int size = 0;
U64 b = 0;
do {
occupancy[size] = b;
reference[size] = sliding_attack(Square(sq), b,
bishop ? bishop_deltas : rook_deltas);
size++;
b = (b - m.mask) & m.mask;
} while (b);
// Find a magic number by trial: keep trying sparse randoms
// A valid magic maps every occupancy subset to a unique (or compatible) index
for (int i = 0; i < size; ) {
m.magic = random_sparse_u64();
// Quick reject: good magics spread bits into the top of the product
if (popcount((m.magic * m.mask) >> 56) < 6) continue;
++cnt;
for (i = 0; i < size; i++) {
unsigned idx = m.index(occupancy[i]);
if (epoch[idx] < cnt) {
epoch[idx] = cnt;
m.attacks[idx] = reference[i];
} else if (m.attacks[idx] != reference[i]) {
break; // Destructive collision: this magic fails
}
// Constructive collision (same attacks): magic still valid
}
}
current += (1 << (64 - m.shift));
}
}
How does init_magics work?
For each square, init_magics enumerates every possible occupancy configuration along the piece's rays using the Carry-Rippler trick. For each configuration, it computes the correct attack bitboard and stores it as a reference.
Then it enters a trial loop: generate a sparse random number, multiply it by the mask, and check if the resulting indices produce any destructive collisions (two different attack sets mapped to the same index). If a collision occurs, try a new random. The epoch-based verification avoids clearing the attack table between attempts, making the search fast. Once a magic passes all subsets, the table is complete for that square.
- Below init_magics, add the Bitboards::init() function that pre-computes attack tables for non-sliding pieces:
void Bitboards::init() {
// Knight attacks: all 8 L-shaped jumps, masked to prevent file wrap
for (int sq = 0; sq < 64; sq++) {
U64 b = square_bb(Square(sq));
KnightAttacks[sq] =
((b << 17) & ~FileABB) | ((b << 15) & ~FileHBB) |
((b << 10) & ~(FileABB | FileBBB)) | ((b << 6) & ~(FileGBB | FileHBB)) |
((b >> 6) & ~(FileABB | FileBBB)) | ((b >> 10) & ~(FileGBB | FileHBB)) |
((b >> 15) & ~FileABB) | ((b >> 17) & ~FileHBB);
}
// King attacks: all 8 adjacent squares
for (int sq = 0; sq < 64; sq++) {
U64 b = square_bb(Square(sq));
KingAttacks[sq] = shift_north(b) | shift_south(b) | shift_east(b) | shift_west(b)
| shift_north_east(b) | shift_north_west(b)
| shift_south_east(b) | shift_south_west(b);
}
// Pawn attacks: diagonal captures only (not pushes)
for (int sq = 0; sq < 64; sq++) {
U64 b = square_bb(Square(sq));
PawnAttacks[WHITE][sq] = shift_north_east(b) | shift_north_west(b);
PawnAttacks[BLACK][sq] = shift_south_east(b) | shift_south_west(b);
}
// Initialize magic bitboard tables for sliding pieces (finds magics at runtime)
init_magics(BishopMagics, BishopTable, true);
init_magics(RookMagics, RookTable, false);
// Between and Line bitboards for pin/check detection
for (int s1 = 0; s1 < 64; s1++) {
for (int s2 = 0; s2 < 64; s2++) {
U64 b1 = square_bb(Square(s1));
U64 b2 = square_bb(Square(s2));
if (bishop_attacks(Square(s1), 0) & b2) {
LineBB[s1][s2] = (bishop_attacks(Square(s1), 0) & bishop_attacks(Square(s2), 0)) | b1 | b2;
BetweenBB[s1][s2] = bishop_attacks(Square(s1), b2) & bishop_attacks(Square(s2), b1);
}
if (rook_attacks(Square(s1), 0) & b2) {
LineBB[s1][s2] = (rook_attacks(Square(s1), 0) & rook_attacks(Square(s2), 0)) | b1 | b2;
BetweenBB[s1][s2] = rook_attacks(Square(s1), b2) & rook_attacks(Square(s2), b1);
}
}
}
}
What does Bitboards::init() produce?
After this function runs, the engine can answer "where can a knight on E4 attack?" with a single array lookup: KnightAttacks[E4]. The same applies to kings and pawns.
The BetweenBB and LineBB tables help detect pins and checks. BetweenBB[A1][H8] returns all squares between A1 and H8 on the diagonal. This is critical for the legal move generator in later steps.
- Save src/bitboard.cpp.
- Confirm all three files exist by running this in your terminal:
ls src/types.h src/bitboard.h src/bitboard.cpp
You should see all three filenames listed in the output.
Missing one of the files?
Make sure you created the files inside the src subdirectory, not in the project root. The path should be nexus/src/types.h, not nexus/types.h.
help me figure out why my source files are not in the right directory
✔️ Awesome, I've got everything!
Great. Double check you saved src/bitboard.cpp.
ⓧ I'd like to double check the full code
#include "bitboard.h"
#include <cstring>
U64 KnightAttacks[SQUARE_NB];
U64 KingAttacks[SQUARE_NB];
U64 PawnAttacks[COLOR_NB][SQUARE_NB];
U64 BetweenBB[SQUARE_NB][SQUARE_NB];
U64 LineBB[SQUARE_NB][SQUARE_NB];
Magic BishopMagics[SQUARE_NB];
Magic RookMagics[SQUARE_NB];
static U64 BishopTable[0x1480];
static U64 RookTable[0x19000];
static U64 prng_state;
static U64 random_u64() {
prng_state ^= prng_state >> 12;
prng_state ^= prng_state << 25;
prng_state ^= prng_state >> 27;
return prng_state * 2685821657736338717ULL;
}
static U64 random_sparse_u64() {
return random_u64() & random_u64() & random_u64();
}
static U64 sliding_attack(Square sq, U64 occupied, const int deltas[4][2]) {
U64 attacks = 0;
for (int i = 0; i < 4; i++) {
int dr = deltas[i][0], df = deltas[i][1];
int r = rank_of(sq) + dr, f = file_of(sq) + df;
while (r >= 0 && r < 8 && f >= 0 && f < 8) {
Square s = make_square(File(f), Rank(r));
attacks |= square_bb(s);
if (occupied & square_bb(s)) break;
r += dr; f += df;
}
}
return attacks;
}
static U64 bishop_mask(Square sq) {
static const int deltas[4][2] = {{1,1},{1,-1},{-1,1},{-1,-1}};
U64 edges = Rank1BB | Rank8BB | FileABB | FileHBB;
return sliding_attack(sq, 0, deltas) & ~edges;
}
static U64 rook_mask(Square sq) {
static const int deltas[4][2] = {{1,0},{-1,0},{0,1},{0,-1}};
U64 edges = ((Rank1BB | Rank8BB) & ~(Rank1BB << (rank_of(sq) * 8)))
| ((FileABB | FileHBB) & ~(FileABB << file_of(sq)));
return sliding_attack(sq, 0, deltas) & ~edges;
}
static void init_magics(Magic magics[], U64 table[], bool bishop) {
static const int bishop_deltas[4][2] = {{1,1},{1,-1},{-1,1},{-1,-1}};
static const int rook_deltas[4][2] = {{1,0},{-1,0},{0,1},{0,-1}};
U64 occupancy[4096], reference[4096];
int epoch[4096] = {};
int cnt = 0;
U64* current = table;
prng_state = bishop ? 234907523ULL : 981275349ULL;
for (int sq = 0; sq < 64; sq++) {
Magic& m = magics[sq];
m.mask = bishop ? bishop_mask(Square(sq)) : rook_mask(Square(sq));
m.shift = 64 - popcount(m.mask);
m.attacks = current;
int size = 0;
U64 b = 0;
do {
occupancy[size] = b;
reference[size] = sliding_attack(Square(sq), b,
bishop ? bishop_deltas : rook_deltas);
size++;
b = (b - m.mask) & m.mask;
} while (b);
for (int i = 0; i < size; ) {
m.magic = random_sparse_u64();
if (popcount((m.magic * m.mask) >> 56) < 6) continue;
++cnt;
for (i = 0; i < size; i++) {
unsigned idx = m.index(occupancy[i]);
if (epoch[idx] < cnt) {
epoch[idx] = cnt;
m.attacks[idx] = reference[i];
} else if (m.attacks[idx] != reference[i]) {
break;
}
}
}
current += (1 << (64 - m.shift));
}
}
void Bitboards::init() {
for (int sq = 0; sq < 64; sq++) {
U64 b = square_bb(Square(sq));
KnightAttacks[sq] =
((b << 17) & ~FileABB) | ((b << 15) & ~FileHBB) |
((b << 10) & ~(FileABB | FileBBB)) | ((b << 6) & ~(FileGBB | FileHBB)) |
((b >> 6) & ~(FileABB | FileBBB)) | ((b >> 10) & ~(FileGBB | FileHBB)) |
((b >> 15) & ~FileABB) | ((b >> 17) & ~FileHBB);
}
for (int sq = 0; sq < 64; sq++) {
U64 b = square_bb(Square(sq));
KingAttacks[sq] = shift_north(b) | shift_south(b) | shift_east(b) | shift_west(b)
| shift_north_east(b) | shift_north_west(b)
| shift_south_east(b) | shift_south_west(b);
}
for (int sq = 0; sq < 64; sq++) {
U64 b = square_bb(Square(sq));
PawnAttacks[WHITE][sq] = shift_north_east(b) | shift_north_west(b);
PawnAttacks[BLACK][sq] = shift_south_east(b) | shift_south_west(b);
}
init_magics(BishopMagics, BishopTable, true);
init_magics(RookMagics, RookTable, false);
for (int s1 = 0; s1 < 64; s1++) {
for (int s2 = 0; s2 < 64; s2++) {
U64 b1 = square_bb(Square(s1));
U64 b2 = square_bb(Square(s2));
if (bishop_attacks(Square(s1), 0) & b2) {
LineBB[s1][s2] = (bishop_attacks(Square(s1), 0) & bishop_attacks(Square(s2), 0)) | b1 | b2;
BetweenBB[s1][s2] = bishop_attacks(Square(s1), b2) & bishop_attacks(Square(s2), b1);
}
if (rook_attacks(Square(s1), 0) & b2) {
LineBB[s1][s2] = (rook_attacks(Square(s1), 0) & rook_attacks(Square(s2), 0)) | b1 | b2;
BetweenBB[s1][s2] = rook_attacks(Square(s1), b2) & rook_attacks(Square(s2), b1);
}
}
}
}
Your type system and bitboard foundation are ready. In the next step, you will build the magic bitboard lookup system for sliding pieces (bishops and rooks), giving the engine O(1) attack computation for every piece type on the board.
Build the Position and Move Mechanics
Your bitboard infrastructure can generate attacks for every piece type in constant time. Now you need a way to represent an entire chess position and apply moves to it.
In this step, you will build the Position class that holds the full game state. You will also implement make/unmake move, the core pattern that lets the search algorithm try millions of moves per second by playing them forward and rolling them back.
In this step, get ready to:
- Build the Position class with bitboard arrays, Zobrist hashing, and state history.
- Implement FEN string parsing so you can set up any position.
- Implement make move and unmake move with full state restoration.
Create the position header file
The Position class is the heart of any chess engine. It stores which pieces sit on which squares, whose turn it is, castling rights, en passant state, and a Zobrist hash for fast position comparison.
- Create a new file called src/position.h inside your nexus project folder.
- Paste the following code into src/position.h:
#pragma once
#include "types.h"
#include "bitboard.h"
#include <vector>
struct StateInfo {
U64 hash;
int castling;
Square ep_square;
int halfmove_clock;
Piece captured;
Move move;
};
class Position {
public:
Position();
void set_fen(const std::string& fen);
std::string fen() const;
void make_move(Move m);
void unmake_move();
bool is_legal(Move m) const;
// Board queries
U64 pieces(Color c) const { return color_bb[c]; }
U64 pieces(PieceType pt) const { return piece_bb[pt]; }
U64 pieces(Color c, PieceType pt) const { return color_bb[c] & piece_bb[pt]; }
U64 occupied() const { return color_bb[WHITE] | color_bb[BLACK]; }
Piece piece_on(Square s) const { return board[s]; }
Color side_to_move() const { return stm; }
Square ep_square() const { return state().ep_square; }
int castling_rights() const { return state().castling; }
U64 hash() const { return state().hash; }
int halfmove_clock() const { return state().halfmove_clock; }
int fullmove_number() const { return fullmove; }
Square king_square(Color c) const { return lsb(pieces(c, KING)); }
// Attack detection
U64 attackers_to(Square s, U64 occ) const;
bool in_check() const;
bool is_draw() const;
// Game history for undo
int ply() const { return history.size(); }
private:
U64 piece_bb[PIECE_TYPE_NB];
U64 color_bb[COLOR_NB];
Piece board[SQUARE_NB];
Color stm;
int fullmove;
std::vector<StateInfo> history;
void put_piece(Piece p, Square s);
void remove_piece(Square s);
void move_piece(Square from, Square to);
StateInfo& state() { return history.back(); }
const StateInfo& state() const { return history.back(); }
};
namespace Zobrist {
void init();
extern U64 psq[PIECE_NB][SQUARE_NB];
extern U64 en_passant[FILE_NB];
extern U64 castling[16];
extern U64 side;
}
What does this header declare?
- StateInfo stores everything that changes with each move: the Zobrist hash, castling rights, en passant square, halfmove clock, the captured piece, and which move was played.
- Position holds two bitboard arrays (piece_bb indexed by piece type, color_bb indexed by color) plus a mailbox board[64] for O(1) square lookup.
- The history vector stores one StateInfo per move, enabling full undo via unmake_move().
- The Zobrist namespace provides random numbers for incremental hashing. Each piece-square combination, castling configuration, en passant file, and side to move gets a unique random 64-bit key.
- Save src/position.h.
Implement Zobrist hashing and position helpers
Zobrist hashing gives every position a near-unique 64-bit fingerprint. You build the hash incrementally by XOR-ing in random keys each time a piece moves, castling rights change, or the side to move flips.
- Create a new file called src/position.cpp in your src folder.
- Start src/position.cpp with the Zobrist initialization, castling rights mask, and piece-placement helpers by pasting this code:
#include "position.h"
#include <sstream>
#include <random>
namespace Zobrist {
U64 psq[PIECE_NB][SQUARE_NB];
U64 en_passant[FILE_NB];
U64 castling[16];
U64 side;
void init() {
std::mt19937_64 rng(1070372);
for (int p = 0; p < PIECE_NB; p++)
for (int s = 0; s < SQUARE_NB; s++)
psq[p][s] = rng();
for (int f = 0; f < FILE_NB; f++)
en_passant[f] = rng();
for (int cr = 0; cr < 16; cr++)
castling[cr] = rng();
side = rng();
}
}
static const int CastlingRightsMask[64] = {
13, 15, 15, 15, 12, 15, 15, 14,
15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15,
7, 15, 15, 15, 3, 15, 15, 11
};
Position::Position() {
set_fen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1");
}
void Position::put_piece(Piece p, Square s) {
board[s] = p;
piece_bb[type_of(p)] |= square_bb(s);
color_bb[color_of(p)] |= square_bb(s);
}
void Position::remove_piece(Square s) {
Piece p = board[s];
board[s] = NO_PIECE;
piece_bb[type_of(p)] ^= square_bb(s);
color_bb[color_of(p)] ^= square_bb(s);
}
void Position::move_piece(Square from, Square to) {
Piece p = board[from];
U64 fromto = square_bb(from) | square_bb(to);
board[from] = NO_PIECE;
board[to] = p;
piece_bb[type_of(p)] ^= fromto;
color_bb[color_of(p)] ^= fromto;
}
What does this code do?
- Zobrist::init() fills arrays with pseudo-random 64-bit numbers from a fixed seed. The fixed seed ensures reproducibility across runs.
- CastlingRightsMask is a per-square lookup table that masks off castling rights when a king or rook moves from (or gets captured on) its original square. For example, square E1 (index 4) has mask 12 (binary 1100), clearing both white castling bits.
- put_piece, remove_piece, and move_piece keep the mailbox array and bitboard arrays in sync using XOR toggles. XOR is its own inverse, making undo trivial.
- Save src/position.cpp.
Implement FEN parsing
FEN (Forsyth-Edwards Notation) encodes a complete chess position in a single ASCII string. It has six space-separated fields: piece placement, side to move, castling rights, en passant target, halfmove clock, and fullmove number.
- Add the set_fen method below your move_piece function in src/position.cpp:
void Position::set_fen(const std::string& fen) {
std::memset(piece_bb, 0, sizeof(piece_bb));
std::memset(color_bb, 0, sizeof(color_bb));
std::fill(board, board + 64, NO_PIECE);
history.clear();
history.push_back(StateInfo{});
std::istringstream ss(fen);
std::string pieces, side_str, castling_str, ep_str;
int halfmove, fullmove_num;
ss >> pieces >> side_str >> castling_str >> ep_str >> halfmove >> fullmove_num;
int sq = 56;
for (char c : pieces) {
if (c == '/') { sq -= 16; continue; }
if (c >= '1' && c <= '8') { sq += (c - '0'); continue; }
static const std::string piece_chars = "PNBRQKpnbrqk";
int idx = piece_chars.find(c);
if (idx != std::string::npos) {
put_piece(Piece(idx), Square(sq));
sq++;
}
}
stm = (side_str == "w") ? WHITE : BLACK;
fullmove = fullmove_num;
int cr = 0;
for (char c : castling_str) {
if (c == 'K') cr |= WHITE_OO;
if (c == 'Q') cr |= WHITE_OOO;
if (c == 'k') cr |= BLACK_OO;
if (c == 'q') cr |= BLACK_OOO;
}
StateInfo& st = state();
st.castling = cr;
st.ep_square = (ep_str != "-") ? string_to_square(ep_str) : NO_SQUARE;
st.halfmove_clock = halfmove;
st.captured = NO_PIECE;
st.move = Move();
// Compute hash
st.hash = 0;
for (int s = 0; s < 64; s++) {
if (board[s] != NO_PIECE)
st.hash ^= Zobrist::psq[board[s]][s];
}
if (stm == BLACK) st.hash ^= Zobrist::side;
st.hash ^= Zobrist::castling[st.castling];
if (st.ep_square != NO_SQUARE) st.hash ^= Zobrist::en_passant[file_of(st.ep_square)];
}
How does set_fen work?
- It zeroes all bitboards and the mailbox, then walks the FEN piece-placement field rank by rank, starting at square 56 (a8) and adjusting by -16 at each '/' to drop down one rank.
- Digits skip empty squares. Letters map to piece indices through the string PNBRQKpnbrqk which matches the Piece enum order.
- After placing all pieces, it parses castling rights by checking for K/Q/k/q characters, sets the en passant square, and computes the full Zobrist hash from scratch.
- Now add the fen() serialization method directly below set_fen:
std::string Position::fen() const {
std::string result;
for (int r = 7; r >= 0; r--) {
int empty = 0;
for (int f = 0; f < 8; f++) {
Piece p = board[make_square(File(f), Rank(r))];
if (p == NO_PIECE) { empty++; continue; }
if (empty) { result += std::to_string(empty); empty = 0; }
static const std::string chars = "PNBRQKpnbrqk";
result += chars[p];
}
if (empty) result += std::to_string(empty);
if (r > 0) result += '/';
}
result += (stm == WHITE) ? " w " : " b ";
const StateInfo& st = state();
if (st.castling == 0) result += '-';
else {
if (st.castling & WHITE_OO) result += 'K';
if (st.castling & WHITE_OOO) result += 'Q';
if (st.castling & BLACK_OO) result += 'k';
if (st.castling & BLACK_OOO) result += 'q';
}
result += ' ';
result += (st.ep_square != NO_SQUARE) ? square_to_string(st.ep_square) : "-";
result += ' ' + std::to_string(st.halfmove_clock);
result += ' ' + std::to_string(fullmove);
return result;
}
What does fen() do?
This is the reverse of set_fen. It walks the board from rank 8 down to rank 1, counting empty squares and emitting piece characters. The result is a valid FEN string you can paste into any chess GUI to verify your position state.
- Save src/position.cpp.
Seeing a compile error about memset?
Make sure your #include at the top of position.cpp includes <sstream> and <random>. The memset function comes from <cstring> which is pulled in through bitboard.h (included via position.h).
help me fix a compile error in position.cpp
Implement make move and unmake move
The make/unmake pattern is fundamental to chess engine search. Every node in the search tree calls make_move to apply a move, evaluates the resulting position, then calls unmake_move to restore the previous state. This avoids copying the entire position at each node.
- Add the make_move method below your fen() function in src/position.cpp:
void Position::make_move(Move m) {
StateInfo new_st{};
new_st.castling = state().castling;
new_st.ep_square = NO_SQUARE;
new_st.halfmove_clock = state().halfmove_clock + 1;
new_st.captured = NO_PIECE;
new_st.move = m;
new_st.hash = state().hash ^ Zobrist::side;
Square from = m.from(), to = m.to();
Piece moving = board[from];
Piece captured = board[to];
MoveFlag flag = m.flag();
// Remove old hash contributions
new_st.hash ^= Zobrist::castling[new_st.castling];
if (state().ep_square != NO_SQUARE)
new_st.hash ^= Zobrist::en_passant[file_of(state().ep_square)];
if (flag == EN_PASSANT) {
Square cap_sq = make_square(file_of(to), rank_of(from));
new_st.captured = board[cap_sq];
new_st.hash ^= Zobrist::psq[board[cap_sq]][cap_sq];
remove_piece(cap_sq);
} else if (captured != NO_PIECE) {
new_st.captured = captured;
new_st.hash ^= Zobrist::psq[captured][to];
remove_piece(to);
}
new_st.hash ^= Zobrist::psq[moving][from];
if (flag == CASTLING) {
// Move king
move_piece(from, to);
// Move rook
Square rook_from, rook_to;
if (to > from) { rook_from = Square(to + 1); rook_to = Square(to - 1); }
else { rook_from = Square(to - 2); rook_to = Square(to + 1); }
Piece rook = board[rook_from];
new_st.hash ^= Zobrist::psq[rook][rook_from] ^ Zobrist::psq[rook][rook_to];
move_piece(rook_from, rook_to);
new_st.hash ^= Zobrist::psq[moving][to];
} else if (flag == PROMOTION) {
remove_piece(from);
Piece promo_piece = make_piece(stm, m.promotion());
put_piece(promo_piece, to);
new_st.hash ^= Zobrist::psq[promo_piece][to];
} else {
move_piece(from, to);
new_st.hash ^= Zobrist::psq[moving][to];
}
// Update castling rights
new_st.castling &= CastlingRightsMask[from] & CastlingRightsMask[to];
// Update en passant
if (type_of(moving) == PAWN) {
new_st.halfmove_clock = 0;
if (int(to) - int(from) == 16 || int(from) - int(to) == 16) {
new_st.ep_square = Square((int(from) + int(to)) / 2);
}
}
if (captured != NO_PIECE || new_st.captured != NO_PIECE)
new_st.halfmove_clock = 0;
// Add new hash contributions
new_st.hash ^= Zobrist::castling[new_st.castling];
if (new_st.ep_square != NO_SQUARE)
new_st.hash ^= Zobrist::en_passant[file_of(new_st.ep_square)];
stm = ~stm;
if (stm == WHITE) fullmove++;
history.push_back(new_st);
}
What does make_move handle?
- It creates a new StateInfo that preserves the captured piece, updated castling rights, and the incrementally-updated Zobrist hash.
- En passant captures remove the pawn from a different square than the move's destination.
- Castling moves both the king and the rook, with the rook's from/to squares computed from the king's destination.
- Promotions remove the pawn and place a new piece of the promoted type.
- Castling rights are updated using the CastlingRightsMask lookup, so moving or capturing on a corner square automatically revokes the relevant right.
- Now add unmake_move and the remaining position query methods directly below make_move:
void Position::unmake_move() {
StateInfo st = history.back();
history.pop_back();
Move m = st.move;
stm = ~stm;
if (stm == BLACK) fullmove--;
Square from = m.from(), to = m.to();
MoveFlag flag = m.flag();
if (flag == CASTLING) {
move_piece(to, from); // King back
Square rook_from, rook_to;
if (to > from) { rook_from = Square(to + 1); rook_to = Square(to - 1); }
else { rook_from = Square(to - 2); rook_to = Square(to + 1); }
move_piece(rook_to, rook_from); // Rook back
} else if (flag == PROMOTION) {
remove_piece(to);
put_piece(make_piece(stm, PAWN), from);
} else {
move_piece(to, from);
}
// Restore captured piece
if (st.captured != NO_PIECE) {
if (flag == EN_PASSANT) {
Square cap_sq = make_square(file_of(to), rank_of(from));
put_piece(st.captured, cap_sq);
} else {
put_piece(st.captured, to);
}
}
}
U64 Position::attackers_to(Square s, U64 occ) const {
return (PawnAttacks[BLACK][s] & pieces(WHITE, PAWN))
| (PawnAttacks[WHITE][s] & pieces(BLACK, PAWN))
| (KnightAttacks[s] & pieces(KNIGHT))
| (bishop_attacks(s, occ) & (pieces(BISHOP) | pieces(QUEEN)))
| (rook_attacks(s, occ) & (pieces(ROOK) | pieces(QUEEN)))
| (KingAttacks[s] & pieces(KING));
}
bool Position::in_check() const {
return attackers_to(king_square(stm), occupied()) & pieces(~stm);
}
bool Position::is_draw() const {
if (state().halfmove_clock >= 100) return true;
// Repetition detection
U64 h = hash();
int count = 0;
for (int i = int(history.size()) - 3; i >= 0; i -= 2) {
if (history[i].hash == h) {
count++;
if (count >= 2) return true;
}
if (history[i].halfmove_clock == 0) break;
}
return false;
}
bool Position::is_legal(Move m) const {
// Simplified: after make_move, check if opponent king is in check
// This is handled during move generation
return true;
}
How does unmake_move restore state?
- It pops the last StateInfo from the history vector, flips the side to move back, and reverses the piece movements.
- Captured pieces are restored from the saved st.captured field. For en passant, the pawn goes back to the capture square (same file as destination, same rank as origin).
- Castling rights, ep square, halfmove clock, and hash are automatically correct because they live in the previous StateInfo entry, which is now at the top of the vector.
- attackers_to returns a bitboard of all pieces attacking a given square. It uses the pre-computed attack tables you built in the previous step.
- is_draw checks the 50-move rule (halfmove clock >= 100) and threefold repetition by scanning backwards through the hash history.
- Save src/position.cpp.
Compile errors about missing symbols?
If you see errors about PawnAttacks, KnightAttacks, or KingAttacks being undeclared, confirm that position.h includes bitboard.h. Those global arrays are declared as extern in bitboard.h and defined in bitboard.cpp.
help me debug missing symbol errors in my chess engine Position class
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/position.h and src/position.cpp.
ⓧ I'd like to double check the full code
Here is the complete src/position.h:
#pragma once
#include "types.h"
#include "bitboard.h"
#include <vector>
struct StateInfo {
U64 hash;
int castling;
Square ep_square;
int halfmove_clock;
Piece captured;
Move move;
};
class Position {
public:
Position();
void set_fen(const std::string& fen);
std::string fen() const;
void make_move(Move m);
void unmake_move();
bool is_legal(Move m) const;
// Board queries
U64 pieces(Color c) const { return color_bb[c]; }
U64 pieces(PieceType pt) const { return piece_bb[pt]; }
U64 pieces(Color c, PieceType pt) const { return color_bb[c] & piece_bb[pt]; }
U64 occupied() const { return color_bb[WHITE] | color_bb[BLACK]; }
Piece piece_on(Square s) const { return board[s]; }
Color side_to_move() const { return stm; }
Square ep_square() const { return state().ep_square; }
int castling_rights() const { return state().castling; }
U64 hash() const { return state().hash; }
int halfmove_clock() const { return state().halfmove_clock; }
int fullmove_number() const { return fullmove; }
Square king_square(Color c) const { return lsb(pieces(c, KING)); }
// Attack detection
U64 attackers_to(Square s, U64 occ) const;
bool in_check() const;
bool is_draw() const;
// Game history for undo
int ply() const { return history.size(); }
private:
U64 piece_bb[PIECE_TYPE_NB];
U64 color_bb[COLOR_NB];
Piece board[SQUARE_NB];
Color stm;
int fullmove;
std::vector<StateInfo> history;
void put_piece(Piece p, Square s);
void remove_piece(Square s);
void move_piece(Square from, Square to);
StateInfo& state() { return history.back(); }
const StateInfo& state() const { return history.back(); }
};
namespace Zobrist {
void init();
extern U64 psq[PIECE_NB][SQUARE_NB];
extern U64 en_passant[FILE_NB];
extern U64 castling[16];
extern U64 side;
}
Here is the complete src/position.cpp:
#include "position.h"
#include <sstream>
#include <random>
namespace Zobrist {
U64 psq[PIECE_NB][SQUARE_NB];
U64 en_passant[FILE_NB];
U64 castling[16];
U64 side;
void init() {
std::mt19937_64 rng(1070372);
for (int p = 0; p < PIECE_NB; p++)
for (int s = 0; s < SQUARE_NB; s++)
psq[p][s] = rng();
for (int f = 0; f < FILE_NB; f++)
en_passant[f] = rng();
for (int cr = 0; cr < 16; cr++)
castling[cr] = rng();
side = rng();
}
}
static const int CastlingRightsMask[64] = {
13, 15, 15, 15, 12, 15, 15, 14,
15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15,
7, 15, 15, 15, 3, 15, 15, 11
};
Position::Position() {
set_fen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1");
}
void Position::put_piece(Piece p, Square s) {
board[s] = p;
piece_bb[type_of(p)] |= square_bb(s);
color_bb[color_of(p)] |= square_bb(s);
}
void Position::remove_piece(Square s) {
Piece p = board[s];
board[s] = NO_PIECE;
piece_bb[type_of(p)] ^= square_bb(s);
color_bb[color_of(p)] ^= square_bb(s);
}
void Position::move_piece(Square from, Square to) {
Piece p = board[from];
U64 fromto = square_bb(from) | square_bb(to);
board[from] = NO_PIECE;
board[to] = p;
piece_bb[type_of(p)] ^= fromto;
color_bb[color_of(p)] ^= fromto;
}
void Position::set_fen(const std::string& fen) {
std::memset(piece_bb, 0, sizeof(piece_bb));
std::memset(color_bb, 0, sizeof(color_bb));
std::fill(board, board + 64, NO_PIECE);
history.clear();
history.push_back(StateInfo{});
std::istringstream ss(fen);
std::string pieces, side_str, castling_str, ep_str;
int halfmove, fullmove_num;
ss >> pieces >> side_str >> castling_str >> ep_str >> halfmove >> fullmove_num;
int sq = 56;
for (char c : pieces) {
if (c == '/') { sq -= 16; continue; }
if (c >= '1' && c <= '8') { sq += (c - '0'); continue; }
static const std::string piece_chars = "PNBRQKpnbrqk";
int idx = piece_chars.find(c);
if (idx != std::string::npos) {
put_piece(Piece(idx), Square(sq));
sq++;
}
}
stm = (side_str == "w") ? WHITE : BLACK;
fullmove = fullmove_num;
int cr = 0;
for (char c : castling_str) {
if (c == 'K') cr |= WHITE_OO;
if (c == 'Q') cr |= WHITE_OOO;
if (c == 'k') cr |= BLACK_OO;
if (c == 'q') cr |= BLACK_OOO;
}
StateInfo& st = state();
st.castling = cr;
st.ep_square = (ep_str != "-") ? string_to_square(ep_str) : NO_SQUARE;
st.halfmove_clock = halfmove;
st.captured = NO_PIECE;
st.move = Move();
// Compute hash
st.hash = 0;
for (int s = 0; s < 64; s++) {
if (board[s] != NO_PIECE)
st.hash ^= Zobrist::psq[board[s]][s];
}
if (stm == BLACK) st.hash ^= Zobrist::side;
st.hash ^= Zobrist::castling[st.castling];
if (st.ep_square != NO_SQUARE) st.hash ^= Zobrist::en_passant[file_of(st.ep_square)];
}
std::string Position::fen() const {
std::string result;
for (int r = 7; r >= 0; r--) {
int empty = 0;
for (int f = 0; f < 8; f++) {
Piece p = board[make_square(File(f), Rank(r))];
if (p == NO_PIECE) { empty++; continue; }
if (empty) { result += std::to_string(empty); empty = 0; }
static const std::string chars = "PNBRQKpnbrqk";
result += chars[p];
}
if (empty) result += std::to_string(empty);
if (r > 0) result += '/';
}
result += (stm == WHITE) ? " w " : " b ";
const StateInfo& st = state();
if (st.castling == 0) result += '-';
else {
if (st.castling & WHITE_OO) result += 'K';
if (st.castling & WHITE_OOO) result += 'Q';
if (st.castling & BLACK_OO) result += 'k';
if (st.castling & BLACK_OOO) result += 'q';
}
result += ' ';
result += (st.ep_square != NO_SQUARE) ? square_to_string(st.ep_square) : "-";
result += ' ' + std::to_string(st.halfmove_clock);
result += ' ' + std::to_string(fullmove);
return result;
}
void Position::make_move(Move m) {
StateInfo new_st{};
new_st.castling = state().castling;
new_st.ep_square = NO_SQUARE;
new_st.halfmove_clock = state().halfmove_clock + 1;
new_st.captured = NO_PIECE;
new_st.move = m;
new_st.hash = state().hash ^ Zobrist::side;
Square from = m.from(), to = m.to();
Piece moving = board[from];
Piece captured = board[to];
MoveFlag flag = m.flag();
// Remove old hash contributions
new_st.hash ^= Zobrist::castling[new_st.castling];
if (state().ep_square != NO_SQUARE)
new_st.hash ^= Zobrist::en_passant[file_of(state().ep_square)];
if (flag == EN_PASSANT) {
Square cap_sq = make_square(file_of(to), rank_of(from));
new_st.captured = board[cap_sq];
new_st.hash ^= Zobrist::psq[board[cap_sq]][cap_sq];
remove_piece(cap_sq);
} else if (captured != NO_PIECE) {
new_st.captured = captured;
new_st.hash ^= Zobrist::psq[captured][to];
remove_piece(to);
}
new_st.hash ^= Zobrist::psq[moving][from];
if (flag == CASTLING) {
// Move king
move_piece(from, to);
// Move rook
Square rook_from, rook_to;
if (to > from) { rook_from = Square(to + 1); rook_to = Square(to - 1); }
else { rook_from = Square(to - 2); rook_to = Square(to + 1); }
Piece rook = board[rook_from];
new_st.hash ^= Zobrist::psq[rook][rook_from] ^ Zobrist::psq[rook][rook_to];
move_piece(rook_from, rook_to);
new_st.hash ^= Zobrist::psq[moving][to];
} else if (flag == PROMOTION) {
remove_piece(from);
Piece promo_piece = make_piece(stm, m.promotion());
put_piece(promo_piece, to);
new_st.hash ^= Zobrist::psq[promo_piece][to];
} else {
move_piece(from, to);
new_st.hash ^= Zobrist::psq[moving][to];
}
// Update castling rights
new_st.castling &= CastlingRightsMask[from] & CastlingRightsMask[to];
// Update en passant
if (type_of(moving) == PAWN) {
new_st.halfmove_clock = 0;
if (int(to) - int(from) == 16 || int(from) - int(to) == 16) {
new_st.ep_square = Square((int(from) + int(to)) / 2);
}
}
if (captured != NO_PIECE || new_st.captured != NO_PIECE)
new_st.halfmove_clock = 0;
// Add new hash contributions
new_st.hash ^= Zobrist::castling[new_st.castling];
if (new_st.ep_square != NO_SQUARE)
new_st.hash ^= Zobrist::en_passant[file_of(new_st.ep_square)];
stm = ~stm;
if (stm == WHITE) fullmove++;
history.push_back(new_st);
}
void Position::unmake_move() {
StateInfo st = history.back();
history.pop_back();
Move m = st.move;
stm = ~stm;
if (stm == BLACK) fullmove--;
Square from = m.from(), to = m.to();
MoveFlag flag = m.flag();
if (flag == CASTLING) {
move_piece(to, from); // King back
Square rook_from, rook_to;
if (to > from) { rook_from = Square(to + 1); rook_to = Square(to - 1); }
else { rook_from = Square(to - 2); rook_to = Square(to + 1); }
move_piece(rook_to, rook_from); // Rook back
} else if (flag == PROMOTION) {
remove_piece(to);
put_piece(make_piece(stm, PAWN), from);
} else {
move_piece(to, from);
}
// Restore captured piece
if (st.captured != NO_PIECE) {
if (flag == EN_PASSANT) {
Square cap_sq = make_square(file_of(to), rank_of(from));
put_piece(st.captured, cap_sq);
} else {
put_piece(st.captured, to);
}
}
}
U64 Position::attackers_to(Square s, U64 occ) const {
return (PawnAttacks[BLACK][s] & pieces(WHITE, PAWN))
| (PawnAttacks[WHITE][s] & pieces(BLACK, PAWN))
| (KnightAttacks[s] & pieces(KNIGHT))
| (bishop_attacks(s, occ) & (pieces(BISHOP) | pieces(QUEEN)))
| (rook_attacks(s, occ) & (pieces(ROOK) | pieces(QUEEN)))
| (KingAttacks[s] & pieces(KING));
}
bool Position::in_check() const {
return attackers_to(king_square(stm), occupied()) & pieces(~stm);
}
bool Position::is_draw() const {
if (state().halfmove_clock >= 100) return true;
// Repetition detection
U64 h = hash();
int count = 0;
for (int i = int(history.size()) - 3; i >= 0; i -= 2) {
if (history[i].hash == h) {
count++;
if (count >= 2) return true;
}
if (history[i].halfmove_clock == 0) break;
}
return false;
}
bool Position::is_legal(Move m) const {
// Simplified: after make_move, check if opponent king is in check
// This is handled during move generation
return true;
}
Your engine can now represent any chess position and play moves forward and backward. Next up, you will build the legal move generator that produces every valid move from any position.
Generate Legal Moves and Validate with Perft
You have a Position class that can make and unmake moves. Now you need the engine to figure out which moves are available in any given position.
Move generation is the heart of a chess engine. Every search node needs a complete list of legal moves. In this step, you will build a pseudo-legal move generator that handles all piece types (including castling, en passant, and promotion), filter those moves for legality, and validate correctness with perft testing.
In this step, get ready to:
- Generate pseudo-legal moves for all piece types including castling, en passant, and promotion.
- Filter pseudo-legal moves to produce only legal moves.
- Validate move generation correctness with perft testing.
Generate pseudo-legal moves for all piece types
A pseudo-legal move is one that follows piece movement rules but might leave the king in check. Generating pseudo-legal first and filtering later is the standard approach because it separates concerns and keeps the generator fast.
- Create a new file src/movegen.h in your src folder.
- Add the move list structure and function declarations by pasting the following code:
#pragma once
#include "position.h"
struct MoveList {
Move moves[MAX_MOVES];
int scores[MAX_MOVES];
int count = 0;
void add(Move m) { moves[count++] = m; }
};
void generate_moves(const Position& pos, MoveList& list);
U64 perft(Position& pos, int depth);
What does this header define?
The MoveList struct holds up to 256 moves (MAX_MOVES) along with a score array used later for move ordering. The add() method appends a move and increments the count.
The two function declarations expose the move generator and perft for use by other translation units.
- Save src/movegen.h.
Now you will build the pawn move generator. Pawns are the most complex piece to handle because they have directional movement, double pushes, captures on diagonals, en passant, and promotion.
- Create a new file src/movegen.cpp and paste the following code for the pawn move generator:
#include "movegen.h"
static void generate_pawn_moves(const Position& pos, MoveList& list) {
Color us = pos.side_to_move();
Color them = ~us;
U64 pawns = pos.pieces(us, PAWN);
U64 empty = ~pos.occupied();
U64 enemies = pos.pieces(them);
int push_dir = (us == WHITE) ? 8 : -8;
U64 rank3 = (us == WHITE) ? Rank3BB : Rank6BB;
U64 rank7 = (us == WHITE) ? Rank7BB : Rank2BB;
U64 promo_pawns = pawns & rank7;
U64 non_promo = pawns & ~rank7;
// Single pushes (non-promotion)
U64 single = (us == WHITE) ? shift_north(non_promo) & empty
: shift_south(non_promo) & empty;
U64 b = single;
while (b) {
Square to = pop_lsb(b);
list.add(Move(Square(to - push_dir), to));
}
// Double pushes
U64 double_push = (us == WHITE) ? shift_north(single & rank3) & empty
: shift_south(single & rank3) & empty;
b = double_push;
while (b) {
Square to = pop_lsb(b);
list.add(Move(Square(to - 2 * push_dir), to));
}
// Promotion pushes
U64 promo_push = (us == WHITE) ? shift_north(promo_pawns) & empty
: shift_south(promo_pawns) & empty;
b = promo_push;
while (b) {
Square to = pop_lsb(b);
Square from = Square(to - push_dir);
list.add(Move(from, to, PROMOTION, 3)); // Queen
list.add(Move(from, to, PROMOTION, 2)); // Rook
list.add(Move(from, to, PROMOTION, 1)); // Bishop
list.add(Move(from, to, PROMOTION, 0)); // Knight
}
// Captures (non-promotion)
U64 left_cap = (us == WHITE) ? shift_north_west(non_promo) & enemies
: shift_south_west(non_promo) & enemies;
U64 right_cap = (us == WHITE) ? shift_north_east(non_promo) & enemies
: shift_south_east(non_promo) & enemies;
b = left_cap;
while (b) {
Square to = pop_lsb(b);
int cap_dir = (us == WHITE) ? -7 : 9;
list.add(Move(Square(to + cap_dir), to));
}
b = right_cap;
while (b) {
Square to = pop_lsb(b);
int cap_dir = (us == WHITE) ? -9 : 7;
list.add(Move(Square(to + cap_dir), to));
}
// Promotion captures
U64 pleft = (us == WHITE) ? shift_north_west(promo_pawns) & enemies
: shift_south_west(promo_pawns) & enemies;
U64 pright = (us == WHITE) ? shift_north_east(promo_pawns) & enemies
: shift_south_east(promo_pawns) & enemies;
b = pleft;
while (b) {
Square to = pop_lsb(b);
int cap_dir = (us == WHITE) ? -7 : 9;
Square from = Square(to + cap_dir);
for (int promo = 3; promo >= 0; promo--)
list.add(Move(from, to, PROMOTION, promo));
}
b = pright;
while (b) {
Square to = pop_lsb(b);
int cap_dir = (us == WHITE) ? -9 : 7;
Square from = Square(to + cap_dir);
for (int promo = 3; promo >= 0; promo--)
list.add(Move(from, to, PROMOTION, promo));
}
// En passant
if (pos.ep_square() != NO_SQUARE) {
U64 ep_attackers = PawnAttacks[them][pos.ep_square()] & non_promo;
while (ep_attackers) {
Square from = pop_lsb(ep_attackers);
list.add(Move(from, pos.ep_square(), EN_PASSANT));
}
}
}
What does this code do?
The function splits pawns into promotion-eligible (on 7th rank) and non-promotion pawns, then generates moves for each category separately.
- Single and double pushes use the shift functions to advance all pawns in parallel via bitwise operations.
- Captures use diagonal shifts and intersect with enemy pieces.
- Promotion generates four moves per square (queen, rook, bishop, knight).
- En passant uses the pre-computed PawnAttacks table to find pawns that can capture the en passant square.
- Save src/movegen.cpp.
Next, add the main move generation function that handles all other piece types plus castling.
- In src/movegen.cpp, add the following function below generate_pawn_moves:
void generate_moves(const Position& pos, MoveList& list) {
Color us = pos.side_to_move();
U64 occ = pos.occupied();
U64 friendly = pos.pieces(us);
U64 targets = ~friendly;
generate_pawn_moves(pos, list);
// Knights
U64 knights = pos.pieces(us, KNIGHT);
while (knights) {
Square from = pop_lsb(knights);
U64 attacks = KnightAttacks[from] & targets;
while (attacks) { list.add(Move(from, pop_lsb(attacks))); }
}
// Bishops
U64 bishops = pos.pieces(us, BISHOP);
while (bishops) {
Square from = pop_lsb(bishops);
U64 attacks = bishop_attacks(from, occ) & targets;
while (attacks) { list.add(Move(from, pop_lsb(attacks))); }
}
// Rooks
U64 rooks = pos.pieces(us, ROOK);
while (rooks) {
Square from = pop_lsb(rooks);
U64 attacks = rook_attacks(from, occ) & targets;
while (attacks) { list.add(Move(from, pop_lsb(attacks))); }
}
// Queens
U64 queens = pos.pieces(us, QUEEN);
while (queens) {
Square from = pop_lsb(queens);
U64 attacks = queen_attacks(from, occ) & targets;
while (attacks) { list.add(Move(from, pop_lsb(attacks))); }
}
// King
Square ksq = pos.king_square(us);
U64 king_moves = KingAttacks[ksq] & targets;
while (king_moves) { list.add(Move(ksq, pop_lsb(king_moves))); }
// Castling
int cr = pos.castling_rights();
if (us == WHITE) {
if ((cr & WHITE_OO) && !(occ & (square_bb(F1) | square_bb(G1)))) {
if (!(pos.attackers_to(E1, occ) & pos.pieces(BLACK))
&& !(pos.attackers_to(F1, occ) & pos.pieces(BLACK))
&& !(pos.attackers_to(G1, occ) & pos.pieces(BLACK)))
list.add(Move(E1, G1, CASTLING));
}
if ((cr & WHITE_OOO) && !(occ & (square_bb(D1) | square_bb(C1) | square_bb(B1)))) {
if (!(pos.attackers_to(E1, occ) & pos.pieces(BLACK))
&& !(pos.attackers_to(D1, occ) & pos.pieces(BLACK))
&& !(pos.attackers_to(C1, occ) & pos.pieces(BLACK)))
list.add(Move(E1, C1, CASTLING));
}
} else {
if ((cr & BLACK_OO) && !(occ & (square_bb(F8) | square_bb(G8)))) {
if (!(pos.attackers_to(E8, occ) & pos.pieces(WHITE))
&& !(pos.attackers_to(F8, occ) & pos.pieces(WHITE))
&& !(pos.attackers_to(G8, occ) & pos.pieces(WHITE)))
list.add(Move(E8, G8, CASTLING));
}
if ((cr & BLACK_OOO) && !(occ & (square_bb(D8) | square_bb(C8) | square_bb(B8)))) {
if (!(pos.attackers_to(E8, occ) & pos.pieces(WHITE))
&& !(pos.attackers_to(D8, occ) & pos.pieces(WHITE))
&& !(pos.attackers_to(C8, occ) & pos.pieces(WHITE)))
list.add(Move(E8, C8, CASTLING));
}
}
}
What does this code do?
This is the main entry point for move generation. It calls generate_pawn_moves first, then iterates over each piece type using the pre-computed attack tables.
- Knights and kings use the lookup tables you built in Step 2.
- Bishops, rooks, and queens use the magic bitboard functions from Step 3.
- Castling checks three conditions: the right still exists, the path is clear, and no square the king crosses is attacked.
- Save src/movegen.cpp.
Filter pseudo-legal moves to produce only legal moves
A pseudo-legal move might leave the king in check. The simplest and most reliable way to filter: make the move, check if the opponent can capture the king, then unmake.
- In src/movegen.cpp, add this helper function below generate_moves:
// Filter to legal moves only
static bool is_legal_move(Position& pos, Move m) {
pos.make_move(m);
bool legal = !(pos.attackers_to(pos.king_square(~pos.side_to_move()), pos.occupied())
& pos.pieces(pos.side_to_move()));
pos.unmake_move();
return legal;
}
Why filter after generation?
After making a move, the side that just moved becomes the opponent. The function checks whether that opponent's pieces attack the moving side's king. If they do, the move was illegal (it left the king in check).
This make-then-check approach leverages the full attackers_to function and avoids duplicating pin/check detection logic inside the generator.
- Save src/movegen.cpp.
Add perft testing to validate move generation correctness
Perft (performance test) counts all legal move paths to a given depth. By comparing your results against known reference values, you can verify that your move generator handles every edge case correctly.
- In src/movegen.cpp, add the perft function below is_legal_move:
U64 perft(Position& pos, int depth) {
if (depth == 0) return 1;
MoveList list;
generate_moves(pos, list);
U64 nodes = 0;
for (int i = 0; i < list.count; i++) {
if (!is_legal_move(pos, list.moves[i])) continue;
pos.make_move(list.moves[i]);
nodes += perft(pos, depth - 1);
pos.unmake_move();
}
return nodes;
}
What does perft do?
At each position, perft generates all moves, filters illegal ones, then recursively counts leaf nodes at the target depth. At depth 0 it returns 1 (the current position counts as one leaf).
The reference values for the starting position are: perft(1) = 20, perft(2) = 400, perft(3) = 8902, perft(4) = 197281, perft(5) = 4865609. If your results match, your move generator is correct.
- Save src/movegen.cpp.
Now verify the move generator by building the project and running a perft test. Temporarily update src/main.cpp to call perft directly.
- Replace the contents of src/main.cpp with this temporary test harness:
#include "bitboard.h"
#include "position.h"
#include "movegen.h"
#include <iostream>
int main() {
Bitboards::init();
Zobrist::init();
Position pos;
for (int d = 1; d <= 5; d++) {
U64 nodes = perft(pos, d);
std::cout << "perft(" << d << ") = " << nodes << std::endl;
}
return 0;
}
Why a temporary main?
The final main.cpp will call uci_loop() once you build the UCI protocol handler in Step 9. For now, this temporary version lets you validate perft results immediately.
- Save src/main.cpp.
- Build and run the project by running these commands from your project root:
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
./build/nexus
You should see output matching these reference values:
perft(1) = 20
perft(2) = 400
perft(3) = 8902
perft(4) = 197281
perft(5) = 4865609
Perft numbers don't match?
If perft(1) is wrong, check your piece move generation. If perft(1) is 20 but deeper values differ, you likely have a bug in en passant, castling, or promotion handling.
Common issues include: forgetting to check that squares between king and rook are empty for castling, not generating all four promotion piece types, or computing pawn capture directions incorrectly for black.
Help me debug my perft results.
💡 How long should perft(5) take?
On a modern Mac, perft(5) should complete in under 2 seconds with -O3 optimization. If it takes significantly longer, make sure you built with the Release configuration shown above.
✔️ Awesome, I've got everything!
Great. Make sure both src/movegen.h and src/movegen.cpp are saved. You will restore main.cpp to its final form in Step 9 when you add the UCI protocol handler.
ⓧ I'd like to double check the full code
Here is the complete src/movegen.h:
#pragma once
#include "position.h"
struct MoveList {
Move moves[MAX_MOVES];
int scores[MAX_MOVES];
int count = 0;
void add(Move m) { moves[count++] = m; }
};
void generate_moves(const Position& pos, MoveList& list);
U64 perft(Position& pos, int depth);
Here is the complete src/movegen.cpp:
#include "movegen.h"
static void generate_pawn_moves(const Position& pos, MoveList& list) {
Color us = pos.side_to_move();
Color them = ~us;
U64 pawns = pos.pieces(us, PAWN);
U64 empty = ~pos.occupied();
U64 enemies = pos.pieces(them);
int push_dir = (us == WHITE) ? 8 : -8;
U64 rank3 = (us == WHITE) ? Rank3BB : Rank6BB;
U64 rank7 = (us == WHITE) ? Rank7BB : Rank2BB;
U64 promo_pawns = pawns & rank7;
U64 non_promo = pawns & ~rank7;
// Single pushes (non-promotion)
U64 single = (us == WHITE) ? shift_north(non_promo) & empty
: shift_south(non_promo) & empty;
U64 b = single;
while (b) {
Square to = pop_lsb(b);
list.add(Move(Square(to - push_dir), to));
}
// Double pushes
U64 double_push = (us == WHITE) ? shift_north(single & rank3) & empty
: shift_south(single & rank3) & empty;
b = double_push;
while (b) {
Square to = pop_lsb(b);
list.add(Move(Square(to - 2 * push_dir), to));
}
// Promotion pushes
U64 promo_push = (us == WHITE) ? shift_north(promo_pawns) & empty
: shift_south(promo_pawns) & empty;
b = promo_push;
while (b) {
Square to = pop_lsb(b);
Square from = Square(to - push_dir);
list.add(Move(from, to, PROMOTION, 3)); // Queen
list.add(Move(from, to, PROMOTION, 2)); // Rook
list.add(Move(from, to, PROMOTION, 1)); // Bishop
list.add(Move(from, to, PROMOTION, 0)); // Knight
}
// Captures (non-promotion)
U64 left_cap = (us == WHITE) ? shift_north_west(non_promo) & enemies
: shift_south_west(non_promo) & enemies;
U64 right_cap = (us == WHITE) ? shift_north_east(non_promo) & enemies
: shift_south_east(non_promo) & enemies;
b = left_cap;
while (b) {
Square to = pop_lsb(b);
int cap_dir = (us == WHITE) ? -7 : 9;
list.add(Move(Square(to + cap_dir), to));
}
b = right_cap;
while (b) {
Square to = pop_lsb(b);
int cap_dir = (us == WHITE) ? -9 : 7;
list.add(Move(Square(to + cap_dir), to));
}
// Promotion captures
U64 pleft = (us == WHITE) ? shift_north_west(promo_pawns) & enemies
: shift_south_west(promo_pawns) & enemies;
U64 pright = (us == WHITE) ? shift_north_east(promo_pawns) & enemies
: shift_south_east(promo_pawns) & enemies;
b = pleft;
while (b) {
Square to = pop_lsb(b);
int cap_dir = (us == WHITE) ? -7 : 9;
Square from = Square(to + cap_dir);
for (int promo = 3; promo >= 0; promo--)
list.add(Move(from, to, PROMOTION, promo));
}
b = pright;
while (b) {
Square to = pop_lsb(b);
int cap_dir = (us == WHITE) ? -9 : 7;
Square from = Square(to + cap_dir);
for (int promo = 3; promo >= 0; promo--)
list.add(Move(from, to, PROMOTION, promo));
}
// En passant
if (pos.ep_square() != NO_SQUARE) {
U64 ep_attackers = PawnAttacks[them][pos.ep_square()] & non_promo;
while (ep_attackers) {
Square from = pop_lsb(ep_attackers);
list.add(Move(from, pos.ep_square(), EN_PASSANT));
}
}
}
void generate_moves(const Position& pos, MoveList& list) {
Color us = pos.side_to_move();
U64 occ = pos.occupied();
U64 friendly = pos.pieces(us);
U64 targets = ~friendly;
generate_pawn_moves(pos, list);
// Knights
U64 knights = pos.pieces(us, KNIGHT);
while (knights) {
Square from = pop_lsb(knights);
U64 attacks = KnightAttacks[from] & targets;
while (attacks) { list.add(Move(from, pop_lsb(attacks))); }
}
// Bishops
U64 bishops = pos.pieces(us, BISHOP);
while (bishops) {
Square from = pop_lsb(bishops);
U64 attacks = bishop_attacks(from, occ) & targets;
while (attacks) { list.add(Move(from, pop_lsb(attacks))); }
}
// Rooks
U64 rooks = pos.pieces(us, ROOK);
while (rooks) {
Square from = pop_lsb(rooks);
U64 attacks = rook_attacks(from, occ) & targets;
while (attacks) { list.add(Move(from, pop_lsb(attacks))); }
}
// Queens
U64 queens = pos.pieces(us, QUEEN);
while (queens) {
Square from = pop_lsb(queens);
U64 attacks = queen_attacks(from, occ) & targets;
while (attacks) { list.add(Move(from, pop_lsb(attacks))); }
}
// King
Square ksq = pos.king_square(us);
U64 king_moves = KingAttacks[ksq] & targets;
while (king_moves) { list.add(Move(ksq, pop_lsb(king_moves))); }
// Castling
int cr = pos.castling_rights();
if (us == WHITE) {
if ((cr & WHITE_OO) && !(occ & (square_bb(F1) | square_bb(G1)))) {
if (!(pos.attackers_to(E1, occ) & pos.pieces(BLACK))
&& !(pos.attackers_to(F1, occ) & pos.pieces(BLACK))
&& !(pos.attackers_to(G1, occ) & pos.pieces(BLACK)))
list.add(Move(E1, G1, CASTLING));
}
if ((cr & WHITE_OOO) && !(occ & (square_bb(D1) | square_bb(C1) | square_bb(B1)))) {
if (!(pos.attackers_to(E1, occ) & pos.pieces(BLACK))
&& !(pos.attackers_to(D1, occ) & pos.pieces(BLACK))
&& !(pos.attackers_to(C1, occ) & pos.pieces(BLACK)))
list.add(Move(E1, C1, CASTLING));
}
} else {
if ((cr & BLACK_OO) && !(occ & (square_bb(F8) | square_bb(G8)))) {
if (!(pos.attackers_to(E8, occ) & pos.pieces(WHITE))
&& !(pos.attackers_to(F8, occ) & pos.pieces(WHITE))
&& !(pos.attackers_to(G8, occ) & pos.pieces(WHITE)))
list.add(Move(E8, G8, CASTLING));
}
if ((cr & BLACK_OOO) && !(occ & (square_bb(D8) | square_bb(C8) | square_bb(B8)))) {
if (!(pos.attackers_to(E8, occ) & pos.pieces(WHITE))
&& !(pos.attackers_to(D8, occ) & pos.pieces(WHITE))
&& !(pos.attackers_to(C8, occ) & pos.pieces(WHITE)))
list.add(Move(E8, C8, CASTLING));
}
}
}
// Filter to legal moves only
static bool is_legal_move(Position& pos, Move m) {
pos.make_move(m);
bool legal = !(pos.attackers_to(pos.king_square(~pos.side_to_move()), pos.occupied())
& pos.pieces(pos.side_to_move()));
pos.unmake_move();
return legal;
}
U64 perft(Position& pos, int depth) {
if (depth == 0) return 1;
MoveList list;
generate_moves(pos, list);
U64 nodes = 0;
for (int i = 0; i < list.count; i++) {
if (!is_legal_move(pos, list.moves[i])) continue;
pos.make_move(list.moves[i]);
nodes += perft(pos, depth - 1);
pos.unmake_move();
}
return nodes;
}
Your move generator is battle-tested. Next up, you will build an evaluation function that tells the engine which positions are winning and which are losing.
Write the Evaluation Function
Your engine can now generate every legal move in a given position. But it has no idea which moves are good. A move generator without evaluation is like a car without a steering wheel.
In this step, you will build the evaluation function that assigns a numeric score to any board position. The engine uses this score to compare positions and decide which moves to play. Your evaluation combines material counting (who has more pieces) with positional knowledge (where the pieces sit on the board).
In this step, get ready to:
- Implement material counting with standard piece values.
- Add piece-square tables for middlegame and endgame positioning.
- Implement tapered evaluation that blends scores based on game phase.
Create the evaluation header and piece values
Every piece on the board has an intrinsic material value. Pawns are worth 100 centipawns, knights 320, bishops 330, rooks 500, and queens 900. These values are the foundation of the engine's judgment.
- In Cursor, create a new file called src/evaluate.h inside your nexus project.
- Add the header guard and function declaration by pasting this code:
#pragma once
#include "position.h"
int evaluate(const Position& pos);
What does this header do?
This declares a single function evaluate() that takes a board position and returns an integer score in centipawns. A positive score means the side to move is winning.
- Save src/evaluate.h.
- Now create a new file called src/evaluate.cpp in the same src/ folder.
- Start the file with the include and the piece value array by pasting this code:
#include "evaluate.h"
static const int PieceValue[PIECE_TYPE_NB] = { 100, 320, 330, 500, 900, 0 };
What do these values represent?
Each index corresponds to a PieceType enum value: PAWN=100, KNIGHT=320, BISHOP=330, ROOK=500, QUEEN=900, KING=0. The king gets 0 because it is never captured or traded. All values are in centipawns (hundredths of a pawn).
Bishops score slightly higher than knights (330 vs 320), reflecting the well-known bishop pair advantage in open positions.
- Save src/evaluate.cpp.
- You won't see a change yet. The next chunk adds the piece-square tables that work alongside these values.
Add piece-square tables for middlegame and endgame
Material counting alone is blind to piece placement. A knight on the rim is dim, as the chess saying goes. Piece-square tables encode positional bonuses for each piece on each square.
- In src/evaluate.cpp, add all of the following piece-square tables below the PieceValue array:
// Piece-square tables (from white's perspective, flipped for black)
static const int PawnPST[64] = {
0, 0, 0, 0, 0, 0, 0, 0,
50, 50, 50, 50, 50, 50, 50, 50,
10, 10, 20, 30, 30, 20, 10, 10,
5, 5, 10, 25, 25, 10, 5, 5,
0, 0, 0, 20, 20, 0, 0, 0,
5, -5,-10, 0, 0,-10, -5, 5,
5, 10, 10,-20,-20, 10, 10, 5,
0, 0, 0, 0, 0, 0, 0, 0
};
static const int KnightPST[64] = {
-50,-40,-30,-30,-30,-30,-40,-50,
-40,-20, 0, 0, 0, 0,-20,-40,
-30, 0, 10, 15, 15, 10, 0,-30,
-30, 5, 15, 20, 20, 15, 5,-30,
-30, 0, 15, 20, 20, 15, 0,-30,
-30, 5, 10, 15, 15, 10, 5,-30,
-40,-20, 0, 5, 5, 0,-20,-40,
-50,-40,-30,-30,-30,-30,-40,-50
};
static const int BishopPST[64] = {
-20,-10,-10,-10,-10,-10,-10,-20,
-10, 0, 0, 0, 0, 0, 0,-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, 10, 10, 10, 10, 10, 10,-10,
-10, 5, 0, 0, 0, 0, 5,-10,
-20,-10,-10,-10,-10,-10,-10,-20
};
static const int RookPST[64] = {
0, 0, 0, 0, 0, 0, 0, 0,
5, 10, 10, 10, 10, 10, 10, 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, 0, 0, 0, 0, 0, 0, -5,
0, 0, 0, 5, 5, 0, 0, 0
};
static const int QueenPST[64] = {
-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
};
static const int KingMiddlegamePST[64] = {
-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,
-20,-30,-30,-40,-40,-30,-30,-20,
-10,-20,-20,-20,-20,-20,-20,-10,
20, 20, 0, 0, 0, 0, 20, 20,
20, 30, 10, 0, 0, 10, 30, 20
};
static const int KingEndgamePST[64] = {
-50,-40,-30,-20,-20,-30,-40,-50,
-30,-20,-10, 0, 0,-10,-20,-30,
-30,-10, 20, 30, 30, 20,-10,-30,
-30,-10, 30, 40, 40, 30,-10,-30,
-30,-10, 30, 40, 40, 30,-10,-30,
-30,-10, 20, 30, 30, 20,-10,-30,
-30,-30, 0, 0, 0, 0,-30,-30,
-50,-30,-30,-30,-30,-30,-30,-50
};
static const int* PST[PIECE_TYPE_NB] = {
PawnPST, KnightPST, BishopPST, RookPST, QueenPST, KingMiddlegamePST
};
static int mirror(int sq) { return sq ^ 56; }
What do piece-square tables encode?
Each 64-element array assigns a bonus (positive) or penalty (negative) to placing that piece on each square. Index 0 is A1, index 63 is H8. The tables are written from white's perspective. For black pieces, the mirror() function flips the square vertically (XOR with 56 swaps rank 1 with rank 8).
The king has TWO tables. In the middlegame, the king wants to stay safely behind pawns (corners score highest). In the endgame, the king becomes active and centralisation is rewarded. The engine blends these based on how many pieces remain on the board.
The PST pointer array lets the evaluate function index any piece type's table with a single array lookup.
- Save src/evaluate.cpp.
- You won't see a change yet. The next chunk uses these tables inside the evaluate function.
Implement tapered evaluation with game phase blending
A single set of positional values cannot cover both the opening and the endgame. Tapered evaluation solves this by computing two scores (middlegame and endgame) and blending them based on how much material remains.
- In src/evaluate.cpp, add the evaluate() function below the mirror() helper:
int evaluate(const Position& pos) {
int mg_score = 0;
int eg_score = 0;
int phase = 0;
for (int pt = PAWN; pt < PIECE_TYPE_NB; pt++) {
U64 white_pieces = pos.pieces(WHITE, PieceType(pt));
U64 black_pieces = pos.pieces(BLACK, PieceType(pt));
while (white_pieces) {
Square s = pop_lsb(white_pieces);
mg_score += PieceValue[pt] + PST[pt][s];
eg_score += PieceValue[pt] + (pt == KING ? KingEndgamePST[s] : PST[pt][s]);
}
while (black_pieces) {
Square s = pop_lsb(black_pieces);
mg_score -= PieceValue[pt] + PST[pt][mirror(s)];
eg_score -= PieceValue[pt] + (pt == KING ? KingEndgamePST[mirror(s)] : PST[pt][mirror(s)]);
}
// Phase calculation
static const int phase_weight[] = { 0, 1, 1, 2, 4, 0 };
phase += phase_weight[pt] * (popcount(pos.pieces(WHITE, PieceType(pt)))
+ popcount(pos.pieces(BLACK, PieceType(pt))));
}
// Tapered eval
int total_phase = 24; // 4*1 + 4*1 + 4*2 + 2*4
phase = std::min(phase, total_phase);
int score = (mg_score * phase + eg_score * (total_phase - phase)) / total_phase;
return (pos.side_to_move() == WHITE) ? score : -score;
}
What does this function do?
The function iterates over every piece type and color using bitboards. For each piece found, it adds material value plus the positional bonus from the piece-square table. White's scores are added, black's subtracted.
The phase variable tracks how much material remains on the board. Knights and bishops count as 1, rooks as 2, queens as 4. Total phase is 24 (all minor and major pieces present). As pieces disappear, the engine smoothly transitions from middlegame to endgame scoring.
The final score is returned relative to the side to move. If it is white's turn, a positive score means white is better. If it is black's turn, the score is negated so the search always maximises a positive value.
- Save src/evaluate.cpp.
- Verify the file compiles by running this command from your project root:
clang++ -std=c++20 -c src/evaluate.cpp -Isrc
- You should see no output and no errors. A file called evaluate.o appears in your project directory.
Seeing a compile error?
- Check that src/evaluate.h includes position.h and declares int evaluate(const Position& pos); exactly.
- Make sure the PieceValue array uses PIECE_TYPE_NB as its size. This constant is defined in types.h.
- If you see an error about std::min, confirm that types.h includes <algorithm> or add #include <algorithm> at the top of evaluate.cpp.
Help me debug a compile error in my evaluate.cpp
- Clean up the object file by running:
rm -f evaluate.o
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/evaluate.h and src/evaluate.cpp.
ⓧ I'd like to double check the full code
Here is the complete src/evaluate.h:
#pragma once
#include "position.h"
int evaluate(const Position& pos);
And here is the complete src/evaluate.cpp:
#include "evaluate.h"
static const int PieceValue[PIECE_TYPE_NB] = { 100, 320, 330, 500, 900, 0 };
// Piece-square tables (from white's perspective, flipped for black)
static const int PawnPST[64] = {
0, 0, 0, 0, 0, 0, 0, 0,
50, 50, 50, 50, 50, 50, 50, 50,
10, 10, 20, 30, 30, 20, 10, 10,
5, 5, 10, 25, 25, 10, 5, 5,
0, 0, 0, 20, 20, 0, 0, 0,
5, -5,-10, 0, 0,-10, -5, 5,
5, 10, 10,-20,-20, 10, 10, 5,
0, 0, 0, 0, 0, 0, 0, 0
};
static const int KnightPST[64] = {
-50,-40,-30,-30,-30,-30,-40,-50,
-40,-20, 0, 0, 0, 0,-20,-40,
-30, 0, 10, 15, 15, 10, 0,-30,
-30, 5, 15, 20, 20, 15, 5,-30,
-30, 0, 15, 20, 20, 15, 0,-30,
-30, 5, 10, 15, 15, 10, 5,-30,
-40,-20, 0, 5, 5, 0,-20,-40,
-50,-40,-30,-30,-30,-30,-40,-50
};
static const int BishopPST[64] = {
-20,-10,-10,-10,-10,-10,-10,-20,
-10, 0, 0, 0, 0, 0, 0,-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, 10, 10, 10, 10, 10, 10,-10,
-10, 5, 0, 0, 0, 0, 5,-10,
-20,-10,-10,-10,-10,-10,-10,-20
};
static const int RookPST[64] = {
0, 0, 0, 0, 0, 0, 0, 0,
5, 10, 10, 10, 10, 10, 10, 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, 0, 0, 0, 0, 0, 0, -5,
0, 0, 0, 5, 5, 0, 0, 0
};
static const int QueenPST[64] = {
-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
};
static const int KingMiddlegamePST[64] = {
-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,
-20,-30,-30,-40,-40,-30,-30,-20,
-10,-20,-20,-20,-20,-20,-20,-10,
20, 20, 0, 0, 0, 0, 20, 20,
20, 30, 10, 0, 0, 10, 30, 20
};
static const int KingEndgamePST[64] = {
-50,-40,-30,-20,-20,-30,-40,-50,
-30,-20,-10, 0, 0,-10,-20,-30,
-30,-10, 20, 30, 30, 20,-10,-30,
-30,-10, 30, 40, 40, 30,-10,-30,
-30,-10, 30, 40, 40, 30,-10,-30,
-30,-10, 20, 30, 30, 20,-10,-30,
-30,-30, 0, 0, 0, 0,-30,-30,
-50,-30,-30,-30,-30,-30,-30,-50
};
static const int* PST[PIECE_TYPE_NB] = {
PawnPST, KnightPST, BishopPST, RookPST, QueenPST, KingMiddlegamePST
};
static int mirror(int sq) { return sq ^ 56; }
int evaluate(const Position& pos) {
int mg_score = 0;
int eg_score = 0;
int phase = 0;
for (int pt = PAWN; pt < PIECE_TYPE_NB; pt++) {
U64 white_pieces = pos.pieces(WHITE, PieceType(pt));
U64 black_pieces = pos.pieces(BLACK, PieceType(pt));
while (white_pieces) {
Square s = pop_lsb(white_pieces);
mg_score += PieceValue[pt] + PST[pt][s];
eg_score += PieceValue[pt] + (pt == KING ? KingEndgamePST[s] : PST[pt][s]);
}
while (black_pieces) {
Square s = pop_lsb(black_pieces);
mg_score -= PieceValue[pt] + PST[pt][mirror(s)];
eg_score -= PieceValue[pt] + (pt == KING ? KingEndgamePST[mirror(s)] : PST[pt][mirror(s)]);
}
// Phase calculation
static const int phase_weight[] = { 0, 1, 1, 2, 4, 0 };
phase += phase_weight[pt] * (popcount(pos.pieces(WHITE, PieceType(pt)))
+ popcount(pos.pieces(BLACK, PieceType(pt))));
}
// Tapered eval
int total_phase = 24; // 4*1 + 4*1 + 4*2 + 2*4
phase = std::min(phase, total_phase);
int score = (mg_score * phase + eg_score * (total_phase - phase)) / total_phase;
return (pos.side_to_move() == WHITE) ? score : -score;
}
Your engine now has eyes. It can look at any position and judge who stands better based on material and piece placement. Next up, you will build the search function that explores future moves and picks the best one.
Implement Alpha-Beta Search with Iterative Deepening
Your engine can evaluate any static position, but chess is about looking ahead. A position that looks equal right now might become winning if you see the forced checkmate three moves deep.
In this step, you will build the search system that explores the game tree. The engine uses alpha-beta pruning to ignore losing branches, iterative deepening to manage time, quiescence search to avoid the horizon effect, and a transposition table to remember positions it has already evaluated.
In this step, get ready to:
- Build a transposition table to cache search results.
- Implement quiescence search and move ordering.
- Create the negamax alpha-beta search with iterative deepening.
Build the transposition table
A transposition table is a hash map that caches search results for positions the engine has already seen. In chess, the same position can be reached through different move orders. Without caching, the engine would waste time re-evaluating identical positions.
- Create a new file src/tt.h and add the transposition table header:
#pragma once
#include "types.h"
// Flags describe what kind of bound the stored score represents
enum TTFlag : uint8_t { TT_NONE, TT_EXACT, TT_ALPHA, TT_BETA };
// Each entry stores a position's hash, best move, score, and search depth
struct TTEntry {
U64 key;
int16_t score;
int16_t depth;
TTFlag flag;
Move best_move;
};
// Hash table with depth-preferred replacement
class TranspositionTable {
public:
TranspositionTable(size_t mb = 64);
~TranspositionTable();
void clear();
void store(U64 key, int score, int depth, TTFlag flag, Move best);
bool probe(U64 key, TTEntry& entry) const;
void resize(size_t mb);
private:
TTEntry* table;
size_t num_entries;
};
extern TranspositionTable TT;
What does this code do?
TTFlag describes the score type: TT_EXACT means the score is exact, TT_ALPHA means it is an upper bound, and TT_BETA means it is a lower bound.
The TranspositionTable class allocates a large array of entries indexed by the position's Zobrist hash. The default size is 64 MB, which holds millions of entries.
- Save src/tt.h.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/tt.h before moving on.
ⓧ I'd like to double check the full code
#pragma once
#include "types.h"
enum TTFlag : uint8_t { TT_NONE, TT_EXACT, TT_ALPHA, TT_BETA };
struct TTEntry {
U64 key;
int16_t score;
int16_t depth;
TTFlag flag;
Move best_move;
};
class TranspositionTable {
public:
TranspositionTable(size_t mb = 64);
~TranspositionTable();
void clear();
void store(U64 key, int score, int depth, TTFlag flag, Move best);
bool probe(U64 key, TTEntry& entry) const;
void resize(size_t mb);
private:
TTEntry* table;
size_t num_entries;
};
extern TranspositionTable TT;
- Create a new file src/tt.cpp and add the transposition table implementation:
#include "tt.h"
#include <cstring>
// Global transposition table instance (64 MB default)
TranspositionTable TT(64);
TranspositionTable::TranspositionTable(size_t mb) : table(nullptr), num_entries(0) {
resize(mb);
}
TranspositionTable::~TranspositionTable() { delete[] table; }
void TranspositionTable::resize(size_t mb) {
delete[] table;
num_entries = (mb * 1024 * 1024) / sizeof(TTEntry);
table = new TTEntry[num_entries]();
}
void TranspositionTable::clear() {
std::memset(table, 0, num_entries * sizeof(TTEntry));
}
void TranspositionTable::store(U64 key, int score, int depth, TTFlag flag, Move best) {
size_t idx = key % num_entries;
TTEntry& e = table[idx];
// Depth-preferred replacement: overwrite if new search is deeper
if (e.key == 0 || depth >= e.depth || e.key == key) {
e.key = key;
e.score = int16_t(score);
e.depth = int16_t(depth);
e.flag = flag;
e.best_move = best;
}
}
bool TranspositionTable::probe(U64 key, TTEntry& entry) const {
size_t idx = key % num_entries;
const TTEntry& e = table[idx];
if (e.key == key) { entry = e; return true; }
return false;
}
What does this code do?
The store() function uses a depth-preferred replacement scheme. It only overwrites an existing entry if the new search was at least as deep, or if the stored position matches the incoming key.
The probe() function checks if a position exists in the table by comparing the full 64-bit hash key. Collisions are possible but rare with a well-distributed Zobrist hash.
- Save src/tt.cpp.
- You won't see a change yet. The transposition table will be used by the search functions next.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/tt.cpp before moving on.
ⓧ I'd like to double check the full code
#include "tt.h"
#include <cstring>
TranspositionTable TT(64);
TranspositionTable::TranspositionTable(size_t mb) : table(nullptr), num_entries(0) {
resize(mb);
}
TranspositionTable::~TranspositionTable() { delete[] table; }
void TranspositionTable::resize(size_t mb) {
delete[] table;
num_entries = (mb * 1024 * 1024) / sizeof(TTEntry);
table = new TTEntry[num_entries]();
}
void TranspositionTable::clear() {
std::memset(table, 0, num_entries * sizeof(TTEntry));
}
void TranspositionTable::store(U64 key, int score, int depth, TTFlag flag, Move best) {
size_t idx = key % num_entries;
TTEntry& e = table[idx];
if (e.key == 0 || depth >= e.depth || e.key == key) {
e.key = key;
e.score = int16_t(score);
e.depth = int16_t(depth);
e.flag = flag;
e.best_move = best;
}
}
bool TranspositionTable::probe(U64 key, TTEntry& entry) const {
size_t idx = key % num_entries;
const TTEntry& e = table[idx];
if (e.key == key) { entry = e; return true; }
return false;
}
Implement quiescence search and move ordering
The search needs two critical components before the main negamax function can work correctly. Move ordering ensures the best moves are searched first, which dramatically increases the number of branches alpha-beta can prune. Quiescence search prevents the engine from evaluating positions mid-capture, which would produce wildly inaccurate scores.
- Create a new file src/search.h and add the search interface:
#pragma once
#include "position.h"
#include "movegen.h"
#include "tt.h"
#include <chrono>
#include <atomic>
// Holds all state for a single search invocation
struct SearchInfo {
int depth;
int score;
U64 nodes;
Move best_move;
Move pv[MAX_PLY];
int pv_length;
std::chrono::steady_clock::time_point start_time;
int time_limit_ms;
std::atomic<bool> stopped;
};
void search(Position& pos, SearchInfo& info);
What does this code do?
SearchInfo bundles everything the search needs: how deep to go, how long it can take, how many nodes it has explored, and a flag to stop early if time runs out. The std::atomic<bool> stopped field allows the UCI thread to signal the search to halt.
- Save src/search.h.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/search.h before moving on.
ⓧ I'd like to double check the full code
#pragma once
#include "position.h"
#include "movegen.h"
#include "tt.h"
#include <chrono>
#include <atomic>
struct SearchInfo {
int depth;
int score;
U64 nodes;
Move best_move;
Move pv[MAX_PLY];
int pv_length;
std::chrono::steady_clock::time_point start_time;
int time_limit_ms;
std::atomic<bool> stopped;
};
void search(Position& pos, SearchInfo& info);
- Create a new file src/search.cpp and add the includes, static data, and helper functions:
#include "search.h"
#include "evaluate.h"
#include <algorithm>
#include <iostream>
#include <cmath>
// Killer moves: non-captures that caused beta cutoffs at each ply
static Move killers[MAX_PLY][2];
// History heuristic: tracks which quiet moves have been successful
static int history[COLOR_NB][SQUARE_NB][SQUARE_NB];
// Periodically check if search time has expired
static void check_time(SearchInfo& info) {
if (info.time_limit_ms > 0) {
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - info.start_time).count();
if (elapsed >= info.time_limit_ms) info.stopped = true;
}
}
// Assign scores to moves for ordering (higher = searched first)
static void score_moves(const Position& pos, MoveList& list, Move tt_move, int ply) {
for (int i = 0; i < list.count; i++) {
Move m = list.moves[i];
if (m == tt_move) {
list.scores[i] = 100000;
} else if (pos.piece_on(m.to()) != NO_PIECE || m.flag() == EN_PASSANT) {
// MVV-LVA
int victim = (m.flag() == EN_PASSANT) ? PAWN : type_of(pos.piece_on(m.to()));
int attacker = type_of(pos.piece_on(m.from()));
list.scores[i] = 10000 + victim * 10 - attacker;
} else if (m == killers[ply][0]) {
list.scores[i] = 9000;
} else if (m == killers[ply][1]) {
list.scores[i] = 8000;
} else {
list.scores[i] = history[pos.side_to_move()][m.from()][m.to()];
}
}
}
// Selection sort: pick the highest-scored move starting from index
static Move pick_move(MoveList& list, int start) {
int best_idx = start;
for (int i = start + 1; i < list.count; i++) {
if (list.scores[i] > list.scores[best_idx]) best_idx = i;
}
std::swap(list.moves[start], list.moves[best_idx]);
std::swap(list.scores[start], list.scores[best_idx]);
return list.moves[start];
}
// Check move legality by making it and verifying king safety
static bool is_legal(Position& pos, Move m) {
pos.make_move(m);
bool legal = !(pos.attackers_to(pos.king_square(~pos.side_to_move()), pos.occupied())
& pos.pieces(pos.side_to_move()));
pos.unmake_move();
return legal;
}
What does this code do?
- score_moves() assigns a priority to each move. The TT move (from a previous search) gets the highest score. Captures are scored by MVV-LVA: prefer capturing a queen with a pawn over capturing a pawn with a queen. Killer moves and history scores handle quiet moves.
- pick_move() uses selection sort to grab the best remaining move. This is faster than a full sort because alpha-beta typically cuts off after the first few moves.
- is_legal() uses make/unmake to verify that a move does not leave the king in check.
💡 What is MVV-LVA?
MVV-LVA stands for Most Valuable Victim, Least Valuable Attacker. It prioritizes captures where you take a high-value piece with a low-value piece. Capturing a queen with a pawn scores much higher than capturing a pawn with a queen.
- Now add the quiescence search function below the is_legal function in src/search.cpp:
static int quiescence(Position& pos, int alpha, int beta, SearchInfo& info) {
if (info.stopped) return 0;
info.nodes++;
int stand_pat = evaluate(pos);
if (stand_pat >= beta) return beta;
if (stand_pat > alpha) alpha = stand_pat;
MoveList list;
generate_moves(pos, list);
for (int i = 0; i < list.count; i++) {
Move m = list.moves[i];
// Only search captures and promotions in quiescence
if (pos.piece_on(m.to()) == NO_PIECE && m.flag() != EN_PASSANT && m.flag() != PROMOTION)
continue;
if (!is_legal(pos, m)) continue;
// MVV-LVA score for ordering
int victim = (m.flag() == EN_PASSANT) ? PAWN :
(m.flag() == PROMOTION) ? QUEEN : type_of(pos.piece_on(m.to()));
list.scores[i] = victim * 10 - type_of(pos.piece_on(m.from()));
}
// Sort captures by score
for (int i = 0; i < list.count; i++) {
Move m = pick_move(list, i);
if (pos.piece_on(m.to()) == NO_PIECE && m.flag() != EN_PASSANT && m.flag() != PROMOTION)
continue;
if (!is_legal(pos, m)) continue;
pos.make_move(m);
int score = -quiescence(pos, -beta, -alpha, info);
pos.unmake_move();
if (score >= beta) return beta;
if (score > alpha) alpha = score;
}
return alpha;
}
Why do we need quiescence search?
Without quiescence search, the engine might evaluate a position right after a pawn captures a queen. The evaluation would think the capturing side is winning, but in reality the queen's defender is about to recapture. This is called the horizon effect.
Quiescence search continues searching only captures and promotions until the position is "quiet." The stand_pat score acts as a baseline. If the static evaluation already beats beta, there is no need to search further.
- Save src/search.cpp.
- You won't see a change yet. The next substep adds the main search function that calls quiescence.
Seeing red squiggles in your editor?
Your editor may show errors because search.cpp is not yet complete. The functions we have added so far are internal helpers. These warnings will disappear once we finish the file in the next substep.
help me debug red squiggles in my search.cpp file
Implement negamax and iterative deepening
The core search algorithm is negamax with alpha-beta pruning. It explores moves recursively, maintaining a window (alpha to beta) of scores it cares about. Any branch that falls outside this window gets pruned immediately, saving enormous time.
- Add the negamax function below quiescence in src/search.cpp:
static int negamax(Position& pos, int alpha, int beta, int depth, int ply, SearchInfo& info) {
if (info.stopped) return 0;
if ((info.nodes & 2047) == 0) check_time(info);
if (pos.is_draw()) return 0;
if (depth <= 0) return quiescence(pos, alpha, beta, info);
info.nodes++;
bool pv_node = (beta - alpha > 1);
// Transposition table probe
TTEntry tt_entry{};
Move tt_move;
if (TT.probe(pos.hash(), tt_entry)) {
tt_move = tt_entry.best_move;
if (!pv_node && tt_entry.depth >= depth) {
if (tt_entry.flag == TT_EXACT) return tt_entry.score;
if (tt_entry.flag == TT_ALPHA && tt_entry.score <= alpha) return alpha;
if (tt_entry.flag == TT_BETA && tt_entry.score >= beta) return beta;
}
}
MoveList list;
generate_moves(pos, list);
score_moves(pos, list, tt_move, ply);
Move best_move;
int best_score = -INFINITE;
int moves_searched = 0;
TTFlag tt_flag = TT_ALPHA;
for (int i = 0; i < list.count; i++) {
Move m = pick_move(list, i);
if (!is_legal(pos, m)) continue;
pos.make_move(m);
int score;
if (moves_searched == 0) {
score = -negamax(pos, -beta, -alpha, depth - 1, ply + 1, info);
} else {
// PVS: search with null window first
score = -negamax(pos, -alpha - 1, -alpha, depth - 1, ply + 1, info);
if (score > alpha && score < beta) {
score = -negamax(pos, -beta, -alpha, depth - 1, ply + 1, info);
}
}
pos.unmake_move();
moves_searched++;
if (info.stopped) return 0;
if (score > best_score) {
best_score = score;
best_move = m;
if (score > alpha) {
alpha = score;
tt_flag = TT_EXACT;
if (score >= beta) {
tt_flag = TT_BETA;
// Update killers and history for quiet moves
if (pos.piece_on(m.to()) == NO_PIECE && m.flag() != EN_PASSANT) {
killers[ply][1] = killers[ply][0];
killers[ply][0] = m;
history[pos.side_to_move()][m.from()][m.to()] += depth * depth;
}
break;
}
}
}
}
if (moves_searched == 0) {
return pos.in_check() ? (-MATE_SCORE + ply) : 0;
}
TT.store(pos.hash(), best_score, depth, tt_flag, best_move);
return best_score;
}
What does this code do?
- The function first checks for draw (repetition or 50-move rule) and drops into quiescence when depth reaches zero.
- It probes the transposition table. If the stored entry is deep enough and the node is not a PV node, it can return immediately.
- PVS (Principal Variation Search) searches the first move with a full window, then subsequent moves with a null window. If a later move beats alpha, it re-searches with the full window. This saves time because most moves at cut-nodes fail low.
- When a quiet move causes a beta cutoff, it is stored as a killer move and its history score is incremented by depth squared. Deeper cutoffs are worth more.
- If no legal moves exist, the position is either checkmate (return negative mate score offset by ply) or stalemate (return 0).
- Add the search() function below negamax to complete src/search.cpp:
void search(Position& pos, SearchInfo& info) {
info.nodes = 0;
info.stopped = false;
info.start_time = std::chrono::steady_clock::now();
std::memset(killers, 0, sizeof(killers));
std::memset(history, 0, sizeof(history));
Move best_move;
int best_score = 0;
// Iterative deepening
for (int depth = 1; depth <= info.depth; depth++) {
int score = negamax(pos, -INFINITE, INFINITE, depth, 0, info);
if (info.stopped) break;
best_score = score;
// Get best move from TT
TTEntry entry{};
if (TT.probe(pos.hash(), entry)) {
best_move = entry.best_move;
}
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - info.start_time).count();
std::cout << "info depth " << depth
<< " score cp " << score
<< " nodes " << info.nodes
<< " time " << elapsed
<< " nps " << (elapsed > 0 ? info.nodes * 1000 / elapsed : info.nodes)
<< std::endl;
info.score = score;
}
// Store best move so callers (CLI, UCI) can retrieve it
info.best_move = best_move;
std::cout << "bestmove " << square_to_string(best_move.from())
<< square_to_string(best_move.to());
if (best_move.flag() == PROMOTION) {
static const char promo_chars[] = "nbrq";
std::cout << promo_chars[best_move.promotion() - KNIGHT];
}
std::cout << std::endl;
}
What is iterative deepening?
Iterative deepening searches depth 1 first, then depth 2, then depth 3, and so on until time expires. This sounds wasteful, but each iteration takes exponentially longer than the last. The earlier iterations are cheap and populate the transposition table with good moves, making deeper iterations much faster.
The info output lines follow the UCI protocol format. They report the current depth, score in centipawns, node count, elapsed time, and nodes per second to any connected GUI.
- Save src/search.cpp.
- Verify the new files compile by running this command in your terminal from the nexus folder:
clang++ -c -std=c++20 -Isrc src/tt.cpp src/search.cpp
- If the command completes silently with no output, your code compiled successfully. You should see tt.o and search.o object files in your project folder.
Seeing compile errors?
If you see errors about missing includes, double check that src/search.h includes "tt.h" and src/tt.h includes "types.h".
If you see errors about undeclared identifiers, make sure your functions in search.cpp are in the correct order: check_time, score_moves, pick_move, is_legal, quiescence, negamax, search. Each function must appear before any function that calls it.
help me fix compile errors in my search.cpp or tt.cpp files
- Clean up the object files by running:
rm -f tt.o search.o
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/search.h and src/search.cpp.
ⓧ I'd like to double check the full code
#include "search.h"
#include "evaluate.h"
#include <algorithm>
#include <iostream>
#include <cmath>
static Move killers[MAX_PLY][2];
static int history[COLOR_NB][SQUARE_NB][SQUARE_NB];
static void check_time(SearchInfo& info) {
if (info.time_limit_ms > 0) {
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - info.start_time).count();
if (elapsed >= info.time_limit_ms) info.stopped = true;
}
}
static void score_moves(const Position& pos, MoveList& list, Move tt_move, int ply) {
for (int i = 0; i < list.count; i++) {
Move m = list.moves[i];
if (m == tt_move) {
list.scores[i] = 100000;
} else if (pos.piece_on(m.to()) != NO_PIECE || m.flag() == EN_PASSANT) {
// MVV-LVA
int victim = (m.flag() == EN_PASSANT) ? PAWN : type_of(pos.piece_on(m.to()));
int attacker = type_of(pos.piece_on(m.from()));
list.scores[i] = 10000 + victim * 10 - attacker;
} else if (m == killers[ply][0]) {
list.scores[i] = 9000;
} else if (m == killers[ply][1]) {
list.scores[i] = 8000;
} else {
list.scores[i] = history[pos.side_to_move()][m.from()][m.to()];
}
}
}
static Move pick_move(MoveList& list, int start) {
int best_idx = start;
for (int i = start + 1; i < list.count; i++) {
if (list.scores[i] > list.scores[best_idx]) best_idx = i;
}
std::swap(list.moves[start], list.moves[best_idx]);
std::swap(list.scores[start], list.scores[best_idx]);
return list.moves[start];
}
static bool is_legal(Position& pos, Move m) {
pos.make_move(m);
bool legal = !(pos.attackers_to(pos.king_square(~pos.side_to_move()), pos.occupied())
& pos.pieces(pos.side_to_move()));
pos.unmake_move();
return legal;
}
static int quiescence(Position& pos, int alpha, int beta, SearchInfo& info) {
if (info.stopped) return 0;
info.nodes++;
int stand_pat = evaluate(pos);
if (stand_pat >= beta) return beta;
if (stand_pat > alpha) alpha = stand_pat;
MoveList list;
generate_moves(pos, list);
for (int i = 0; i < list.count; i++) {
Move m = list.moves[i];
// Only search captures and promotions in quiescence
if (pos.piece_on(m.to()) == NO_PIECE && m.flag() != EN_PASSANT && m.flag() != PROMOTION)
continue;
if (!is_legal(pos, m)) continue;
// MVV-LVA score for ordering
int victim = (m.flag() == EN_PASSANT) ? PAWN :
(m.flag() == PROMOTION) ? QUEEN : type_of(pos.piece_on(m.to()));
list.scores[i] = victim * 10 - type_of(pos.piece_on(m.from()));
}
// Sort captures by score
for (int i = 0; i < list.count; i++) {
Move m = pick_move(list, i);
if (pos.piece_on(m.to()) == NO_PIECE && m.flag() != EN_PASSANT && m.flag() != PROMOTION)
continue;
if (!is_legal(pos, m)) continue;
pos.make_move(m);
int score = -quiescence(pos, -beta, -alpha, info);
pos.unmake_move();
if (score >= beta) return beta;
if (score > alpha) alpha = score;
}
return alpha;
}
static int negamax(Position& pos, int alpha, int beta, int depth, int ply, SearchInfo& info) {
if (info.stopped) return 0;
if ((info.nodes & 2047) == 0) check_time(info);
if (pos.is_draw()) return 0;
if (depth <= 0) return quiescence(pos, alpha, beta, info);
info.nodes++;
bool pv_node = (beta - alpha > 1);
// Transposition table probe
TTEntry tt_entry{};
Move tt_move;
if (TT.probe(pos.hash(), tt_entry)) {
tt_move = tt_entry.best_move;
if (!pv_node && tt_entry.depth >= depth) {
if (tt_entry.flag == TT_EXACT) return tt_entry.score;
if (tt_entry.flag == TT_ALPHA && tt_entry.score <= alpha) return alpha;
if (tt_entry.flag == TT_BETA && tt_entry.score >= beta) return beta;
}
}
MoveList list;
generate_moves(pos, list);
score_moves(pos, list, tt_move, ply);
Move best_move;
int best_score = -INFINITE;
int moves_searched = 0;
TTFlag tt_flag = TT_ALPHA;
for (int i = 0; i < list.count; i++) {
Move m = pick_move(list, i);
if (!is_legal(pos, m)) continue;
pos.make_move(m);
int score;
if (moves_searched == 0) {
score = -negamax(pos, -beta, -alpha, depth - 1, ply + 1, info);
} else {
// PVS: search with null window first
score = -negamax(pos, -alpha - 1, -alpha, depth - 1, ply + 1, info);
if (score > alpha && score < beta) {
score = -negamax(pos, -beta, -alpha, depth - 1, ply + 1, info);
}
}
pos.unmake_move();
moves_searched++;
if (info.stopped) return 0;
if (score > best_score) {
best_score = score;
best_move = m;
if (score > alpha) {
alpha = score;
tt_flag = TT_EXACT;
if (score >= beta) {
tt_flag = TT_BETA;
// Update killers and history for quiet moves
if (pos.piece_on(m.to()) == NO_PIECE && m.flag() != EN_PASSANT) {
killers[ply][1] = killers[ply][0];
killers[ply][0] = m;
history[pos.side_to_move()][m.from()][m.to()] += depth * depth;
}
break;
}
}
}
}
if (moves_searched == 0) {
return pos.in_check() ? (-MATE_SCORE + ply) : 0;
}
TT.store(pos.hash(), best_score, depth, tt_flag, best_move);
return best_score;
}
void search(Position& pos, SearchInfo& info) {
info.nodes = 0;
info.stopped = false;
info.start_time = std::chrono::steady_clock::now();
std::memset(killers, 0, sizeof(killers));
std::memset(history, 0, sizeof(history));
Move best_move;
int best_score = 0;
// Iterative deepening
for (int depth = 1; depth <= info.depth; depth++) {
int score = negamax(pos, -INFINITE, INFINITE, depth, 0, info);
if (info.stopped) break;
best_score = score;
// Get best move from TT
TTEntry entry{};
if (TT.probe(pos.hash(), entry)) {
best_move = entry.best_move;
}
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - info.start_time).count();
std::cout << "info depth " << depth
<< " score cp " << score
<< " nodes " << info.nodes
<< " time " << elapsed
<< " nps " << (elapsed > 0 ? info.nodes * 1000 / elapsed : info.nodes)
<< std::endl;
info.score = score;
}
// Store best move so callers (CLI, UCI) can retrieve it
info.best_move = best_move;
std::cout << "bestmove " << square_to_string(best_move.from())
<< square_to_string(best_move.to());
if (best_move.flag() == PROMOTION) {
static const char promo_chars[] = "nbrq";
std::cout << promo_chars[best_move.promotion() - KNIGHT];
}
std::cout << std::endl;
}
Your engine now has a brain. It can search millions of positions per second, remember what it has seen, and find tactical combinations several moves deep. Next up, you will connect everything to the UCI protocol so you can play against it in a real chess GUI.
Integrate the UCI Protocol and Test in CuteChess
Integrate the UCI Protocol and Test the Engine
Your engine can now search, evaluate, and order moves. The one thing missing is a way to talk to the outside world.
Chess GUIs communicate with engines through the Universal Chess Interface (UCI) protocol. In this step, you will implement a UCI command loop so Nexus can receive positions, compute best moves, and report search info. Then you will compile the full project and verify the handshake from the terminal.
In this step, get ready to:
- Implement the UCI protocol handler with position parsing, time management, and search dispatch.
- Create the main entry point and build the Nexus binary.
- Verify the UCI handshake and run a search from the terminal.
Create the UCI protocol handler
The UCI protocol is text-based. The GUI sends commands like position and go on stdin, and the engine replies with bestmove on stdout. You will create two files: a tiny header declaring the loop, and a larger implementation that handles every command.
- Create a new file called src/uci.h inside your nexus project folder and paste the following code:
#pragma once
void uci_loop();
What does this header do?
It declares a single function that all other translation units can call. The entire UCI implementation lives in uci.cpp behind this interface.
- Create a new file called src/uci.cpp and add the includes, static state, and the parse_move helper by pasting this code:
#include "uci.h"
#include "search.h"
#include "movegen.h"
#include <iostream>
#include <sstream>
#include <thread>
static Position pos;
static std::thread search_thread;
static SearchInfo search_info;
// Convert a UCI move string (e.g. "e2e4", "e7e8q") into an internal Move
static Move parse_move(Position& p, const std::string& str) {
Square from = string_to_square(str.substr(0, 2));
Square to = string_to_square(str.substr(2, 2));
MoveList list;
generate_moves(p, list);
for (int i = 0; i < list.count; i++) {
Move m = list.moves[i];
if (m.from() == from && m.to() == to) {
if (m.flag() == PROMOTION) {
if (str.length() >= 5) {
char pc = str[4];
int promo = (pc == 'q') ? 3 : (pc == 'r') ? 2 : (pc == 'b') ? 1 : 0;
if (m.promotion() == PieceType(promo + KNIGHT)) return m;
} else {
if (m.promotion() == QUEEN) return m; // default to queen
}
} else {
return m;
}
}
}
return Move();
}
What does this code do?
The static Position holds the current game state. The search thread runs in the background so the engine can respond to stop at any time.
The parse_move function takes a UCI move string in pure coordinate notation (like e2e4 or e7e8q) and matches it against the generated move list to find the corresponding internal Move struct.
- Below the parse_move function in src/uci.cpp, add the position_command handler:
// Handle "position startpos moves ..." or "position fen ... moves ..."
static void position_command(std::istringstream& ss) {
std::string token;
ss >> token;
if (token == "startpos") {
pos.set_fen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1");
ss >> token; // consume "moves" if present
} else if (token == "fen") {
std::string fen;
while (ss >> token && token != "moves") fen += token + " ";
pos.set_fen(fen);
}
// Apply moves
while (ss >> token) {
Move m = parse_move(pos, token);
if (m) pos.make_move(m);
}
}
What does this code do?
GUIs send the full game history every time they ask for a move. This function resets the board (either to the starting position or a given FEN) and replays every move in the list to reach the current state.
- Below position_command, add the go_command handler that manages time control and starts the search thread:
// Handle "go" with time control, depth, or movetime
static void go_command(std::istringstream& ss) {
std::string token;
int wtime = 0, btime = 0, winc = 0, binc = 0, movetime = 0, max_depth = 64;
while (ss >> token) {
if (token == "wtime") ss >> wtime;
else if (token == "btime") ss >> btime;
else if (token == "winc") ss >> winc;
else if (token == "binc") ss >> binc;
else if (token == "movetime") ss >> movetime;
else if (token == "depth") ss >> max_depth;
else if (token == "infinite") { max_depth = 64; movetime = 0; }
}
search_info.depth = max_depth;
if (movetime > 0) {
search_info.time_limit_ms = movetime;
} else if (wtime > 0 || btime > 0) {
int time = (pos.side_to_move() == WHITE) ? wtime : btime;
int inc = (pos.side_to_move() == WHITE) ? winc : binc;
search_info.time_limit_ms = time / 30 + inc / 2;
} else {
search_info.time_limit_ms = 0; // infinite
}
if (search_thread.joinable()) search_thread.join();
search_thread = std::thread([&]() { search(pos, search_info); });
}
What does this code do?
The go_command parses time information from the GUI. It uses a simple formula (remaining time / 30 + half the increment) to set a per-move time budget. The search runs on a separate thread so the main loop can still respond to stop.
- Finally, add the main uci_loop function below go_command:
void uci_loop() {
std::string line, token;
while (std::getline(std::cin, line)) {
std::istringstream ss(line);
ss >> token;
if (token == "uci") {
std::cout << "id name Nexus 1.0" << std::endl;
std::cout << "id author NextWork" << std::endl;
std::cout << "option name Hash type spin default 64 min 1 max 1024" << std::endl;
std::cout << "uciok" << std::endl;
}
else if (token == "isready") {
std::cout << "readyok" << std::endl;
}
else if (token == "ucinewgame") {
TT.clear();
pos.set_fen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1");
}
else if (token == "position") {
position_command(ss);
}
else if (token == "go") {
go_command(ss);
}
else if (token == "stop") {
search_info.stopped = true;
if (search_thread.joinable()) search_thread.join();
}
else if (token == "undo") {
if (pos.ply() > 0) pos.unmake_move();
}
else if (token == "setoption") {
std::string name, value;
ss >> token; // "name"
ss >> name;
ss >> token; // "value"
ss >> value;
if (name == "Hash") {
TT.resize(std::stoi(value));
}
}
else if (token == "perft") {
int depth;
ss >> depth;
auto start = std::chrono::steady_clock::now();
U64 nodes = perft(pos, depth);
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start).count();
std::cout << "Nodes: " << nodes << " Time: " << elapsed << "ms" << std::endl;
}
else if (token == "d") {
std::cout << pos.fen() << std::endl;
}
else if (token == "quit") {
search_info.stopped = true;
if (search_thread.joinable()) search_thread.join();
break;
}
}
}
What does this code do?
This is the heart of the engine's communication layer. It reads commands from stdin in an infinite loop and dispatches each one.
- `uci` responds with the engine name, author, and available options, followed by `uciok`.
- `isready` / `readyok` is a ping/pong for synchronization.
- `position` and `go` set up the board and start searching.
- `stop` halts the search and forces the thread to join.
- `undo` uses unmake_move for interactive debugging.
- `perft` validates move generation at a given depth.
- Save src/uci.h and src/uci.cpp.
Seeing a compile error about thread?
Make sure you included <thread> at the top of uci.cpp. Also confirm your CMakeLists.txt sets CMAKE_CXX_STANDARD to 20.
Help me debug a thread-related compile error in my UCI code.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
#pragma once
void uci_loop();
#include "uci.h"
#include "search.h"
#include "movegen.h"
#include <iostream>
#include <sstream>
#include <thread>
static Position pos;
static std::thread search_thread;
static SearchInfo search_info;
static Move parse_move(Position& p, const std::string& str) {
Square from = string_to_square(str.substr(0, 2));
Square to = string_to_square(str.substr(2, 2));
MoveList list;
generate_moves(p, list);
for (int i = 0; i < list.count; i++) {
Move m = list.moves[i];
if (m.from() == from && m.to() == to) {
if (m.flag() == PROMOTION) {
if (str.length() >= 5) {
char pc = str[4];
int promo = (pc == 'q') ? 3 : (pc == 'r') ? 2 : (pc == 'b') ? 1 : 0;
if (m.promotion() == PieceType(promo + KNIGHT)) return m;
} else {
if (m.promotion() == QUEEN) return m; // default to queen
}
} else {
return m;
}
}
}
return Move();
}
static void position_command(std::istringstream& ss) {
std::string token;
ss >> token;
if (token == "startpos") {
pos.set_fen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1");
ss >> token; // consume "moves" if present
} else if (token == "fen") {
std::string fen;
while (ss >> token && token != "moves") fen += token + " ";
pos.set_fen(fen);
}
// Apply moves
while (ss >> token) {
Move m = parse_move(pos, token);
if (m) pos.make_move(m);
}
}
static void go_command(std::istringstream& ss) {
std::string token;
int wtime = 0, btime = 0, winc = 0, binc = 0, movetime = 0, max_depth = 64;
while (ss >> token) {
if (token == "wtime") ss >> wtime;
else if (token == "btime") ss >> btime;
else if (token == "winc") ss >> winc;
else if (token == "binc") ss >> binc;
else if (token == "movetime") ss >> movetime;
else if (token == "depth") ss >> max_depth;
else if (token == "infinite") { max_depth = 64; movetime = 0; }
}
search_info.depth = max_depth;
if (movetime > 0) {
search_info.time_limit_ms = movetime;
} else if (wtime > 0 || btime > 0) {
int time = (pos.side_to_move() == WHITE) ? wtime : btime;
int inc = (pos.side_to_move() == WHITE) ? winc : binc;
search_info.time_limit_ms = time / 30 + inc / 2;
} else {
search_info.time_limit_ms = 0; // infinite
}
if (search_thread.joinable()) search_thread.join();
search_thread = std::thread([&]() { search(pos, search_info); });
}
void uci_loop() {
std::string line, token;
while (std::getline(std::cin, line)) {
std::istringstream ss(line);
ss >> token;
if (token == "uci") {
std::cout << "id name Nexus 1.0" << std::endl;
std::cout << "id author NextWork" << std::endl;
std::cout << "option name Hash type spin default 64 min 1 max 1024" << std::endl;
std::cout << "uciok" << std::endl;
}
else if (token == "isready") {
std::cout << "readyok" << std::endl;
}
else if (token == "ucinewgame") {
TT.clear();
pos.set_fen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1");
}
else if (token == "position") {
position_command(ss);
}
else if (token == "go") {
go_command(ss);
}
else if (token == "stop") {
search_info.stopped = true;
if (search_thread.joinable()) search_thread.join();
}
else if (token == "undo") {
if (pos.ply() > 0) pos.unmake_move();
}
else if (token == "setoption") {
std::string name, value;
ss >> token; // "name"
ss >> name;
ss >> token; // "value"
ss >> value;
if (name == "Hash") {
TT.resize(std::stoi(value));
}
}
else if (token == "perft") {
int depth;
ss >> depth;
auto start = std::chrono::steady_clock::now();
U64 nodes = perft(pos, depth);
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start).count();
std::cout << "Nodes: " << nodes << " Time: " << elapsed << "ms" << std::endl;
}
else if (token == "d") {
std::cout << pos.fen() << std::endl;
}
else if (token == "quit") {
search_info.stopped = true;
if (search_thread.joinable()) search_thread.join();
break;
}
}
}
Create the main entry point and build the engine
The engine needs a main() function that initializes the attack tables and Zobrist keys, then enters the UCI loop.
- Create a new file called src/main.cpp and paste the following code:
#include "bitboard.h"
#include "position.h"
#include "uci.h"
int main() {
Bitboards::init();
Zobrist::init();
uci_loop();
return 0;
}
What does this code do?
`Bitboards::init()` pre-computes all attack tables (knight, king, pawn, and magic bitboard tables for sliders). `Zobrist::init()` fills the random number arrays used for incremental hashing. After initialization, uci_loop() takes over and the engine listens on stdin until it receives quit.
- Save src/main.cpp.
Now build the full project. CMake generates the build system, and make compiles everything into the nexus binary.
- In your terminal, navigate into the nexus project folder and build by running these commands:
mkdir -p build && cd build
cmake ..
make
- Confirm the binary exists by running:
ls nexus
You should see nexus listed. This is your compiled chess engine.
Build fails with undefined reference errors?
Check that CMakeLists.txt lists src/uci.cpp in the add_executable call. Every .cpp file must be listed there.
Help me fix linker errors when building my chess engine.
- Quick-test the UCI handshake by running:
echo "uci" | ./nexus
You should see output starting with id name Nexus 1.0 and ending with uciok.
Your chess engine is alive. It handles every core UCI command, manages its own time, and plays real chess through the universal protocol. Next up, you will build a terminal interface so you can play against Nexus directly without any external GUI.
Build a Terminal Interface with Two Play Modes
Your engine speaks UCI fluently, but UCI is designed for programs to talk to programs. To actually see Nexus play chess, you need a human-friendly interface.
In this step, you will build an interactive terminal frontend that renders the board with Unicode chess pieces and supports two modes: Player vs Engine (you type moves and watch Nexus respond) and Engine vs Engine (Nexus plays itself while you watch). No external dependencies required.
In this step, get ready to:
- Create a Unicode board renderer that prints the position to the terminal.
- Implement a Player vs Engine mode where you type moves and the engine responds.
- Implement an Engine vs Engine self-play mode that runs automatically.
Create the board renderer and play loop
The terminal interface lives in a single new file. It uses Unicode chess symbols and ANSI background colors to draw the board, then provides a command loop for both play modes.
- Create a new file called src/cli.cpp inside your nexus project folder and paste the following code:
#include "position.h"
#include "movegen.h"
#include "search.h"
#include <iostream>
#include <string>
#include <thread>
#include <chrono>
// Unicode chess pieces indexed by Piece enum
static const char* piece_unicode[] = {
"\u2659", "\u2658", "\u2657", "\u2656", "\u2655", "\u2654", // white P N B R Q K
"\u265F", "\u265E", "\u265D", "\u265C", "\u265B", "\u265A", // black P N B R Q K
" " // NO_PIECE
};
// Print the board with ANSI colors
static void print_board(const Position& pos) {
std::cout << "\n";
for (int r = 7; r >= 0; r--) {
std::cout << " " << (r + 1) << " ";
for (int f = 0; f < 8; f++) {
Square sq = make_square(File(f), Rank(r));
Piece pc = pos.piece_on(sq);
// Alternate background: light (47) and dark (100)
const char* bg = ((r + f) % 2 == 0) ? "\033[47m" : "\033[100m";
std::cout << bg << " " << piece_unicode[pc] << " \033[0m";
}
std::cout << "\n";
}
std::cout << " a b c d e f g h\n\n";
std::cout << (pos.side_to_move() == WHITE ? "White" : "Black") << " to move\n";
}
What does this code do?
The piece_unicode array maps each internal piece index to a Unicode chess symbol. The print_board function loops through ranks top-to-bottom and files left-to-right, printing each square with an alternating light/dark ANSI background color.
- Below print_board, add the Player vs Engine and Engine vs Engine game loops:
// Player vs Engine: human plays white, engine plays black
static void play_vs_engine() {
Position pos;
pos.set_fen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1");
SearchInfo info;
info.depth = 6;
info.time_limit_ms = 2000;
std::cout << "=== Player vs Engine ===\n";
std::cout << "Enter moves in coordinate notation (e.g. e2e4). Type 'quit' to exit.\n";
while (true) {
print_board(pos);
if (pos.side_to_move() == WHITE) {
// Human's turn
std::string input;
std::cout << "Your move: ";
std::getline(std::cin, input);
if (input == "quit") break;
Square from = string_to_square(input.substr(0, 2));
Square to = string_to_square(input.substr(2, 2));
MoveList list;
generate_moves(pos, list);
Move found;
for (int i = 0; i < list.count; i++) {
Move m = list.moves[i];
if (m.from() == from && m.to() == to) {
if (m.flag() == PROMOTION) {
char pc = (input.length() >= 5) ? input[4] : 'q';
int promo = (pc == 'q') ? 3 : (pc == 'r') ? 2 : (pc == 'b') ? 1 : 0;
if (m.promotion() == PieceType(promo + KNIGHT)) { found = m; break; }
} else { found = m; break; }
}
}
if (!found) {
std::cout << "Illegal move. Try again.\n";
continue;
}
pos.make_move(found);
} else {
// Engine's turn
std::cout << "Engine is thinking...\n";
info.stopped = false;
info.nodes = 0;
search(pos, info);
// The search outputs bestmove to stdout via UCI info lines
// For the CLI, we use the PV move directly
Move best = info.best_move;
if (!best) break;
std::cout << "Engine plays: " << square_to_string(best.from())
<< square_to_string(best.to()) << "\n";
pos.make_move(best);
}
// Check for game over
MoveList check_list;
generate_moves(pos, check_list);
bool has_legal = false;
for (int i = 0; i < check_list.count; i++) {
if (is_legal(pos, check_list.moves[i])) { has_legal = true; break; }
}
if (!has_legal) {
print_board(pos);
std::cout << (pos.in_check() ? "Checkmate!" : "Stalemate!") << "\n";
break;
}
}
}
// Engine vs Engine: both sides play automatically
static void engine_vs_engine() {
Position pos;
pos.set_fen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1");
SearchInfo info;
info.depth = 6;
info.time_limit_ms = 1000;
std::cout << "=== Engine vs Engine ===\n";
std::cout << "Press Ctrl+C to stop.\n";
int move_num = 1;
while (true) {
print_board(pos);
info.stopped = false;
info.nodes = 0;
search(pos, info);
Move best = info.best_move;
if (!best) break;
std::string side = (pos.side_to_move() == WHITE) ? "White" : "Black";
std::cout << move_num << ". " << side << ": "
<< square_to_string(best.from()) << square_to_string(best.to()) << "\n";
pos.make_move(best);
move_num++;
// Check for game over
MoveList check_list;
generate_moves(pos, check_list);
bool has_legal = false;
for (int i = 0; i < check_list.count; i++) {
if (is_legal(pos, check_list.moves[i])) { has_legal = true; break; }
}
if (!has_legal) {
print_board(pos);
std::cout << (pos.in_check() ? "Checkmate!" : "Stalemate!") << "\n";
break;
}
if (pos.is_draw()) {
print_board(pos);
std::cout << "Draw!\n";
break;
}
// Brief pause so you can follow the game
std::this_thread::sleep_for(std::chrono::milliseconds(500));
}
}
What does this code do?
The play_vs_engine function alternates between reading your move from stdin and running the engine search. It validates your input against the legal move list and prints an error if the move is illegal.
The engine_vs_engine function runs both sides automatically with a 500ms pause between moves so you can watch the game unfold. Both functions check for checkmate, stalemate, and draw after every move.
Add command-line flags to main
The binary needs to know which mode to launch. You will add --play and --self flags. Without flags, it defaults to the UCI loop for compatibility with chess GUIs.
- In src/cli.cpp, add a public entry point at the bottom of the file:
// Public entry points for the CLI modes
void cli_play() { play_vs_engine(); }
void cli_selfplay() { engine_vs_engine(); }
- Create a header file src/cli.h with the declarations:
#pragma once
void cli_play();
void cli_selfplay();
- Open src/main.cpp and replace the entire file with this updated version that handles command-line arguments:
#include "bitboard.h"
#include "position.h"
#include "uci.h"
#include "cli.h"
#include <string>
int main(int argc, char* argv[]) {
Bitboards::init();
Zobrist::init();
// Check for CLI mode flags
if (argc > 1) {
std::string mode = argv[1];
if (mode == "--play") {
cli_play();
return 0;
} else if (mode == "--self") {
cli_selfplay();
return 0;
}
}
// Default: UCI protocol mode
uci_loop();
return 0;
}
What does this code do?
The updated main() checks the first command-line argument. If the user passes --play, it launches the interactive Player vs Engine mode. If they pass --self, it launches Engine vs Engine. With no arguments, it enters the UCI loop as before.
Rebuild and test both modes
- Add src/cli.cpp to the add_executable list in your CMakeLists.txt.
- Rebuild the project by running:
cd build && cmake --build . && cd ..
- Test the Player vs Engine mode by running:
./build/nexus --play
You should see the board rendered with Unicode pieces. Type e2e4 to make your first move and watch Nexus respond.
- Test Engine vs Engine mode by running:
./build/nexus --self
You should see the board updating every half-second as Nexus plays both sides automatically.
Board looks garbled or shows question marks?
Your terminal might not support Unicode. Make sure your terminal emulator is set to UTF-8 encoding. On macOS, Terminal.app and iTerm2 work out of the box. On Windows, use Windows Terminal (not the legacy Command Prompt).
Help me fix Unicode chess pieces not displaying correctly in my terminal.
You now have a fully playable terminal chess interface. Next up, you will build a web frontend that renders the same engine in a browser with a drag-and-drop chessboard.
Build a Web Frontend with Two Play Modes
The terminal interface works, but a graphical chessboard with drag-and-drop is a much richer experience. You can build one using raw POSIX sockets to serve a web page without any external library.
In this step, you will add a minimal HTTP server to Nexus (using only sys/socket.h from the OS) that serves an HTML page with a chessboard and exposes a REST API. The browser handles rendering via chessboard.js loaded from a CDN. Same two modes: Player vs Engine and Engine vs Engine.
In this step, get ready to:
- Create a minimal HTTP server using raw POSIX sockets.
- Serve an HTML page with a drag-and-drop chessboard powered by chessboard.js.
- Expose REST API endpoints that the browser calls to get engine moves.
Create the HTTP server and REST API
The web server uses sys/socket.h which ships with every macOS and Linux system. It listens on port 8080, serves the HTML page on GET /, and handles two API endpoints: POST /api/move (player makes a move, engine responds) and POST /api/engine-move (engine makes the next move for the current side).
- Create a new file called src/web.h and paste the declaration:
#pragma once
void web_server();
- Create a new file called src/web.cpp and paste the server implementation:
#include "web.h"
#include "position.h"
#include "movegen.h"
#include "search.h"
#include <iostream>
#include <sstream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
static Position pos;
// The full HTML/JS frontend as an embedded string
static const char* HTML_PAGE = R"HTML(
<!DOCTYPE html>
<html>
<head>
<title>Nexus Chess</title>
<link rel="stylesheet" href="https://unpkg.com/@chrisoakman/chessboardjs@1.0.0/dist/chessboard-1.0.0.min.css">
<style>
body { font-family: sans-serif; text-align: center; background: #1a1a2e; color: #eee; padding: 20px; }
#board { width: 480px; margin: 20px auto; }
button { margin: 10px; padding: 10px 20px; font-size: 16px; cursor: pointer;
background: #16213e; color: #eee; border: 1px solid #0f3460; border-radius: 4px; }
button:hover { background: #0f3460; }
#status { margin: 10px; font-size: 18px; }
#moves { max-height: 200px; overflow-y: auto; margin: 10px auto; width: 480px; text-align: left; font-family: monospace; font-size: 14px; }
</style>
</head>
<body>
<h1>Nexus Chess Engine</h1>
<div>
<button onclick="newGame('pvp')">Player vs Engine</button>
<button onclick="newGame('eve')">Engine vs Engine</button>
</div>
<div id="status">Select a mode to start</div>
<div id="board"></div>
<div id="moves"></div>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://unpkg.com/@chrisoakman/chessboardjs@1.0.0/dist/chessboard-1.0.0.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/chess.js/0.10.3/chess.min.js"></script>
<script>
var board, game, mode;
function newGame(m) {
mode = m;
game = new Chess();
board = Chessboard('board', {
draggable: mode === 'pvp',
position: 'start',
onDrop: onDrop,
pieceTheme: 'https://unpkg.com/@chrisoakman/chessboardjs@1.0.0/dist/img/chesspieces/wikipedia/{piece}.png'
});
document.getElementById('moves').innerHTML = '';
document.getElementById('status').innerText = mode === 'pvp' ? 'Your move (White)' : 'Engine vs Engine running...';
fetch('/api/newgame', {method:'POST'});
if (mode === 'eve') setTimeout(eveLoop, 500);
}
function onDrop(source, target) {
var move = source + target;
var promotion = '';
// Check for pawn promotion
var piece = game.get(source);
if (piece && piece.type === 'p' && (target[1] === '8' || target[1] === '1')) {
promotion = 'q';
move += 'q';
}
var result = game.move({from: source, to: target, promotion: promotion || undefined});
if (result === null) return 'snapback';
logMove('You', source + target + (promotion || ''));
document.getElementById('status').innerText = 'Engine thinking...';
fetch('/api/move', {method:'POST', body: JSON.stringify({move: source+target+(promotion||'')}), headers:{'Content-Type':'application/json'}})
.then(r => r.json())
.then(data => {
if (data.move) {
var em = data.move;
game.move({from: em.substring(0,2), to: em.substring(2,4), promotion: em.length > 4 ? em[4] : undefined});
board.position(game.fen());
logMove('Nexus', em);
}
updateStatus();
});
}
function eveLoop() {
if (game.game_over()) { document.getElementById('status').innerText = 'Game over!'; return; }
fetch('/api/engine-move', {method:'POST'})
.then(r => r.json())
.then(data => {
if (data.move) {
var em = data.move;
var side = game.turn() === 'w' ? 'White' : 'Black';
game.move({from: em.substring(0,2), to: em.substring(2,4), promotion: em.length > 4 ? em[4] : undefined});
board.position(game.fen());
logMove(side, em);
}
if (!game.game_over()) setTimeout(eveLoop, 800);
else document.getElementById('status').innerText = 'Game over!';
});
}
function logMove(who, move) {
var div = document.getElementById('moves');
div.innerHTML += who + ': ' + move + '\n';
div.scrollTop = div.scrollHeight;
}
function updateStatus() {
if (game.game_over()) document.getElementById('status').innerText = 'Game over!';
else document.getElementById('status').innerText = 'Your move (White)';
}
</script>
</body>
</html>
)HTML";
// Parse the move string from a JSON body like {"move":"e2e4"}
static std::string extract_move(const std::string& body) {
auto p = body.find("\"move\"");
if (p == std::string::npos) return "";
auto q1 = body.find('"', p + 6);
auto q2 = body.find('"', q1 + 1);
return body.substr(q1 + 1, q2 - q1 - 1);
}
// Find and apply a move, return the engine's response move as a string
static std::string apply_and_respond(const std::string& move_str) {
// Apply human move
Square from = string_to_square(move_str.substr(0, 2));
Square to = string_to_square(move_str.substr(2, 2));
MoveList list;
generate_moves(pos, list);
for (int i = 0; i < list.count; i++) {
Move m = list.moves[i];
if (m.from() == from && m.to() == to) {
if (m.flag() == PROMOTION) {
char pc = (move_str.length() >= 5) ? move_str[4] : 'q';
int promo = (pc == 'q') ? 3 : (pc == 'r') ? 2 : (pc == 'b') ? 1 : 0;
if (m.promotion() != PieceType(promo + KNIGHT)) continue;
}
pos.make_move(m);
break;
}
}
// Engine responds
SearchInfo info{};
info.depth = 6;
info.time_limit_ms = 2000;
search(pos, info);
Move best = info.best_move;
if (!best) return "";
std::string reply = square_to_string(best.from()) + square_to_string(best.to());
if (best.flag() == PROMOTION) reply += "q";
pos.make_move(best);
return reply;
}
// Engine makes a move for the current side (used in Engine vs Engine)
static std::string engine_move() {
SearchInfo info{};
info.depth = 6;
info.time_limit_ms = 1000;
search(pos, info);
Move best = info.best_move;
if (!best) return "";
std::string reply = square_to_string(best.from()) + square_to_string(best.to());
if (best.flag() == PROMOTION) reply += "q";
pos.make_move(best);
return reply;
}
// Send an HTTP response
static void send_response(int client, int status, const std::string& content_type, const std::string& body) {
std::ostringstream ss;
ss << "HTTP/1.1 " << status << " OK\r\n"
<< "Content-Type: " << content_type << "\r\n"
<< "Content-Length: " << body.size() << "\r\n"
<< "Connection: close\r\n\r\n"
<< body;
std::string resp = ss.str();
write(client, resp.c_str(), resp.size());
}
void web_server() {
pos.set_fen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1");
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(8080);
bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
listen(server_fd, 10);
std::cout << "Nexus web server running at http://localhost:8080\n";
while (true) {
int client = accept(server_fd, nullptr, nullptr);
char buf[4096] = {};
read(client, buf, sizeof(buf) - 1);
std::string request(buf);
if (request.find("GET / ") == 0) {
send_response(client, 200, "text/html", HTML_PAGE);
} else if (request.find("POST /api/newgame") != std::string::npos) {
pos.set_fen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1");
send_response(client, 200, "application/json", "{\"ok\":true}");
} else if (request.find("POST /api/move") != std::string::npos) {
auto body_start = request.find("\r\n\r\n");
std::string body = (body_start != std::string::npos) ? request.substr(body_start + 4) : "";
std::string move_str = extract_move(body);
std::string reply = apply_and_respond(move_str);
send_response(client, 200, "application/json", "{\"move\":\"" + reply + "\"}");
} else if (request.find("POST /api/engine-move") != std::string::npos) {
std::string reply = engine_move();
send_response(client, 200, "application/json", "{\"move\":\"" + reply + "\"}");
} else {
send_response(client, 404, "text/plain", "Not found");
}
close(client);
}
}
What does this code do?
The server creates a TCP socket on port 8080, accepts connections one at a time, reads the HTTP request, and routes it. GET / returns the full HTML page (embedded as a raw string literal). The two POST endpoints call the engine's search function and return the best move as JSON.
The HTML page loads chessboard.js, chess.js, and jQuery from CDNs. These are fetched by the user's browser at runtime. Your C++ binary has zero external dependencies.
Wire up the --web flag and rebuild
- Open src/main.cpp and add #include "web.h" at the top with the other includes.
- Inside the argument-checking block, add a new branch for --web:
} else if (mode == "--web") {
web_server();
return 0;
}
- Add src/web.cpp to the add_executable list in CMakeLists.txt.
- Rebuild the project by running:
cd build && cmake --build . && cd ..
- Launch the web server by running:
./build/nexus --web
You should see Nexus web server running at http://localhost:8080 in your terminal.
- Open your browser and navigate to http://localhost:8080.
- Click Player vs Engine and drag a white piece to make a move. The engine should respond within 2 seconds.
- Click Engine vs Engine and watch both sides play automatically.
Browser shows a blank page or connection refused?
Make sure the engine is still running in your terminal (it should be blocking on accept). If port 8080 is already in use, you will see a bind error. Kill any other process using that port or change the port number in web.cpp.
Help me fix the web server not loading in my browser.
Your engine now has three interfaces: UCI for compatibility with any chess GUI, a terminal mode for quick play, and a web frontend for a graphical experience. Next up, the Secret Mission challenges you to make the engine search deeper by adding pruning techniques.
Secret mission
Add Null Move Pruning and Late Move Reductions
Your engine plays legal chess and finds decent moves. But it's leaving performance on the table. Two pruning techniques used by every top engine can make yours search 2-3 plies deeper in the same time window. Can you implement them?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine, so there are no cloud costs or ongoing charges. Decide whether to keep your resources for future development, pause them to free up space, or delete them entirely.
Resources you used:
- The nexus project folder on your Desktop (all source files and the compiled binary).
- CuteChess GUI (installed via Homebrew).
- CMake (installed via Homebrew).
Keep everything running
No action needed. Choose this if you plan to keep developing your chess engine or want to test it further in CuteChess.
- Your nexus binary is ready to load into any UCI-compatible GUI at any time.
- CMake stays available for building other C++ projects.
- CuteChess stays available for testing any chess engine you build in the future.
Pause - I'll come back to this later
Shut down any running processes to free up memory. Your files stay on disk so you can rebuild later.
- Quit CuteChess if it is still open.
- Close Cursor.
- Your project folder and Homebrew packages remain installed. No disk cleanup is needed for a pause.
Delete - I don't want to use this again
Remove all project files and the tools you installed for this project.
- Delete the project folder by running:
- Uninstall CuteChess by running:
- Uninstall CMake by running:
rm -rf ~/Desktop/nexus
This removes all source files, the compiled binary, and the build directory.
brew uninstall cutechess
brew uninstall cmake
What about Xcode Command Line Tools?
Xcode Command Line Tools provide your C++ compiler and many other developer utilities. Most macOS development workflows depend on them, so it is best to leave them installed unless you are certain you no longer need any command-line development tools.
Nice Work!
Nice Work!
You just built a complete chess engine from scratch in C++. Nexus uses the same fundamental algorithms that power the world's strongest engines, and it plays real, legal chess with genuine tactical awareness.
Here's what you accomplished:
- Built a bitboard-based board representation with full chess rules (castling, en passant, promotion, 50-move rule) and a make/unmake move system for both search traversal and user-facing undo/redo.
- Implemented alpha-beta search with iterative deepening, quiescence search, and a transposition table using Zobrist hashing for fast position lookup.
- Integrated the UCI protocol so the engine plugs into any chess GUI, and validated move generation correctness with perft testing.
- Added null move pruning and Late Move Reductions to boost search depth by 2-3 plies in the same time window.
Ready to quiz yourself?