Object Collision: the sprite-vs-sprite lane
The physics world resolves sprites against tiles and bodies. It does not resolve sprite against sprite; that's a separate lane, on purpose. Two characters touching is ObjectCollisionManager's job.
WHY IT'S SEPARATE
- The world's job is movement resolution (don't let sprites walk into solids).
- The manager's job is contact detection between moving things (who touched who, how deep).
- Different queries, different cadence: the world runs every
move_*; the manager runs onecheck_all_collisions()pass per frame.
The manager is a uniform spatial grid (cell_size=128.0 by default, rebuilt per query): objects only narrowphase against objects in their own or adjacent cells. Mixed shapes all work: rect, circle, capsule, polygon, and multi-shape objects (via a collision_shapes attribute).
THE API
| Method | Does |
|---|---|
add_object(obj) | register; duplicates warn and are skipped |
remove_object(obj) | unregister; missing objects warn |
clear() | remove everything |
check_all_collisions() | all-vs-all via the grid; each pair reported once |
check_object(obj) | one object vs all others (linear scan; need not be managed) |
check_object_first(obj) | first hit only, in insertion order |
Each hit is a CollisionHit(object_a, object_b, normal, depth):
| Member | Does |
|---|---|
normal | direction to separate (from A to B) |
depth | penetration depth |
resolve() | separates both objects by half the depth along the normal |
slide_velocity(vx, vy) | projects a velocity onto the surface; approach component stripped |
involves(obj) / other(obj) | who's in this hit |
WIRING IT IN
manager = ObjectCollisionManager(cell_size=128.0) # uniform spatial grid
manager.add_object(player)
manager.add_object(enemy1)
manager.add_object(enemy2)
# per frame: all-vs-all
for hit in manager.check_all_collisions():
hit.resolve() # separate both objects along the normal
# or: vx, vy = hit.slide_velocity(player.vx, player.vy) # strip approach
# or one-vs-all (enemy against everything, linear scan)
for hit in manager.check_object(enemy1):
if hit.involves(player):
print("enemy touched the player")
other = hit.other(enemy1) # -> playerCELL SIZE TUNING
cell_size is a cost trade-off. Too small: many empty cells, grid rebuild overhead. Too big: every object ends up in the same cell and the broadphase is a lie. For 32px tiles, 128 is a sane default; the comparison example spatial-cell-size-tuning.py benchmarks this empirically. cell_size must be finite and positive; anything else raises ValueError.