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:

python
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.

python
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.

python
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

python
"""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