Module 9 of 20 Intermediate Phase: Game Mechanics

Collision Detection

Every real game lives or dies on how well it detects when things touch. In this module you'll master every collision technique Pygame offers — fast rectangle checks, precise circle math, pixel-perfect masks — and learn how to turn a raw "yes they overlap" into believable responses: stopping at walls, taking enemy damage, firing triggers, and building separate hitboxes and hurtboxes for combat games. We'll close with the optimization tricks that keep collision checks fast even with hundreds of sprites on screen.

📚
10 Lessons
Grouped into 5 sections
⏱️
~5 Hours
Estimated time
🎯
Intermediate
Difficulty
🧩
Rect & Mask
Primary tools
Section 1

Rectangle Collision & Circle Collision

The simplest and fastest collision test in Pygame is rectangle-vs-rectangle. Every sprite already has a .rect attribute, and pygame.Rect ships with a built-in method — colliderect() — that answers a single question: do these two bounding boxes overlap on both the X and Y axis at the same time? Because it's just four number comparisons under the hood, it is extremely cheap to run every single frame, even for hundreds of objects.

rect_collision.py
import pygame

player_rect = pygame.Rect(100, 100, 40, 60)
coin_rect = pygame.Rect(120, 110, 20, 20)

if player_rect.colliderect(coin_rect):
    print("Coin collected!")
    coin_rect = None  # remove the coin

Rect collision is great for boxy things — platforms, walls, UI panels, most enemies — but it's inaccurate for round shapes. A circular coin or a spherical bullet drawn with pygame.draw.circle() will "collide" a moment too early or too late if you only check its bounding rect, because the corners of the rectangle stick out past the circle. For round objects, circle collision is the correct tool: compare the distance between two centers to the sum of their radii.

circle_collision.py
import math

def circles_collide(pos1, r1, pos2, r2):
    dx = pos1[0] - pos2[0]
    dy = pos1[1] - pos2[1]
    distance = math.hypot(dx, dy)
    return distance < (r1 + r2)

player_pos, player_radius = (300, 200), 18
enemy_pos, enemy_radius = (310, 205), 14

if circles_collide(player_pos, player_radius, enemy_pos, enemy_radius):
    print("Circle collision detected!")
Rectangle Collision
Uses colliderect(). Extremely fast, built into every Rect object, perfect for platforms, walls, and boxy sprites. Slightly inaccurate at corners for round shapes.
Circle Collision
Compares center-to-center distance against the sum of two radii using math.hypot(). Pixel-accurate for round sprites like balls, coins, and orbs, and cheaper than mask collision.
Section 2

Mask Collision & Collision Response

Sometimes a bounding box just isn't good enough — think of two irregularly shaped spaceships whose sprites have lots of transparent padding around them. Mask collision solves this by building a bitmask from the actual opaque pixels of an image and testing pixel-by-pixel overlap instead of box overlap. It's the most accurate collision method Pygame offers, but also the most expensive, so it's normally reserved for a handful of important sprites rather than every object on screen.

mask_collision.py
import pygame

class Player(pygame.sprite.Sprite):
    def __init__(self, image, pos):
        super().__init__()
        self.image = image
        self.rect = self.image.get_rect(topleft=pos)
        self.mask = pygame.mask.from_surface(self.image)

# Both sprites need a .mask attribute built the same way.
if pygame.sprite.collide_mask(player_sprite, enemy_sprite):
    print("Pixel-perfect hit!")

Detecting a collision is only half the job — you also need a collision response: what actually happens once two things touch. The most common response pattern for platformers is axis-separated resolution — move on the X axis, resolve any X overlap, then move on the Y axis and resolve any Y overlap separately. This avoids the classic bug where diagonal movement lets a player clip through the corner of a wall.

collision_response.py
def move_and_collide(rect, vel_x, vel_y, walls):
    rect.x += vel_x
    for wall in walls:
        if rect.colliderect(wall):
            if vel_x > 0:
                rect.right = wall.left
            elif vel_x < 0:
                rect.left = wall.right

    rect.y += vel_y
    for wall in walls:
        if rect.colliderect(wall):
            if vel_y > 0:
                rect.bottom = wall.top
            elif vel_y < 0:
                rect.top = wall.bottom
    return rect
MethodAccuracyPerformanceBest Use Case
Rect CollisionApproximateFastestPlatforms, walls, UI, most sprites
Circle CollisionAccurate (round)Very fastBalls, coins, bullets, orbs
Mask CollisionPixel-perfectSlowestIrregular shapes, precision-critical hits
Section 3

Wall Collision & Enemy Collision with Groups

In practice, "wall collision" is just the axis-separated response from Section 2 applied to a list or Group of level-geometry rects. Store your walls as pygame.Rect objects (often generated from a tile map, which you'll build in Module 13), loop through them every frame, and snap the player's rect back to the wall's edge on overlap. This single pattern is the backbone of nearly every platformer and top-down game you'll build in this course.

Enemy collision usually needs to test one sprite (or a small group) against many others at once — for example, "did any bullet in the bullets group hit any enemy in the enemies group?" Pygame's sprite module gives you two purpose-built functions for exactly this: pygame.sprite.spritecollide() for one sprite versus a Group, and pygame.sprite.groupcollide() for Group versus Group.

group_collision.py
import pygame

enemies = pygame.sprite.Group()
bullets = pygame.sprite.Group()

# One sprite vs a group
hit_list = pygame.sprite.spritecollide(player, enemies, False)
if hit_list:
    player.take_damage(10)

# Group vs group — kills both sprites on collision (True, True)
hits = pygame.sprite.groupcollide(bullets, enemies, True, True)
for bullet, hit_enemies in hits.items():
    score += 10 * len(hit_enemies)
🎮
Input
Move Player
Check
Wall / Enemy Collide
🔀
Resolve
Snap / Damage
🖼️
Render
Draw Frame
Section 4

Trigger Zones & Hitboxes

Not every overlap should physically block movement. A trigger zone is a non-solid rect that the player can walk straight through, but which fires an event the moment it's entered — opening a door, starting a cutscene, spawning enemies, or saving the game. The trick is tracking an "activated" flag so the trigger only fires once (or resets when the player leaves, depending on the design).

trigger_zone.py
class TriggerZone(pygame.sprite.Sprite):
    def __init__(self, rect):
        super().__init__()
        self.rect = rect
        self.activated = False

    def check(self, player):
        if self.rect.colliderect(player.rect):
            if not self.activated:
                self.activated = True
                print("Trigger fired: door unlocked!")
        else:
            self.activated = False  # reset when the player leaves

In combat games, movement collision and combat collision need to be tracked separately, which is where hitboxes come in. A hitbox is the rectangle that represents where a character's attack can land a hit — it's usually only "active" for a few frames during the attack animation, and it's completely separate from the sprite's normal movement rect.

🚪
Level Triggers
Open doors, start dialogue, or load the next level when the player enters an invisible zone.
👊
Attack Hitboxes
A short-lived rect spawned in front of the attacker, active only during the "impact" frame of an animation.
🧲
Pickup Sensors
A wide, forgiving zone around collectibles so players don't need pixel-perfect precision to grab items.
Section 5

Hurtboxes & Collision Optimization

If a hitbox is "where I can deal damage," a hurtbox is "where I can receive damage." Keeping these separate is what makes fighting games feel fair: a character's hurtbox might shrink while they're crouching or dodging, even though their hitbox (when attacking) stays a fixed shape. Pairing a hurtbox with a short window of invulnerability frames after a hit also prevents a single attack from draining an entire health bar in one frame of overlap.

hitbox_hurtbox.py
class Fighter:
    def __init__(self, rect):
        self.hurtbox = rect.copy()   # area that can BE hit
        self.hitbox = None           # area that CAN hit (active only on attack)
        self.invulnerable_frames = 0
        self.health = 100

    def attack(self):
        self.hitbox = pygame.Rect(
            self.hurtbox.right, self.hurtbox.y, 30, self.hurtbox.height
        )

    def take_hit(self, damage):
        if self.invulnerable_frames == 0:
            self.health -= damage
            self.invulnerable_frames = 45  # ~0.75s at 60 FPS

    def update(self):
        if self.invulnerable_frames > 0:
            self.invulnerable_frames -= 1

Finally, checking every sprite against every other sprite (an "every pair" or O(n²) approach) becomes painfully slow once you have hundreds of objects. Collision optimization usually means splitting the world into a coarse grid and only testing objects that share, or neighbor, the same grid cell — a technique called spatial partitioning or "broad phase" collision.

spatial_grid.py
CELL_SIZE = 64

def cell_key(pos):
    return (int(pos[0] // CELL_SIZE), int(pos[1] // CELL_SIZE))

def build_grid(sprites):
    grid = {}
    for sprite in sprites:
        key = cell_key(sprite.rect.center)
        grid.setdefault(key, []).append(sprite)
    return grid

def nearby_sprites(pos, grid):
    cx, cy = cell_key(pos)
    result = []
    for dx in (-1, 0, 1):
        for dy in (-1, 0, 1):
            result.extend(grid.get((cx + dx, cy + dy), []))
    return result
Rule of thumb: only run the expensive narrow-phase check (rect, circle, or mask) on the small handful of objects the broad-phase grid says are actually nearby — never on the whole level.
Visual Reference

Rect Collision vs Circle Collision, Side by Side

The two cheapest collision tests look different because they measure different things: rect collision asks whether two bounding boxes overlap on both axes, while circle collision asks whether the distance between two centers is smaller than the sum of their radii.

A B
Rect vs Rect — colliderect() is true because the boxes overlap on both X and Y
distance A B
Circle vs Circle — collide because distance < r1 + r2 (65.2 < 83)

🧠 Quick Knowledge Check

1. Which built-in Rect method checks if two rectangles overlap?
2. In circle-based collision, what value do you compare the distance between centers to?
3. Why use pygame.sprite.collide_mask() instead of colliderect()?
4. What's the key difference between a hitbox and a hurtbox?
5. Why use a spatial grid for collision optimization?
Finished Module 9?

Mark it complete to track your progress through the Pygame Mastery course.

🗒 Cheat Sheet 📝 Worksheet