Adding Collision Detection

Introduction

Welcome back to Creating the Core Game Mechanics with Pygame! At the end of the previous lesson our program already did quite a lot: spawn_obstacles() creates five obstacle Rects at random positions, a for loop moves and recycles them every frame, and the arrow keys steer the green player around the window.

There is one problem, and it is the difference between a screensaver and a game: the player can drive straight through an obstacle and nothing happens. Falling squares that cannot hurt you are simply decoration.

In this lesson we add the missing rule. We will learn what a "collision" means for two rectangles, meet the pygame.Rect.colliderect() method that reports overlaps for us, check every obstacle once per frame, and finally end the run when the player is hit. By the end, our dodge game will actually be losable — which is exactly what makes dodging worth doing.

What "Collision" Means for Two Rects

Every pygame.Rect occupies a rectangular region of the screen described by four edges: left, right, top, and bottom. Two Rects collide when those two regions share at least one pixel of space — in other words, when they overlap both horizontally and vertically.

That "and" matters. Two rectangles can sit in the same horizontal lane while one is far above the other, and they still do not touch. Only when their horizontal ranges overlap and their vertical ranges overlap do we call it a collision.

Three rectangle arrangements showing when colliderect returns true or false

Writing those edge comparisons by hand is possible, but it is fiddly and easy to get wrong. Pygame performs all four comparisons for us through a single method: colliderect().

Meet pygame.Rect.colliderect()

You call colliderect() on one Rect and pass another Rect as its argument. It returns a boolean: True when the two rectangles overlap and False when they do not.

Python
box_a = pygame.Rect(100, 100, 50, 50)
box_b = pygame.Rect(120, 120, 50, 50)
box_c = pygame.Rect(400, 400, 50, 50)

print(box_a.colliderect(box_b))
print(box_a.colliderect(box_c))

The output is:

text
True
False

box_b starts only 20 pixels to the right and below box_a, so the two squares clearly share space and the answer is True. box_c sits hundreds of pixels away, so the answer is False.

Two details are worth remembering. First, the method is symmetric: player.colliderect(obstacle) and obstacle.colliderect(player) always produce the same result, so pick whichever reads more naturally. Second, because it returns a boolean, the call can be used directly as the condition of an if statement — no comparison with True is needed.

Checking Every Obstacle

A single colliderect() call answers a question about exactly one pair of rectangles. Our game has five obstacles, and any one of them could be the one that hits the player, so we need to ask the question five times per frame. That is precisely what a for loop over the list is for:

Python
        for obstacle in obstacles:
            if player.colliderect(obstacle):
                print("Hit!")

The loop takes each Rect out of obstacles in turn and compares it with player. If none of them overlaps, nothing happens and the frame continues as usual.

Starting with a print() is a deliberate first step. It lets us confirm that the detection logic is firing at the right moments while the game keeps running, instead of immediately closing the window and leaving us guessing.

Placing the Check in the Game Loop

Where the collision loop goes inside the frame matters just as much as what it contains. It belongs after the player has moved and after the obstacles have moved, but before anything is drawn:

Python
        for obstacle in obstacles:
            obstacle.y += obstacle_speed
            if obstacle.y > HEIGHT:
                obstacle.x = random.randint(
                    0, WIDTH - OBSTACLE_SIZE
                )
                obstacle.y = random.randint(-100, 0)

        for obstacle in obstacles:
            if player.colliderect(obstacle):
                running = False

Checking after movement means we are testing the exact positions that are about to be drawn, so what the player sees on screen and what the collision code decides always agree. If we checked before movement, the game would be reacting to last frame's picture.

Keeping the collision loop separate from the movement loop is also a readability choice. Each loop then has one clear responsibility: one moves and recycles obstacles, the other decides whether the run is over. Merging them would work, but the code becomes harder to read and to change later.

Ending the Game

Our main loop is controlled by a single boolean flag:

Python
        for obstacle in obstacles:
            if player.colliderect(obstacle):
                running = False

Setting running = False does not stop the program instantly. Python simply continues with the rest of the current frame: the screen is cleared, the player and obstacles are drawn, and the display is flipped. Only when execution returns to the top of the while running: statement is the condition re-evaluated, found to be False, and the loop exited. Then pygame.quit() shuts Pygame down cleanly.

That behavior is a small feature rather than a flaw: the player gets to see the frame in which the crash happened before the window closes.

The Complete Program

Python
import asyncio
import random
import pygame

WIDTH, HEIGHT = 800, 600
FPS = 60
OBSTACLE_SIZE = 40
DARK_BLUE = (20, 28, 48)
GREEN = (87, 215, 170)
RED = (220, 50, 50)

def spawn_obstacles(count):
    new_obstacles = []
    for _ in range(count):
        x = random.randint(0, WIDTH - OBSTACLE_SIZE)
        y = random.randint(-HEIGHT, 0)
        new_obstacles.append(
            pygame.Rect(x, y, OBSTACLE_SIZE, OBSTACLE_SIZE)
        )
    return new_obstacles

async def main():
    pygame.init()
    screen = pygame.display.set_mode((WIDTH, HEIGHT))
    pygame.display.set_caption("Dodge Game")
    clock = pygame.time.Clock()

    player = pygame.Rect(375, 540, 50, 40)
    player_speed = 5

    obstacles = spawn_obstacles(5)
    obstacle_speed = 4

    running = True
    while running:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False

        keys = pygame.key.get_pressed()
        if keys[pygame.K_LEFT]:
            player.x -= player_speed
        if keys[pygame.K_RIGHT]:
            player.x += player_speed
        if keys[pygame.K_UP]:
            player.y -= player_speed
        if keys[pygame.K_DOWN]:
            player.y += player_speed
        player.clamp_ip(screen.get_rect())

        for obstacle in obstacles:
            obstacle.y += obstacle_speed
            if obstacle.y > HEIGHT:
                obstacle.x = random.randint(
                    0, WIDTH - OBSTACLE_SIZE
                )
                obstacle.y = random.randint(-100, 0)

        for obstacle in obstacles:
            if player.colliderect(obstacle):
                running = False

        screen.fill(DARK_BLUE)
        pygame.draw.rect(screen, GREEN, player)
        for obstacle in obstacles:
            pygame.draw.rect(screen, RED, obstacle)
        pygame.display.flip()
        clock.tick(FPS)
        await asyncio.sleep(0)

    pygame.quit()

asyncio.run(main())

Common Pitfalls and Tuning Tips

  • Keep the collision check inside the while loop. Placed above it, the test would run once at startup and never again.
  • Check after movement, so the result matches the positions that are about to be drawn.
  • Do not use ==. Comparing two Rects with == asks whether they have identical position and size, which is almost never true and is not what "overlap" means.
  • Pass a Rect, not a tuple or a number. colliderect() expects another rectangle; use collidepoint() if you ever need to test a single point instead.
  • Rectangle dimensions are the hitboxes. The player is 50 × 40 and each obstacle is 40 × 40, so those numbers decide exactly how forgiving the game feels. Shrinking the player rectangle slightly is a classic way to make an arcade game feel fairer.
  • Console output in the browser preview. When the game runs in the browser, print() text is sent to the browser's developer console instead of a terminal panel, so do not worry if you cannot see it in the game window.

Conclusion and Next Steps

A collision is nothing more than two rectangles sharing space, and colliderect() reports that overlap as a simple True or False. By looping over every obstacle once per frame and switching running off when one of them overlaps the player, our dodge game gains the one thing it was missing: a way to lose.

Right now, though, every run ends the same way, whether it lasted two seconds or two minutes. In the next unit we will add a score so that successful dodges are actually rewarded.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal