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:

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.