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()andconvert_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.
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.
# 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.
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.pyfor a global profile. - Monitor the number of
blitcalls per frame: it is often the bottleneck. - Prefer
pygame.display.update(rects)overflip()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
"""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
