Lesson 2: Spatial Hashing & Object Pools
Bullet storms, swarms and particle-heavy scenes all hit the same wall: testing every sprite against every other one grows far faster than the number of sprites. In this lesson you build a spatial hash that tests only neighbors, an object pool that reuses short-lived sprites, and a cache that stops you redoing the same image work every frame.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Calculate how many tests all-pairs collision needs, and explain why a quick per-pair check doesn't reduce that number.
- Build a spatial hash Group that files sprites into grid cells and stays correct when sprites are added, removed, killed or moved.
- Measure all-pairs testing against the hash on your own machine with
timeit. - Build an object pool with a stated protocol: acquire, reset every field, release exactly once.
- Cache rotated frames and sheet slices so the same image work is done once, with a cache that cannot grow forever.
Project: Swarm, hundreds of arrows tested only against their neighbors, with pooled sparks and cached rotations.
In This Lesson
๐ The All-Pairs Problem
Imagine a party where every guest must shake hands with every other guest. With 10 guests that's 45 handshakes. With 100 guests it's 4,950. Double the guests and the handshakes roughly quadruple.
Testing every sprite in a group against every other one does exactly that. Even done carefully, with each pair tested once, n sprites need n ร (n โ 1) รท 2 tests every frame.
| Sprites | Pairs tested every frame |
|---|---|
| 10 | 45 |
| 100 | 4,950 |
| 300 | 44,850 |
| 1,000 | 499,500 |
A tempting fix is to add a quick check before the real one, such as "skip the pair if their centers are more than 50 pixels apart". It makes each test a little cheaper, but you still visit every pair: 1,000 sprites still means 499,500 checks. (It can also miss a big sprite whose center is far away but whose edge touches.) The real speed-up comes from never looking at far-away pairs at all. That job is called the broad phase, and the exact test that follows is the narrow phase.
๐งฑ Spatial Hashing: Buckets on a Grid
A post office doesn't compare every letter with every other letter. It drops each letter into a bin for its ZIP code, and a carrier only looks inside one bin. A spatial hash does that for sprites: lay an invisible grid over the world, drop each sprite into the cells its rect covers, and test a sprite only against the sprites that share one of its cells.
CELL = 64 # pixels per cell
def cell_of(x, y):
return (int(x // CELL), int(y // CELL)) # // rounds DOWN, so x = -5 gives cell -1
cell_of(130, 70) # (2, 1)
cell_of(-5, 10) # (-1, 0): negative coordinates work too
The grid lives in a dictionary that maps (cx, cy) to a set of sprites. Only cells that hold something are stored, so the world can be any size (even infinite) and there are no rows or columns to set up.
A rect can straddle a cell border, so it is filed in every cell it touches, from (left // CELL, top // CELL) to (right // CELL, bottom // CELL). Choose a cell size about one to two times your typical sprite's size: too small and each sprite is filed in many cells; too large and each cell holds so many sprites that you are back to testing almost everything.
Add dots and watch the two numbers. All-pairs grows with the square of the count; the hash grows roughly in step with it. Try each cell size and see which gives the fewest tests.
๐ Keeping the Hash Honest
A hash is only useful if it's correct. Two bugs make it lie:
- A stale entry. A sprite is killed or removed from the group, but it stays in its buckets. Now bullets hit invisible ghosts.
- A missed move. A sprite moves into a new cell, but it is still filed under the old one. Sprites in the new cell never test against it, so real hits are missed.
The fix for the first bug is to let pygame tell you. Every add(), remove(), kill() and empty() ends up calling the group's add_internal(sprite) or remove_internal(sprite). Override those two methods, call the original with super(), and file or unfile the sprite, and no code path can leave the hash stale. The fix for the second bug is to re-file sprites after they move, but only the ones whose cells actually changed.
Here is the complete group, with a short self-check you can run on its own. Save it as spatial_hash.py:
"""spatial_hash.py: a sprite Group that knows which grid cells its sprites are in."""
import pygame
class SpatialHashGroup(pygame.sprite.Group):
"""A Group that also files every sprite into grid cells, and keeps
that filing correct on add, remove, kill and movement."""
def __init__(self, cell_size=64, *sprites):
self.cell_size = cell_size
self.buckets = {} # (cx, cy) -> set of sprites in that cell
self.cells_of = {} # sprite -> tuple of the cells it is filed under
super().__init__(*sprites)
def cells_for(self, rect):
size = self.cell_size
x0, x1 = int(rect.left // size), int(rect.right // size)
y0, y1 = int(rect.top // size), int(rect.bottom // size)
return tuple((cx, cy) for cx in range(x0, x1 + 1) for cy in range(y0, y1 + 1))
# pygame calls these two for add()/remove()/kill()/empty(), so the hash can never go stale.
def add_internal(self, sprite, layer=None):
super().add_internal(sprite, layer)
self._file(sprite)
def remove_internal(self, sprite):
super().remove_internal(sprite)
self._unfile(sprite)
def _file(self, sprite):
cells = self.cells_for(sprite.rect)
for cell in cells:
self.buckets.setdefault(cell, set()).add(sprite)
self.cells_of[sprite] = cells
def _unfile(self, sprite):
for cell in self.cells_of.pop(sprite, ()):
bucket = self.buckets[cell]
bucket.discard(sprite)
if not bucket:
del self.buckets[cell] # no empty buckets left behind
def refresh(self, sprite):
"""Re-file a sprite that moved, but only if its cells changed."""
if self.cells_for(sprite.rect) != self.cells_of.get(sprite):
self._unfile(sprite)
self._file(sprite)
def update(self, *args, **kwargs):
super().update(*args, **kwargs) # sprites move (and may kill themselves)
for sprite in self.sprites():
self.refresh(sprite)
def near(self, rect):
"""Every sprite filed in a cell that rect touches (the broad phase)."""
found = set()
for cell in self.cells_for(rect):
found.update(self.buckets.get(cell, ()))
return found
def in_sync(self):
"""True when every sprite is filed under exactly the cells its rect covers
and no bucket holds a sprite that has left the group."""
filed = set()
for cell, bucket in self.buckets.items():
if not bucket:
return False
filed.update(bucket)
return filed == set(self.sprites()) and all(
self.cells_of[s] == self.cells_for(s.rect) for s in self.sprites())
def colliding_pairs(self):
"""Return (pairs, tests): the touching pairs and how many rect tests it took."""
pairs, tests = [], 0
for sprite in self.sprites():
for other in self.near(sprite.rect):
if id(other) <= id(sprite): # test each pair once, never against itself
continue
tests += 1
if sprite.rect.colliderect(other.rect):
pairs.append((sprite, other))
return pairs, tests
if __name__ == "__main__":
# A quick self-check: file three boxes, move one, kill one, ask who is near.
class Box(pygame.sprite.Sprite):
def __init__(self, x, y, *groups):
super().__init__()
self.image = pygame.Surface((20, 20))
self.rect = self.image.get_frect(topleft=(x, y))
self.add(*groups) # join AFTER rect exists
boxes = SpatialHashGroup(64)
a, b, c = Box(10, 10, boxes), Box(25, 20, boxes), Box(500, 300, boxes)
print("near a:", len(boxes.near(a.rect))) # a and b share a cell
c.rect.topleft = (40, 40)
boxes.refresh(c) # c moved next to them
print("near a after c moved:", len(boxes.near(a.rect)))
print("touching pairs:", len(boxes.colliding_pairs()[0])) # a and b overlap
b.kill() # remove_internal unfiles it
print("after kill:", len(boxes.colliding_pairs()[0]), "in sync:", boxes.in_sync())
Notice two details. The sprite joins the group after its rect exists, because filing needs the rect. And colliding_pairs() skips a pair unless id(other) > id(sprite), so each pair is tested once and nothing is tested against itself.
Measure it yourself
Never trust a speed claim you can't reproduce, including this one. This program times both approaches with timeit. Save it as hash_timing.py, next to spatial_hash.py:
"""Time all-pairs testing against the spatial hash on YOUR machine.
Save spatial_hash.py (from this lesson) in the same folder first."""
import random
import timeit
import pygame
from spatial_hash import SpatialHashGroup
class Box(pygame.sprite.Sprite):
def __init__(self, x, y, *groups):
super().__init__()
self.image = pygame.Surface((12, 12))
self.rect = self.image.get_frect(topleft=(x, y))
self.add(*groups)
def all_pairs(sprites):
hits = 0
for i, a in enumerate(sprites):
for b in sprites[i + 1:]: # every pair once
if a.rect.colliderect(b.rect):
hits += 1
return hits
rng = random.Random(1)
for count in (100, 300, 1000):
group = SpatialHashGroup(64)
boxes = [Box(rng.uniform(0, 788), rng.uniform(0, 588), group) for _ in range(count)]
# Run each 5 times, 3 rounds; keep the best round, per call.
brute = min(timeit.repeat("all_pairs(boxes)", globals=globals(), number=5, repeat=3)) / 5
hashed = min(timeit.repeat("group.colliding_pairs()", globals=globals(), number=5, repeat=3)) / 5
print(f"{count:5} boxes: all pairs {brute * 1000:6.1f} ms hash {hashed * 1000:5.1f} ms")
On the author's machine (Intel Core i7-12700K, Windows Subsystem for Linux, Python 3.10, pygame-ce 2.5.8), 1,000 boxes of 12 pixels spread over 800 ร 600 took about 50 ms per frame to test all pairs, and about 4 to 5 ms with the hash. At 60 FPS a frame has only about 16.7 ms in total. Your numbers will differ; what matters is the trend as the count grows.
โ Growth Mindset: Invisible Data Needs Making Visible
A hash bug doesn't crash. It just quietly misses a hit, or reports one that isn't there, and it's easy to feel lost. Make the invisible visible: draw the grid, color the sprites a query returns, and add a check such as in_sync() that you can print. When a test like that prints False, you know exactly which half of the bookkeeping to read. You haven't debugged a data structure like this yet; after this lesson you will have.
โป๏ธ Object Pools
A bowling alley doesn't make new bowling shoes for every customer. It keeps a rack of shoes, hands out a pair, and cleans and returns them at the end. An object pool is that rack for short-lived game objects such as bullets, sparks and hit numbers.
Pooling helps when making an object is expensive (it builds surfaces or masks), when you want a hard limit on how many can exist, or both. It adds bookkeeping, so for a few slow-changing objects plain creation is simpler; measure before you pool. When you do pool, give it a clear protocol:
- Create everything up front. The pool makes all its objects once, at startup.
acquire()resets, then activates. It takes a free object, calls itsreset(...), adds it to the groups and returns it. When nothing is free it returnsNone, and the caller simply skips that spawn.reset()sets every field. A reused object still holds whatever its last life left behind: old velocity, old timer, old color. Anythingreset()forgets comes back as a ghost from the previous life.release()happens exactly once. It callskill()(so the object leaves every group) and puts it back. Releasing twice would put the same object in the free list twice and hand it out to two owners, so the pool ignores a second release.
import pygame
WIDTH, HEIGHT = 640, 400
class Bullet(pygame.sprite.Sprite):
def __init__(self):
super().__init__()
self.image = pygame.Surface((6, 14))
self.image.fill((255, 220, 90))
self.rect = self.image.get_frect()
self.vel = pygame.Vector2()
self.in_pool = True
def reset(self, pos, vel):
"""Set EVERY field a bullet uses. Anything you forget keeps last life's value."""
self.rect.center = pos
self.vel.update(vel)
def update(self, dt):
self.rect.center += self.vel * dt
class Pool:
"""acquire() -> reset() + join groups; release() -> kill() + back in the pool."""
def __init__(self, factory, size):
self.free = [factory() for _ in range(size)] # everything is made up front
self.size = size
def acquire(self, groups, **settings):
if not self.free:
return None # pool empty: skip this spawn
obj = self.free.pop()
obj.in_pool = False
obj.reset(**settings)
obj.add(*groups)
return obj
def release(self, obj):
if obj.in_pool: # releasing twice would duplicate it
return
obj.kill()
obj.in_pool = True
self.free.append(obj)
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Pool: hold SPACE to fire")
clock = pygame.time.Clock()
font = pygame.font.Font(None, 28)
bullets = pygame.sprite.Group()
pool = Pool(Bullet, 20)
gun = pygame.Vector2(WIDTH / 2, HEIGHT - 20)
cooldown = 0.0
running = True
while running:
dt = clock.tick(60) / 1000
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
cooldown = max(0.0, cooldown - dt)
if pygame.key.get_pressed()[pygame.K_SPACE] and cooldown == 0:
pool.acquire([bullets], pos=gun, vel=(0, -400))
cooldown = 0.06 # about 16 shots per second
bullets.update(dt)
for bullet in bullets.sprites():
if bullet.rect.bottom < 0:
pool.release(bullet) # back to the pool, not deleted
screen.fill((20, 22, 34))
bullets.draw(screen)
text = f"flying {len(bullets)} in pool {len(pool.free)} / {pool.size}"
screen.blit(font.render(text, True, (230, 230, 230)), (10, 10))
pygame.display.flip()
pygame.quit()
Hold Space: at most 20 bullets fly at once, and the counter shows them going out and coming back. Notice that bullets are released by the loop that noticed they left the screen, not deleted.
โ Growth Mindset: Ghosts From a Past Life
The classic pool bug looks spooky: a new spark appears already half faded, or a fresh bullet flies sideways. It is not random. The object was reused and reset() missed a field. When that happens, list every attribute set in __init__ and check that reset() sets each one. Bugs like this are a sign you are working with real engine patterns, not a sign you are doing it wrong.
๐ผ๏ธ Caching Transformed Frames
The same idea, "do the work once, then reuse it", applies to images. pygame.transform.rotate() and scale() create a brand-new Surface every call. A ship that turns every frame would build a new image every frame, and if it uses masks, a new mask too.
Instead, round the angle to a step (say 10ยฐ) and keep one rotated copy per step. Rounding does two jobs: it makes cache hits likely, and it puts a hard ceiling on the cache size (360 รท 10 = 36 images), so memory can't grow forever.
class RotationCache:
"""Rotated copies of one image, one per step degrees, made on first use."""
def __init__(self, image, step=10):
self.image = image
self.step = step
self.frames = {} # at most 360 / step entries
def get(self, angle):
key = int(round(angle / self.step)) * self.step % 360
if key not in self.frames:
self.frames[key] = pygame.transform.rotate(self.image, key)
return self.frames[key]
ship_frames = RotationCache(ship_image) # one cache, shared by every ship
ship.image = ship_frames.get(ship.angle) # cheap after the first time
ship.rect = ship.image.get_frect(center=ship.pos) # rotated images change size
If your sprites use masks, cache a mask next to each rotated frame in the same way. And the same rule applies to sprite sheets: slice the frames once when the game loads and keep the list, instead of calling subsurface() in draw(). A subsurface shares pixels with the sheet, so the list costs very little memory.
def slice_sheet(sheet, frame_w, frame_h):
"""Cut every frame ONCE, at load time."""
frames = []
for y in range(0, sheet.get_height(), frame_h):
for x in range(0, sheet.get_width(), frame_w):
frames.append(sheet.subsurface((x, y, frame_w, frame_h)))
return frames
๐ก Why this matters
Spatial hashes, pools and caches share one habit: move work out of the frame loop. Every frame has a fixed time budget, so work that repeats identical results is work stolen from the things players notice.
๐๏ธ Practice Exercise: Swarm
Objective: make 300 arrows collide-check only against their neighbors, keep the hash correct when arrows are removed, throw pooled sparks off the walls, and reuse cached rotations.
Time: about 35 minutes. Starter file: swarm_starter.py (your instructor has it). It runs, but files every arrow in one giant cell. Watch the HUD's "hash tests" number as you work. Keys: Space adds 50 arrows, X removes 50, G toggles the grid.
- Write
cells_for(rect)so it returns every cell the rect overlaps. "Hash tests" should drop from tens of thousands to around a thousand. (โ 8 min) - In
remove_internal, unfile the sprite. Press X: without this, the closing message saysHash in sync: False. (โ 5 min) - Write
refresh(sprite): re-file a sprite only when its cells changed. (โ 5 min) - Finish the pool:
acquire()(returnNonewhen empty),release()(ignore a second release) andSpark.reset()(set every field). Sparks now fly off the walls. (โ 12 min) - Make
RotationCache.get()round the angle and cache the result. "Cached angles" should stop at 36. (โ 5 min)
You are done when:
- "hash tests" is a small fraction of "all-pairs tests", and touching arrows get red rings;
- sparks appear at the walls and "free sparks" never goes above 40 or below 0;
- "cached angles" stays at or below 36;
- after pressing X a few times, closing the window prints
Hash in sync: True.
๐ก Hint
For cells_for, write two range() calls from the first to the last cell (add 1 to the stop, because range excludes it) and combine them in one tuple comprehension. For refresh, the two helpers _file and _unfile already do the real work: you only decide when to call them.
โ Example Solution
If your instructor hands you the lab file, you will see a few extra lines marked lab runtime near the top, plus an extra and frame_budget() condition on the main loop. They let the instructor's checker run the program automatically for a fixed number of frames; when you run it yourself they do nothing. You never need to write them.
"""Swarm: Intermediate Lesson 2 practice exercise (solution).
Hundreds of arrows share a SpatialHashGroup, so each one is only tested
against its neighbors. Wall bounces throw sparks from a fixed-size pool,
and rotated arrow images come from a small cache.
SPACE adds 50 arrows, X removes 50, G toggles the grid.
Close the window to quit.
"""
import math
import random
import pygame
WIDTH, HEIGHT = 800, 600
CELL = 64 # spatial-hash cell size in pixels
SPARK_POOL_SIZE = 40
SPARK_LIFE = 0.35 # seconds
class SpatialHashGroup(pygame.sprite.Group):
"""A Group that also files every sprite into grid cells, and keeps
that filing correct on add, remove, kill and movement."""
def __init__(self, cell_size=CELL, *sprites):
self.cell_size = cell_size
self.buckets = {} # (cx, cy) -> set of sprites in that cell
self.cells_of = {} # sprite -> tuple of the cells it is filed under
super().__init__(*sprites)
def cells_for(self, rect):
size = self.cell_size
x0, x1 = int(rect.left // size), int(rect.right // size)
y0, y1 = int(rect.top // size), int(rect.bottom // size)
return tuple((cx, cy) for cx in range(x0, x1 + 1) for cy in range(y0, y1 + 1))
# pygame calls these two for add()/remove()/kill()/empty(), so the hash can never go stale.
def add_internal(self, sprite, layer=None):
super().add_internal(sprite, layer)
self._file(sprite)
def remove_internal(self, sprite):
super().remove_internal(sprite)
self._unfile(sprite)
def _file(self, sprite):
cells = self.cells_for(sprite.rect)
for cell in cells:
self.buckets.setdefault(cell, set()).add(sprite)
self.cells_of[sprite] = cells
def _unfile(self, sprite):
for cell in self.cells_of.pop(sprite, ()):
bucket = self.buckets[cell]
bucket.discard(sprite)
if not bucket:
del self.buckets[cell] # no empty buckets left behind
def refresh(self, sprite):
"""Re-file a sprite that moved, but only if its cells changed."""
if self.cells_for(sprite.rect) != self.cells_of.get(sprite):
self._unfile(sprite)
self._file(sprite)
def update(self, *args, **kwargs):
super().update(*args, **kwargs) # sprites move (and may kill themselves)
for sprite in self.sprites():
self.refresh(sprite)
def near(self, rect):
"""Every sprite filed in a cell that rect touches (the broad phase)."""
found = set()
for cell in self.cells_for(rect):
found.update(self.buckets.get(cell, ()))
return found
def in_sync(self):
"""True when every sprite is filed under exactly the cells its rect covers
and no bucket holds a sprite that has left the group."""
filed = set()
for cell, bucket in self.buckets.items():
if not bucket:
return False
filed.update(bucket)
return filed == set(self.sprites()) and all(
self.cells_of[s] == self.cells_for(s.rect) for s in self.sprites())
def colliding_pairs(self):
"""Return (pairs, tests): the touching pairs and how many rect tests it took."""
pairs, tests = [], 0
for sprite in self.sprites():
for other in self.near(sprite.rect):
if id(other) <= id(sprite): # test each pair once, never against itself
continue
tests += 1
if sprite.rect.colliderect(other.rect):
pairs.append((sprite, other))
return pairs, tests
class RotationCache:
"""Rotated copies of one image, one per step degrees, made on first use."""
def __init__(self, image, step=10):
self.image = image
self.step = step
self.frames = {} # at most 360 / step entries, so it cannot grow forever
def get(self, angle):
key = int(round(angle / self.step)) * self.step % 360
if key not in self.frames:
self.frames[key] = pygame.transform.rotate(self.image, key)
return self.frames[key]
class Arrow(pygame.sprite.Sprite):
def __init__(self, pos, vel, cache, *groups):
super().__init__()
self.cache = cache
self.pos = pygame.Vector2(pos)
self.vel = pygame.Vector2(vel)
self.image = cache.get(heading(self.vel))
self.rect = self.image.get_frect(center=self.pos)
self.touching = False
self.add(*groups) # join the hash group only once rect exists
def update(self, dt, bounced):
self.pos += self.vel * dt
if not 0 <= self.pos.x <= WIDTH:
self.vel.x = -self.vel.x
self.pos.x = max(0, min(WIDTH, self.pos.x))
bounced.append(pygame.Vector2(self.pos))
if not 0 <= self.pos.y <= HEIGHT:
self.vel.y = -self.vel.y
self.pos.y = max(0, min(HEIGHT, self.pos.y))
bounced.append(pygame.Vector2(self.pos))
self.image = self.cache.get(heading(self.vel))
self.rect = self.image.get_frect(center=self.pos)
class Spark(pygame.sprite.Sprite):
"""A pooled effect. reset() must set EVERY field, because a reused
spark still holds whatever its last life left behind."""
def __init__(self):
super().__init__()
self.image = pygame.Surface((6, 6))
self.image.fill((255, 210, 90))
self.rect = self.image.get_frect()
self.vel = pygame.Vector2()
self.life = 0.0
self.in_pool = True
def reset(self, pos, vel):
self.rect.center = pos
self.vel.update(vel)
self.life = SPARK_LIFE
def update(self, dt):
self.rect.center += self.vel * dt
self.life = max(0.0, self.life - dt)
class Pool:
"""A fixed set of reusable objects.
Protocol: acquire(groups, **settings) calls obj.reset(**settings) and
adds obj to the groups; release(obj) kills it (it leaves every group)
and puts it back. Releasing twice does nothing. When the pool is empty,
acquire() returns None and the caller simply skips that spawn."""
def __init__(self, factory, size):
self.free = [factory() for _ in range(size)]
self.size = size
def acquire(self, groups, **settings):
if not self.free:
return None
obj = self.free.pop()
obj.in_pool = False
obj.reset(**settings)
obj.add(*groups)
return obj
def release(self, obj):
if obj.in_pool:
return
obj.kill()
obj.in_pool = True
self.free.append(obj)
def heading(vel):
"""Degrees to pass to pygame.transform.rotate so a right-pointing image faces vel.
rotate() turns counterclockwise, and screen y points down, hence the minus."""
return -math.degrees(math.atan2(vel.y, vel.x))
def make_arrow_image():
image = pygame.Surface((18, 10), pygame.SRCALPHA)
pygame.draw.polygon(image, (120, 200, 255), [(0, 0), (18, 5), (0, 10)])
return image
def add_arrows(arrows, cache, rng, count):
for _ in range(count):
angle = rng.uniform(0, 360)
speed = rng.uniform(60, 140) # px/s
vel = pygame.Vector2(speed, 0).rotate(angle)
pos = (rng.uniform(20, WIDTH - 20), rng.uniform(20, HEIGHT - 20))
Arrow(pos, vel, cache, arrows)
def main():
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Swarm")
clock = pygame.time.Clock()
font = pygame.font.Font(None, 26) # created once
rng = random.Random(3)
cache = RotationCache(make_arrow_image())
arrows = SpatialHashGroup(CELL)
sparks = pygame.sprite.Group()
pool = Pool(Spark, SPARK_POOL_SIZE)
add_arrows(arrows, cache, rng, 300)
show_grid = True
tests = 0
running = True
while running:
dt = clock.tick(60) / 1000
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
add_arrows(arrows, cache, rng, 50)
elif event.type == pygame.KEYDOWN and event.key == pygame.K_x:
for arrow in arrows.sprites()[:50]:
arrow.kill() # the hash must forget them too
elif event.type == pygame.KEYDOWN and event.key == pygame.K_g:
show_grid = not show_grid
bounced = []
arrows.update(dt, bounced)
for pos in bounced:
vel = pygame.Vector2(rng.uniform(80, 160), 0).rotate(rng.uniform(0, 360))
pool.acquire([sparks], pos=pos, vel=vel) # None when the pool is empty
sparks.update(dt)
for spark in [s for s in sparks if s.life == 0]:
pool.release(spark)
for arrow in arrows:
arrow.touching = False
pairs, tests = arrows.colliding_pairs()
for a, b in pairs:
a.touching = b.touching = True
screen.fill((16, 18, 28))
if show_grid:
for x in range(0, WIDTH, CELL):
pygame.draw.line(screen, (34, 38, 56), (x, 0), (x, HEIGHT))
for y in range(0, HEIGHT, CELL):
pygame.draw.line(screen, (34, 38, 56), (0, y), (WIDTH, y))
arrows.draw(screen)
for arrow in arrows:
if arrow.touching:
pygame.draw.circle(screen, (255, 120, 120), arrow.rect.center, 11, 1)
sparks.draw(screen)
n = len(arrows)
lines = [f"Arrows {n} all-pairs tests {n * (n - 1) // 2} hash tests {tests}",
f"Touching pairs {len(pairs)} free sparks {len(pool.free)}/{pool.size} "
f"cached angles {len(cache.frames)} FPS {clock.get_fps():.0f}"]
for i, text in enumerate(lines):
screen.blit(font.render(text, True, (230, 230, 230)), (10, 10 + 22 * i))
pygame.display.flip()
pygame.quit()
n = len(arrows)
print(f"Arrows: {n}")
print(f"All-pairs tests: {n * (n - 1) // 2}, hash tests: {tests}")
print(f"Hash in sync: {arrows.in_sync()}")
if __name__ == "__main__":
main()
๐ Learning Journal
Take five minutes to write in your learning journal (a notebook or a plain text file works). Jot down:
- Key concepts you learned today
- Techniques that clicked (and the ones that haven't, yet)
- Questions or confusion to bring to the next session
- Ideas to try in your own game
- Progress and feelings: how did this lesson go for you?
โ๏ธ This lesson's prompts:
- Run
hash_timing.pyand write down your numbers. At what sprite count does all-pairs testing stop fitting in a 16.7 ms frame on your machine? - Explain the difference between a stale entry and a missed move in your own words. Which one would players notice first?
- Which objects in a game you like are probably pooled? What would happen if
reset()forgot one of their fields?
๐ Summary
All-pairs collision tests grow with the square of the sprite count, and making each test cheaper doesn't change that. A spatial hash files sprites into grid cells so that each sprite only meets its neighbors, and overriding add_internal and remove_internal keeps the hash correct through every add, remove and kill, while refresh() follows movement. You measured the difference with timeit instead of taking it on faith. Object pools and rotation caches apply the same habit to objects and images: do the setup work once, reuse it, and put a ceiling on how much can pile up.
๐ Key Takeaways
- n sprites means n ร (n โ 1) รท 2 pairs; the broad phase's job is to skip far-away pairs entirely.
- A spatial hash is a dict from
(cx, cy)to a set of sprites; a rect is filed in every cell it touches. - Override
add_internal/remove_internalsokill()can never leave the hash stale, and re-file sprites whose cells changed. - Measure speed claims with
timeiton your own machine. - A pool's protocol: create up front, acquire โ
reset()every field, release exactly once, and returnNonewhen empty. - Cache rotated frames by a rounded angle so the cache has a fixed maximum size.
๐ญ Looking Ahead
Your sprites are organized and fast. Next, in Interpolation & Easing, you make them move with style: smooth starts and stops, overshoots, following that feels the same at any frame rate, and turning the short way around.
โ Common Questions
How do I pick the cell size?
Start at about one to two times your typical sprite's width, then try half and double while watching a test counter like the one in the exercise. The best size depends on how big your sprites are and how bunched together they get.
What about sprites that are much bigger than a cell?
They get filed in many cells, which is correct but makes them cost more. If you have a few huge objects (a boss, a wall), keep them in a separate small group and test them against everything directly.
Is a spatial hash the same as a quadtree?
They solve the same problem. A quadtree splits space into smaller boxes where objects are crowded; a hash uses one fixed grid. For many similar-sized sprites, a uniform grid is simpler and works well.
Does pooling make Python programs faster?
Sometimes, and only a measurement can tell you. It helps most when creating an object does real work, like drawing surfaces or building masks. Its other benefit is the hard cap: a pool of 40 sparks can never become 4,000.
Why use id() to skip duplicate pairs?
Without it, every nearby pair would be tested twice: once when sprite A looks up its neighbors and finds B, and again when B finds A. Each sprite would also find itself. Comparing id() numbers gives every pair a fixed order, so only one side runs the test and nothing is tested against itself.
๐ฏ Quick Quiz
Question 1: With 200 sprites, how many pairs does all-pairs testing check each frame?
Question 2: Why does SpatialHashGroup override remove_internal()?
Question 3: A sprite moves from cell (2, 3) to (3, 3), but refresh() is never called. What goes wrong?
Question 4: In the pool protocol, what must a pooled object's reset() do?
Question 5: A RotationCache uses step=10. At most how many rotated images can it hold?
๐ Going Further
- Query a circle: add
near_point(pos, radius)that builds a rect around the circle, asksnear(), and then keeps only sprites within the radius. It is the start of "which enemies are in range of this tower?". - Tune the cell size: in Swarm, press Space until you have 800 arrows and compare cell sizes of 32, 64 and 128 on the HUD.
- Pool policy: instead of returning
Nonewhen empty, try recycling the oldest active spark. Which feels better in a busy scene? - Read the docs: Python's timeit and pygame-ce's pygame.transform.
- Coming up in Game Dev III: Advanced: Profiling & Performance teaches
cProfilefor finding which function is actually slow before you optimize anything.