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.
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.
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.
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!")
colliderect(). Extremely fast, built into every Rect object, perfect for platforms, walls, and boxy sprites. Slightly inaccurate at corners for round shapes.math.hypot(). Pixel-accurate for round sprites like balls, coins, and orbs, and cheaper than mask collision.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.
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.
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
| Method | Accuracy | Performance | Best Use Case |
|---|---|---|---|
| Rect Collision | Approximate | Fastest | Platforms, walls, UI, most sprites |
| Circle Collision | Accurate (round) | Very fast | Balls, coins, bullets, orbs |
| Mask Collision | Pixel-perfect | Slowest | Irregular shapes, precision-critical hits |
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.
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)
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).
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.
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.
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.
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
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.