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.
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.
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.
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 rectanglerect.colliderect(other): detects overlaprect.left,rect.right,rect.top,rect.bottom: rectangle edges
Complete code
"""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
