Quick answer: Pygame’s collision detection is discrete — it only checks positions once per frame. Fast-moving sprites can jump past thin obstacles entirely (tunnelling). Fix this with sub-stepping (move in smaller increments and check each step), line-segment intersection for projectiles, or swept AABB for axis-aligned boxes.
Your bullet fires at high speed, passes straight through an enemy, and nothing happens. Or a player running at full sprint phases through a wall on the rare frame where the frame rate dips. These are textbook examples of discrete collision detection failing, and they’re the most common physics complaint in Pygame projects. Unlike engines with built-in continuous collision detection, Pygame gives you raw rectangle and mask comparison functions — it’s up to you to call them at the right granularity.
Why Discrete Collision Detection Misses Fast Sprites
Every frame, Pygame moves a sprite by its velocity vector and then checks whether its bounding rectangle overlaps with other rectangles. This works perfectly when movement is small relative to sprite size. It breaks when a sprite moves more than its own width or height in a single frame.
Consider a 6×6 pixel bullet moving at 50 pixels per frame to the right. A wall is 4 pixels wide. At frame N the bullet is to the left of the wall. At frame N+1 it has moved 50 pixels to the right — now it’s to the right of the wall. The bullet’s rectangle never overlapped the wall’s rectangle at any checked moment. The collision is missed entirely.
# Broken: discrete check misses fast bullet
class Bullet(pygame.sprite.Sprite):
def __init__(self, x, y):
super().__init__()
self.image = pygame.Surface((6, 6))
self.rect = self.image.get_rect(center=(x, y))
self.speed = 50 # pixels per frame — too fast for discrete check
def update(self):
self.rect.x += self.speed
# spritecollide called once per frame — bullet may have skipped the wall
“Rule of thumb: if a sprite moves more than half its narrowest dimension per frame, you need sub-stepping or swept detection for reliable collision.”
Fix 1: Sub-Stepping
Sub-stepping solves tunnelling by dividing each frame’s movement into smaller steps and checking collision after each one. The step size should be no larger than the narrowest obstacle the sprite needs to collide with.
def update(self, walls: pygame.sprite.Group):
steps = max(1, int(abs(self.speed) / 8)) # step size: 8px
step_size = self.speed / steps
for _ in range(steps):
self.rect.x += step_size
hit = pygame.sprite.spritecollideany(self, walls)
if hit:
self.on_hit(hit)
self.kill()
return
The number of steps is calculated from the sprite’s speed divided by the desired step size (8 pixels in this example). A bullet moving 50 pixels per frame will take 6–7 sub-steps, each checked independently. No wall thicker than 8 pixels can be missed. The CPU overhead is proportional to the number of steps and the size of the collision group — keep your wall group small by spatially partitioning it if you have many obstacles.
Fix 2: Line-Segment Intersection for Projectiles
For very fast projectiles — hitscan weapons, laser beams, or anything where the frame-to-frame displacement is large — sub-stepping can still require too many steps to be efficient. A better approach is to treat the projectile’s movement as a line segment from its previous position to its new position and test that segment against obstacle rectangles.
import pygame
from pygame.math import Vector2
def segment_rect_intersect(
p1: Vector2, p2: Vector2, rect: pygame.Rect
) -> bool:
"""Return True if line segment p1->p2 intersects rect."""
# Test using parametric lerp along the segment
dx, dy = p2.x - p1.x, p2.y - p1.y
t_min, t_max = 0.0, 1.0
for axis, d, lo, hi in [
("x", dx, rect.left, rect.right),
("y", dy, rect.top, rect.bottom),
]:
if abs(d) < 1e-8:
origin = p1.x if axis == "x" else p1.y
if origin < lo or origin > hi:
return False
else:
t1 = (lo - (p1.x if axis == "x" else p1.y)) / d
t2 = (hi - (p1.x if axis == "x" else p1.y)) / d
t_min = max(t_min, min(t1, t2))
t_max = min(t_max, max(t1, t2))
return t_min <= t_max
# Usage in update()
prev_pos = Vector2(self.rect.center)
self.rect.x += self.velocity.x
self.rect.y += self.velocity.y
curr_pos = Vector2(self.rect.center)
for wall in wall_group:
if segment_rect_intersect(prev_pos, curr_pos, wall.rect):
self.on_hit(wall)
break
This approach has constant cost regardless of speed — one line-rectangle test per obstacle per frame — and correctly handles any velocity magnitude. It is the right choice for projectiles that are expected to move many screen lengths per second.
Fix 3: Swept AABB for Moving Platforms and Boxes
When both the moving object and the target are axis-aligned rectangles (the most common case in 2D platformers), swept AABB detection computes the exact time of first contact during the frame rather than checking only at the end position. This technique returns a contact time t between 0 and 1, where 0 means contact at the start of the frame and 1 means contact at the end.
def swept_aabb(
mover: pygame.Rect, velocity: Vector2, target: pygame.Rect
) -> float:
"""Return contact time (0.0-1.0) or 1.0 if no collision this frame."""
if velocity.x > 0:
x_entry = (target.left - mover.right) / velocity.x
x_exit = (target.right - mover.left) / velocity.x
elif velocity.x < 0:
x_entry = (target.right - mover.left) / velocity.x
x_exit = (target.left - mover.right) / velocity.x
else:
x_entry, x_exit = -float("inf"), float("inf")
if velocity.y > 0:
y_entry = (target.top - mover.bottom) / velocity.y
y_exit = (target.bottom - mover.top) / velocity.y
elif velocity.y < 0:
y_entry = (target.bottom - mover.top) / velocity.y
y_exit = (target.top - mover.bottom) / velocity.y
else:
y_entry, y_exit = -float("inf"), float("inf")
entry = max(x_entry, y_entry)
exit_ = min(x_exit, y_exit)
if entry > exit_ or x_entry > 1 or y_entry > 1 or exit_ <= 0:
return 1.0 # no collision
return max(0.0, entry)
Move the sprite to position + velocity * contact_time instead of position + velocity and handle the remaining (1 - contact_time) fraction of movement after resolving the collision normal. This produces smooth, physically correct stopping behaviour for platforms and boxes.
Choosing the Right Collision Method
Not every sprite needs swept detection — using it everywhere wastes CPU cycles. Here is a practical guide for picking the right method:
- spritecollide / spritecollideany: Correct for objects moving less than their own width/height per frame. Fast, zero setup. Use for slow enemies, pickups, player hitboxes.
- groupcollide: Tests every sprite in group A against every sprite in group B. Use when you need to know which specific pair collided (e.g., bullets vs enemies).
- Sub-stepping: Best for medium-speed projectiles when step count stays under ~10. Simple to implement, works with all existing Pygame collision functions.
- Line-segment intersection: Best for hitscan and very fast projectiles. Constant cost regardless of speed. Slightly more complex to implement.
- Swept AABB: Best for platformer-style movement where you need exact contact position and normal for response (sliding along walls, landing on platforms).
- collide_mask: Pixel-perfect but slow. Use only for slow objects where visual accuracy matters, such as a character touching a precisely shaped hazard.
Tunnelling bugs are particularly nasty to track down post-release because they’re often frame-rate dependent — they only appear on machines where clock.tick() returns a larger delta than expected. Capturing frame time alongside collision event logs in your bug reports (using a tool like Bugnet) helps you correlate “bullet went through wall” reports with specific hardware performance characteristics.
Discrete collision is fast and correct — until your sprite is faster than itself.