What you will learn

This chapter is a major milestone: we refactor all the code using Pygame's Sprite system. Instead of managing global variables and dictionaries, each game element becomes an object with its own data and behaviors. This is the foundation of object-oriented programming applied to game development.

The concept

A Sprite in Pygame is an object that has two essential attributes:

  • self.image: the Surface (image) to draw
  • self.rect: the rectangle that defines position and size (for drawing and collisions)

To create a Sprite, you inherit from pygame.sprite.Sprite and define self.image and self.rect in the __init__ constructor.

Groups are sprite containers. They offer powerful advantages:

  • group.draw(screen): draws all sprites in the group with a single call
  • group.update(): calls the update() method of each sprite
  • pygame.sprite.spritecollide(sprite, group, dokill): detects collisions between a sprite and an entire group

GroupSingle is a special group that contains only one sprite. It is perfect for the paddle and the ball.

Step by step

1. The Paddle class

We encapsulate all paddle logic in a class. The constructor creates the image and positions the rectangle. The update() method handles movement.

python
class Paddle(pygame.sprite.Sprite):
    def __init__(self):
        super().__init__()
        self.image = pygame.Surface(
            (PADDLE_WIDTH, PADDLE_HEIGHT), pygame.SRCALPHA)
        for y in range(PADDLE_HEIGHT):
            brightness = 200 + int(55 * (1 - y / PADDLE_HEIGHT))
            pygame.draw.line(
                self.image, (brightness, brightness, brightness),
                (0, y), (PADDLE_WIDTH, y))
        self.image = self.image.convert_alpha()
        self.rect = self.image.get_rect()
        self.rect.centerx = SCREEN_WIDTH // 2
        self.rect.y = SCREEN_HEIGHT - 40
        self.use_mouse = False

    def update(self):
        if self.use_mouse:
            mx, _ = pygame.mouse.get_pos()
            self.rect.centerx = mx
        else:
            keys = pygame.key.get_pressed()
            if keys[pygame.K_LEFT] or keys[pygame.K_a]:
                self.rect.x -= PADDLE_SPEED
            if keys[pygame.K_RIGHT] or keys[pygame.K_d]:
                self.rect.x += PADDLE_SPEED
        self.rect.left = max(0, self.rect.left)
        self.rect.right = min(SCREEN_WIDTH, self.rect.right)

super().__init__() calls the constructor of pygame.sprite.Sprite. This is mandatory. Without this call, groups will not work correctly.

2. The Ball class

The ball encapsulates its movement, bouncing, and reset. We keep separate float coordinates for precision.

python
class Ball(pygame.sprite.Sprite):
    def __init__(self):
        super().__init__()
        # ... (image creation)
        self.float_x = float(SCREEN_WIDTH // 2)
        self.float_y = float(SCREEN_HEIGHT // 2)
        self.speed = BALL_BASE_SPEED
        angle = math.radians(-60)
        self.vx = self.speed * math.cos(angle)
        self.vy = self.speed * math.sin(angle)

    def update(self, dt):
        self.float_x += self.vx * dt
        self.float_y += self.vy * dt
        self.rect.center = (int(self.float_x), int(self.float_y))

    def bounce_wall(self):
        """Handle wall bounces. Returns True if bounced."""
        # ... (bounce logic)

    def bounce_off_paddle(self, paddle):
        """Bounce off paddle. Returns True if bounced."""
        # ... (bounce logic)

3. The Brick class

Each brick is an independent Sprite with its own image and position. The score_value() method returns the points earned when this brick is destroyed.

python
class Brick(pygame.sprite.Sprite):
    def __init__(self, x, y, color, row):
        super().__init__()
        self.image = pygame.Surface(
            (BRICK_WIDTH, BRICK_HEIGHT), pygame.SRCALPHA)
        pygame.draw.rect(
            self.image, color,
            (0, 0, BRICK_WIDTH, BRICK_HEIGHT))
        # ... (highlight and shadow)
        self.rect = self.image.get_rect(topleft=(x, y))
        self.row = row

    def score_value(self):
        return (BRICK_ROWS - self.row) * 10

4. Creating groups

We use GroupSingle for the paddle and ball (one sprite each) and Group for the bricks.

python
paddle = Paddle()
ball = Ball()
bricks = create_brick_group()

paddle_group = pygame.sprite.GroupSingle(paddle)
ball_group = pygame.sprite.GroupSingle(ball)

5. Drawing and collision detection

Drawing is done in a single line per group. Collision detection uses spritecollide which returns the list of hit sprites. The True parameter automatically removes the hit sprites from the group.

python
    # Draw all sprites
    bricks.draw(screen)
    paddle_group.draw(screen)
    ball_group.draw(screen)

    # Collision detection
    hit_bricks = pygame.sprite.spritecollide(
        ball, bricks, True)
    if hit_bricks:
        for brick in hit_bricks:
            score += brick.score_value()
        ball.vy = -ball.vy

Key takeaways

Tip: the third parameter of spritecollide (dokill) is very handy. When set to True, colliding sprites are automatically removed from all their groups. No more manual deletion.

Common pitfall: forgetting super().__init__() in the Sprite constructor will cause an error when you try to add the sprite to a group.

Sprite advantage: the code is better organized, easier to read and maintain. Each game object is self-contained: it knows how to draw itself, move, and react.

Pygame functions used:

  • pygame.sprite.Sprite: base class for sprites
  • pygame.sprite.Group: sprite container
  • pygame.sprite.GroupSingle: single-sprite group
  • group.draw(surface): draws all sprites
  • group.update(): updates all sprites
  • pygame.sprite.spritecollide(): sprite vs group collision

Complete code

python
"""Brick Breaker - Chapter 10: Sprites and Groups"""
import pygame
import sys
import math
import array

pygame.init()
pygame.mixer.init(frequency=44100, size=-16, channels=1, buffer=512)

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

PADDLE_WIDTH = 100
PADDLE_HEIGHT = 15
PADDLE_SPEED = 8

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

MAX_LIVES = 3


def generate_beep(frequency=440, duration_ms=100, volume=0.3):
    sample_rate = 44100
    n_samples = int(sample_rate * duration_ms / 1000)
    buf = array.array("h", [0] * n_samples)
    max_amp = int(32767 * volume)
    for i in range(n_samples):
        t = i / sample_rate
        value = int(max_amp * math.sin(2 * math.pi * frequency * t))
        fade_samples = min(500, n_samples // 4)
        if i >= n_samples - fade_samples:
            value = int(value * (n_samples - i) / fade_samples)
        buf[i] = value
    return pygame.mixer.Sound(buffer=buf)


snd_paddle = generate_beep(frequency=500, duration_ms=60, volume=0.3)
snd_wall = generate_beep(frequency=300, duration_ms=50, volume=0.2)
snd_brick = generate_beep(frequency=700, duration_ms=80, volume=0.3)
snd_lose_life = generate_beep(frequency=150, duration_ms=300, volume=0.4)
snd_game_over = generate_beep(frequency=100, duration_ms=500, volume=0.5)
snd_win = generate_beep(frequency=880, duration_ms=400, volume=0.4)


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)


class Paddle(pygame.sprite.Sprite):
    def __init__(self):
        super().__init__()
        self.image = pygame.Surface(
            (PADDLE_WIDTH, PADDLE_HEIGHT), pygame.SRCALPHA)
        for y in range(PADDLE_HEIGHT):
            brightness = 200 + int(55 * (1 - y / PADDLE_HEIGHT))
            pygame.draw.line(
                self.image, (brightness, brightness, brightness),
                (0, y), (PADDLE_WIDTH, y))
        pygame.draw.rect(self.image, (0, 0, 0, 0), (0, 0, 3, 3))
        pygame.draw.rect(self.image, (0, 0, 0, 0),
                         (PADDLE_WIDTH - 3, 0, 3, 3))
        self.image = self.image.convert_alpha()
        self.rect = self.image.get_rect()
        self.rect.centerx = SCREEN_WIDTH // 2
        self.rect.y = SCREEN_HEIGHT - 40
        self.use_mouse = False

    def update(self):
        if self.use_mouse:
            mx, _ = pygame.mouse.get_pos()
            self.rect.centerx = mx
        else:
            keys = pygame.key.get_pressed()
            if keys[pygame.K_LEFT] or keys[pygame.K_a]:
                self.rect.x -= PADDLE_SPEED
            if keys[pygame.K_RIGHT] or keys[pygame.K_d]:
                self.rect.x += PADDLE_SPEED
        self.rect.left = max(0, self.rect.left)
        self.rect.right = min(SCREEN_WIDTH, self.rect.right)


class Ball(pygame.sprite.Sprite):
    def __init__(self):
        super().__init__()
        size = BALL_RADIUS * 4
        self.image = pygame.Surface((size, size), pygame.SRCALPHA)
        center = size // 2
        for r in range(BALL_RADIUS * 2, BALL_RADIUS, -1):
            alpha = int(100 * (1 - (r - BALL_RADIUS) / BALL_RADIUS))
            pygame.draw.circle(
                self.image, (100, 100, 255, alpha),
                (center, center), r)
        pygame.draw.circle(
            self.image, (255, 255, 255), (center, center), BALL_RADIUS)
        pygame.draw.circle(
            self.image, (255, 255, 255, 200),
            (center - 2, center - 2), BALL_RADIUS // 3)
        self.image = self.image.convert_alpha()
        self.rect = self.image.get_rect()
        self.float_x = float(SCREEN_WIDTH // 2)
        self.float_y = float(SCREEN_HEIGHT // 2)
        self.rect.center = (int(self.float_x), int(self.float_y))
        self.speed = BALL_BASE_SPEED
        angle = math.radians(-60)
        self.vx = self.speed * math.cos(angle)
        self.vy = self.speed * math.sin(angle)

    def reset(self):
        self.float_x = float(SCREEN_WIDTH // 2)
        self.float_y = float(SCREEN_HEIGHT // 2)
        self.rect.center = (int(self.float_x), int(self.float_y))
        self.speed = BALL_BASE_SPEED
        angle = math.radians(-60)
        self.vx = self.speed * math.cos(angle)
        self.vy = self.speed * math.sin(angle)

    def update(self, dt):
        self.float_x += self.vx * dt
        self.float_y += self.vy * dt
        self.rect.center = (int(self.float_x), int(self.float_y))

    def bounce_wall(self):
        bounced = False
        if self.float_x - BALL_RADIUS <= 0:
            self.float_x = BALL_RADIUS
            self.vx = abs(self.vx)
            self.speed += BALL_SPEED_INCREMENT
            bounced = True
        if self.float_x + BALL_RADIUS >= SCREEN_WIDTH:
            self.float_x = SCREEN_WIDTH - BALL_RADIUS
            self.vx = -abs(self.vx)
            self.speed += BALL_SPEED_INCREMENT
            bounced = True
        if self.float_y - BALL_RADIUS <= 0:
            self.float_y = BALL_RADIUS
            self.vy = abs(self.vy)
            self.speed += BALL_SPEED_INCREMENT
            bounced = True
        if bounced:
            self.rect.center = (int(self.float_x), int(self.float_y))
        return bounced

    def fell_off_bottom(self):
        return self.float_y - BALL_RADIUS > SCREEN_HEIGHT

    def bounce_off_paddle(self, paddle):
        if self.rect.colliderect(paddle.rect) and self.vy > 0:
            self.float_y = paddle.rect.top - BALL_RADIUS
            hit_pos = ((self.float_x - paddle.rect.left)
                       / PADDLE_WIDTH)
            angle = 150 - hit_pos * 120
            rad = math.radians(angle)
            self.speed += BALL_SPEED_INCREMENT
            self.vx = self.speed * math.cos(rad)
            self.vy = -abs(self.speed * math.sin(rad))
            self.rect.center = (int(self.float_x),
                                int(self.float_y))
            return True
        return False


class Brick(pygame.sprite.Sprite):
    def __init__(self, x, y, color, row):
        super().__init__()
        self.image = pygame.Surface(
            (BRICK_WIDTH, BRICK_HEIGHT), pygame.SRCALPHA)
        pygame.draw.rect(
            self.image, color, (0, 0, BRICK_WIDTH, BRICK_HEIGHT))
        highlight = tuple(min(255, c + 50) for c in color)
        pygame.draw.line(
            self.image, highlight, (1, 1), (BRICK_WIDTH - 2, 1))
        pygame.draw.line(
            self.image, highlight, (1, 1), (1, BRICK_HEIGHT - 2))
        shadow = tuple(max(0, c - 50) for c in color)
        pygame.draw.line(
            self.image, shadow,
            (1, BRICK_HEIGHT - 1), (BRICK_WIDTH - 1, BRICK_HEIGHT - 1))
        pygame.draw.line(
            self.image, shadow,
            (BRICK_WIDTH - 1, 1), (BRICK_WIDTH - 1, BRICK_HEIGHT - 1))
        self.image = self.image.convert_alpha()
        self.rect = self.image.get_rect(topleft=(x, y))
        self.row = row
        self.color = color

    def score_value(self):
        return (BRICK_ROWS - self.row) * 10


def create_brick_group():
    group = pygame.sprite.Group()
    for row in range(BRICK_ROWS):
        color = rainbow_color(row, BRICK_ROWS)
        for col in range(BRICK_COLS):
            x = BRICK_OFFSET_X + col * (BRICK_WIDTH + BRICK_PADDING)
            y = BRICK_OFFSET_Y + row * (BRICK_HEIGHT + BRICK_PADDING)
            group.add(Brick(x, y, color, row))
    return group


screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
pygame.display.set_caption("Brick Breaker - Chapter 10")
clock = pygame.time.Clock()

hud_font = pygame.font.SysFont("monospace", 18)
big_font = pygame.font.SysFont("monospace", 48, bold=True)
medium_font = pygame.font.SysFont("monospace", 24)
small_font = pygame.font.SysFont("monospace", 14)

paddle = Paddle()
ball = Ball()
bricks = create_brick_group()

paddle_group = pygame.sprite.GroupSingle(paddle)
ball_group = pygame.sprite.GroupSingle(ball)

lives = MAX_LIVES
score = 0
level = 1
STATE_PLAYING = 0
STATE_GAME_OVER = 1
STATE_YOU_WIN = 2
game_state = STATE_PLAYING
master_volume = 0.5
sound_muted = False


def play_sound(sound):
    if not sound_muted:
        sound.set_volume(master_volume)
        sound.play()


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:
                if game_state == STATE_PLAYING:
                    paddle.use_mouse = not paddle.use_mouse
            if event.key == pygame.K_F1:
                sound_muted = not sound_muted
            if (event.key == pygame.K_PLUS
                    or event.key == pygame.K_KP_PLUS
                    or event.key == pygame.K_EQUALS):
                master_volume = min(1.0, master_volume + 0.1)
            if (event.key == pygame.K_MINUS
                    or event.key == pygame.K_KP_MINUS):
                master_volume = max(0.0, master_volume - 0.1)
            if event.key == pygame.K_r and game_state != STATE_PLAYING:
                lives = MAX_LIVES
                score = 0
                level = 1
                bricks = create_brick_group()
                ball.reset()
                game_state = STATE_PLAYING

    if game_state == STATE_PLAYING:
        paddle_group.update()
        ball.update(dt)

        if ball.bounce_wall():
            play_sound(snd_wall)

        if ball.fell_off_bottom():
            lives -= 1
            if lives <= 0:
                game_state = STATE_GAME_OVER
                play_sound(snd_game_over)
            else:
                play_sound(snd_lose_life)
                ball.reset()

        if ball.bounce_off_paddle(paddle):
            play_sound(snd_paddle)

        hit_bricks = pygame.sprite.spritecollide(
            ball, bricks, True)
        if hit_bricks:
            for brick in hit_bricks:
                score += brick.score_value()
            ball.vy = -ball.vy
            ball.speed += BALL_SPEED_INCREMENT
            play_sound(snd_brick)

        if len(bricks) == 0:
            game_state = STATE_YOU_WIN
            play_sound(snd_win)

    screen.fill(BG_COLOR)

    bricks.draw(screen)
    paddle_group.draw(screen)
    ball_group.draw(screen)

    score_surf = hud_font.render(f"Score: {score}", True, (255, 255, 100))
    screen.blit(score_surf, (10, 10))

    lives_surf = hud_font.render(f"Lives: {lives}", True, (255, 100, 100))
    screen.blit(lives_surf,
                (SCREEN_WIDTH // 2 - lives_surf.get_width() // 2, 10))

    level_surf = hud_font.render(f"Level: {level}", True, (100, 200, 255))
    screen.blit(level_surf,
                (SCREEN_WIDTH - level_surf.get_width() - 10, 10))

    vol_label = "MUTED" if sound_muted else f"Vol: {int(master_volume * 100)}"
    vol_color = (255, 80, 80) if sound_muted else (150, 150, 150)
    vol_surf = small_font.render(vol_label, True, vol_color)
    screen.blit(vol_surf,
                (SCREEN_WIDTH - vol_surf.get_width() - 10, 30))

    if game_state == STATE_GAME_OVER:
        overlay = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT),
                                 pygame.SRCALPHA)
        overlay.fill((0, 0, 0, 150))
        screen.blit(overlay, (0, 0))
        go_text = big_font.render("GAME OVER", True, (255, 60, 60))
        screen.blit(go_text,
                    (SCREEN_WIDTH // 2 - go_text.get_width() // 2,
                     SCREEN_HEIGHT // 2 - 40))
        final_score = medium_font.render(f"Final score: {score}",
                                         True, (255, 255, 255))
        screen.blit(final_score,
                    (SCREEN_WIDTH // 2 - final_score.get_width() // 2,
                     SCREEN_HEIGHT // 2 + 20))
        restart = medium_font.render("Press R to restart",
                                     True, (200, 200, 200))
        screen.blit(restart,
                    (SCREEN_WIDTH // 2 - restart.get_width() // 2,
                     SCREEN_HEIGHT // 2 + 60))

    elif game_state == STATE_YOU_WIN:
        overlay = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT),
                                 pygame.SRCALPHA)
        overlay.fill((0, 0, 0, 150))
        screen.blit(overlay, (0, 0))
        win_text = big_font.render("YOU WIN!", True, (100, 255, 100))
        screen.blit(win_text,
                    (SCREEN_WIDTH // 2 - win_text.get_width() // 2,
                     SCREEN_HEIGHT // 2 - 40))
        final_score = medium_font.render(f"Final score: {score}",
                                         True, (255, 255, 255))
        screen.blit(final_score,
                    (SCREEN_WIDTH // 2 - final_score.get_width() // 2,
                     SCREEN_HEIGHT // 2 + 20))
        restart = medium_font.render("Press R to restart",
                                     True, (200, 200, 200))
        screen.blit(restart,
                    (SCREEN_WIDTH // 2 - restart.get_width() // 2,
                     SCREEN_HEIGHT // 2 + 60))

    info = small_font.render(
        "[M] Mouse/Kbd  [F1] Mute  [+/-] Volume  [R] Restart  [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-ch10.zip