Building a Chess Engine, Part 2
Part 2 covers move generation in full: precomputed attack tables for non-sliding pieces, ray-cast attacks for sliding pieces, pseudo-legal generation for all piece types, and the make-and-verify filter that produces a clean list of legal moves.
Attack Tables
Before generating moves, the engine needs to know what squares each piece attacks from any given square. For non-sliding pieces - pawns, knights, and kings - these attack sets don't depend on the positions of other pieces, so they can be precomputed once at compile time and stored in lookup tables.
Compile-time tables with constexpr
C++17's constexpr is powerful enough that the entire table generation runs at compile time. The function computes knight attacks from every square, and the result is a compile-time constant - zero runtime cost, no initialisation function needed.
constexpr uint64_t knightAttacksFrom(int sq){
uint64_t attacks = 0;
int r = sq / 8, f = sq % 8;
constexpr int dr[8] = {-2,-2,-1,-1, 1, 1, 2, 2};
constexpr int df[8] = {-1, 1,-2, 2,-2, 2,-1, 1};
for(int i = 0; i < 8; i++){
int rr = r + dr[i], ff = f + df[i];
if (rr >= 0 && rr < 8 && ff >= 0 && ff < 8)
attacks |= (1ULL << (rr*8 + ff));
}
return attacks;
}
inline constexpr auto KNIGHT_ATTACKS = generateKnightAttacks();
inline constexpr auto KING_ATTACKS = generateKingAttacks();
The king attack table is generated the same way, using bitwise shifts with the file masks to prevent wrap-around: east and north-east shifts are masked with NOT_FILE_H, west and north-west shifts with NOT_FILE_A.
Sliding piece attacks
Bishops, rooks, and queens are different - their attack sets depend on where other pieces are blocking them. A rook on A1 attacks all of the A-file when the board is empty, but only up to the first blocker when pieces are present. These can't be precomputed without knowing the occupancy.
The approach here is straightforward ray casting: walk outward from the piece's square in each direction, setting bits as you go, until you walk off the board or hit a blocker. The blocker's square is included in the attack set (because it can be captured) and then the ray stops.
inline uint64_t rookAttacksFrom(int from, uint64_t occupied){
int r = from / 8, f = from % 8;
uint64_t attacks = 0;
for(int i = r+1; i < 8; i++){
int shift = i*8+f;
attacks |= 1ULL << shift;
if ((1ULL << shift) & occupied) break;
}
// South, East, West follow the same pattern...
return attacks;
}
This is called "classical" or "ray-casting" attack generation. It's not the fastest possible approach - most modern engines use magic bitboards, which encode the entire attack set for every square and every possible occupancy configuration into a lookup table indexed by a hash. But ray casting is intuitive and readable and fast enough for a first pass. Future work would be to extend the engine to use magic bitboards.
Pseudo-Legal Move Generation
Pseudo-legal move generation produces all moves a piece could make ignoring whether they leave the king in check. Separating pseudo-legal from legal generation is the standard approach because it lets you generate moves cheaply in bulk and filter out the illegal ones in a separate, controlled step.
Pawns
Pawns are the most complex piece to generate for because they have asymmetric movement, direction that depends on color, four different promotion pieces, en passant, and a special double push from the starting rank. The white and black cases are handled separately to keep the shift directions clear.
For white, a single push is (pawns << 8) & empty. The & empty ensures pawns can't push through other pieces. A double push is ((oneStep & RANK_3) << 8) & empty - first check that the one-step result lands on rank 3 (meaning the pawn started on rank 2), then push again. Captures go diagonally: left capture is (pawns & NOT_FILE_A) << 7, right is (pawns & NOT_FILE_H) << 9, then intersected with the enemy occupancy.
uint64_t oneStep = (pawns << 8) & empty;
uint64_t quiet = oneStep & ~RANK_8;
uint64_t promo = oneStep & RANK_8;
uint64_t twoStep = ((oneStep & RANK_3) << 8) & empty;
uint64_t capLeft = ((pawns & NOT_FILE_A) << 7) & blackOccupancy;
uint64_t capRight = ((pawns & NOT_FILE_H) << 9) & blackOccupancy;
Iterating over the resulting bitboards and converting to moves is a standard popLSB loop. The from square for a push is always derived from to by reversing the shift - e.g. for a white single push, from = to - 8.
Promotions generate four moves each - one for queen, rook, bishop, and knight. All four are added because the best promotion piece is position-dependent (knight underpromotions occasionally win games that queen promotions lose due to stalemate), and the search will naturally find the best one.
En passant
En passant requires care. The engine stores an enPassantSquare that is set when a double pawn push happens and cleared to -1 at the start of every other move. To find white pawns that can capture en passant, the engine looks at the squares adjacent to the en passant target square and checks if a white pawn sits there.
void MoveGen::generateWhiteEnPassant(std::vector<Move>& moves){
if (board.enPassantSquare == -1) return;
uint64_t epB = 1ULL << board.enPassantSquare;
uint64_t fromLeft = ((epB & NOT_FILE_A) >> 9) & board.pieces[WHITE][PAWN];
uint64_t fromRight = ((epB & NOT_FILE_H) >> 7) & board.pieces[WHITE][PAWN];
}
Sliding pieces
Rooks, bishops, and queens use the ray-cast attack functions from attacks.hpp directly. The pattern is the same for all three: get the attack bitboard from the piece's square using the current occupancy, mask out squares occupied by friendly pieces (can't capture your own pieces), then loop over the remaining targets.
void MoveGen::generateRookMoves(std::vector<Move>& moves){
uint64_t rooks = board.pieces[board.turn][ROOK];
uint64_t own = (board.turn == WHITE) ? board.whiteOccupancy
: board.blackOccupancy;
uint64_t enemy = (board.turn == WHITE) ? board.blackOccupancy
: board.whiteOccupancy;
while(rooks){
int from = popLSB(rooks);
uint64_t targets = rookAttacksFrom(from, board.occupied) & ~own;
while(targets){
int to = popLSB(targets);
MoveFlag flag = ((1ULL << to) & enemy) ? CAPTURE : QUIET;
moves.emplace_back(makeMove(from, to, ROOK, flag));
}
}
}
The queen is just a rook and bishop combined - its targets are the union of rook and bishop attacks from the same square.
Castling
Castling has three conditions beyond just having the right including: the squares between king and rook must be empty, and the king must not pass through or land on an attacked square (castling out of check is also illegal, though that's caught by the legal move filter). These three square checks are done with isSquareAttacked calls.
if ((board.castlingRights & WHITE_KINGSIDE)
&& !(board.occupied & ((1ULL << F1) | (1ULL << G1)))
&& !board.isSquareAttacked(E1, BLACK)
&& !board.isSquareAttacked(F1, BLACK)
&& !board.isSquareAttacked(G1, BLACK))
moves.emplace_back(makeMove(E1, G1, KING, KING_CASTLE));
The castling rights are updated in makeMove whenever a king or rook moves, and also whenever a rook's home square is captured - clearing the right for a rook that no longer exists.
Legal Move Generation
A pseudo-legal move is illegal if it leaves the moving side's king in check. The cleanest way to test this is the make-and-verify approach: make the move on a copy of the board, then ask if the king is in check. If it is, the move is discarded.
std::vector<Move> MoveGen::generateMoves(){
std::vector<Move> pl_moves = generatePlMoves();
std::vector<Move> moves;
for(const Move& m : pl_moves){
Board next = board;
next.makeMove(m);
if (!next.inCheck(board.turn))
moves.push_back(m);
}
return moves;
}
Notice board.turn, not next.turn - by the time we're checking, makeMove has already flipped the turn, so we need to explicitly check the side that just moved.
inCheck finds the king square and calls isSquareAttacked from the opponent's perspective.
bool Board::inCheck(Color side) const {
uint64_t king = pieces[side][KING];
int kingSq = std::countr_zero(king);
return isSquareAttacked(kingSq, opposite(side));
}
isSquareAttacked checks each piece type in turn. For pawns, it reverses the perspective - instead of asking "what does a white pawn on every square attack", it asks "which squares can attack this square as if they were white pawns", and checks if there's actually a white pawn there.
bool Board::isSquareAttacked(int sq, Color attacker) const {
uint64_t target = 1ULL << sq;
if (attacker == WHITE){
uint64_t pawnAttackers =
((target & NOT_FILE_A) >> 9) | ((target & NOT_FILE_H) >> 7);
if (pawnAttackers & pieces[WHITE][PAWN]) return true;
}
if (KNIGHT_ATTACKS[sq] & pieces[attacker][KNIGHT]) return true;
if (KING_ATTACKS[sq] & pieces[attacker][KING]) return true;
if (rookAttacksFrom(sq, occupied) &
(pieces[attacker][ROOK] | pieces[attacker][QUEEN])) return true;
if (bishopAttacksFrom(sq, occupied) &
(pieces[attacker][BISHOP] | pieces[attacker][QUEEN])) return true;
return false;
}
The make-and-verify approach is simple and correct. Its cost is one board copy and one full attack check per pseudo-legal move.
An empty move list from generateMoves means either checkmate or stalemate - distinguishable by calling inCheck on the result.