Quick answer: Use a two-stage check: first collide_rect to cull, then collide_mask only for pairs that pass rect overlap. Cache sprite.mask = pygame.mask.from_surface(image) once.
A 2D action game with 200 enemies uses pixel-perfect mask collision. Frame rate drops to 30 FPS during combat. Switching to rect collision restores 60 FPS but produces inaccurate hits on irregular sprites. The mask path is correct but too slow.
Why Mask Collision Is Expensive
pygame.mask.Mask.overlap scans the two masks pixel-by-pixel within their bounding overlap to find any pair of set pixels. Cost scales with overlap area. Done naively for every pair, performance is O(N² × pixels).
For 200 sprites that’s 200 × 199 / 2 = 19,900 pair checks. Each at 64×64 = 4096 pixels = 81M pixel ops per frame. Slow.
The Two-Stage Approach
def collide_two_stage(s1, s2):
if not s1.rect.colliderect(s2.rect):
return False
return pygame.sprite.collide_mask(s1, s2) is not None
Rect overlap is O(1) per pair. Mask collision runs only when rects actually overlap. For typical scattered enemies, this culls 95%+ of pair checks.
Use spritecollide with the Custom Callback
hits = pygame.sprite.spritecollide(
player,
enemies,
dokill=False,
collided=collide_two_stage
)
Provides bulk operation with your custom two-stage function. Internal C loop applied; faster than a Python for loop over the same logic.
Cache the Mask
class Enemy(pygame.sprite.Sprite):
def __init__(self, image):
super().__init__()
self.image = image
self.rect = image.get_rect()
self.mask = pygame.mask.from_surface(image) # once
def change_animation(self, new_image):
self.image = new_image
self.mask = pygame.mask.from_surface(new_image) # on frame change only
Don’t regenerate the mask every frame. Build once at sprite construction; rebuild only when the image changes.
Spatial Grid for Massive Counts
For 1000+ sprites, even rect culling is slow if you check every pair. Maintain a spatial hash grid:
grid = {}
for sprite in sprites:
cell = (sprite.rect.x // 64, sprite.rect.y // 64)
grid.setdefault(cell, []).append(sprite)
# Check only sprites in nearby cells
for cell, sprites_in_cell in grid.items():
for s1, s2 in combinations(sprites_in_cell, 2):
if collide_two_stage(s1, s2):
...
Reduces N² to roughly N × constant. Significant speedup for dense scenes.
Verifying
Profile with cProfile. Before fix: collide_mask dominates. After: spritecollide with two-stage costs an order of magnitude less. Frame rate returns to 60 even with high sprite counts.
“Mask collision is accurate but expensive. Rect-cull first, mask only on overlap. Cache the mask once.”
Profile early in 2D action games — mask collision is the most common perf cliff. Two-stage culling is cheap insurance.