What you will learn
In this chapter the brick field becomes three times taller than the screen. A camera follows the ball vertically and scrolls the world. We also add an endless mode where new rows of bricks appear continuously.
The concept
In a scrolling game, objects exist in a world space larger than the screen. The camera defines which portion of the world is visible. To display an object, we subtract the camera offset from its world position.
The formula is:
screen_position = world_position - camera_offset
For smooth scrolling, we use linear interpolation (lerp). Instead of jumping directly to the target, the camera moves a fraction of the remaining distance each frame:
class Camera:
def __init__(self):
self.offset_y = 0.0
self.target_y = 0.0
def update(self, ball_y):
self.target_y = ball_y - SCREEN_HEIGHT * 0.6
self.target_y = max(0, self.target_y)
# Smooth interpolation
self.offset_y += (self.target_y - self.offset_y) * 0.05
def apply(self, world_y):
return world_y - self.offset_y
Step by step
1. A larger world
We increase BRICK_ROWS to 18 (from 6 before). The bricks now occupy a vertical space much larger than the screen height.
2. The Camera class
The camera keeps two values: the current offset and the target. Each frame, the offset moves toward the target with a smoothing factor (0.05 = 5 pct of the remaining distance). The apply() method converts a world coordinate into a screen coordinate.
3. Drawing with the camera
Each brick has a world position (world_y) that does not change. At draw time, we calculate its screen position using the camera. We only draw visible bricks to save resources.
def draw_at(self, surface, camera):
screen_y = camera.apply(self.world_y)
if -BRICK_HEIGHT < screen_y < SCREEN_HEIGHT:
surface.blit(self.image, (self.rect.x, int(screen_y)))
4. Screen-space collisions
The ball and paddle remain in screen coordinates. To detect ball-brick collisions, we compute the screen rect of each brick on the fly.
5. Endless mode
Every few seconds, a new row of bricks is added above the visible field. The bricks scroll down naturally thanks to the camera.
if endless_mode:
endless_timer += dt
if endless_timer >= ENDLESS_ROW_INTERVAL:
endless_timer -= ENDLESS_ROW_INTERVAL
add_endless_row(bricks, endless_next_y)
endless_next_y -= (BRICK_HEIGHT + BRICK_PADDING)
Key takeaways
Tip: lerp (linear interpolation) is the key to pleasant scrolling. A factor that is too high gives jerky movement, a factor that is too low gives annoying lag.
Common pitfall: mixing world and screen coordinates. Use explicit variable names (world_y vs screen_y) to avoid confusion.
Optimization: only drawing visible objects (culling) is an essential technique when the world is larger than the screen.
Complete code
"""Brick Breaker - Chapter 12: Camera and Scrolling"""
import pygame
import sys
import math
import array
import random
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 = 18
BRICK_COLS = 10
BRICK_WIDTH = 70
BRICK_HEIGHT = 20
BRICK_PADDING = 5
BRICK_OFFSET_X = 30
BRICK_OFFSET_Y = 50
MAX_LIVES = 3
# Game states
STATE_MENU = 0
STATE_PLAYING = 1
STATE_PAUSED = 2
STATE_GAME_OVER = 3
STATE_YOU_WIN = 4
# World height: brick field is 3x the screen height
WORLD_HEIGHT = SCREEN_HEIGHT * 3
CAMERA_SMOOTH = 0.05
ENDLESS_ROW_INTERVAL = 5.0
# ── 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)
# ── Helper functions ────────────────────────────────────────────────
def rainbow_color(row, total_rows):
hue = int(360 * row / max(1, total_rows))
color = pygame.Color(0)
color.hsva = (hue, 100, 100, 100)
return (color.r, color.g, color.b)
# ── Camera ──────────────────────────────────────────────────────────
class Camera:
"""Vertical camera that follows the ball with smooth lerp."""
def __init__(self):
self.offset_y = 0.0
self.target_y = 0.0
def update(self, ball_y):
"""Smoothly follow the ball vertically."""
# Target: keep ball roughly in the lower third of the screen
self.target_y = ball_y - SCREEN_HEIGHT * 0.6
# Clamp so we never show below the paddle area
self.target_y = max(0, self.target_y)
# Smooth interpolation (lerp)
self.offset_y += (self.target_y - self.offset_y) * CAMERA_SMOOTH
def apply(self, world_y):
"""Convert world Y coordinate to screen Y coordinate."""
return world_y - self.offset_y
def reset(self):
self.offset_y = 0.0
self.target_y = 0.0
# ── Sprite classes ──────────────────────────────────────────────────
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()
# Ball uses world coordinates for Y
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.world_y = y
self.row = row
self.color = color
def score_value(self):
return (BRICK_ROWS - self.row) * 10
def draw_at(self, surface, camera):
"""Draw the brick at its camera-adjusted position."""
screen_y = camera.apply(self.world_y)
# Only draw if visible
if -BRICK_HEIGHT < screen_y < SCREEN_HEIGHT:
surface.blit(self.image, (self.rect.x, int(screen_y)))
def get_screen_rect(self, camera):
"""Return rect in screen coordinates for collision."""
screen_y = camera.apply(self.world_y)
return pygame.Rect(self.rect.x, int(screen_y), BRICK_WIDTH, BRICK_HEIGHT)
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
def add_endless_row(bricks, top_y):
"""Add a new row of bricks at the given world Y position."""
row_index = random.randint(0, BRICK_ROWS - 1)
color = rainbow_color(row_index, BRICK_ROWS)
for col in range(BRICK_COLS):
x = BRICK_OFFSET_X + col * (BRICK_WIDTH + BRICK_PADDING)
bricks.add(Brick(x, top_y, color, row_index))
# ── Game setup ──────────────────────────────────────────────────────
screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
pygame.display.set_caption("Brick Breaker - Chapter 12")
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)
camera = Camera()
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
game_state = STATE_MENU
master_volume = 0.5
sound_muted = False
menu_blink_timer = 0.0
endless_timer = 0.0
endless_next_y = -BRICK_HEIGHT - BRICK_PADDING
endless_mode = False
def play_sound(sound):
if not sound_muted:
sound.set_volume(master_volume)
sound.play()
def start_game():
global lives, score, level, bricks, game_state
global endless_timer, endless_next_y, endless_mode
lives = MAX_LIVES
score = 0
level = 1
bricks = create_brick_group()
ball.reset()
camera.reset()
endless_timer = 0.0
endless_next_y = -BRICK_HEIGHT - BRICK_PADDING
endless_mode = False
game_state = STATE_PLAYING
def start_endless():
global lives, score, level, bricks, game_state
global endless_timer, endless_next_y, endless_mode
lives = MAX_LIVES
score = 0
level = 1
bricks = create_brick_group()
ball.reset()
camera.reset()
endless_timer = 0.0
endless_next_y = -BRICK_HEIGHT - BRICK_PADDING
endless_mode = 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, (SCREEN_WIDTH // 2 - title.get_width() // 2,
SCREEN_HEIGHT // 3 - 30))
if int(menu_blink_timer * 2) % 2 == 0:
start_text = medium_font.render("Press SPACE to start", True, (255, 255, 255))
screen.blit(start_text, (SCREEN_WIDTH // 2 - start_text.get_width() // 2,
SCREEN_HEIGHT // 2 + 20))
endless_text = medium_font.render("Press E for Endless mode", True, (200, 200, 255))
screen.blit(endless_text, (SCREEN_WIDTH // 2 - endless_text.get_width() // 2,
SCREEN_HEIGHT // 2 + 60))
instr_lines = [
"[LEFT/RIGHT] or [A/D] Move paddle",
"[M] Mouse [P] Pause [F1] Mute [+/-] Volume",
]
y_off = SCREEN_HEIGHT // 2 + 110
for line in instr_lines:
surf = small_font.render(line, True, (150, 150, 150))
screen.blit(surf, (SCREEN_WIDTH // 2 - surf.get_width() // 2, y_off))
y_off += 20
def draw_hud():
score_surf = hud_font.render("Score: {}".format(score), True, (255, 255, 100))
screen.blit(score_surf, (10, 10))
lives_surf = hud_font.render("Lives: {}".format(lives), True, (255, 100, 100))
screen.blit(lives_surf, (SCREEN_WIDTH // 2 - lives_surf.get_width() // 2, 10))
mode_label = "Endless" if endless_mode else "Level: {}".format(level)
level_surf = hud_font.render(mode_label, True, (100, 200, 255))
screen.blit(level_surf, (SCREEN_WIDTH - level_surf.get_width() - 10, 10))
vol_label = "MUTED" if sound_muted else "Vol: {}".format(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))
cam_info = small_font.render(
"Camera Y: {:.0f} Bricks: {}".format(camera.offset_y, len(bricks)),
True, (100, 100, 100))
screen.blit(cam_info, (10, 30))
def draw_overlay(title_text, title_color):
overlay = pygame.Surface((SCREEN_WIDTH, SCREEN_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, (SCREEN_WIDTH // 2 - t.get_width() // 2, SCREEN_HEIGHT // 2 - 40))
fs = medium_font.render("Final score: {}".format(score), True, (255, 255, 255))
screen.blit(fs, (SCREEN_WIDTH // 2 - fs.get_width() // 2, SCREEN_HEIGHT // 2 + 20))
r = medium_font.render("Press SPACE for menu", True, (200, 200, 200))
screen.blit(r, (SCREEN_WIDTH // 2 - r.get_width() // 2, SCREEN_HEIGHT // 2 + 60))
def draw_game_world():
"""Draw bricks with camera offset, paddle and ball at screen positions."""
screen.fill(BG_COLOR)
for brick in bricks:
brick.draw_at(screen, camera)
paddle_group.draw(screen)
ball_group.draw(screen)
# ── Main loop ───────────────────────────────────────────────────────
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_F1:
sound_muted = not sound_muted
if event.key in (pygame.K_PLUS, pygame.K_KP_PLUS, pygame.K_EQUALS):
master_volume = min(1.0, master_volume + 0.1)
if event.key in (pygame.K_MINUS, pygame.K_KP_MINUS):
master_volume = max(0.0, master_volume - 0.1)
if game_state == STATE_MENU:
if event.key == pygame.K_SPACE:
start_game()
elif event.key == pygame.K_e:
start_endless()
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:
paddle_group.update()
ball.update(dt)
# Camera follows the ball
camera.update(ball.float_y)
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()
camera.reset()
if ball.bounce_off_paddle(paddle):
play_sound(snd_paddle)
# Collision: check bricks using screen-space rects
ball_rect = ball.rect
hit_list = []
for brick in bricks:
screen_rect = brick.get_screen_rect(camera)
if ball_rect.colliderect(screen_rect):
hit_list.append(brick)
if hit_list:
for brick in hit_list:
score += brick.score_value()
brick.kill()
ball.vy = -ball.vy
ball.speed += BALL_SPEED_INCREMENT
play_sound(snd_brick)
# Endless mode: add new rows periodically
if endless_mode:
endless_timer += dt
if endless_timer >= ENDLESS_ROW_INTERVAL:
endless_timer -= ENDLESS_ROW_INTERVAL
add_endless_row(bricks, endless_next_y)
endless_next_y -= (BRICK_HEIGHT + BRICK_PADDING)
# Win condition (non-endless only)
if not endless_mode and len(bricks) == 0:
game_state = STATE_YOU_WIN
play_sound(snd_win)
# ── Draw ────────────────────────────────────────────────────
if game_state == STATE_MENU:
draw_menu()
elif game_state == STATE_PLAYING:
draw_game_world()
draw_hud()
info = small_font.render(
"[M] Mouse [P] Pause [F1] Mute [+/-] Volume [ESC] Quit",
True, (150, 150, 150))
screen.blit(info, (10, SCREEN_HEIGHT - 25))
elif game_state == STATE_PAUSED:
draw_game_world()
draw_hud()
draw_overlay("PAUSED", (200, 200, 255))
elif game_state == STATE_GAME_OVER:
draw_game_world()
draw_hud()
draw_overlay("GAME OVER", (255, 60, 60))
elif game_state == STATE_YOU_WIN:
draw_game_world()
draw_hud()
draw_overlay("YOU WIN!", (100, 255, 100))
pygame.display.flip()
pygame.quit()
sys.exit()
Download
Download the source code for this chapter: brick-breaker-ch12.zip
