Lesson 9: Collision Detection
A coin that can't be picked up and a wall you can walk through are not a game yet. In this lesson you teach your sprites to notice when they touch, and to react in ways that feel fair: collect, bump, block and slide.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Test whether two rectangles overlap, by hand and with
colliderect()andcollidepoint(). - Explain why an integer
Rectmakes slow objects stall, and usepygame.FRectto keep float positions. - Choose between rectangle and circle tests, and collide whole sprite groups with
spritecollide()andgroupcollide(). - Resolve wall collisions one axis at a time, so the player slides along walls instead of sticking to them.
- Debug three classic collision bugs: coins inside walls, damage every frame, and health below zero.
Project: Coin Dash, a top-down game where you collect coins in a walled arena while two patrolling drones try to bump you.
In This Lesson
💥 Detect, Then Respond
Think of a game of tag. Two things happen when you tag someone: first you notice that your hand touched them, then the game reacts, and they become "it". Collisions in a game work the same way, in two separate steps:
- Detection asks a yes-or-no question: are these two things touching right now?
- Response decides what happens next: the coin disappears and the score goes up, the wall pushes the player back, the enemy takes a heart.
Keeping the two steps apart makes collision code much easier to read and fix. Detection is almost always one of two cheap tests, shown below. Response is where your game's personality lives.
💡 Why this matters
Nearly every rule in a game is a collision rule in disguise: "touching lava hurts", "reaching the flag wins", "bullets destroy asteroids". Once you can detect and respond reliably, you can build most arcade games you have ever played.
📦 Rectangles: The Workhorse Test
The most common test in 2D games checks two axis-aligned bounding boxes, or AABBs: rectangles whose sides line up with the screen's x and y axes (they are never rotated). Two such rectangles overlap only when they overlap on the x-axis and on the y-axis. Written out by hand, that is four comparisons:
import pygame
def rects_overlap(a, b):
"""True when rectangles a and b overlap. Each is (x, y, width, height)."""
ax, ay, aw, ah = a
bx, by, bw, bh = b
return ax < bx + bw and ax + aw > bx and ay < by + bh and ay + ah > by
player = pygame.Rect(100, 100, 50, 50)
coin = pygame.Rect(140, 130, 20, 20)
wall = pygame.Rect(150, 100, 20, 80) # starts exactly at the player's right edge
print(rects_overlap(player, coin), player.colliderect(coin)) # True True
print(rects_overlap(player, wall), player.colliderect(wall)) # False False: edges only touch
print(player.collidepoint(120, 110)) # True: the point is inside
print(player.collidepoint(150, 110)) # False: x = 150 is just past the right edge
Run it and compare the two columns: your function and pygame's colliderect() agree every time. Look closely at the wall. It starts at x = 150, exactly where the player's right edge is, so the two rectangles touch but do not overlap, and both tests say False. That detail matters later: a player pushed back so that it sits flush against a wall is not "colliding" with it any more.
collidepoint(x, y) asks whether a single point is inside a rectangle. It is perfect for mouse clicks on buttons. A rectangle includes its left and top edges but not its right and bottom edges, which is why the point at x = 150 is outside a rectangle that starts at 100 and is 50 wide.
Try both tests in the demo. Move the blue shape over the obstacles with your mouse (or click the canvas and use the arrow keys), then switch the mode to compare the rectangle test with the circle test you will write in a moment.
🎯 FRect: Positions That Keep Their Fractions
A pygame.Rect stores whole numbers only. In the Game Loop lesson you moved objects by speed * dt, which is usually a fraction of a pixel per frame. Give that fraction to a Rect and it is thrown away every single frame. Here is what that does to a slow-moving snail:
import pygame
speed = 30 # pixels per second: a slow snail
dt = 1 / 60 # one frame at 60 FPS
int_rect = pygame.Rect(0, 0, 20, 20)
float_rect = pygame.FRect(0, 0, 20, 20)
for frame in range(60): # one second of frames
int_rect.x += speed * dt # 0 + 0.5 is stored as 0 again
float_rect.x += speed * dt # 0.5 px is kept
print("Rect after one second: ", int_rect.x)
print("FRect after one second:", round(float_rect.x, 1))
The Rect never moves at all: each frame it becomes 0.5, and a Rect stores that as 0 again. The FRect covers exactly the 30 pixels it should. Faster objects don't freeze, but they go the wrong speed: 2.7 pixels a frame silently becomes 2.
pygame.FRect is pygame-ce's floating-point rectangle. It has the same attributes (x, centerx, right, bottom…) and the same methods (colliderect, collidepoint, clamp_ip…), and it can collide with ordinary Rects. You get one from an image with get_frect() instead of get_rect():
self.rect = self.image.get_frect(center=(x, y)) # a float rect, centered on (x, y)
In the Vectors lesson you kept a separate Vector2 position and copied it into the rect every frame. That is still a fine pattern. In this lesson the FRect is the position, which keeps collision code short: you move the rect, test the rect, and push the rect back out of walls. Walls and other things that never move can stay plain Rects.
⭕ Circles: A Distance Check
Rectangles are a poor fit for round things. Two balls whose boxes touch at the corners are clearly not touching, yet the rectangle test says they are. For balls, coins, planets and explosions, compare the distance between the centers with the two radii added together:
import pygame
def circles_overlap(center_a, radius_a, center_b, radius_b):
"""True when two circles overlap: centers closer than the radii added up."""
distance = pygame.Vector2(center_a).distance_to(center_b)
return distance < radius_a + radius_b
print(circles_overlap((100, 100), 20, (130, 100), 15)) # True: 30 < 35
print(circles_overlap((100, 100), 20, (140, 100), 15)) # False: 40 is not < 35
Vector2.distance_to() does the Pythagoras for you. You will also see code that compares distance_squared_to(center_b) with (radius_a + radius_b) ** 2; it gives the same answer without taking a square root, and either version is fine.
Circles have one more advantage: they are the same in every direction, so a rotating sprite can keep the same circle hitbox while its image turns.
👾 Whole Groups at Once
In the Images & Sprite Classes lesson you put sprites into pygame.sprite.Groups. pygame's collision helpers test a sprite, or a whole group, against another group in one call, using each sprite's rect:
import pygame
class Box(pygame.sprite.Sprite):
"""A colored square sprite centered on (x, y)."""
def __init__(self, x, y, size, color):
super().__init__()
self.image = pygame.Surface((size, size))
self.image.fill(color)
self.rect = self.image.get_frect(center=(x, y))
GOLD, RED, WHITE = (255, 210, 60), (255, 90, 90), (255, 255, 255)
player = Box(200, 200, 30, (80, 160, 255))
coins = pygame.sprite.Group(Box(210, 205, 12, GOLD), Box(400, 50, 12, GOLD))
enemies = pygame.sprite.Group(Box(100, 100, 30, RED), Box(300, 100, 30, RED))
bullets = pygame.sprite.Group(Box(100, 110, 6, WHITE), Box(500, 300, 6, WHITE))
# One sprite against a group. True = remove every coin that was hit.
picked = pygame.sprite.spritecollide(player, coins, True)
print("Picked up:", len(picked), "coins left:", len(coins))
# A group against a group. Returns {enemy: [bullets that hit it]}.
hits = pygame.sprite.groupcollide(enemies, bullets, True, True)
print("Enemies hit:", len(hits), "enemies left:", len(enemies))
# Just "is anything touching?" Returns one sprite, or None.
enemy = pygame.sprite.spritecollideany(player, enemies)
print("Touching an enemy?", enemy is not None)
| Call | Returns | Use it for |
|---|---|---|
spritecollide(sprite, group, dokill) | A list of the group's sprites that touch sprite | Player vs coins, player vs enemies |
groupcollide(group_a, group_b, kill_a, kill_b) | A dict: each hit sprite of group_a → list of the group_b sprites touching it | Bullets vs enemies |
spritecollideany(sprite, group) | One touching sprite, or None | "Am I touching anything at all?" |
The dokill arguments are handy: True removes the hit sprites from every group they belong to, so a collected coin stops being drawn and stops being tested on the very next frame.
All three helpers use rectangles by default. To use circles instead, pass pygame.sprite.collide_circle as the last argument. If a sprite has a radius attribute, collide_circle uses it; otherwise it uses a circle just big enough to enclose the sprite's rect.
class Drone(pygame.sprite.Sprite):
def __init__(self, x, y):
super().__init__()
self.radius = 16 # collide_circle uses this if it exists
self.image = pygame.Surface((32, 32), pygame.SRCALPHA)
pygame.draw.circle(self.image, (255, 95, 95), (16, 16), self.radius)
self.rect = self.image.get_frect(center=(x, y))
bumped = pygame.sprite.spritecollide(player, drones, False, pygame.sprite.collide_circle)
Notice the radius (16) matches the drawn ball. A hitbox that is a little smaller than the picture, like the player's 13-pixel circle in the exercise, makes near misses look like near misses. That is a design choice, not a rule: try both and see which feels fairer to you.
🧱 Walls That Let You Slide
Detecting that the player hit a wall is easy. The response is where beginners get stuck, sometimes literally. The simplest idea is "if the move hits a wall, undo the move". Run the program below and press Tab to try it: hold → and ↑ together against the right-hand wall. In UNDO mode the player freezes, because every diagonal step touches the wall and gets canceled, including the upward half that was perfectly fine.
The fix is to move one axis at a time:
- Move on x. If you now overlap a wall, push back out on x only: moving right, set
rect.right = wall.left; moving left, setrect.left = wall.right. - Then move on y and do the same with
topandbottom.
Because each step changes only one axis, you always know which side of the wall you hit, and the other half of the movement survives. The player slides along the wall, exactly as players expect.
"""Wall Slide: Intro Lesson 9, "Walls That Let You Slide" (solution).
Move the square with the arrow keys or WASD. Press TAB to switch between
the two ways of stopping at a wall:
UNDO undo the whole move when it hits a wall (you get stuck)
PER-AXIS move and fix x, then move and fix y (you slide along walls)
Close the window to quit.
"""
import pygame
WIDTH, HEIGHT = 800, 600
SPEED = 250 # pixels per second
BG_COLOR = (18, 22, 34)
WALL_COLOR = (110, 120, 140)
PLAYER_COLOR = (90, 190, 255)
TEXT_COLOR = (235, 235, 235)
MOVE_KEYS = {
pygame.K_LEFT: (-1, 0), pygame.K_a: (-1, 0),
pygame.K_RIGHT: (1, 0), pygame.K_d: (1, 0),
pygame.K_UP: (0, -1), pygame.K_w: (0, -1),
pygame.K_DOWN: (0, 1), pygame.K_s: (0, 1),
}
WALLS = [
pygame.Rect(200, 100, 400, 30),
pygame.Rect(200, 470, 400, 30),
pygame.Rect(570, 130, 30, 200),
]
def move_undo(rect, velocity, dt, walls):
"""Move both axes at once; if that hits a wall, cancel the whole move."""
old_x, old_y = rect.x, rect.y
rect.x += velocity.x * dt
rect.y += velocity.y * dt
if rect.collidelist(walls) != -1:
rect.x, rect.y = old_x, old_y
def move_per_axis(rect, velocity, dt, walls):
"""Move and fix x first, then move and fix y, so walls stop only one axis."""
rect.x += velocity.x * dt
for wall in walls:
if rect.colliderect(wall):
if velocity.x > 0:
rect.right = wall.left # moving right: stop at the wall's left side
elif velocity.x < 0:
rect.left = wall.right
rect.y += velocity.y * dt
for wall in walls:
if rect.colliderect(wall):
if velocity.y > 0:
rect.bottom = wall.top # moving down: stop on top of the wall
elif velocity.y < 0:
rect.top = wall.bottom
def main():
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Wall Slide")
clock = pygame.time.Clock()
font = pygame.font.Font(None, 30)
player = pygame.FRect(360, 300, 36, 36) # FRect keeps the fractions of a pixel
held = set() # movement keys held down right now
per_axis = True
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:
if event.key == pygame.K_TAB:
per_axis = not per_axis
elif event.key in MOVE_KEYS:
held.add(event.key)
elif event.type == pygame.KEYUP:
held.discard(event.key)
velocity = pygame.Vector2()
for key in held:
velocity += MOVE_KEYS[key]
if velocity.length_squared() > 0:
velocity = velocity.normalize() * SPEED # same speed on diagonals
if per_axis:
move_per_axis(player, velocity, dt, WALLS)
else:
move_undo(player, velocity, dt, WALLS)
player.clamp_ip(pygame.Rect(0, 0, WIDTH, HEIGHT))
screen.fill(BG_COLOR)
for wall in WALLS:
pygame.draw.rect(screen, WALL_COLOR, wall)
pygame.draw.rect(screen, PLAYER_COLOR, player)
mode = "PER-AXIS (slides)" if per_axis else "UNDO (sticks)"
label = font.render(f"TAB to switch. Mode: {mode}", True, TEXT_COLOR)
screen.blit(label, (12, 12))
pygame.display.flip()
pygame.quit()
print(f"Player stopped at ({player.x:.1f}, {player.y:.1f}).")
if __name__ == "__main__":
main()
A few details worth noticing:
heldis a set of the movement keys that are down, updated fromKEYDOWNandKEYUPevents. It gives the same answer aspygame.key.get_pressed()for these keys.- Adding up the key directions gives a vector like (1, −1) for up-right. Normalizing it before multiplying by
SPEEDkeeps diagonal movement from being faster, and thelength_squared() > 0check avoids normalizing a zero vector, which raises an error. - After pushing out, the player sits flush against the wall. As you saw with
colliderect, touching is not overlapping, so next frame the wall does not "catch" it again.
✅ Growth Mindset: Stuck in a Wall? You Found the Interesting Bug
Almost everyone's first wall code traps the player, jitters, or lets it squeeze through a corner. That is not a sign you're bad at this; it is the most famous bug in 2D games, and you just met it early. The habit that fixes it is to slow the problem down: set SPEED to 20, draw the player's rect outline, and print rect and velocity for a few frames. When you can see which axis pushed the player where, the fix usually takes one line.
🩹 Fair Responses: Collect, Bump, Recover
Detection runs every frame, and so does the response. That causes three classic bugs.
1. Damage every frame. A drone that overlaps the player for a third of a second overlaps it on about 20 frames at 60 FPS. Take a heart on each one and a single bump ends the game. The usual fix is a short safe time (often called invincibility frames): after a hit, ignore further hits for a moment, and blink the player so it is obvious. Count it in seconds with dt, like every timer in this course:
SAFE_TIME = 1.0 # seconds
def take_hit(health, safe_timer):
"""Return (health, safe_timer) after a bump. No damage while still safe."""
if safe_timer > 0:
return health, safe_timer
return max(0, health - 1), SAFE_TIME
# every frame, in the update step:
safe_timer = max(0.0, safe_timer - dt)
2. Health below zero. max(0, health - 1) keeps health from going negative, so the HUD never shows "Health: -3" and a "health == 0" check for game over always works.
3. Coins inside walls. Random positions ignore your level. Pick a spot, test it, and only keep it when it is free. collidelist() returns the index of the first rect it overlaps, or -1 for none:
while len(coins) < count:
coin = Coin(rng.randint(30, WIDTH - 50), rng.randint(60, HEIGHT - 50))
if coin.rect.collidelist(walls) == -1: # -1 means it touches no wall
coins.add(coin)
One more problem is worth knowing by name, even though you won't hit it in this lesson: tunneling. Collisions are only checked once per frame, so an object that moves further in one frame than a wall is thick can jump right over it. Keep walls thicker than your fastest object moves in one frame; the FAQ has another guard.
| Symptom | Likely cause | Fix |
|---|---|---|
| Slow objects never move | Position stored in an integer Rect | Use FRect (get_frect()) |
| Player sticks to walls | Undoing the whole move | Move and resolve x, then y |
| One bump takes all your health | Damage applied every frame of the overlap | A safe timer in seconds |
| Health shows a negative number | Subtracting without a floor | max(0, health - 1) |
| A coin can never be collected | It spawned inside a wall | Retry until collidelist(walls) == -1 |
| A fast object passes through a wall | It moved further than the wall is thick in one frame | Thicker walls, slower objects, or a cap on dt |
🏋️ Practice Exercise: Coin Dash
Objective: finish a small arcade game where you collect eight coins in a walled arena, slide along the walls, and survive two patrolling drones.
Time: about 40 minutes. Starter file: coin_dash_starter.py (your instructor has it). It already draws the arena, moves the player and the drones, and shows the HUD, but nothing collides yet: you walk through walls, coins can't be collected and drones do no harm. Its numbered comments match the steps below.
- Run the starter and walk through a wall, a coin and a drone, so you know what "broken" looks like. (≈ 3 min)
- In
move_and_collide()(comment 1), push the rect out of walls on x after the x move, then on y after the y move. Hold two arrow keys against a wall: you should slide. (≈ 10 min) - In
spawn_coins()(comment 2), only add a coin when it touches no blocked rect and no other coin. Press R a few times to check new layouts. (≈ 7 min) - In the update step (comment 3), collect coins with
spritecollide(player, coins, True)and add the number collected toscore. (≈ 5 min) - Below that (comment 4), detect drone bumps with
spritecollide(..., pygame.sprite.collide_circle)and calltake_hit(). (≈ 5 min) - Finish
take_hit()(comment 5) with a safe time and a floor of zero. Let a drone hit you: you should lose exactly one heart and blink. (≈ 7 min) - Play a full round, win and lose once each, and press R to restart. (≈ 3 min)
You are done when:
- walls stop you, and holding two arrow keys against a wall slides you along it;
- coins never appear inside a wall, and each one disappears the moment you touch it;
- one drone bump costs exactly one heart, you blink for a second, and health never goes below 0;
- collecting all eight coins shows "You win!", losing every heart shows "Game over", and R starts a new round.
💡 Hint
For step 2, copy the pattern from Walls That Let You Slide: loop over the walls after each axis move and check rect.colliderect(wall). Use the sign of velocity.x (or velocity.y) to decide which side to snap to. If you lose a heart on every frame of a bump, take_hit() is probably returning a new safe_timer even while the old one is still above 0.
✅ Example Solution
If your instructor hands you the lab file, you will see a few extra lines marked lab runtime near the top and and frame_budget() in the 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.
"""Coin Dash: Intro Lesson 9 practice exercise (solution).
Collect every coin with the arrow keys or WASD. Walls block you but let
you slide along them. Two patrolling drones cost you one heart per bump,
and after a bump you blink and are safe for a moment. Press R to restart.
Close the window to quit.
"""
import random
import pygame
WIDTH, HEIGHT = 800, 600
FPS = 60
PLAYER_SPEED = 260 # pixels per second
PLAYER_SIZE = 32
COIN_COUNT = 8
MAX_HEALTH = 3
SAFE_TIME = 1.0 # seconds of safety after a bump
BG_COLOR = (16, 20, 32)
WALL_COLOR = (105, 115, 135)
PLAYER_COLOR = (90, 190, 255)
COIN_COLOR = (255, 205, 60)
DRONE_COLOR = (255, 95, 95)
TEXT_COLOR = (235, 235, 235)
MOVE_KEYS = {
pygame.K_LEFT: (-1, 0), pygame.K_a: (-1, 0),
pygame.K_RIGHT: (1, 0), pygame.K_d: (1, 0),
pygame.K_UP: (0, -1), pygame.K_w: (0, -1),
pygame.K_DOWN: (0, 1), pygame.K_s: (0, 1),
}
WALLS = [
pygame.Rect(200, 150, 400, 24),
pygame.Rect(200, 426, 400, 24),
pygame.Rect(388, 174, 24, 80),
pygame.Rect(100, 220, 24, 160),
pygame.Rect(676, 220, 24, 160),
]
class Player(pygame.sprite.Sprite):
def __init__(self, x, y):
super().__init__()
self.image = pygame.Surface((PLAYER_SIZE, PLAYER_SIZE))
self.image.fill(PLAYER_COLOR)
self.rect = self.image.get_frect(center=(x, y)) # FRect: a float position
self.radius = 13 # circle hitbox a little smaller than the square
self.health = MAX_HEALTH
self.safe_timer = 0.0 # seconds left of safety after a bump
class Coin(pygame.sprite.Sprite):
def __init__(self, x, y):
super().__init__()
self.image = pygame.Surface((20, 20), pygame.SRCALPHA)
pygame.draw.circle(self.image, COIN_COLOR, (10, 10), 10)
self.rect = self.image.get_frect(topleft=(x, y))
class Drone(pygame.sprite.Sprite):
"""A red ball that patrols back and forth along one line."""
def __init__(self, x, y, vx, vy):
super().__init__()
self.radius = 16
self.image = pygame.Surface((32, 32), pygame.SRCALPHA)
pygame.draw.circle(self.image, DRONE_COLOR, (16, 16), self.radius)
self.rect = self.image.get_frect(center=(x, y))
self.velocity = pygame.Vector2(vx, vy) # pixels per second
def update(self, dt):
self.rect.x += self.velocity.x * dt
self.rect.y += self.velocity.y * dt
# Bounce off the window edges: push back inside, then turn around.
if self.rect.left < 0:
self.rect.left = 0
self.velocity.x = abs(self.velocity.x)
elif self.rect.right > WIDTH:
self.rect.right = WIDTH
self.velocity.x = -abs(self.velocity.x)
if self.rect.top < 0:
self.rect.top = 0
self.velocity.y = abs(self.velocity.y)
elif self.rect.bottom > HEIGHT:
self.rect.bottom = HEIGHT
self.velocity.y = -abs(self.velocity.y)
def move_and_collide(rect, velocity, dt, walls):
"""Move rect by velocity * dt one axis at a time, stopping at walls."""
rect.x += velocity.x * dt
for wall in walls:
if rect.colliderect(wall):
if velocity.x > 0:
rect.right = wall.left
elif velocity.x < 0:
rect.left = wall.right
rect.y += velocity.y * dt
for wall in walls:
if rect.colliderect(wall):
if velocity.y > 0:
rect.bottom = wall.top
elif velocity.y < 0:
rect.top = wall.bottom
def spawn_coins(count, walls, keep_clear, rng):
"""Return a Group of coins placed where they touch no wall and no keep_clear rect."""
coins = pygame.sprite.Group()
blocked = [wall.inflate(16, 16) for wall in walls] + [keep_clear]
while len(coins) < count:
coin = Coin(rng.randint(30, WIDTH - 50), rng.randint(60, HEIGHT - 50))
on_free_spot = coin.rect.collidelist(blocked) == -1
if on_free_spot and pygame.sprite.spritecollideany(coin, coins) is None:
coins.add(coin)
return coins
def take_hit(health, safe_timer):
"""Return (health, safe_timer) after a bump. No damage while still safe."""
if safe_timer > 0:
return health, safe_timer
return max(0, health - 1), SAFE_TIME
def new_game(rng):
player = Player(WIDTH / 2, HEIGHT / 2)
coins = spawn_coins(COIN_COUNT, WALLS, player.rect.inflate(120, 120), rng)
drones = pygame.sprite.Group(
Drone(60, 80, 220, 0), # top lane, left and right
Drone(740, 520, -180, 0), # bottom lane, right and left
)
return player, coins, drones
def main():
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Coin Dash")
clock = pygame.time.Clock()
font = pygame.font.Font(None, 32)
big_font = pygame.font.Font(None, 72)
rng = random.Random(9) # same coin layout every run
player, coins, drones = new_game(rng)
score = 0
held = set()
running = True
while running:
dt = clock.tick(FPS) / 1000
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN:
if event.key in MOVE_KEYS:
held.add(event.key)
elif event.key == pygame.K_r:
player, coins, drones = new_game(rng)
score = 0
elif event.type == pygame.KEYUP:
held.discard(event.key)
playing = player.health > 0 and len(coins) > 0
if playing:
# Move the player, stopping (and sliding) at walls.
velocity = pygame.Vector2()
for key in held:
velocity += MOVE_KEYS[key]
if velocity.length_squared() > 0:
velocity = velocity.normalize() * PLAYER_SPEED
move_and_collide(player.rect, velocity, dt, WALLS)
player.rect.clamp_ip(screen.get_rect())
# Coins: rectangle test, and dokill=True removes them from the group.
collected = pygame.sprite.spritecollide(player, coins, True)
score += len(collected)
# Drones: circle test, with a short safe time after each bump.
drones.update(dt)
player.safe_timer = max(0.0, player.safe_timer - dt)
if pygame.sprite.spritecollide(player, drones, False, pygame.sprite.collide_circle):
player.health, player.safe_timer = take_hit(player.health, player.safe_timer)
screen.fill(BG_COLOR)
for wall in WALLS:
pygame.draw.rect(screen, WALL_COLOR, wall)
coins.draw(screen)
drones.draw(screen)
blink_off = player.safe_timer > 0 and int(player.safe_timer * 10) % 2 == 0
if not blink_off:
screen.blit(player.image, player.rect)
hud = font.render(f"Coins {score}/{COIN_COUNT} Health {player.health}/{MAX_HEALTH}",
True, TEXT_COLOR)
screen.blit(hud, (12, 12))
if not playing:
message = "You win!" if player.health > 0 else "Game over"
text = big_font.render(message, True, TEXT_COLOR)
screen.blit(text, text.get_rect(center=(WIDTH / 2, HEIGHT / 2)))
pygame.display.flip()
pygame.quit()
print(f"Coins {score}/{COIN_COUNT}, health {player.health}/{MAX_HEALTH}")
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:
- Pick a game you know well. List three collisions in it and, for each one, the response. Which would you test with rectangles and which with circles?
- In your own words, explain why "undo the whole move" makes a player stick to walls, and why moving one axis at a time fixes it.
- When Coin Dash misbehaved today, what did you print or draw to find out why? What will you try first next time?
📝 Summary
Collision handling has two halves: detection asks whether two things touch, and response decides what happens. You tested rectangles by hand and with colliderect(), saw that touching edges don't count as overlap, and switched to FRect so fractional movement is not thrown away. You used circles for round things, collided whole sprite groups with one call, and stopped the player at walls one axis at a time so it slides. Finally, you made the responses fair: a safe time after a hit, health that never goes negative, and coins that never spawn inside walls.
🎓 Key Takeaways
- Rectangles overlap when they overlap on x and on y; rectangles that only share an edge do not collide.
- An integer
Rectdrops fractions every frame; usepygame.FRect(get_frect()) for anything that moves. - Circles collide when the distance between centers is less than the radii added together.
spritecollide,groupcollideandspritecollideanytest whole groups;dokill=Trueremoves what was hit, andcollide_circleswitches to circles.- Move and resolve x, then move and resolve y, so walls block one axis and the player slides.
- Responses run every frame: add a safe timer in seconds, clamp health with
max(0, ...), and spawn things only in free spots.
🔭 Looking Ahead
Your collisions work, but they are silent. In the next lesson, Sound & Music, you add a "ding" to every coin, a thud to every bump, and music underneath, with volume controls and a mute key.
❓ Common Questions
Should walls be Rect or FRect?
Either works. Things that never move lose nothing to whole numbers, so plain Rects are fine for walls, and colliderect happily compares an FRect with a Rect. Use FRect for anything that moves by speed * dt.
Why is my player still one pixel away from the wall?
It probably isn't: after rect.right = wall.left the two edges are at the same x, which looks like a gap only if you draw the wall or player with an outline. Print both values to check. If there really is a gap, look for an integer Rect somewhere in the chain.
Can I use pygame.key.get_pressed() instead of the held set?
Yes. Both tell you which keys are down this frame. The set is updated from events, so it is easy to test and to reset (for example, when a round restarts). get_pressed() is the approach from the Keyboard, Mouse & Gamepad lesson and works just as well in your own games.
My coin and the player clearly overlap on screen, but nothing happens.
Check that the sprite's rect moves with the sprite. A common slip is moving a separate x, y or Vector2 and forgetting to copy it into rect, so pygame tests an old, stale rectangle. Drawing the rect outline with pygame.draw.rect(screen, color, sprite.rect, 1) shows exactly what pygame is testing.
How do I stop fast objects from going through walls?
At this level, keep walls thicker than the fastest object moves in one frame (speed × dt), and cap dt so one slow frame can't make a giant jump: dt = min(clock.tick(60) / 1000, 0.05). For very fast bullets, you can also split one big move into a few smaller steps and check after each.
What about checking the exact pixels of an image?
pygame can do pixel-perfect tests with masks. They are more precise than rectangles or circles, and they are covered later in the series (see Going Further). For most arcade games, a well-sized rectangle or circle feels just as good.
🎯 Quick Quiz
Question 1: What does pygame.Rect(0, 0, 10, 10).colliderect(pygame.Rect(10, 0, 10, 10)) return?
Question 2: A snail moves at 30 px/s at 60 FPS. Stored in a pygame.Rect, it never moves. Why?
Question 3: Which call removes every coin the player is touching from the coins group?
Question 4: Why does the wall code move and resolve x first, then move and resolve y?
Question 5: A drone overlaps the player on 20 frames in a row, and the code has no safe timer. What happens?
🌟 Going Further
- Smaller player hitbox: test coins against
player.rect.inflate(-8, -8), a copy of the rect 4 pixels smaller on each side, and see whether collecting feels better or worse. - A key and a door: add a key sprite and a door wall. The door is in
WALLSuntil you touch the key; then remove it from the list. - Drones that bounce off walls: give
Drone.update()the wall list and use the per-axis idea to turn a drone around when it hits one. - Read the docs: skim the pygame-ce pages for pygame.Rect and FRect and pygame.sprite. Find
clip()andcollidelistall()and think of a use for each. - Coming up in Game Dev II: Intermediate: Groups, Layers & Masks adds pixel-perfect collision with masks, and Spatial Hashing & Object Pools shows how to test hundreds of objects without checking every pair.