Tutorial 16: Basic Collision Detection

Geometry  ·  Rectangle  ·  BoundingBox  ·  BoundingSphere

ℹ

What you’ll learn

  • Rectangle overlap and containment tests with Intersects() and Contains().
  • Doing circle collision by hand when a rectangle is the wrong shape.
  • Where BoundingBox and BoundingSphere fit in.
  • Keeping broad-phase costs down as object counts grow.

Before you start — Tutorial 12: Moving Sprites and Basic Animation — you need something moving before collision means anything.

CNA provides the same geometric intersection utilities as XNA 4.0 (Rectangle, BoundingBox, BoundingSphere and friends live in Microsoft::Xna::Framework). For 2D games Rectangle AABB tests cover most use cases. For 3D, BoundingBox and BoundingSphere handle axis-aligned and spherical volumes. This tutorial covers all three plus a manual circle-circle test and an overview of pixel-perfect collision.

Rectangle.Intersects()

Rectangle is an integer-coordinate AABB defined by X, Y, Width, and Height. The Intersects() method returns true when the rectangles overlap by at least one pixel:

#include "Microsoft/Xna/Framework/Rectangle.hpp"

using namespace Microsoft::Xna::Framework;

Rectangle player(100, 200, 48, 64);    // x, y, width, height
Rectangle enemy (120, 220, 48, 64);

if (player.Intersects(enemy)) {
    // Collision! Handle damage, bounce, etc.
}

// To get the overlapping region itself, use the static Rectangle::Intersect().
// It returns the intersection, or an empty Rectangle (0,0,0,0) if there is none:
Rectangle overlap = Rectangle::Intersect(player, enemy);
if (!overlap.getIsEmptyProperty()) {
    // overlap now holds the shared area (here 28 x 44 pixels)
}

// (There is also an out-parameter form, Intersects(other, bool& result),
// kept for XNA compatibility. It reports the bool only, not the region.)

The integer coordinates map naturally to sprite pixel positions. If your player is drawn at (px, py) and is 48x64 pixels, build its rectangle as Rectangle(px, py, 48, 64). Remember that Y increases downward in screen space.

Rectangle.Contains()

Contains() tests whether a point or another rectangle is fully inside:

Rectangle room(0, 0, 800, 600);
Point  mousePos(350, 400);

// Point containment
if (room.Contains(mousePos)) {
    // mouse is inside the room
}

// Rectangle containment — true only if 'item' is fully inside 'room'
Rectangle item(10, 10, 100, 50);
if (room.Contains(item)) {
    // item fits completely within room
}

// There is no Vector2 overload (XNA 4.0 has none): convert to a Point first.
// Casting truncates toward zero; use std::lround if you want rounding.
Vector2 projectile(350.7f, 200.3f);
if (room.Contains(Point(static_cast<int>(projectile.X),
                        static_cast<int>(projectile.Y)))) { }

Circle-based collision (manual)

XNA does not have a 2D Circle type, but circle-circle overlap is a one-liner using Vector2::Distance():

struct Circle {
    Vector2 center;
    float   radius;

    bool Intersects(const Circle& other) const {
        float dist = Vector2::Distance(center, other.center);
        return dist < (radius + other.radius);
    }
};

// Usage
Circle player { Vector2(100.0f, 200.0f), 20.0f };
Circle bullet { Vector2(115.0f, 210.0f),  6.0f };

if (player.Intersects(bullet)) {
    // hit!
}

For circle vs rectangle, test whether the closest point on the rectangle to the circle center is within the circle radius:

bool CircleVsRect(Vector2 circleCenter, float radius, Rectangle rect) {
    float closestX = std::clamp(circleCenter.X,
                                static_cast<float>(rect.X),
                                static_cast<float>(rect.X + rect.Width));
    float closestY = std::clamp(circleCenter.Y,
                                static_cast<float>(rect.Y),
                                static_cast<float>(rect.Y + rect.Height));
    float dx = circleCenter.X - closestX;
    float dy = circleCenter.Y - closestY;
    return (dx * dx + dy * dy) < (radius * radius);
}

BoundingBox and BoundingSphere (3D)

For 3D games CNA provides axis-aligned bounding boxes and spheres in Microsoft::Xna::Framework:

#include "Microsoft/Xna/Framework/BoundingBox.hpp"
#include "Microsoft/Xna/Framework/BoundingSphere.hpp"

// BoundingBox — defined by min and max corners
BoundingBox boxA(Vector3(-1, -1, -1), Vector3(1, 1, 1));
BoundingBox boxB(Vector3( 0,  0,  0), Vector3(2, 2, 2));

// Contains() answers "how much of B is inside A?" with a ContainmentType:
ContainmentType ct = boxA.Contains(boxB);
// ContainmentType::Disjoint / Intersects / Contains

// Intersects() is the plain yes/no overlap test and returns a bool:
bool overlaps = boxA.Intersects(boxB);

// BoundingSphere — centre + radius
BoundingSphere sphere(Vector3(0, 0, 0), 1.5f);
bool sphereHitsBox = sphere.Intersects(boxA);

// Sphere vs sphere
BoundingSphere enemySphere(Vector3(2, 0, 0), 0.5f);
bool sphereHit = sphere.Intersects(enemySphere);

You can also create a BoundingSphere that encloses a std::vector<Vector3> of points with BoundingSphere::CreateFromPoints(), or a BoundingBox with BoundingBox::CreateFromPoints() — useful for computing bounds from loaded mesh data. Both also intersect Rays (returning a std::optional<float> distance), which is what picking code needs.

Pixel-perfect collision overview

Pixel-perfect collision checks every non-transparent pixel pair between two sprites. It is more accurate but much slower than bounding-box tests. The general approach in CNA:

  1. First run a Rectangle::Intersects() test as a broad phase. Skip pixel checks if bounding boxes do not overlap.
  2. Read the texture data with Texture2D::GetData() into a std::vector<Color> (one Color per pixel).
  3. Iterate over the overlapping pixel region. For each pixel in the intersection rectangle, check whether both sprites have non-zero alpha (alpha > threshold).
// Read sprite pixels once (expensive — cache this in LoadContent)
std::vector<Color> pixelsA(texA.getWidthProperty() * texA.getHeightProperty());
texA.GetData(pixelsA.data(), static_cast<int>(pixelsA.size()));
// ... the same for pixelsB / texB ...

// Narrow-phase check inside the bounding-box intersection
Rectangle inter = Rectangle::Intersect(rectA, rectB);
if (inter.getIsEmptyProperty()) return false;   // broad phase says no

for (int y = inter.Y; y < inter.Y + inter.Height; ++y) {
    for (int x = inter.X; x < inter.X + inter.Width; ++x) {
        int idxA = (x - rectA.X) + (y - rectA.Y) * rectA.Width;
        int idxB = (x - rectB.X) + (y - rectB.Y) * rectB.Width;
        if (pixelsA[idxA].getAProperty() > 10 && pixelsB[idxB].getAProperty() > 10)
            return true;   // pixel-level hit
    }
}
return false;

For most games rectangle tests are sufficient. Reserve pixel-perfect for cases where sprite silhouettes are unusual (e.g. a curved ship outline) and accuracy is critical.

Complete example: player vs enemies, bouncing ball

#include <algorithm>
#include <cmath>
#include <memory>
#include <vector>
#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/Color.hpp"
#include "Microsoft/Xna/Framework/Rectangle.hpp"
#include "Microsoft/Xna/Framework/Vector2.hpp"
#include "Microsoft/Xna/Framework/Input/Keyboard.hpp"
#include "Microsoft/Xna/Framework/Input/Keys.hpp"
#include "Microsoft/Xna/Framework/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteBatch.hpp"
#include "Microsoft/Xna/Framework/Graphics/Texture2D.hpp"

using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;
using namespace Microsoft::Xna::Framework::Input;

struct Enemy {
    Rectangle bounds;
    bool alive = true;
};

class CollisionDemo final : public Game {
public:
    CollisionDemo() : graphics_(this) {
        graphics_.setPreferredBackBufferWidthProperty(800);
        graphics_.setPreferredBackBufferHeightProperty(600);
    }

protected:
    void LoadContent() override {
        spriteBatch_ = std::make_unique<SpriteBatch>(getGraphicsDeviceProperty());
        // Load<T> returns each texture by value; the members are plain Texture2D values.
        playerTex_   = getContentProperty().Load<Texture2D>("Sprites/player");
        enemyTex_    = getContentProperty().Load<Texture2D>("Sprites/enemy");
        ballTex_     = getContentProperty().Load<Texture2D>("Sprites/ball");

        playerPos_ = Vector2(375.0f, 500.0f);

        // Place a row of enemies
        for (int i = 0; i < 5; ++i) {
            enemies_.push_back({ Rectangle(100 + i * 130, 100, 48, 48), true });
        }

        // Ball starts at centre, moving diagonally
        ballPos_ = Vector2(400.0f, 300.0f);
        ballVel_ = Vector2(200.0f, 150.0f);   // pixels per second
    }

    void Update(GameTime& gt) override {
        auto kb = Keyboard::GetState();
        float dt = static_cast<float>(gt.getElapsedGameTimeProperty().getTotalSecondsProperty());

        // Move player left/right
        if (kb.IsKeyDown(Keys::Left))  playerPos_.X -= 200.0f * dt;
        if (kb.IsKeyDown(Keys::Right)) playerPos_.X += 200.0f * dt;

        Rectangle playerRect(static_cast<int>(playerPos_.X), static_cast<int>(playerPos_.Y), 48, 64);

        // Check player vs each enemy
        for (auto& e : enemies_) {
            if (!e.alive) continue;
            if (playerRect.Intersects(e.bounds)) {
                e.alive = false;   // destroy enemy on touch
            }
        }

        // Update bouncing ball
        ballPos_ = ballPos_ + ballVel_ * dt;

        // Bounce off walls
        if (ballPos_.X < 0 || ballPos_.X > 784) {
            ballVel_.X = -ballVel_.X;
            ballPos_.X = std::clamp(ballPos_.X, 0.0f, 784.0f);
        }
        if (ballPos_.Y < 0 || ballPos_.Y > 584) {
            ballVel_.Y = -ballVel_.Y;
            ballPos_.Y = std::clamp(ballPos_.Y, 0.0f, 584.0f);
        }

        // Ball vs player (circle approximation)
        Vector2 ballCenter = ballPos_ + Vector2(8.0f, 8.0f);
        Vector2 playerCenter = playerPos_ + Vector2(24.0f, 32.0f);
        if (Vector2::Distance(ballCenter, playerCenter) < 36.0f) {
            ballVel_.Y = -std::abs(ballVel_.Y);  // bounce up
        }
    }

    void Draw(const GameTime&) override {
        auto& gd = getGraphicsDeviceProperty();
        gd.Clear(Color::DarkSlateBlue);

        spriteBatch_->Begin();
        spriteBatch_->Draw(playerTex_, playerPos_, Color::White);
        for (auto& e : enemies_) {
            if (e.alive)
                spriteBatch_->Draw(enemyTex_,
                    Vector2(static_cast<float>(e.bounds.X), static_cast<float>(e.bounds.Y)),
                    Color::White);
        }
        spriteBatch_->Draw(ballTex_, ballPos_, Color::Yellow);
        spriteBatch_->End();
        // Game::EndDraw() presents the frame after Draw() returns.
    }

private:
    GraphicsDeviceManager         graphics_;
    std::unique_ptr<SpriteBatch>  spriteBatch_;
    Texture2D      playerTex_, enemyTex_, ballTex_;
    Vector2                       playerPos_;
    Vector2                       ballPos_, ballVel_;
    std::vector<Enemy>            enemies_;
};

int main() { CollisionDemo game; game.Run(); }

Performance tips

  • Broad phase first — always do a cheap rectangle or sphere test before any narrow-phase check.
  • Avoid O(n²) for large entity counts — partition space with a grid or quadtree when you have more than ~50 collidable objects per frame.
  • Cache pixel data — Texture2D::GetData() is slow. Read it once in LoadContent() and store in a std::vector<Color>.
  • Separate collision from rendering bounds — your collision rectangle does not need to match the sprite art exactly. Shrink it by 20–30% for a more forgiving feel.