Quick answer: Pygame Rects use integer coordinates and the rect from image.get_rect() matches the entire surface, not just visible pixels. Either shrink the rect via inflate(-w, -h), track float positions separately, or use pygame.mask for pixel-perfect overlap.
Here is how to fix Pygame collision detection that fires when sprites are clearly not touching. Two characters appear half a screen apart but the damage event still triggers. Or hits register at the corner of a sprite where the visible art has transparent padding. Rects are bounding boxes around the entire surface, not the visible silhouette.
The Symptom
colliderect returns true when sprites visually do not overlap. Or collision callbacks fire at slightly wrong moments. Looking at debug-drawn rects, they are larger than expected because they include transparent pixel padding around the art.
What Causes This
Rect from get_rect covers the whole surface. A 64x64 sprite with art only in the central 32x32 has a 64x64 collision rect that overlaps before the art does.
Integer position truncation. Assigning rect.x = 100.7 stores 100. Sub-pixel motion clamps to integer boundaries; collisions can flicker on/off as positions round.
Anchor mismatch. If you position by topleft but compare distances center-to-center, collisions can trigger asymmetrically.
No mask for pixel-shaped sprites. Round or irregular sprites need pixel-level checks; rect-only is too generous.
The Fix
Step 1: Shrink the collision rect.
class Player(pygame.sprite.Sprite):
def __init__(self, image, pos):
super().__init__()
self.image = image
self.rect = image.get_rect(center=pos)
# Tighter hitbox: shrink 8px on each side
self.hitbox = self.rect.inflate(-16, -16)
def update(self):
self.hitbox.center = self.rect.center
# Use hitbox for collisions; rect for blitting
if player.hitbox.colliderect(enemy.hitbox):
handle_hit()
Step 2: Track float positions separately.
class Sprite:
def __init__(self, image, x, y):
self.image = image
self.pos = pygame.math.Vector2(x, y)
self.rect = image.get_rect(center=(int(x), int(y)))
def update(self, dt, vel):
self.pos += vel * dt
self.rect.center = (round(self.pos.x), round(self.pos.y))
Step 3: Use mask for pixel-perfect collision.
self.mask = pygame.mask.from_surface(self.image)
def collides_pixel(a, b):
offset = (b.rect.x - a.rect.x, b.rect.y - a.rect.y)
return a.mask.overlap(b.mask, offset) is not None
if collides_pixel(player, enemy):
handle_hit()
Mask collision is more expensive but accurate. Cache the mask once at sprite creation; do not rebuild every frame.
Step 4: Combine rect + mask for performance. Rect first as a quick reject; mask only when rects overlap:
def collide_combined(a, b):
if not a.rect.colliderect(b.rect):
return False
offset = (b.rect.x - a.rect.x, b.rect.y - a.rect.y)
return a.mask.overlap(b.mask, offset) is not None
Step 5: Visualize hitboxes in debug mode.
if DEBUG:
pygame.draw.rect(screen, (255, 0, 0), player.hitbox, 1)
pygame.draw.rect(screen, (0, 255, 0), enemy.hitbox, 1)
The debug overlay reveals exactly when boxes touch, which makes false positives obvious.
“Rects are bounding boxes. Tighten them or use masks for the silhouette. Track positions as floats; round only when assigning to rect.”
Related Issues
For sprite group ordering, see Sprite Group Draw Order. For clock and timing, see Pygame Clock CPU.
Inflate to shrink. Mask for pixel art. Float pos plus int rect. Collisions accurate.