What you will learn

In this chapter we explore optimization techniques with Pygame: FPS counter, draw call tracking, dirty rect rendering (only redraw changed areas), and using convert() and convert_alpha() to speed up blitting. The stress test uses 500+ bricks.

The concept

Performance in a Pygame game depends on three main factors:

  • Number of draw calls: each surface.blit() has a cost. Fewer is better.
  • Surface format: convert() and convert_alpha() adapt the pixel format to match the display, speeding up blitting 3 to 10 times.
  • Redrawn area: with dirty rect rendering, only changed areas are updated, greatly reducing work.

Step by step

1. The performance monitor

The PerfMonitor class measures each frame time, computes average FPS, and counts draw calls. Results are displayed as an overlay.

python
class PerfMonitor:
    def begin_frame(self):
        self.frame_draw_calls = 0
        self._frame_start = time.perf_counter()

    def count_draw(self, n=1):
        self.frame_draw_calls += n

    def end_frame(self, dt):
        elapsed = time.perf_counter()
                  - self._frame_start
        # ... compute FPS ...

2. convert() and convert_alpha()

Every sprite surface is converted using convert_alpha() after creation. This aligns the internal pixel format with the display, eliminating costly conversions during blit.

python
# BEFORE: raw surface (slow)
self.image = pygame.Surface((w, h), pygame.SRCALPHA)
# ... draw ...

# AFTER: converted surface (fast)
self.image = self.image.convert_alpha()

3. Dirty rect rendering

Dirty rect mode (D key) only redraws changed areas. The static background (base colour + intact bricks) is captured once. Each frame, only areas that moved are restored and redrawn.

python
class DirtyRectRenderer:
    def mark_dirty(self, rect):
        self.dirty_rects.append(rect)

    def restore_dirty(self, surface):
        for rect in self.dirty_rects:
            surface.blit(self.bg_buffer,
                         rect.topleft, rect)

    def flip(self, screen):
        pygame.display.update(self.dirty_rects)

4. Slots for particles

The Particle class uses __slots__ to reduce memory usage and speed up attribute access when there are many particles.

Profiling tips

To profile your Pygame game:

  • Use time.perf_counter() to measure specific code sections.
  • Run python -m cProfile main.py for a global profile.
  • Monitor the number of blit calls per frame: it is often the bottleneck.
  • Prefer pygame.display.update(rects) over flip() when little has changed.

Key takeaways

Tip: always call convert_alpha() on your surfaces after creation. It is the simplest and most effective optimization.

Common pitfall: dirty rect rendering adds complexity. It is only useful when a large portion of the screen is static.

Complete code

python
"""Brick Breaker - Chapter 21: Optimization"""
import pygame
import sys
import math
import array
import random
import json
import os
import time

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

GAME_WIDTH = 800
GAME_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

# Stress-test: many bricks to showcase optimization
BRICK_ROWS = 20
BRICK_COLS = 25
BRICK_WIDTH = 28
BRICK_HEIGHT = 10
BRICK_PADDING = 2
BRICK_OFFSET_X = 15
BRICK_OFFSET_Y = 40

MAX_LIVES = 3

# Game states
STATE_MENU = 0
STATE_PLAYING = 1
STATE_PAUSED = 2
STATE_GAME_OVER = 3
STATE_YOU_WIN = 4

# Animation constants
TRAIL_LENGTH = 8
TRAIL_INTERVAL = 0.016
BRICK_SHATTER_DURATION = 0.3
PADDLE_GLOW_DURATION = 0.2

HIGH_SCORE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                               "highscores.json")


# -- Sound generation -------------------------------------------------------

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)


# -- Helpers ----------------------------------------------------------------

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 load_high_scores():
    if os.path.exists(HIGH_SCORE_FILE):
        try:
            with open(HIGH_SCORE_FILE, "r") as f:
                return json.load(f)[:10]
        except (json.JSONDecodeError, IOError):
            pass
    return []


def save_high_scores(scores):
    scores = sorted(scores, key=lambda s: s["score"], reverse=True)[:10]
    try:
        with open(HIGH_SCORE_FILE, "w") as f:
            json.dump(scores, f, indent=2)
    except IOError:
        pass


def add_high_score(new_score):
    scores = load_high_scores()
    scores.append({"score": new_score})
    scores = sorted(scores, key=lambda s: s["score"], reverse=True)[:10]
    save_high_scores(scores)


# -- Performance monitor ---------------------------------------------------

class PerfMonitor:
    """Tracks FPS, draw calls, frame times, and provides profiling info."""

    def __init__(self):
        self.fps = 0.0
        self.draw_calls = 0
        self.frame_draw_calls = 0
        self.frame_times = []
        self.update_timer = 0.0
        self.avg_frame_time = 0.0
        self.min_frame_time = 0.0
        self.max_frame_time = 0.0
        self.total_bricks = 0
        self.visible = True

    def begin_frame(self):
        self.frame_draw_calls = 0
        self._frame_start = time.perf_counter()

    def count_draw(self, n=1):
        self.frame_draw_calls += n

    def end_frame(self, dt):
        elapsed = time.perf_counter() - self._frame_start
        self.frame_times.append(elapsed)
        if len(self.frame_times) > 60:
            self.frame_times.pop(0)

        self.draw_calls = self.frame_draw_calls
        self.update_timer += dt
        if self.update_timer >= 0.5:
            self.update_timer = 0.0
            if self.frame_times:
                self.avg_frame_time = (sum(self.frame_times)
                                       / len(self.frame_times))
                self.min_frame_time = min(self.frame_times)
                self.max_frame_time = max(self.frame_times)
            self.fps = 1.0 / self.avg_frame_time if self.avg_frame_time > 0 else 0

    def draw(self, surface, font, dirty_mode=False, x=10, y=10):
        if not self.visible:
            return
        lines = [
            "FPS: {:.1f}".format(self.fps),
            "Frame: {:.2f}ms (avg) / {:.2f}ms (max)".format(
                self.avg_frame_time * 1000, self.max_frame_time * 1000),
            "Draw calls: {}".format(self.draw_calls),
            "Bricks: {}".format(self.total_bricks),
            "Render: {}".format("DIRTY RECT" if dirty_mode else "FULL"),
        ]
        # Background box
        box_w = 340
        box_h = len(lines) * 16 + 8
        bg = pygame.Surface((box_w, box_h), pygame.SRCALPHA)
        bg.fill((0, 0, 0, 180))
        surface.blit(bg, (x - 2, y - 2))
        for i, line in enumerate(lines):
            color = (0, 255, 0) if self.fps >= 55 else (
                (255, 255, 0) if self.fps >= 30 else (255, 50, 50))
            ts = font.render(line, True, color)
            surface.blit(ts, (x, y + i * 16))


# -- Particle System (optimized) -------------------------------------------

class Particle:
    __slots__ = ['x', 'y', 'vx', 'vy', 'lifetime', 'max_lifetime',
                 'color', 'size', 'alive', 'gravity']

    def __init__(self, x, y, vx, vy, lifetime, color, size):
        self.x = float(x)
        self.y = float(y)
        self.vx = vx
        self.vy = vy
        self.lifetime = lifetime
        self.max_lifetime = lifetime
        self.color = color
        self.size = size
        self.alive = True
        self.gravity = 120.0

    def update(self, dt):
        self.x += self.vx * dt
        self.y += self.vy * dt
        self.vy += self.gravity * dt
        self.lifetime -= dt
        if self.lifetime <= 0:
            self.alive = False

    def draw(self, surface):
        if not self.alive:
            return
        progress = 1.0 - (self.lifetime / self.max_lifetime)
        alpha = int(255 * (1 - progress))
        current_size = max(1, int(self.size * (1 - progress * 0.5)))
        if alpha <= 0 or current_size <= 0:
            return
        r, g, b = self.color
        surf = pygame.Surface((current_size * 2, current_size * 2),
                              pygame.SRCALPHA)
        pygame.draw.circle(surf, (r, g, b, alpha),
                           (current_size, current_size), current_size)
        surface.blit(surf,
                     (int(self.x) - current_size, int(self.y) - current_size))


class ParticleSystem:
    def __init__(self):
        self.particles = []

    def update(self, dt):
        for p in self.particles:
            p.update(dt)
        self.particles = [p for p in self.particles if p.alive]

    def draw(self, surface, perf=None):
        for p in self.particles:
            p.draw(surface)
        if perf:
            perf.count_draw(len(self.particles))

    def clear(self):
        self.particles.clear()

    def emit_brick_explosion(self, rect, color, count=8):
        cx, cy = rect.centerx, rect.centery
        for _ in range(count):
            angle = random.uniform(0, 2 * math.pi)
            speed = random.uniform(60, 200)
            vx = speed * math.cos(angle)
            vy = speed * math.sin(angle) - 50
            lt = random.uniform(0.3, 0.8)
            r = min(255, max(0, color[0] + random.randint(-30, 30)))
            g = min(255, max(0, color[1] + random.randint(-30, 30)))
            b = min(255, max(0, color[2] + random.randint(-30, 30)))
            self.particles.append(
                Particle(cx, cy, vx, vy, lt, (r, g, b),
                         random.randint(2, 4)))

    def emit_paddle_sparks(self, paddle_rect, ball_x):
        cx, cy = ball_x, paddle_rect.top
        for _ in range(6):
            angle = random.uniform(-math.pi, 0)
            speed = random.uniform(40, 150)
            self.particles.append(
                Particle(cx, cy, speed * math.cos(angle),
                         speed * math.sin(angle),
                         random.uniform(0.2, 0.5),
                         (random.randint(220, 255), random.randint(200, 255),
                          random.randint(100, 200)),
                         random.randint(1, 3)))

    def emit_wall_dust(self, x, y, direction):
        for _ in range(3):
            if direction == "left":
                vx, vy = random.uniform(20, 80), random.uniform(-50, 50)
            elif direction == "right":
                vx, vy = random.uniform(-80, -20), random.uniform(-50, 50)
            else:
                vx, vy = random.uniform(-50, 50), random.uniform(20, 80)
            gray = random.randint(150, 220)
            self.particles.append(
                Particle(x, y, vx, vy, random.uniform(0.2, 0.4),
                         (gray, gray, gray), random.randint(1, 3)))


# -- Animation classes ------------------------------------------------------

class BrickShatter:
    __slots__ = ['rect', 'color', 'timer', 'duration', 'alive']

    def __init__(self, rect, color):
        self.rect = pygame.Rect(rect)
        self.color = color
        self.timer = 0.0
        self.duration = BRICK_SHATTER_DURATION
        self.alive = True

    def update(self, dt):
        self.timer += dt
        if self.timer >= self.duration:
            self.alive = False

    def draw(self, surface):
        progress = self.timer / self.duration
        if progress < 0.3:
            flash = int(255 * (1 - progress / 0.3))
            r = min(255, self.color[0] + flash)
            g = min(255, self.color[1] + flash)
            b = min(255, self.color[2] + flash)
            alpha = 255
        else:
            fade = (progress - 0.3) / 0.7
            r, g, b = self.color
            alpha = int(255 * (1 - fade))
        if alpha <= 0:
            return
        shrink = int(progress * 10)
        draw_rect = self.rect.inflate(-shrink * 2, -shrink * 2)
        if draw_rect.width <= 0 or draw_rect.height <= 0:
            return
        surf = pygame.Surface((draw_rect.width, draw_rect.height),
                              pygame.SRCALPHA)
        surf.fill((r, g, b, alpha))
        surface.blit(surf, draw_rect.topleft)


class BallTrail:
    def __init__(self):
        self.positions = []
        self.timer = 0.0

    def update(self, dt, ball_x, ball_y):
        self.timer += dt
        if self.timer >= TRAIL_INTERVAL:
            self.timer -= TRAIL_INTERVAL
            self.positions.append((ball_x, ball_y))
            if len(self.positions) > TRAIL_LENGTH:
                self.positions.pop(0)

    def draw(self, surface, perf=None):
        count = len(self.positions)
        for i, (x, y) in enumerate(self.positions):
            ratio = (i + 1) / count if count > 0 else 0
            alpha = int(80 * ratio)
            radius = max(1, int(BALL_RADIUS * ratio * 0.8))
            ts = pygame.Surface((radius * 2, radius * 2), pygame.SRCALPHA)
            pygame.draw.circle(ts, (100, 100, 255, alpha),
                               (radius, radius), radius)
            surface.blit(ts, (int(x) - radius, int(y) - radius))
        if perf:
            perf.count_draw(count)

    def reset(self):
        self.positions.clear()
        self.timer = 0.0


class PaddleGlow:
    def __init__(self):
        self.timer = 0.0
        self.active = False
        self.intensity = 0.0

    def trigger(self):
        self.active = True
        self.timer = 0.0
        self.intensity = 1.0

    def update(self, dt):
        if self.active:
            self.timer += dt
            self.intensity = max(0.0,
                                 1.0 - self.timer / PADDLE_GLOW_DURATION)
            if self.timer >= PADDLE_GLOW_DURATION:
                self.active = False

    def draw(self, surface, paddle_rect, perf=None):
        if not self.active or self.intensity <= 0:
            return
        glow_alpha = int(150 * self.intensity)
        glow_expand = int(6 * self.intensity)
        glow_rect = paddle_rect.inflate(glow_expand * 2, glow_expand * 2)
        glow_surf = pygame.Surface(
            (glow_rect.width, glow_rect.height), pygame.SRCALPHA)
        glow_surf.fill((200, 200, 255, glow_alpha))
        surface.blit(glow_surf, glow_rect.topleft)
        if perf:
            perf.count_draw()


# -- Sprite classes (with convert / convert_alpha optimization) -------------

class Paddle(pygame.sprite.Sprite):
    def __init__(self):
        super().__init__()
        # Use convert_alpha() for faster blitting of per-pixel alpha surfaces
        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))
        # convert_alpha() makes blitting much faster
        self.image = self.image.convert_alpha()
        self.rect = self.image.get_rect()
        self.rect.centerx = GAME_WIDTH // 2
        self.rect.y = GAME_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(GAME_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)
        # convert_alpha() optimization
        self.image = self.image.convert_alpha()
        self.rect = self.image.get_rect()
        self.float_x = float(GAME_WIDTH // 2)
        self.float_y = float(GAME_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(GAME_WIDTH // 2)
        self.float_y = float(GAME_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
        wall_direction = None
        if self.float_x - BALL_RADIUS <= 0:
            self.float_x = BALL_RADIUS
            self.vx = abs(self.vx)
            self.speed += BALL_SPEED_INCREMENT
            bounced = True
            wall_direction = "left"
        if self.float_x + BALL_RADIUS >= GAME_WIDTH:
            self.float_x = GAME_WIDTH - BALL_RADIUS
            self.vx = -abs(self.vx)
            self.speed += BALL_SPEED_INCREMENT
            bounced = True
            wall_direction = "right"
        if self.float_y - BALL_RADIUS <= 0:
            self.float_y = BALL_RADIUS
            self.vy = abs(self.vy)
            self.speed += BALL_SPEED_INCREMENT
            bounced = True
            wall_direction = "top"
        if bounced:
            self.rect.center = (int(self.float_x), int(self.float_y))
        return bounced, wall_direction

    def fell_off_bottom(self):
        return self.float_y - BALL_RADIUS > GAME_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):
    """Brick with pre-rendered convert_alpha() surface for fast blitting."""

    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))
        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))
        # Key optimization: convert_alpha()
        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():
    """Create 500+ bricks for performance stress-testing."""
    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


# -- Dirty rect renderer ---------------------------------------------------

class DirtyRectRenderer:
    """Tracks changed areas and only redraws those regions.

    When enabled, the background is drawn once to a buffer. Each frame,
    only the rectangles that changed (ball, paddle, destroyed bricks, etc.)
    are restored from the background and redrawn. This can be much faster
    when most of the screen is static.
    """

    def __init__(self):
        self.enabled = False
        self.bg_buffer = None
        self.dirty_rects = []
        self.need_full_redraw = True

    def toggle(self):
        self.enabled = not self.enabled
        self.need_full_redraw = True

    def mark_dirty(self, rect):
        """Mark a rectangular area as needing redraw."""
        # Expand slightly to catch anti-aliasing edges
        expanded = pygame.Rect(rect).inflate(4, 4)
        expanded = expanded.clip(pygame.Rect(0, 0, GAME_WIDTH, GAME_HEIGHT))
        self.dirty_rects.append(expanded)

    def capture_background(self, surface):
        """Take a snapshot of the current static background."""
        self.bg_buffer = surface.copy()

    def restore_dirty(self, surface):
        """Restore background under dirty rects."""
        if self.bg_buffer is None:
            return
        for rect in self.dirty_rects:
            surface.blit(self.bg_buffer, rect.topleft, rect)

    def flip(self, screen):
        """Update only dirty areas of the display."""
        if self.need_full_redraw or not self.dirty_rects:
            pygame.display.flip()
            self.need_full_redraw = False
        else:
            pygame.display.update(self.dirty_rects)
        self.dirty_rects.clear()


# -- Game setup -------------------------------------------------------------

screen = pygame.display.set_mode((GAME_WIDTH, GAME_HEIGHT))
pygame.display.set_caption("Brick Breaker - Chapter 21")
# Use convert() on the screen surface for faster blitting
clock = pygame.time.Clock()

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

perf = PerfMonitor()
dirty_renderer = DirtyRectRenderer()

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

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

ball_trail = BallTrail()
paddle_glow = PaddleGlow()
shatter_animations = []
particles = ParticleSystem()

lives = MAX_LIVES
score = 0
game_state = STATE_MENU
master_volume = 0.5
sound_muted = False
menu_blink_timer = 0.0
show_perf = True


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


def start_game():
    global lives, score, bricks, game_state, shatter_animations
    lives = MAX_LIVES
    score = 0
    bricks = create_brick_group()
    ball.reset()
    ball_trail.reset()
    shatter_animations = []
    particles.clear()
    dirty_renderer.need_full_redraw = True
    game_state = STATE_PLAYING


# -- Draw helpers -----------------------------------------------------------

def draw_menu():
    global menu_blink_timer
    menu_blink_timer += clock.get_time() / 1000.0
    screen.fill(BG_COLOR)
    title = big_font.render("BRICK BREAKER", True, (255, 200, 50))
    screen.blit(title, (GAME_WIDTH // 2 - title.get_width() // 2,
                        GAME_HEIGHT // 3 - 60))
    subtitle = medium_font.render("Optimization Edition", True,
                                  (200, 200, 255))
    screen.blit(subtitle, (GAME_WIDTH // 2 - subtitle.get_width() // 2,
                           GAME_HEIGHT // 3 - 10))
    bcount = BRICK_ROWS * BRICK_COLS
    info = medium_font.render("{} bricks stress test".format(bcount),
                              True, (150, 150, 150))
    screen.blit(info, (GAME_WIDTH // 2 - info.get_width() // 2,
                       GAME_HEIGHT // 3 + 25))
    if int(menu_blink_timer * 2) % 2 == 0:
        start = medium_font.render("Press SPACE to start", True,
                                   (255, 255, 255))
        screen.blit(start, (GAME_WIDTH // 2 - start.get_width() // 2,
                            GAME_HEIGHT // 2 + 30))
    hints = small_font.render(
        "[D] Toggle dirty rect mode  [F] Toggle perf display",
        True, (100, 100, 100))
    screen.blit(hints, (GAME_WIDTH // 2 - hints.get_width() // 2,
                        GAME_HEIGHT - 30))


def draw_hud():
    score_surf = hud_font.render("Score: {}".format(score),
                                 True, (255, 255, 100))
    screen.blit(score_surf, (10, GAME_HEIGHT - 20))
    lives_surf = hud_font.render("Lives: {}".format(lives),
                                 True, (255, 100, 100))
    screen.blit(lives_surf,
                (GAME_WIDTH // 2 - lives_surf.get_width() // 2,
                 GAME_HEIGHT - 20))
    mode_text = "DIRTY RECT" if dirty_renderer.enabled else "FULL REDRAW"
    mode_surf = hud_font.render(
        "Mode: {}  [D] toggle  [F] perf".format(mode_text),
        True, (150, 150, 150))
    screen.blit(mode_surf,
                (GAME_WIDTH - mode_surf.get_width() - 10,
                 GAME_HEIGHT - 20))
    perf.count_draw(3)  # HUD elements


def draw_overlay(title_text, title_color):
    overlay = pygame.Surface((GAME_WIDTH, GAME_HEIGHT), pygame.SRCALPHA)
    overlay.fill((0, 0, 0, 150))
    screen.blit(overlay, (0, 0))
    t = big_font.render(title_text, True, title_color)
    screen.blit(t, (GAME_WIDTH // 2 - t.get_width() // 2,
                    GAME_HEIGHT // 2 - 40))
    fs = medium_font.render("Final score: {}".format(score),
                            True, (255, 255, 255))
    screen.blit(fs, (GAME_WIDTH // 2 - fs.get_width() // 2,
                     GAME_HEIGHT // 2 + 20))
    r = medium_font.render("Press SPACE for menu", True, (200, 200, 200))
    screen.blit(r, (GAME_WIDTH // 2 - r.get_width() // 2,
                    GAME_HEIGHT // 2 + 60))


# -- Main loop --------------------------------------------------------------

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

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        if event.type == pygame.KEYDOWN:
            if event.key == pygame.K_ESCAPE:
                if game_state == STATE_PLAYING:
                    game_state = STATE_PAUSED
                elif game_state == STATE_PAUSED:
                    game_state = STATE_PLAYING
                else:
                    running = False
            if event.key == pygame.K_F1:
                sound_muted = not sound_muted
            if event.key == pygame.K_d:
                dirty_renderer.toggle()
            if event.key == pygame.K_f:
                perf.visible = not perf.visible

            if game_state == STATE_MENU:
                if event.key == pygame.K_SPACE:
                    start_game()
            elif game_state == STATE_PLAYING:
                if event.key == pygame.K_m:
                    paddle.use_mouse = not paddle.use_mouse
                if event.key == pygame.K_p:
                    game_state = STATE_PAUSED
            elif game_state == STATE_PAUSED:
                if event.key == pygame.K_p:
                    game_state = STATE_PLAYING
            elif game_state in (STATE_GAME_OVER, STATE_YOU_WIN):
                if event.key == pygame.K_SPACE:
                    game_state = STATE_MENU

    # -- Update --
    if game_state == STATE_PLAYING:
        # Track previous positions for dirty rects
        old_ball_rect = ball.rect.copy()
        old_paddle_rect = paddle.rect.copy()

        paddle_group.update()
        ball.update(dt)

        ball_trail.update(dt, ball.float_x, ball.float_y)
        paddle_glow.update(dt)
        particles.update(dt)
        for anim in shatter_animations:
            anim.update(dt)
        shatter_animations = [a for a in shatter_animations if a.alive]

        # Mark dirty rects
        if dirty_renderer.enabled:
            dirty_renderer.mark_dirty(old_ball_rect.inflate(20, 20))
            dirty_renderer.mark_dirty(ball.rect.inflate(20, 20))
            dirty_renderer.mark_dirty(old_paddle_rect.inflate(16, 16))
            dirty_renderer.mark_dirty(paddle.rect.inflate(16, 16))

        bounced, wall_dir = ball.bounce_wall()
        if bounced:
            play_sound(snd_wall)
            if wall_dir == "left":
                particles.emit_wall_dust(BALL_RADIUS, ball.float_y, "left")
            elif wall_dir == "right":
                particles.emit_wall_dust(
                    GAME_WIDTH - BALL_RADIUS, ball.float_y, "right")
            elif wall_dir == "top":
                particles.emit_wall_dust(ball.float_x, BALL_RADIUS, "top")

        if ball.fell_off_bottom():
            lives -= 1
            dirty_renderer.need_full_redraw = True
            if lives <= 0:
                game_state = STATE_GAME_OVER
                play_sound(snd_game_over)
                add_high_score(score)
            else:
                play_sound(snd_lose_life)
                ball.reset()
                ball_trail.reset()

        if ball.bounce_off_paddle(paddle):
            play_sound(snd_paddle)
            paddle_glow.trigger()
            particles.emit_paddle_sparks(paddle.rect, ball.float_x)

        hit_bricks = pygame.sprite.spritecollide(ball, bricks, True)
        if hit_bricks:
            for brick in hit_bricks:
                score += brick.score_value()
                shatter_animations.append(
                    BrickShatter(brick.rect, brick.color))
                particles.emit_brick_explosion(brick.rect, brick.color, 5)
                if dirty_renderer.enabled:
                    dirty_renderer.mark_dirty(brick.rect)
            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)
            add_high_score(score)

    # -- Draw --
    perf.total_bricks = len(bricks)

    if game_state == STATE_MENU:
        draw_menu()
        perf.count_draw(5)

    elif game_state == STATE_PLAYING:
        if dirty_renderer.enabled and not dirty_renderer.need_full_redraw:
            # Dirty rect mode: restore background, then draw dynamic objects
            dirty_renderer.restore_dirty(screen)

            # Redraw bricks only in dirty areas
            for brick in bricks:
                for dr in dirty_renderer.dirty_rects:
                    if brick.rect.colliderect(dr):
                        screen.blit(brick.image, brick.rect)
                        perf.count_draw()
                        break

            # Dynamic objects
            for anim in shatter_animations:
                anim.draw(screen)
                if dirty_renderer.enabled:
                    dirty_renderer.mark_dirty(anim.rect)
            ball_trail.draw(screen, perf)
            paddle_glow.draw(screen, paddle.rect, perf)
            paddle_group.draw(screen)
            perf.count_draw()
            ball_group.draw(screen)
            perf.count_draw()
            particles.draw(screen, perf)
        else:
            # Full redraw mode
            screen.fill(BG_COLOR)
            bricks.draw(screen)
            perf.count_draw(len(bricks))
            for anim in shatter_animations:
                anim.draw(screen)
            ball_trail.draw(screen, perf)
            paddle_glow.draw(screen, paddle.rect, perf)
            paddle_group.draw(screen)
            perf.count_draw()
            ball_group.draw(screen)
            perf.count_draw()
            particles.draw(screen, perf)

            # Capture background for dirty rect mode
            if dirty_renderer.enabled:
                # Capture just the static portion (bg + bricks)
                bg_snap = pygame.Surface((GAME_WIDTH, GAME_HEIGHT))
                bg_snap.fill(BG_COLOR)
                bricks.draw(bg_snap)
                dirty_renderer.capture_background(bg_snap)

        draw_hud()

        info = small_font.render(
            "[M] Mouse  [P] Pause  [D] DirtyRect  [F] Perf  [ESC] Pause",
            True, (120, 120, 120))
        screen.blit(info, (10, GAME_HEIGHT - 32))

    elif game_state == STATE_PAUSED:
        screen.fill(BG_COLOR)
        bricks.draw(screen)
        perf.count_draw(len(bricks))
        paddle_group.draw(screen)
        ball_group.draw(screen)
        draw_hud()
        draw_overlay("PAUSED", (200, 200, 255))

    elif game_state == STATE_GAME_OVER:
        screen.fill(BG_COLOR)
        bricks.draw(screen)
        paddle_group.draw(screen)
        ball_group.draw(screen)
        draw_hud()
        draw_overlay("GAME OVER", (255, 60, 60))

    elif game_state == STATE_YOU_WIN:
        screen.fill(BG_COLOR)
        paddle_group.draw(screen)
        ball_group.draw(screen)
        draw_hud()
        draw_overlay("YOU WIN!", (100, 255, 100))

    # Performance overlay (always on top)
    perf.draw(screen, small_font, dirty_renderer.enabled,
              x=GAME_WIDTH - 350, y=5)

    perf.end_frame(dt)

    # Display update
    if dirty_renderer.enabled and game_state == STATE_PLAYING:
        dirty_renderer.flip(screen)
    else:
        pygame.display.flip()

pygame.quit()
sys.exit()

Download

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