Hello again! I hope you're ready to add another layer of polish to our chess game.
In the last lesson, you did a fantastic job implementing an interactive pawn promotion menu. By creating a new GameStatus::AwaitingPromotion state and using macroquad::ui, you learned how to pause the game flow to handle special user interactions. This was a huge step forward for our UI.
Today, we'll continue improving the user experience by tackling a common problem in turn-based games: losing track of what just happened. We will highlight the squares of the most recently played move for better visual feedback. This small addition makes the game much easier to follow and gives it a more professional feel.
Here's an example of what we're aiming to achieve:

This feature is common in many digital chess implementations, as you can see from the feature list of other projects like the one in the resource "Rust 3D Chess with the Bevy Engine - GitHub".
To implement this, we'll follow a simple, powerful pattern we've used before:
- Store the data: We'll update our
GameStateto remember the last move. - Update the data: We'll record the move's coordinates every time a piece is moved.
- Draw based on the data: We'll use the stored coordinates to draw highlights on the board.
Let's get started.
Part 1: Storing the Last Move in GameState
Our game's rendering is driven entirely by its state. If we want to draw something new, we first need to store the information required to draw it. In this case, we need to know the source and destination squares of the most recent move.
The GameState struct is the perfect place for this. We need to store two squares, each represented by a (rank, file) tuple. However, at the very beginning of the game, there is no "last move". The Option type is perfect for handling this scenario.
Your Task: Update GameState
- Open the file where your
GameStatestruct is defined. - Add a new field called
last_move. - The type should be
Option<((usize, usize), (usize, usize))>, representingOption<((from_square), (to_square))>.
Your GameState struct should now look something like this:
pub struct GameState {
pub board: Board,
pub current_turn: Color,
pub status: GameStatus,
// Add this new field!
pub last_move: Option<((usize, usize), (usize, usize))>,
}
When you initialize a new game, make sure this field is set to None.
// In your function that creates a new GameState
impl GameState {
pub fn new() -> Self {
// ... existing initialization logic ...
Self {
board: initial_board,
current_turn: Color::White,
status: GameStatus::Ongoing,
last_move: None, // Initialize as None
}
}
}
Part 2: Recording the Move After It Happens
Now that we have a place to store the information, we need to update it at the right time. The best time to record the last move is immediately after a move has been successfully executed.
In your main game loop, you have logic that handles mouse clicks, validates a move, and then updates the board state. This is exactly where we need to add our new code.
Your Task: Update the last_move field
- Locate the section in your main loop where a valid move is performed. This is where you take a piece from a
from_squareand place it on ato_square. - Right after you update the
boardarray, add a line to updategame_state.last_move.
Here's an example of how that logic might look inside your click-handling code:
// Inside your main loop, after a click is detected and a move is validated...
if let Some((from_rank, from_file)) = selected_square {
let to_rank = clicked_rank;
let to_file = clicked_file;
// This is a simplified check. Your code will use your move generation logic.
let is_move_valid = true; // Replace with your validation logic.
if is_move_valid {
// --- This is the section to modify ---
// 1. Execute the move on the board
game_state.board[to_rank][to_file] = game_state.board[from_rank][from_file].take();
// 2. Record the move!
game_state.last_move = Some(((from_rank, from_file), (to_rank, to_file)));
// 3. Clear the selection
selected_square = None;
// 4. Switch turns and update game status (check, checkmate, etc.)
// ... your existing logic for this ...
}
}
With this change, every time a piece moves, your GameState will remember where it came from and where it went.
Part 3: Drawing the Highlights
The final step is to use the last_move data to draw the highlights on the screen. The drawing should happen every frame. The correct drawing order is important for the visual effect:
- Draw the checkerboard pattern.
- Draw the highlights.
- Draw the pieces.
This ensures the highlights appear on top of the board squares but underneath the pieces.
Your Task: Add Drawing Logic to the Main Loop
- In your main game loop (
loop { ... }), find the rendering section. - Place this new code after you draw the board squares but before you draw the pieces.
- We'll use
draw_rectanglewith a semi-transparent color to create the highlight effect. A bright, slightly transparent yellow is a good choice.
// In your main loop, inside the rendering section
clear_background(BLACK);
// Draw the board squares (your existing code)
// ...
// ---- ADD THIS NEW CODE BLOCK ----
// Draw last move highlights
if let Some(((from_rank, from_file), (to_rank, to_file))) = game_state.last_move {
let highlight_color = Color::new(1.0, 1.0, 0.0, 0.4); // Semi-transparent yellow
// Highlight the 'from' square
draw_rectangle(
from_file as f32 * TILE_SIZE,
from_rank as f32 * TILE_SIZE,
TILE_SIZE,
TILE_SIZE,
highlight_color,
);
// Highlight the 'to' square
draw_rectangle(
to_file as f32 * TILE_SIZE,
to_rank as f32 * TILE_SIZE,
TILE_SIZE,
TILE_SIZE,
highlight_color,
);
}
// ---- END OF NEW CODE BLOCK ----
// Draw the pieces (your existing code)
// ...
// Draw UI text (Check, Turn, etc.)
// ...
next_frame().await;
(You will need to replace TILE_SIZE with whatever variable you are using for the size of your board squares.)
Now, run your game. The board will start with no highlights. Make a move, and you should immediately see the source and destination squares light up. When you make the next move, the old highlights will disappear, and the new ones will appear automatically. This works because on every frame, we are re-drawing the entire scene based on the current GameState.
Conclusion
Excellent work! With just a few lines of code, you've added a feature that significantly improves the clarity and usability of your chess game.
Key Takeaways:
- To visualize information that changes over time (like the last move), you must first add a field to your
GameStatestruct to store it. - The state-driven rendering approach is very powerful. By updating
game_state.last_moveonce, the rendering loop automatically handles drawing the highlights correctly on every subsequent frame. - Drawing order is crucial for layering visual elements. We draw highlights on top of the board but underneath the pieces for the desired effect.
Preview of the Next Lesson
Our game is nearly complete from a feature perspective. It's interactive, visually clear, and follows the rules of chess. But what happens when a game ends? In our next lesson, we will complete the user interface by adding a 'New Game' button to reset the game state and start a new match, making our project a fully replayable experience.