Hello! Welcome back to building your chess game in Rust.
In the last lesson, we successfully implemented castling by using the castling_rights field to track the history of king and rook movements. We're continuing with special moves today, focusing on another rule that depends on what happened in the previous turn.
This lesson's goal is to implement the logic for the en passant capture rule. We'll use the en_passant_square field we added to our Board struct to track when this unique pawn capture is available and generate the move accordingly.
1. Understanding the En Passant Rule
"En passant" is French for "in passing." It's a special capture that a pawn can make immediately after an enemy pawn on an adjacent file moves two squares forward from its starting position. The capture happens as if the enemy pawn had only moved one square.
This rule can be a bit tricky, so let's clarify the exact conditions.
En Passant Chess Move Explained - Step by Step Tutorial
This short video from the Juggleboy channel provides an excellent, step-by-step demonstration of the en passant rule. It clearly explains when the move is possible and, just as importantly, when the opportunity is lost.
Watch from 00:33 to 01:54. Pay close attention to the three core conditions for the move to be legal.
To summarize, here are the three absolute requirements for an en passant capture:
- The Capturing Pawn: Your pawn must be on its fifth rank (for White) or fourth rank (for Black).
- The Opponent's Move: An enemy pawn on an adjacent file must move two squares forward in a single turn, landing right next to your pawn.
- The Timing: The en passant capture must be made on the very next turn. If you play any other move, you lose the right to make that specific en passant capture.
Here is a visual to make it concrete:

2. Strategy for Implementation
To implement this rule, our program needs to remember if the last move created an en passant opportunity. This is exactly why we added the en_passant_square: Option<Position> field to our Board struct.
Let's refine how this field will work.
Game State - Creating the Rustic chess engine
The "Rustic Chess" engine guide explains the standard way to handle en passant state, which we will adopt. This will clarify exactly what our en_passant_square field represents.
Read the paragraph explaining the en_passant variable. It starts with 'The variable en_passant marks the "active" en_passant square...'.
As the resource explains, our implementation strategy will have two parts:
-
Updating the State: After every move is made, we need to update
board.en_passant_square.- If the move was a two-square pawn push, we set
en_passant_squaretoSome(position)wherepositionis the square the pawn skipped over. - For any other move, we must reset
en_passant_squaretoNone.
- If the move was a two-square pawn push, we set
-
Generating the Move: When generating pawn moves, we'll check
board.en_passant_square.- If it is
Some(target_square), we then check if the current pawn is in the correct position to attack thattarget_square. - If so, we add a new
Moveto our list of legal moves.
- If it is
3. Hands-On: Updating the en_passant_square State
The logical place to manage the en_passant_square is within your execute_move function (or whatever you've named the function that applies a Move to the Board).
First, at the very beginning of this function, you should always reset the en passant square. This ensures the opportunity from a previous turn is correctly cleared.
Then, after you've processed the move, you check if that move created a new en passant opportunity.
Here is what the logic looks like. You'll need to integrate this into your execute_move function.
// In your `impl Board` block, inside your `execute_move` function
pub fn execute_move(&mut self, mv: &Move) {
// --- Part 1: Reset the en passant square at the start ---
self.en_passant_square = None;
let piece = self.squares[mv.from.rank][mv.from.file].unwrap();
// ... your existing logic to move the piece from 'from' to 'to' ...
// self.squares[mv.to.rank][mv.to.file] = self.squares[mv.from.rank][mv.from.file].take();
// --- Part 2: Set the new en passant square if applicable ---
// This check runs *after* you have determined what piece is moving.
if let PieceType::Pawn = piece.piece_type {
// Check if it was a two-square advance
if (mv.from.rank as i8 - mv.to.rank as i8).abs() == 2 {
// It was a two-square push. The en passant square is the one "skipped" over.
let skipped_rank = (mv.from.rank + mv.to.rank) / 2;
self.en_passant_square = Some(Position { rank: skipped_rank, file: mv.from.file });
}
}
// ... rest of your function (updating castling rights, player to move, etc.) ...
}
Take a moment to integrate this logic. The key is to first clear the state, then check if the current move creates a new one. Using (mv.from.rank as i8 - mv.to.rank as i8).abs() == 2 is a robust way to detect a two-square pawn move for both White and Black.
4. Hands-On: Generating the En Passant Move
Now that our board state is correctly updated, we can generate the actual en passant capture. This logic belongs in your pawn move generation function.
When you are iterating through your pawns to find their moves, you need to add a check for a possible en passant capture.
Here's the logic:
- Check if
self.en_passant_squareisSome. If it'sNone, there's nothing to do. - If it is
Some(target_pos), get the position of the pawn you are currently generating moves for. - Determine if this pawn is on the correct rank to perform the capture (rank 4 for White, rank 3 for Black, assuming 0-indexed from Black's side).
- Check if the pawn is on a file adjacent to the captured pawn's file (which is
target_pos.file). - If all these conditions are met, create a
Movewhere the pawn moves from its current position totarget_pos.
Let's add this to your pawn generation logic.
// Inside your pawn move generation function (e.g., generate_pawn_moves)
// This code should be part of the loop where you check each pawn.
// ... inside a loop over your board's squares ...
// if let Some(piece) = self.squares[r][f] {
// if piece.piece_type == PieceType::Pawn && piece.color == self.player_to_move {
// let current_pos = Position { rank: r, file: f };
// // ... your existing logic for one-step, two-step, and normal captures ...
// // --- Add En Passant Capture Logic ---
// if let Some(target_pos) = self.en_passant_square {
// let (is_on_correct_rank, is_on_adjacent_file) = if self.player_to_move == PieceColor::White {
// (
// current_pos.rank == 3, // White's 5th rank (0-indexed)
// (current_pos.file as i8 - target_pos.file as i8).abs() == 1
// )
// } else { // Black
// (
// current_pos.rank == 4, // Black's 4th rank (0-indexed)
// (current_pos.file as i8 - target_pos.file as i8).abs() == 1
// )
// };
// // Also check that the target square matches where a diagonal capture would go.
// // This handles the case where there's an en passant square but not next to THIS pawn.
// let can_capture_on_target = if self.player_to_move == PieceColor::White {
// target_pos.rank == current_pos.rank - 1 && target_pos.file == target_pos.file
// } else { // Black
// target_pos.rank == current_pos.rank + 1 && target_pos.file == target_pos.file
// };
// if is_on_correct_rank && is_on_adjacent_file && can_capture_on_target {
// moves.push(Move { from: current_pos, to: target_pos });
// }
// }
// }
// }
Important Note: Your execute_move function will need one final adjustment. When it detects an en passant move, it must not only move the capturing pawn but also remove the captured pawn. The captured pawn isn't on the to square; it's adjacent to it. You will need to add special logic to handle this case when you revisit execute_move. We will address this in a later lesson when we finalize the game loop, but it's good to be aware of now.
Conclusion
Excellent work today! You have now implemented one of the most unique and often misunderstood rules in chess. By managing the game state carefully, you've enabled your engine to recognize and generate the en passant capture.
Key Takeaways:
- En passant is a state-dependent rule that requires "memory" of the previous turn.
- The
Option<Position>type is a perfect fit for theen_passant_square, clearly representing whether the opportunity exists or not. - The implementation is split into two key parts: updating the state in
execute_moveand reading the state in the move generation logic. - A two-square pawn push sets the
en_passant_square; any other move clears it.
Preview of the Next Lesson
We're almost done with special moves! The last piece of the puzzle is pawn promotion. In the next lesson, we will implement the logic that allows a pawn to be promoted to a queen, rook, bishop, or knight when it reaches the final rank.