What you will learn

This chapter introduces collision detection, the mechanism that turns a simple graphical program into a real game. You will learn how to detect when the ball hits the paddle, when it hits a brick, and how to react accordingly: change the bounce angle or destroy the brick.

The concept

Pygame uses Rect objects (rectangles) to represent the areas occupied by game elements. The method Rect.colliderect(other_rect) returns True if the two rectangles overlap.

Even though our ball is a circle, we represent it as a bounding box rectangle to simplify detection. This is a very common approximation in 2D games, accurate enough for a brick breaker.

For bricks, we store them in a list of dictionaries. Each dictionary contains the brick's Rect, its color, its image, and its row number. When the ball hits a brick, we simply remove it from the list.

Bounce angle on the paddle

In a real brick breaker, the ball's bounce angle off the paddle depends on where it hits:

  • Left edge of the paddle: the ball goes left (wide angle)
  • Center of the paddle: the ball goes straight up
  • Right edge of the paddle: the ball goes right

We calculate the relative hit position (0.0 to 1.0) and map it to an angle between 150 and 30 degrees.

Step by step

1. Storing bricks as dictionaries

We create a function that generates the list of all bricks. Each brick is a dictionary with a Rect, a color, a pre-computed image, and the row number.

python
def create_bricks():
    bricks = []
    for row in range(BRICK_ROWS):
        color = rainbow_color(row, BRICK_ROWS)
        image = create_brick_image(BRICK_WIDTH, BRICK_HEIGHT, color)
        for col in range(BRICK_COLS):
            x = BRICK_OFFSET_X + col * (BRICK_WIDTH + BRICK_PADDING)
            y = BRICK_OFFSET_Y + row * (BRICK_HEIGHT + BRICK_PADDING)
            brick = {
                "rect": pygame.Rect(x, y, BRICK_WIDTH, BRICK_HEIGHT),
                "color": color,
                "image": image,
                "row": row,
            }
            bricks.append(brick)
    return bricks

2. Ball-paddle collision

We create a Rect for the paddle and one for the ball, then test for overlap. We also check that the ball is moving down (ball_vy > 0) to avoid multiple collisions.

python
    paddle_rect = pygame.Rect(paddle_x, paddle_y,
                              PADDLE_WIDTH, PADDLE_HEIGHT)
    ball_rect = pygame.Rect(ball_x - BALL_RADIUS, ball_y - BALL_RADIUS,
                            BALL_RADIUS * 2, BALL_RADIUS * 2)

    if ball_rect.colliderect(paddle_rect) and ball_vy > 0:
        ball_y = paddle_y - BALL_RADIUS
        # Calculate bounce angle based on hit position
        hit_pos = (ball_x - paddle_x) / PADDLE_WIDTH
        angle = 150 - hit_pos * 120
        rad = math.radians(angle)
        ball_speed += BALL_SPEED_INCREMENT
        ball_vx = ball_speed * math.cos(rad)
        ball_vy = -abs(ball_speed * math.sin(rad))

hit_pos is 0.0 if the ball hits the left edge of the paddle, and 1.0 if it hits the right edge. We linearly interpolate between 150 degrees (leftward) and 30 degrees (rightward). -abs() ensures the ball always goes up after bouncing off the paddle.

3. Ball-brick collision

We iterate over a copy of the list (bricks[:]) so we can remove elements during iteration. We only process one brick per frame to avoid strange behavior.

python
    for brick in bricks[:]:
        if ball_rect.colliderect(brick["rect"]):
            bricks.remove(brick)
            br = brick["rect"]
            # Determine bounce direction
            if ball_x >= br.left and ball_x <= br.right:
                ball_vy = -ball_vy
            else:
                ball_vx = -ball_vx
            ball_speed += BALL_SPEED_INCREMENT
            break  # Only one brick per frame

To determine the bounce direction, we check whether the ball's center is horizontally aligned with the brick (top/bottom hit, reverse vy) or not (side hit, reverse vx).

Key takeaways

Tip: bricks[:] creates a shallow copy of the list. This is essential because you cannot modify a list while iterating over it directly.

Common pitfall: forgetting the ball_vy > 0 check for the paddle can cause a "sticky ball" effect where it bounces multiple times between the paddle and its previous position.

Pygame functions used:

  • pygame.Rect(x, y, w, h): creates a rectangle
  • rect.colliderect(other): detects overlap
  • rect.left, rect.right, rect.top, rect.bottom: rectangle edges

Complete code

python
"""Brick Breaker - Chapter 7: Collision Detection"""
import pygame
import sys
import math

pygame.init()

SCREEN_WIDTH = 800
SCREEN_HEIGHT = 600
FPS = 60
BG_COLOR = (20, 20, 40)

PADDLE_WIDTH = 100
PADDLE_HEIGHT = 15
PADDLE_SPEED = 8
paddle_x = SCREEN_WIDTH // 2 - PADDLE_WIDTH // 2
paddle_y = SCREEN_HEIGHT - 40

BALL_RADIUS = 8
BALL_BASE_SPEED = 300
BALL_SPEED_INCREMENT = 10

BRICK_ROWS = 6
BRICK_COLS = 10
BRICK_WIDTH = 70
BRICK_HEIGHT = 20
BRICK_PADDING = 5
BRICK_OFFSET_X = 30
BRICK_OFFSET_Y = 50


def rainbow_color(row, total_rows):
    hue = int(360 * row / total_rows)
    color = pygame.Color(0)
    color.hsva = (hue, 100, 100, 100)
    return (color.r, color.g, color.b)


def create_paddle_image(width, height):
    surf = pygame.Surface((width, height), pygame.SRCALPHA)
    for y in range(height):
        brightness = 200 + int(55 * (1 - y / height))
        pygame.draw.line(surf, (brightness, brightness, brightness),
                         (0, y), (width, y))
    pygame.draw.rect(surf, (0, 0, 0, 0), (0, 0, 3, 3))
    pygame.draw.rect(surf, (0, 0, 0, 0), (width - 3, 0, 3, 3))
    return surf.convert_alpha()


def create_ball_image(radius):
    size = radius * 4
    surf = pygame.Surface((size, size), pygame.SRCALPHA)
    for r in range(radius * 2, radius, -1):
        alpha = int(100 * (1 - (r - radius) / radius))
        pygame.draw.circle(surf, (100, 100, 255, alpha),
                           (size // 2, size // 2), r)
    pygame.draw.circle(surf, (255, 255, 255),
                       (size // 2, size // 2), radius)
    pygame.draw.circle(surf, (255, 255, 255, 200),
                       (size // 2 - 2, size // 2 - 2), radius // 3)
    return surf.convert_alpha()


def create_brick_image(width, height, color):
    surf = pygame.Surface((width, height), pygame.SRCALPHA)
    pygame.draw.rect(surf, color, (0, 0, width, height))
    highlight = tuple(min(255, c + 50) for c in color)
    pygame.draw.line(surf, highlight, (1, 1), (width - 2, 1))
    pygame.draw.line(surf, highlight, (1, 1), (1, height - 2))
    shadow = tuple(max(0, c - 50) for c in color)
    pygame.draw.line(surf, shadow, (1, height - 1), (width - 1, height - 1))
    pygame.draw.line(surf, shadow, (width - 1, 1), (width - 1, height - 1))
    return surf.convert_alpha()


def create_bricks():
    bricks = []
    for row in range(BRICK_ROWS):
        color = rainbow_color(row, BRICK_ROWS)
        image = create_brick_image(BRICK_WIDTH, BRICK_HEIGHT, color)
        for col in range(BRICK_COLS):
            x = BRICK_OFFSET_X + col * (BRICK_WIDTH + BRICK_PADDING)
            y = BRICK_OFFSET_Y + row * (BRICK_HEIGHT + BRICK_PADDING)
            brick = {
                "rect": pygame.Rect(x, y, BRICK_WIDTH, BRICK_HEIGHT),
                "color": color,
                "image": image,
                "row": row,
            }
            bricks.append(brick)
    return bricks


def reset_ball():
    global ball_x, ball_y, ball_vx, ball_vy, ball_speed
    ball_x = float(SCREEN_WIDTH // 2)
    ball_y = float(SCREEN_HEIGHT // 2)
    ball_speed = BALL_BASE_SPEED
    ball_vx = ball_speed * math.cos(math.radians(-60))
    ball_vy = ball_speed * math.sin(math.radians(-60))


screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
pygame.display.set_caption("Brick Breaker - Chapter 7")
clock = pygame.time.Clock()
font = pygame.font.SysFont("monospace", 14)

paddle_img = create_paddle_image(PADDLE_WIDTH, PADDLE_HEIGHT)
ball_img = create_ball_image(BALL_RADIUS)

bricks = create_bricks()

ball_x = float(SCREEN_WIDTH // 2)
ball_y = float(SCREEN_HEIGHT // 2)
ball_speed = BALL_BASE_SPEED
ball_vx = ball_speed * math.cos(math.radians(-60))
ball_vy = ball_speed * math.sin(math.radians(-60))

use_mouse = False

running = True
while running:
    dt = clock.tick(FPS) / 1000.0

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        if event.type == pygame.KEYDOWN:
            if event.key == pygame.K_ESCAPE:
                running = False
            if event.key == pygame.K_m:
                use_mouse = not use_mouse

    if use_mouse:
        mx, _ = pygame.mouse.get_pos()
        paddle_x = mx - PADDLE_WIDTH // 2
    else:
        keys = pygame.key.get_pressed()
        if keys[pygame.K_LEFT] or keys[pygame.K_a]:
            paddle_x -= PADDLE_SPEED
        if keys[pygame.K_RIGHT] or keys[pygame.K_d]:
            paddle_x += PADDLE_SPEED

    paddle_x = max(0, min(paddle_x, SCREEN_WIDTH - PADDLE_WIDTH))

    # Move ball
    ball_x += ball_vx * dt
    ball_y += ball_vy * dt

    # Bounce off left wall
    if ball_x - BALL_RADIUS <= 0:
        ball_x = BALL_RADIUS
        ball_vx = abs(ball_vx)
        ball_speed += BALL_SPEED_INCREMENT

    # Bounce off right wall
    if ball_x + BALL_RADIUS >= SCREEN_WIDTH:
        ball_x = SCREEN_WIDTH - BALL_RADIUS
        ball_vx = -abs(ball_vx)
        ball_speed += BALL_SPEED_INCREMENT

    # Bounce off top wall
    if ball_y - BALL_RADIUS <= 0:
        ball_y = BALL_RADIUS
        ball_vy = abs(ball_vy)
        ball_speed += BALL_SPEED_INCREMENT

    # Ball falls off bottom: reset
    if ball_y - BALL_RADIUS > SCREEN_HEIGHT:
        reset_ball()
        bricks = create_bricks()

    # Ball vs paddle collision
    paddle_rect = pygame.Rect(paddle_x, paddle_y,
                              PADDLE_WIDTH, PADDLE_HEIGHT)
    ball_rect = pygame.Rect(ball_x - BALL_RADIUS, ball_y - BALL_RADIUS,
                            BALL_RADIUS * 2, BALL_RADIUS * 2)

    if ball_rect.colliderect(paddle_rect) and ball_vy > 0:
        ball_y = paddle_y - BALL_RADIUS
        hit_pos = (ball_x - paddle_x) / PADDLE_WIDTH
        angle = 150 - hit_pos * 120
        rad = math.radians(angle)
        ball_speed += BALL_SPEED_INCREMENT
        ball_vx = ball_speed * math.cos(rad)
        ball_vy = -abs(ball_speed * math.sin(rad))

    # Ball vs bricks collision
    for brick in bricks[:]:
        if ball_rect.colliderect(brick["rect"]):
            bricks.remove(brick)
            br = brick["rect"]
            if ball_x >= br.left and ball_x <= br.right:
                ball_vy = -ball_vy
            else:
                ball_vx = -ball_vx
            ball_speed += BALL_SPEED_INCREMENT
            break

    # Draw
    screen.fill(BG_COLOR)

    for brick in bricks:
        screen.blit(brick["image"], brick["rect"])

    screen.blit(paddle_img, (paddle_x, paddle_y))

    ball_offset = BALL_RADIUS * 2
    screen.blit(ball_img, (ball_x - ball_offset, ball_y - ball_offset))

    bricks_text = font.render(f"Bricks: {len(bricks)}", True,
                              (150, 150, 150))
    screen.blit(bricks_text, (SCREEN_WIDTH - 150, 10))

    info = font.render("[M] Mouse/Keyboard  [Arrows] Move  [ESC] Quit",
                       True, (150, 150, 150))
    screen.blit(info, (10, SCREEN_HEIGHT - 25))

    pygame.display.flip()

pygame.quit()
sys.exit()

Download

Download the source code for this chapter: brick-breaker-ch07.zip