So far, our game objects exist only as single points (Vec2 Position
). While great for movement, it's not enough for interactions like checking if a projectile hits a character.
This lesson tackles that by introducing bounding boxes – simple rectangular shapes that represent the space an object occupies. We'll focus on Axis-Aligned Bounding Boxes (AABBs), implement a BoundingBox
class using SDL_FRect
, add it to our GameObject
, and learn how to draw these boxes for debugging purposes.
In the next lesson, we’ll learn how to use these bounding boxes to detect when they intersect, and some practical reasons why that is useful.
Finally, we’ll end the chapter by revisiting our physics system and use our new bounding boxes to let our objects physically interact with each other.
Let’s imagine we want to add an enemy to our game that can fire projectiles that our player needs to dodge. When our player’s character is represented by a simple point in space, we can’t easily determine if a projectile hit them:
Unfortunately, detecting which objects in our scene are colliding with each other is not a something we can do directly. in real-world games, each object can have a complex shape comprising thousands of pixels (or vertices, in a 3D game) and our scene may have thousands of objects. Understanding how all of these complex shapes might be interacting on every frame is unreasonably expensive.
To help with this, we use bounding volumes. These are much simpler shapes that "bound" (or contain) all the components of a more complex object. They’re not visible to players but, behind the scenes, we use them to understand how the objects in our scene are interacting.
The most common bounding volume we use is a bounding box, which is a simple rectangle in 2D, or a cuboid in 3D:
Now, to understand if our player was hit by a fireball, we just need to do the much simpler calculation of checking whether two rectangles are overlapping:
Quite often, a game needs to check if something hits the actual, complex shape of an object. Common examples include a lighting calculation to understand how an object should be rendered, or a competitive shooter where we want to check if a bullet hit the player, not just their bounding box.
Even in these scenarios, bounding volumes are used as a performance optimization. Our scene can have thousands of objects and any given ray of light or bullet will hit maybe one of them. We can use our bounding volumes to quickly exclude objects from consideration because, if something didn’t hit a bounding volume, we know it didn’t hit anything within that volume either.
More complex projects take this idea further and create bounding volume hierarchies (BVHs) where a bounding volume contains smaller, more accurate volumes, allowing this culling process to be done multiple times.
In 2D games, bounding boxes are simple rectangles. Axis-aligned bounding boxes, or AABBs, are boxes whose edges are parallel to the axes of our space. More simply, we can think of AABBs as being rectangles that have not been rotated:
A bounding box that can be rotated is typically called an oriented bounding box, or OBB.
AABBs are friendlier and faster to work with as they simplify a lot of the calculations required to determine things like whether two bounding boxes intersect.
AABBs are also easier and more memory-efficient to represent, requiring only four scalar values in 2D, or six in 3D.
We’re working with 2D for now, and there are two main ways to represent an AABB in two dimensions:
As we’ve seen, SDL_Rect
uses the first of these conventions, with members called , , , and . Often, we need to use both conventions within the same program, but it’s relatively easy to convert one to the other.
We can calculate the bottom right corner () of an SDL_Rect
through addition:
If we have a box defined by , , , and values, we can calculate its width and height through subtraction:
Our bounding boxes are defined in world space, so we’ll represent them using an SDL_FRect
, which works in the same way as an SDL_Rect
except that the x
, y
, w
, and h
values are stored as floating point numbers instead of integers:
// BoundingBox.h
#pragma once
#include <SDL.h>
class BoundingBox {
public:
BoundingBox(const SDL_FRect& InitialRect)
: Rect{InitialRect} {}
private:
SDL_FRect Rect;
};
We’ll also add a SetPosition()
method to move our bounding box by setting the x
and y
values of the SDL_FRect
:
// BoundingBox.h
#pragma once
#include <SDL.h>
#include "Vec2.h"
class BoundingBox {
public:
BoundingBox(const SDL_FRect& InitialRect)
: Rect{InitialRect} {}
void SetPosition(const Vec2& Position) {
Rect.x = Position.x;
Rect.y = Position.y;
}
private:
SDL_FRect Rect;
};
Let’s update our GameObject
class to give each object a BoundingBox
. Our GameObject
instances already have a Position
variable that can be used to set the top-left corner of the bounding box. We also need to specify its width and height, so let’s add those as constructor parameters:
// GameObject.h
// ...
#include "BoundingBox.h"
// ...
class GameObject {
public:
GameObject(
const std::string& ImagePath,
const Vec2& InitialPosition,
float Width,
float Height,
const Scene& Scene
) : Image{ImagePath},
Position{InitialPosition},
Scene{Scene},
Bounds{SDL_FRect{
InitialPosition.x, InitialPosition.y,
Width, Height
}}
{}
// ...
private:
// ...
BoundingBox Bounds;
};
Once we’ve calculated our object’s new Position
at the end of each Tick()
function, we’ll notify our bounding box:
// GameObject.h
// ...
class GameObject {
public:
// ...
void Tick(float DeltaTime) {
ApplyForce(FrictionForce(DeltaTime));
ApplyForce(DragForce());
Velocity += Acceleration * DeltaTime;
Position += Velocity * DeltaTime;
Acceleration = {0, -9.8};
// Don't fall through the floor
if (Position.y < 2) {
Position.y = 2;
Velocity.y = 0;
}
Bounds.SetPosition(Position);
Clamp(Velocity);
}
// ...
};
Finally, let’s update our scene to include the width and height of our objects, bearing in mind that our world space uses meters as its unit of distance.
In this case, the 1.9
and 1.7
values were chosen based on the size of the image we’re using to represent our character:
// Scene.h
// ...
class Scene {
public:
Scene() {
Objects.emplace_back(
"dwarf.png", Vec2{6, 2}, 1.9, 1.7, *this);
}
// ...
};
Normally, bounding boxes are not rendered to the screen but, when developing a complex game, it’s useful to have that capability to help us understand what’s going on.
To enable this, we need to be able to convert our world space bounding box to an equivalent screen space version. Our Scene
class already has a ToScreenSpace()
function for converting Vec2
objects from world space to screen space. We’ll overload this function with a variation that works with SDL_FRect
objects.
To convert the rectangle's top-left corner (x
, y
), we can use our existing ToScreenSpace(Vec2)
function. For the width (w
) and height (h
), we only need to scale them by the horizontal and vertical scaling factors, respectively:
// Scene.h
// ...
class Scene {
public:
// ...
Vec2 ToScreenSpace(Vec2& Pos) {/*...*/}
SDL_FRect ToScreenSpace(const SDL_FRect& Rect) const {
Vec2 ScreenPos{ToScreenSpace(Vec2{Rect.x, Rect.y})};
float HorizontalScaling{Viewport.w / WorldSpaceWidth};
float VerticalScaling{Viewport.h / WorldSpaceHeight};
return {
ScreenPos.x,
ScreenPos.y,
Rect.w * HorizontalScaling,
Rect.h * VerticalScaling
};
}
// ...
};
Next, we’ll add a Render()
function to our BoundingBox
class. We’ll need to access our Scene
for the ToScreenSpace()
function we just created.
We need access to the Scene
object within our bounding box’s Render()
method, but we can't #include
the Scene
header file directly in BoundingBox.h
. Doing so would create a circular dependency because Scene.h
includes GameObject.h
, which in turn includes BoundingBox.h
.
To resolve this, we can forward-declare Scene
in BoundingBox.h
. We then include "Scene.h"
in a new implementation file - BoundingBox.cpp
- where the Render()
method is actually defined and needs the full Scene
definition.
// BoundingBox.h
// ...
class Scene;
class BoundingBox {
public:
// ...
void Render(
SDL_Surface* Surface, const Scene& Scene);
// ...
};
// BoundingBox.cpp
#include <SDL.h>
#include "Scene.h"
#include "BoundingBox.h"
void BoundingBox::Render(
SDL_Surface* Surface, const Scene& Scene
) {
// ...
}
In addition to converting our bounding box’s SDL_FRect
to screen space, we also need to round its x
, y
, w
, and h
floating point numbers to integers to specify which pixels they occupy on the SDL_Surface
.
To do this rounding, we’ll create a function for converting SDL_FRect
objects to SDL_Rect
s.
This rounding conversion is a general utility function. Often, you'd place such utilities in a separate header file. For simplicity in this lesson, we'll add it directly to our BoundingBox
class:
// BoundingBox.h
// ...
class BoundingBox {
public:
// ..
static SDL_Rect Round(const SDL_FRect& Rect) {
return {
static_cast<int>(std::round(Rect.x)),
static_cast<int>(std::round(Rect.y)),
static_cast<int>(std::round(Rect.w)),
static_cast<int>(std::round(Rect.h))
};
}
// ...
};
Since the Round()
function doesn't need access to any specific BoundingBox
object's data (like Rect
), we can declare it as static
.
This makes it usable even in situations where we don’t have a bounding box instance - we can invoke it using BoundingBox::Round()
in those scenarios.
We can now combine our ToScreenSpace()
and Round()
functions to get our bounding box in screen space, and with its components as rounded int
values:
// BoundingBox.cpp
// ...
void BoundingBox::Render(
SDL_Surface* Surface, const Scene& Scene
) {
auto [x, y, w, h]{
Round(Scene.ToScreenSpace(Rect))};
// ...
};
Unlike previous examples, we don’t want the full area of our rectangle to be filled with a solid color. Instead, we just want to draw it as a rectangular border:
To do this, we can draw four lines - one for each of the top, bottom, left, and right edges. To draw each line, we can simply render a thin rectangle.
For example, our top border will start at the top left of our bounding box and span its full width. As such, it will share its x
, y
, and w
values. However, the height (h
) of this rectangle will be much shorter, representing the thickness of our line.
Let’s set that up:
// BoundingBox.cpp
// ...
void BoundingBox::Render(
SDL_Surface* Surface, const Scene& Scene
) {
auto [x, y, w, h]{
Round(Scene.ToScreenSpace(Rect))};
int LineWidth{4};
SDL_Rect Top{x, y, w, LineWidth};
// ...
};
We can draw the rectangle representing our line in the usual way, passing the surface, rectangle, and color to SDL_FillRect()
:
// BoundingBox.cpp
// ...
void BoundingBox::Render(
SDL_Surface* Surface, const Scene& Scene
) {
auto [x, y, w, h]{
Round(Scene.ToScreenSpace(Rect))};
int LineWidth{4};
SDL_Rect Top{x, y, w, LineWidth};
Uint32 LineColor{SDL_MapRGB(
Surface->format, 220, 0, 0)};
SDL_FillRect(Surface, &Top, LineColor);
}
Let’s draw our other edges by following the same logic. Because each SDL_Rect
defines the top left corner of where the rectangle will be drawn, the right edge will draw slightly outside of our bounding box if we don’t intervene.
To fix this, we move it left by reducing its x
value based on our LineWidth
:
The bottom edge will have a similar problem where it is rendered below our bounding box, so we need to move it up by the LineWidth
value. Because we’re working with screen space values at this point in our function, moving up corresponds to reducing the y
value.
Putting everything together looks like this:
// BoundingBox.cpp
// ...
void BoundingBox::Render(
SDL_Surface* Surface, const Scene& Scene
) {
auto [x, y, w, h]{
Round(Scene.ToScreenSpace(Rect))};
int LineWidth{4};
Uint32 LineColor{SDL_MapRGB(
Surface->format, 220, 0, 0)};
SDL_Rect Top{x, y, w, LineWidth};
SDL_Rect Left{x, y, LineWidth, h};
SDL_Rect Bottom{
x, y + h - LineWidth, w, LineWidth};
SDL_Rect Right{
x + w - LineWidth, y, LineWidth, h};
SDL_FillRect(Surface, &Top, LineColor);
SDL_FillRect(Surface, &Left, LineColor);
SDL_FillRect(Surface, &Bottom, LineColor);
SDL_FillRect(Surface, &Right, LineColor);
}
Finally, we need to update our GameObject
class to call the Render()
method on our bounding box. We only want the bounding boxes to be drawn when we’re debugging our program, so this is a scenario where we might want to use a preprocessor directive:
// GameObject.cpp
// ...
#include "BoundingBox.h"
#define DRAW_BOUNDING_BOXES
void GameObject::Render(SDL_Surface* Surface) {
Image.Render(
Surface, Scene.ToScreenSpace(Position)
);
#ifdef DRAW_BOUNDING_BOXES
Bounds.Render(Surface, Scene);
#endif
}
// ...
If we run our program, we should now see our character’s bounding box drawn, and its position updates as our character moves:
Note that our screenshots include additional trajectory lines to show how objects move. The code to render these lines is included in the GameObject.cpp
file below for those interested.
A complete version of our BoundingBox
class is included below. We have also provided the GameObject
and Scene
classes with the code we added in this lesson highlighted:
#pragma once
#include <SDL.h>
#include "Vec2.h"
class Scene;
class BoundingBox {
public:
BoundingBox(const SDL_FRect& InitialRect)
: Rect{InitialRect} {}
void SetPosition(const Vec2& Position) {
Rect.x = Position.x;
Rect.y = Position.y;
}
static SDL_Rect Round(const SDL_FRect& Rect) {
return {
static_cast<int>(std::round(Rect.x)),
static_cast<int>(std::round(Rect.y)),
static_cast<int>(std::round(Rect.w)),
static_cast<int>(std::round(Rect.h))
};
}
void Render(
SDL_Surface* Surface, const Scene& Scene
);
private:
SDL_FRect Rect;
};
#include <SDL.h>
#include "Scene.h"
#include "BoundingBox.h"
void BoundingBox::Render(
SDL_Surface* Surface, const Scene& Scene
) {
int LineWidth{4};
Uint32 LineColor{
SDL_MapRGB(Surface->format, 220, 0, 0)};
auto [x, y, w, h]{
Round(Scene.ToScreenSpace(Rect))};
SDL_Rect Top{x, y, w, LineWidth};
SDL_Rect Bottom{
x, y + h - LineWidth, w, LineWidth};
SDL_Rect Left{x, y, LineWidth, h};
SDL_Rect Right{
x + w - LineWidth, y, LineWidth, h};
SDL_FillRect(Surface, &Top, LineColor);
SDL_FillRect(Surface, &Bottom, LineColor);
SDL_FillRect(Surface, &Left, LineColor);
SDL_FillRect(Surface, &Right, LineColor);
}
#pragma once
#include <SDL.h>
#include <vector>
#include "GameObject.h"
class Scene {
public:
Scene() {
Objects.emplace_back(
"dwarf.png", Vec2{6, 2}, 1.9, 1.7, *this);
}
Vec2 ToScreenSpace(const Vec2& Pos) const {
auto [vx, vy, vw, vh]{Viewport};
float HorizontalScaling{vw / WorldSpaceWidth};
float VerticalScaling{vh / WorldSpaceHeight};
return {
vx + Pos.x * HorizontalScaling,
vy + (WorldSpaceHeight - Pos.y)
* VerticalScaling
};
}
SDL_FRect ToScreenSpace(const SDL_FRect& Rect) const {
Vec2 ScreenPos{ToScreenSpace(Vec2{Rect.x, Rect.y})};
float HorizontalScaling{Viewport.w / WorldSpaceWidth};
float VerticalScaling{Viewport.h / WorldSpaceHeight};
return {
ScreenPos.x,
ScreenPos.y,
Rect.w * HorizontalScaling,
Rect.h * VerticalScaling
};
}
Vec2 ToWorldSpace(const Vec2& Pos) const {
auto [vx, vy, vw, vh]{Viewport};
float HorizontalScaling{WorldSpaceWidth / vw};
float VerticalScaling{WorldSpaceHeight / vh};
return {
(Pos.x - vx) * HorizontalScaling,
WorldSpaceHeight - (Pos.y - vy)
* VerticalScaling
};
}
void HandleEvent(SDL_Event& E) {
for (GameObject& Object : Objects) {
Object.HandleEvent(E);
}
}
void Tick(float DeltaTime) {
for (GameObject& Object : Objects) {
Object.Tick(DeltaTime);
}
}
void Render(SDL_Surface* Surface) {
SDL_GetClipRect(Surface, &Viewport);
for (GameObject& Object : Objects) {
Object.Render(Surface);
}
}
private:
SDL_Rect Viewport;
std::vector<GameObject> Objects;
float WorldSpaceWidth{14}; // meters
float WorldSpaceHeight{6}; // meters
};
// GameObject.h
#pragma once
#include <SDL.h>
#include "BoundingBox.h"
#include "Vec2.h"
#include "Image.h"
class Scene;
class GameObject {
public:
GameObject(
const std::string& ImagePath,
const Vec2& InitialPosition,
float Width,
float Height,
const Scene& Scene
) : Image{ImagePath},
Position{InitialPosition},
Scene{Scene},
Bounds{SDL_FRect{
InitialPosition.x, InitialPosition.y,
Width, Height
}}
{}
void HandleEvent(const SDL_Event& E);
void Tick(float DeltaTime) {
ApplyForce(FrictionForce(DeltaTime));
ApplyForce(DragForce());
Velocity += Acceleration * DeltaTime;
Position += Velocity * DeltaTime;
Acceleration = {0, -9.8};
// Don't fall through the floor
if (Position.y < 2) {
Position.y = 2;
Velocity.y = 0;
}
Bounds.SetPosition(Position);
Clamp(Velocity);
}
void Render(SDL_Surface* Surface);
void ApplyForce(const Vec2& Force) {
Acceleration += Force / Mass;
}
private:
Image Image;
const Scene& Scene;
Vec2 Position{0, 0};
Vec2 Velocity{0, 0};
Vec2 Acceleration{0, -9.8};
float Mass{70};
BoundingBox Bounds;
float DragCoefficient{0.2};
Vec2 DragForce() const {
return -Velocity * DragCoefficient
* Velocity.GetLength();
}
float GetFrictionCoefficient() const {
if (Position.y > 2) {
return 0;
}
return 0.5;
}
Vec2 FrictionForce(float DeltaTime) const {
float MaxMagnitude{GetFrictionCoefficient()
* Mass * -Acceleration.y};
if (MaxMagnitude <= 0) return Vec2(0, 0);
float StoppingMagnitude{Mass *
Velocity.GetLength() / DeltaTime};
return -Velocity.Normalize() * std::min(
MaxMagnitude, StoppingMagnitude);
}
void Clamp(Vec2& V) const {
V.x = std::abs(V.x) > 0.01 ? V.x : 0;
V.y = std::abs(V.y) > 0.01 ? V.y : 0;
}
void ApplyImpulse(const Vec2& Impulse) {
Velocity += Impulse / Mass;
}
void ApplyPositionalImpulse(
const Vec2& Origin, float Magnitude
) {
Vec2 Displacement{Position - Origin};
Vec2 Direction{Displacement.Normalize()};
float Distance{Displacement.GetLength()};
// Apply inverse-square law with a small
// offset to prevent extreme forces
float AdjustedMagnitude{Magnitude /
((Distance + 0.1f) * (Distance + 0.1f))};
ApplyImpulse(Direction * AdjustedMagnitude);
}
};
#include <SDL.h>
#include "GameObject.h"
#include "Scene.h"
#include "BoundingBox.h"
#define DRAW_BOUNDING_BOXES
#define DRAW_TRAJECTORIES
#ifdef DRAW_TRAJECTORIES
namespace{
SDL_Surface* Trajectories{
SDL_CreateRGBSurfaceWithFormat(
0, 700, 300, 32,
SDL_PIXELFORMAT_RGBA32
)};
}
#endif
void GameObject::Render(SDL_Surface* Surface) {
#ifdef DRAW_TRAJECTORIES
auto [x, y]{Scene.ToScreenSpace(Position)};
SDL_Rect PositionIndicator{
int(x)-16, int(y), 20, 20};
SDL_FillRect(
Trajectories, &PositionIndicator,
SDL_MapRGB(Trajectories->format, 220, 0, 0)
);
SDL_BlitSurface(
Trajectories, nullptr, Surface, nullptr
);
#endif
Image.Render(Surface, Scene.ToScreenSpace(Position));
#ifdef DRAW_BOUNDING_BOXES
Bounds.Render(Surface, Scene);
#endif
}
void GameObject::HandleEvent(const SDL_Event& E) {
if (E.type == SDL_MOUSEBUTTONDOWN) {
// Create explosion at click position
if (E.button.button == SDL_BUTTON_LEFT) {
ApplyPositionalImpulse(
Scene.ToWorldSpace({
static_cast<float>(E.button.x),
static_cast<float>(E.button.y)
}), 1000);
}
} else if (E.type == SDL_KEYDOWN) {
// Jump
if (E.key.keysym.sym == SDLK_SPACE) {
if (Position.y > 2) return;
ApplyImpulse({0.0f, 300.0f});
}
}
}
In this lesson, we introduced axis-aligned bounding boxes (AABBs) as a simple way to represent the space occupied by game objects. We created a BoundingBox
class using SDL_FRect
to store its position and dimensions in world space, integrated it into our GameObject
, and updated the bounding box's position each frame.
We also implemented a rendering function to draw the bounding box outlines for debugging, requiring coordinate space conversion and rounding. Key Takeaways:
x
, y
, w
, h
) or by two opposing corners. SDL_FRect
uses the former with floats.BoundingBox
class storing an SDL_FRect
and added it to GameObject
.GameObject
's position changes.SDL_FRect
to SDL_Rect
) before drawing.Discover bounding boxes: what they are, why we use them, and how to create them
Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games