(All code for this tutorial can be found here: https://github.com/gwowen/breakout-tutorial-rust)
So after last time we have our bat moving around, our ball bouncing around the window, and a wall of bricks drawn, but there’s something missing – they don’t interact, and that’s really not very interesting!
To get to the game we want, we need to add two things: collision detection, and brick destruction. This is going to be more complicated and long than the other posts in this series, but by the end we’ll have the basics of Breakout up and running!
What is AABB and why are we using it?
Axis-Aligned Bounding Box (AABB) is one of the simplest, but most widely used collision detection methods in video games.
An AABB is a rectangular box that surrounds an object in 2D or 3D space. What makes it “axis-aligned” is that its edges are parallel to the coordinate axes (e.g., x, y, and z in 3D). This alignment simplifies calculations significantly compared to arbitrarily oriented boxes or more complex shapes.
We will use AABB rather than writing a more ad-hoc method, to avoid any problems such as “quantum teleporting” balls that might occur with a simpler boundary-checking method. AABB is also used in things like the Box2D physics engine for broadphase physics detection, so we’re in good company in using it!
Introducing our AABB function
This is the AABB implementation we will introduce into our main.rs file:
fn resolve_collision(a: &mut Rect, vel: &mut Vec2, b: &Rect) -> bool {
// early exit
let intersection = match a.intersect(*b) {
Some(intersection) => intersection,
None => return false,
};
let a_center = a.point() + a.size() * 0.5f32;
let b_center = b.point() + b.size() * 0.5f32;
let to = b_center - a_center;
let to_signum = to.signum();
match intersection.w > intersection.h {
true => {
// Bounce on y-axis
a.y -= to_signum.y * intersection.h;
vel.y = -to_signum.y * vel.y.abs();
}
false => {
// Bounce on x-axis
a.x -= to_signum.x * intersection.w;
vel.x = -to_signum.x * vel.x.abs();
}
}
true
}
There’s quite a lot going on here, so let’s break it down step by step.
This function takes three arguments:
- a: &mut Rect: A mutable reference to the AABB of object A, allowing its position to be modified.
- vel: &mut Vec2: A mutable reference to object A’s velocity, a 2D vector, which will be updated upon collision.
- b: &Rect: A reference to the AABB of object B, assumed to be static or processed separately.
It returns a bool indicating whether a collision was detected and resolved (true) or not (false). While that’s the overall function, we’ve still got a lot in it to explain!
Let’s get started.
Step 1: Collision Detection
The function begins with an early exit check, using the inbuilt intersection check of Macroquad:
let intersection = match a.intersect(*b) {
Some(intersection) => intersection,
None => return false,
};
Here, a.intersect(*b) computes the overlapping region between AABBs A and B. If there’s no overlap, it returns None, and the function exits with false, indicating no collision.
If an intersection exists, it returns Some(intersection), where intersection is a Rect representing the overlap area, with:
intersection.w: The width of the overlap (along the x-axis).
intersection.h: The height of the overlap (along the y-axis).
How does the intersection calculation work?
Picture this:
Rectangle A stretches from x_a to x_a + w_a side-to-side (that’s its width, w_a), and from y_a to y_a + h_a top-to-bottom (its height, h_a).
Rectangle B goes from x_b to x_b + w_b on the x-axis (width w_b), and y_b to y_b + h_b on the y-axis (height h_b).
For these two rectangles to overlap, their “shadows” on the x-axis and y-axis need to cross paths.
Here’s how we figure out where they meet:
Side-to-side overlap (x-range): They start at the farther-right starting point (max(x_a, x_b)) and end at the closer-left ending point (min(x_a + w_a, x_b + w_b)).
Up-and-down overlap (y-range): They begin at the higher bottom point (max(y_a, y_b)) and stop at the lower top point (min(y_a + h_a, y_b + h_b)).
Now, let’s measure how much space they’re sharing:
The width of their overlap (intersection.w) is the distance from the start of the overlap to the end: min(x_a + w_a, x_b + w_b) – max(x_a, x_b).
The height (intersection.h) is similar: min(y_a + h_a, y_b + h_b) – max(y_a, y_b).
If either the width or height of this shared space is zero or less (intersection.w ≤ 0 or intersection.h ≤ 0), they’re not actually overlapping and a.intersect(*b) returns false.
But if both numbers are positive, they’re happily sharing some space, and those values tell us how snugly they’re fitting together along each direction and this is returned by the intersection calculation.
Step 2: Computing Centers and Direction
Once an intersection has been confirmed, the function then goes on to calculate the center of both AABBs:
let a_center = a.point() + a.size() * 0.5f32; let b_center = b.point() + b.size() * 0.5f32;
The center of our AABB here is the position plus half its size in each dimension:
a_center = (x_a + w_a/2, y_a + h_a/2)
b_center = (x_b + w_b/2, y_b + h_b/2)
Next, it computes the vector from A’s center to B’s center:
let to = b_center - a_center;
So, to = (x_b + w_b/2 – (x_a + w_a/2), y_b + h_b/2 – (y_a + h_a/2)).
Then, it extracts the sign of each component:
let to_signum = to.signum();
The signum() function returns:
1 if the component is positive,
-1 if negative,
0 if zero (though zero is unlikely here since the AABBs overlap).
Thus, to_signum = (signum(to.x), signum(to.y)) indicates the direction from A’s center to B’s center. For example:
If to_signum.y = 1, B is below A (assuming y increases downward).
If to_signum.y = -1, B is above A.
This direction determines how to adjust A’s position to move it away from B.
Step 3: Choosing the Resolution Axis
The function decides which axis to resolve the collision along:
rust
match intersection.w > intersection.h {
true => {
// Bounce on y-axis
a.y -= to_signum.y * intersection.h;
vel.y = -to_signum.y * vel.y.abs();
}
false => {
// Bounce on x-axis
a.x -= to_signum.x * intersection.w;
vel.x = -to_signum.x * vel.x.abs();
}
}
Here, intersection.w is the overlap along the x-axis, and intersection.h is the overlap along the y-axis. The decision rule is:
If intersection.w > intersection.h, resolve along the y-axis (overlap in y is smaller).
If intersection.w ≤ intersection.h, resolve along the x-axis (overlap in x is smaller or equal).
Why Resolve Along the Smaller Overlap?
In AABB collision resolution, a common strategy is to separate objects along the axis of minimal penetration. The smaller overlap indicates the direction requiring the least movement to eliminate the intersection, which often corresponds to the collision’s “entry” direction. For example:
If two rectangles overlap significantly in x but minimally in y, they likely collided from the top or bottom, so resolving along y is more natural and requires less adjustment.
This approach minimizes positional correction, reducing visual artifacts and maintaining physical plausibility in simulations.
Step 4: Positional Correction
Depending on the chosen axis, the position of A is adjusted:
Y-axis resolution (intersection.w > intersection.h):
rust
a.y -= to_signum.y * intersection.h;
If to_signum.y = 1 (B below A), subtract intersection.h from a.y, moving A upward.
If to_signum.y = -1 (B above A), add intersection.h to a.y, moving A downward.
The adjustment equals the y-overlap (intersection.h), fully separating the AABBs along y.
X-axis resolution (intersection.w ≤ intersection.h):
a.x -= to_signum.x * intersection.w;
If to_signum.x = 1 (B right of A), subtract intersection.w, moving A left.
If to_signum.x = -1 (B left of A), add intersection.w, moving A right.
The adjustment equals the x-overlap (intersection.w).
This ensures A moves away from B by exactly the overlap amount along the chosen axis.
Giving the blocks “lives”
Whew! Having gone through the collision resolution, the next stage is to add “lives” to the blocks so when the ball collides with them, they suffer damage.
Go to the block.rs file and adjust the Block structure in the following way. inserting lives:
pub struct Block {
pub rect: Rect,
pub lives: i32,
}
The lives field will contain the amount of lives that the bricks have. We’ll set it in the new function of the implementation of the Block structure that they start out with two, and then we’ll make it also that when bricks are hit, they first turn red, then turn orange.
impl Block {
pub fn new(pos: Vec2) -> Self {
Self {
rect: Rect::new(pos.x, pos.y, BLOCK_SIZE.x, BLOCK_SIZE.y),
lives: 2,
}
}
pub fn draw(&self) {
// change color when hit
let color = match self.lives {
2 => RED,
_ => ORANGE
};
draw_rectangle(self.rect.x, self.rect.y, self.rect.w, self.rect.h, color);
}
}
Bringing it all together
Having made these changes to the bricks, it’s time to put them together with the collision resolution function to get things to start breaking (in a good way!).
In the main function, we’re going to insert these pieces of code into the main game loop:
for ball in balls.iter_mut() {
// check collision of balls
resolve_collision(&mut ball.rect, &mut ball.vel, &player.rect);
for block in blocks.iter_mut() {
// check collision with blocks
if resolve_collision(&mut ball.rect, &mut ball.vel, &block.rect) {
block.lives -= 1;
}
}
}
// remove all blocks with lives less than one
blocks.retain(|block| block.lives > 0);
So as the game goes on, we loop through the balls (more on that in the next article!) present on the screen, and check to see if they’ve collided with the player, and if so the velocity of the ball changes.
But also, we check if we’ve resolved a collision between the ball and a block, and then if we have the lives of the block are decreased by one, and the color changes.
The last line of code checks that all blocks on screen have lives greater than zero, and if the block has less than zero lives, it is removed.
So, after putting it all together, we should see something like this!
As can be seen, some of the bricks have been hit, causing them to turn orange, and some have been destroyed! That’s pretty much the core mechanics of the game, however we still need to add scoring, lives and game states to get the full, finished product. We will add all of these in our next installment… see you then!