What you will learn

In this chapter we add physics: special brick types (red = accelerate, blue = decelerate, green = gravity), and power-ups that fall from destroyed special bricks (wider paddle, extra life, multi-ball bonus).

The concept

Special bricks add variety to gameplay. When the ball hits a red brick, it speeds up. A blue brick slows it down. A green brick activates a gravity effect on the ball for a few seconds (it gets pulled downward).

Power-ups are items that fall with gravity and are caught by the paddle.

Step by step

1. Brick types

Each brick has a brick_type attribute: NORMAL, RED, BLUE or GREEN. Special bricks are marked with a coloured circle at the centre.

python
BRICK_NORMAL = 0
BRICK_RED = 1    # accelerates ball
BRICK_BLUE = 2   # decelerates ball
BRICK_GREEN = 3  # heavy ball (gravity)

2. Ball effects

The ball has new methods: accelerate() increases speed, decelerate() reduces it (minimum 150), and make_heavy() activates gravity.

python
def accelerate(self, amount=60):
    self.speed += amount
    angle = math.atan2(self.vy, self.vx)
    self.vx = self.speed * math.cos(angle)
    self.vy = self.speed * math.sin(angle)

3. The PowerUp class

A power-up falls with gravity and a bobbing motion. The player catches it by touching it with the paddle. Three types: W (wider paddle), + (extra life), M (multi-ball).

4. Wider paddle

The W power-up widens the paddle by 50 pourcent for 8 seconds. The paddle rebuilds its image with the new width and reverts after the timer expires.

Key takeaways

Tip: to change the ball direction without altering its speed, use atan2 to get the current angle.

Common pitfall: forgetting to set a minimum speed after deceleration can make the ball too slow or stationary.

Complete code

python
"""Brick Breaker - Chapter 18: Physics"""
import pygame
import sys
import math
import array
import random
import json
import os

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

BRICK_ROWS = 6
BRICK_COLS = 10
BRICK_WIDTH = 70
BRICK_HEIGHT = 20
BRICK_PADDING = 5
BRICK_OFFSET_X = 30
BRICK_OFFSET_Y = 50

MAX_LIVES = 3

# Game states
STATE_MENU = 0
STATE_PLAYING = 1
STATE_PAUSED = 2
STATE_GAME_OVER = 3
STATE_YOU_WIN = 4
STATE_DIFFICULTY = 5
STATE_HIGH_SCORES = 6

# Difficulty
DIFFICULTY_EASY = 0
DIFFICULTY_NORMAL = 1
DIFFICULTY_HARD = 2
DIFFICULTY_NAMES = ["Easy", "Normal", "Hard"]
DIFFICULTY_SPEEDS = [200, 300, 420]

# Brick types
BRICK_NORMAL = 0
BRICK_RED = 1       # accelerates ball
BRICK_BLUE = 2      # decelerates ball
BRICK_GREEN = 3     # heavy ball (gravity)

# Power-up types
POWERUP_WIDER = 0   # wider paddle
POWERUP_LIFE = 1    # extra life
POWERUP_MULTI = 2   # multi-ball hint (visual cue)

POWERUP_COLORS = {
    POWERUP_WIDER: (255, 200, 0),
    POWERUP_LIFE:  (255, 50, 50),
    POWERUP_MULTI: (50, 150, 255),
}
POWERUP_LABELS = {
    POWERUP_WIDER: "W",
    POWERUP_LIFE:  "+",
    POWERUP_MULTI: "M",
}
POWERUP_SIZE = 20
POWERUP_FALL_SPEED = 120
POWERUP_DURATION = 8.0  # seconds for timed power-ups

# 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)
snd_click = generate_beep(frequency=600, duration_ms=40, volume=0.2)
snd_powerup = generate_beep(frequency=900, duration_ms=120, volume=0.3)
snd_speed_up = generate_beep(frequency=1000, duration_ms=60, volume=0.25)
snd_speed_down = generate_beep(frequency=200, duration_ms=100, volume=0.25)


# -- Helper functions -------------------------------------------------------

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)


# -- High Scores -----------------------------------------------------------

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, difficulty):
    scores = load_high_scores()
    scores.append({"score": new_score,
                   "difficulty": DIFFICULTY_NAMES[difficulty]})
    scores = sorted(scores, key=lambda s: s["score"], reverse=True)[:10]
    save_high_scores(scores)
    return scores


# -- Screen manager ---------------------------------------------------------

class ScreenManager:
    def __init__(self):
        self.game_surface = pygame.Surface((GAME_WIDTH, GAME_HEIGHT))
        self.fullscreen = False
        self.window_width = GAME_WIDTH
        self.window_height = GAME_HEIGHT
        self.screen = pygame.display.set_mode(
            (self.window_width, self.window_height), pygame.RESIZABLE)
        pygame.display.set_caption("Brick Breaker - Chapter 18")
        self._update_scaling()

    def _update_scaling(self):
        win_w, win_h = self.screen.get_size()
        game_aspect = GAME_WIDTH / GAME_HEIGHT
        win_aspect = win_w / win_h
        if win_aspect > game_aspect:
            scaled_h = win_h
            scaled_w = int(win_h * game_aspect)
        else:
            scaled_w = win_w
            scaled_h = int(win_w / game_aspect)
        self.scaled_rect = pygame.Rect(
            (win_w - scaled_w) // 2, (win_h - scaled_h) // 2,
            scaled_w, scaled_h)

    def toggle_fullscreen(self):
        self.fullscreen = not self.fullscreen
        if self.fullscreen:
            self.screen = pygame.display.set_mode((0, 0), pygame.FULLSCREEN)
        else:
            self.screen = pygame.display.set_mode(
                (self.window_width, self.window_height), pygame.RESIZABLE)
        self._update_scaling()

    def handle_resize(self, event):
        if not self.fullscreen:
            self.window_width = event.w
            self.window_height = event.h
            self.screen = pygame.display.set_mode(
                (event.w, event.h), pygame.RESIZABLE)
            self._update_scaling()

    def present(self):
        self.screen.fill((0, 0, 0))
        scaled = pygame.transform.smoothscale(
            self.game_surface,
            (self.scaled_rect.width, self.scaled_rect.height))
        self.screen.blit(scaled, self.scaled_rect.topleft)
        pygame.display.flip()

    def window_to_game(self, wx, wy):
        gx = (wx - self.scaled_rect.x) * GAME_WIDTH / self.scaled_rect.width
        gy = (wy - self.scaled_rect.y) * GAME_HEIGHT / self.scaled_rect.height
        return (int(gx), int(gy))


# -- Button class -----------------------------------------------------------

class Button:
    def __init__(self, x, y, width, height, label, font,
                 color=(80, 80, 120), hover_color=(120, 120, 180),
                 text_color=(255, 255, 255)):
        self.rect = pygame.Rect(x, y, width, height)
        self.label = label
        self.font = font
        self.color = color
        self.hover_color = hover_color
        self.text_color = text_color
        self.hovered = False
        self.clicked = False

    def update(self, mouse_pos, mouse_click):
        self.hovered = self.rect.collidepoint(mouse_pos)
        self.clicked = self.hovered and mouse_click
        return self.clicked

    def draw(self, surface):
        current_color = self.hover_color if self.hovered else self.color
        shadow_rect = self.rect.move(3, 3)
        pygame.draw.rect(surface, (0, 0, 0, 100), shadow_rect,
                         border_radius=6)
        pygame.draw.rect(surface, current_color, self.rect, border_radius=6)
        border_color = (200, 200, 255) if self.hovered else (100, 100, 150)
        pygame.draw.rect(surface, border_color, self.rect, width=2,
                         border_radius=6)
        text_surf = self.font.render(self.label, True, self.text_color)
        text_rect = text_surf.get_rect(center=self.rect.center)
        surface.blit(text_surf, text_rect)


# -- Particle System --------------------------------------------------------

class Particle:
    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:
            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):
        for p in self.particles:
            p.draw(surface)

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

    def emit_brick_explosion(self, rect, color):
        cx, cy = rect.centerx, rect.centery
        for _ in range(15):
            angle = random.uniform(0, 2 * math.pi)
            speed = random.uniform(60, 200)
            vx = speed * math.cos(angle)
            vy = speed * math.sin(angle) - 50
            lifetime = 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)))
            size = random.randint(2, 5)
            self.particles.append(
                Particle(cx, cy, vx, vy, lifetime, (r, g, b), size))

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

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


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

class BrickShatter:
    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):
        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))
            trail_surf = pygame.Surface((radius * 2, radius * 2),
                                        pygame.SRCALPHA)
            pygame.draw.circle(trail_surf, (100, 100, 255, alpha),
                               (radius, radius), radius)
            surface.blit(trail_surf, (int(x) - radius, int(y) - radius))

    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):
        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)


# -- PowerUp class ----------------------------------------------------------

class PowerUp:
    """A falling power-up item released from a special brick."""

    def __init__(self, x, y, ptype):
        self.x = float(x)
        self.y = float(y)
        self.ptype = ptype
        self.color = POWERUP_COLORS[ptype]
        self.label = POWERUP_LABELS[ptype]
        self.alive = True
        self.vy = POWERUP_FALL_SPEED
        self.gravity = 60.0
        self.bob_timer = random.uniform(0, math.pi * 2)
        self.size = POWERUP_SIZE

    def update(self, dt):
        self.vy += self.gravity * dt
        self.y += self.vy * dt
        self.bob_timer += dt * 4
        if self.y > GAME_HEIGHT + self.size:
            self.alive = False

    def get_rect(self):
        return pygame.Rect(int(self.x) - self.size // 2,
                           int(self.y) - self.size // 2,
                           self.size, self.size)

    def draw(self, surface):
        if not self.alive:
            return
        bob_offset = math.sin(self.bob_timer) * 3
        cx = int(self.x)
        cy = int(self.y + bob_offset)
        # Glow
        glow_surf = pygame.Surface((self.size * 2, self.size * 2),
                                   pygame.SRCALPHA)
        pygame.draw.circle(glow_surf,
                           (self.color[0], self.color[1], self.color[2], 60),
                           (self.size, self.size), self.size)
        surface.blit(glow_surf, (cx - self.size, cy - self.size))
        # Body
        pygame.draw.circle(surface, self.color, (cx, cy), self.size // 2)
        pygame.draw.circle(surface, (255, 255, 255), (cx, cy),
                           self.size // 2, 2)
        # Label
        font = pygame.font.SysFont("monospace", 14, bold=True)
        lbl = font.render(self.label, True, (255, 255, 255))
        surface.blit(lbl, (cx - lbl.get_width() // 2,
                           cy - lbl.get_height() // 2))


# -- Sprite classes ---------------------------------------------------------

class Paddle(pygame.sprite.Sprite):
    def __init__(self):
        super().__init__()
        self.base_width = PADDLE_WIDTH
        self.current_width = PADDLE_WIDTH
        self.wide_timer = 0.0
        self._build_image()
        self.rect.centerx = GAME_WIDTH // 2
        self.rect.y = GAME_HEIGHT - 40
        self.use_mouse = False

    def _build_image(self):
        w = self.current_width
        self.image = pygame.Surface((w, 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), (w, y))
        pygame.draw.rect(self.image, (0, 0, 0, 0), (0, 0, 3, 3))
        pygame.draw.rect(self.image, (0, 0, 0, 0), (w - 3, 0, 3, 3))
        # Tint if wide
        if self.current_width > self.base_width:
            tint = pygame.Surface((w, PADDLE_HEIGHT), pygame.SRCALPHA)
            tint.fill((255, 200, 0, 40))
            self.image.blit(tint, (0, 0))
        self.image = self.image.convert_alpha()
        old_center = self.rect.center if hasattr(self, 'rect') else (
            GAME_WIDTH // 2, GAME_HEIGHT - 40)
        self.rect = self.image.get_rect(center=old_center)

    def make_wider(self, duration=POWERUP_DURATION):
        self.current_width = int(self.base_width * 1.5)
        self.wide_timer = duration
        self._build_image()

    def update(self, dt=0, mouse_game_x=None):
        # Update power-up timer
        if self.wide_timer > 0:
            self.wide_timer -= dt
            if self.wide_timer <= 0:
                self.wide_timer = 0
                self.current_width = self.base_width
                self._build_image()

        if self.use_mouse and mouse_game_x is not None:
            self.rect.centerx = mouse_game_x
        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)
        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)
        self.heavy = False
        self.heavy_timer = 0.0
        self.heavy_gravity = 40.0  # pixels/s^2 downward pull

    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)
        self.heavy = False
        self.heavy_timer = 0.0

    def make_heavy(self, duration=5.0):
        self.heavy = True
        self.heavy_timer = duration

    def update(self, dt):
        # Heavy ball: gravity pulls downward
        if self.heavy:
            self.vy += self.heavy_gravity * dt
            self.heavy_timer -= dt
            if self.heavy_timer <= 0:
                self.heavy = False
                self.heavy_timer = 0.0

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

    def accelerate(self, amount=60):
        """Increase speed (from red brick)."""
        self.speed += amount
        current_angle = math.atan2(self.vy, self.vx)
        self.vx = self.speed * math.cos(current_angle)
        self.vy = self.speed * math.sin(current_angle)

    def decelerate(self, amount=40):
        """Decrease speed (from blue brick), minimum 150."""
        self.speed = max(150, self.speed - amount)
        current_angle = math.atan2(self.vy, self.vx)
        self.vx = self.speed * math.cos(current_angle)
        self.vy = self.speed * math.sin(current_angle)


class Brick(pygame.sprite.Sprite):
    def __init__(self, x, y, color, row, brick_type=BRICK_NORMAL):
        super().__init__()
        self.brick_type = brick_type
        self.color = color
        self.row = row
        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))
        # Special brick marker
        if brick_type != BRICK_NORMAL:
            marker_colors = {
                BRICK_RED: (255, 60, 60),
                BRICK_BLUE: (60, 60, 255),
                BRICK_GREEN: (60, 255, 60),
            }
            mc = marker_colors.get(brick_type, (255, 255, 255))
            pygame.draw.circle(self.image, mc,
                               (BRICK_WIDTH // 2, BRICK_HEIGHT // 2), 4)
            pygame.draw.circle(self.image, (255, 255, 255),
                               (BRICK_WIDTH // 2, BRICK_HEIGHT // 2), 4, 1)
        self.image = self.image.convert_alpha()
        self.rect = self.image.get_rect(topleft=(x, y))

    def score_value(self):
        base = (BRICK_ROWS - self.row) * 10
        if self.brick_type != BRICK_NORMAL:
            base += 20  # bonus for special bricks
        return base


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)
            # 20 pct chance of special brick
            roll = random.random()
            if roll < 0.07:
                btype = BRICK_RED
                bcolor = (220, 60, 60)
            elif roll < 0.14:
                btype = BRICK_BLUE
                bcolor = (60, 60, 220)
            elif roll < 0.20:
                btype = BRICK_GREEN
                bcolor = (60, 200, 60)
            else:
                btype = BRICK_NORMAL
                bcolor = color
            group.add(Brick(x, y, bcolor, row, btype))
    return group


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

scr_mgr = ScreenManager()
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)
button_font = pygame.font.SysFont("monospace", 22, bold=True)

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()
powerups = []

lives = MAX_LIVES
score = 0
level = 1
game_state = STATE_MENU
current_difficulty = DIFFICULTY_NORMAL
master_volume = 0.5
sound_muted = False
menu_blink_timer = 0.0

# Buttons
btn_start = Button(GAME_WIDTH // 2 - 100, GAME_HEIGHT // 2 + 10,
                   200, 45, "Start", button_font,
                   color=(40, 100, 60), hover_color=(60, 150, 90))
btn_difficulty = Button(GAME_WIDTH // 2 - 100, GAME_HEIGHT // 2 + 65,
                        200, 45, "Difficulty", button_font)
btn_high_scores = Button(GAME_WIDTH // 2 - 100, GAME_HEIGHT // 2 + 120,
                         200, 45, "High Scores", button_font)
btn_quit = Button(GAME_WIDTH // 2 - 100, GAME_HEIGHT // 2 + 175,
                  200, 45, "Quit", button_font,
                  color=(120, 40, 40), hover_color=(180, 60, 60))
menu_buttons = [btn_start, btn_difficulty, btn_high_scores, btn_quit]

btn_easy = Button(GAME_WIDTH // 2 - 100, GAME_HEIGHT // 2 - 30,
                  200, 45, "Easy", button_font,
                  color=(40, 120, 40), hover_color=(60, 180, 60))
btn_normal = Button(GAME_WIDTH // 2 - 100, GAME_HEIGHT // 2 + 30,
                    200, 45, "Normal", button_font,
                    color=(120, 120, 40), hover_color=(180, 180, 60))
btn_hard = Button(GAME_WIDTH // 2 - 100, GAME_HEIGHT // 2 + 90,
                  200, 45, "Hard", button_font,
                  color=(120, 40, 40), hover_color=(180, 60, 60))
btn_diff_back = Button(GAME_WIDTH // 2 - 100, GAME_HEIGHT // 2 + 160,
                       200, 45, "Back", button_font)
difficulty_buttons = [btn_easy, btn_normal, btn_hard, btn_diff_back]

btn_resume = Button(GAME_WIDTH // 2 - 100, GAME_HEIGHT // 2 - 20,
                    200, 45, "Resume", button_font,
                    color=(40, 100, 60), hover_color=(60, 150, 90))
btn_restart = Button(GAME_WIDTH // 2 - 100, GAME_HEIGHT // 2 + 40,
                     200, 45, "Restart", button_font,
                     color=(120, 120, 40), hover_color=(180, 180, 60))
btn_pause_quit = Button(GAME_WIDTH // 2 - 100, GAME_HEIGHT // 2 + 100,
                        200, 45, "Quit to Menu", button_font,
                        color=(120, 40, 40), hover_color=(180, 60, 60))
pause_buttons = [btn_resume, btn_restart, btn_pause_quit]

btn_hs_back = Button(GAME_WIDTH // 2 - 100, GAME_HEIGHT - 80,
                     200, 45, "Back", button_font)


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


def spawn_powerup(brick):
    """Spawn a power-up from a special brick."""
    if brick.brick_type == BRICK_NORMAL:
        return
    # 50 pct chance to drop a power-up from a special brick
    if random.random() > 0.5:
        return
    ptype = random.choice([POWERUP_WIDER, POWERUP_LIFE, POWERUP_MULTI])
    powerups.append(PowerUp(brick.rect.centerx, brick.rect.centery, ptype))


def apply_powerup(pu):
    """Apply a caught power-up."""
    global lives
    if pu.ptype == POWERUP_WIDER:
        paddle.make_wider()
    elif pu.ptype == POWERUP_LIFE:
        lives = min(lives + 1, MAX_LIVES + 2)
    elif pu.ptype == POWERUP_MULTI:
        # Multi-ball hint: just add score bonus (full multi-ball is complex)
        pass


def start_game():
    global lives, score, level, bricks, game_state, shatter_animations
    global powerups
    lives = MAX_LIVES
    score = 0
    level = 1
    bricks = create_brick_group()
    ball.reset()
    ball.speed = DIFFICULTY_SPEEDS[current_difficulty]
    angle = math.radians(-60)
    ball.vx = ball.speed * math.cos(angle)
    ball.vy = ball.speed * math.sin(angle)
    ball_trail.reset()
    shatter_animations = []
    particles.clear()
    powerups = []
    paddle.current_width = paddle.base_width
    paddle.wide_timer = 0.0
    paddle._build_image()
    game_state = STATE_PLAYING


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

def draw_menu(surface):
    global menu_blink_timer
    menu_blink_timer += clock.get_time() / 1000.0
    surface.fill(BG_COLOR)
    title = big_font.render("BRICK BREAKER", True, (255, 200, 50))
    surface.blit(title, (GAME_WIDTH // 2 - title.get_width() // 2,
                         GAME_HEIGHT // 3 - 60))
    subtitle = medium_font.render("Physics Edition", True, (200, 200, 255))
    surface.blit(subtitle, (GAME_WIDTH // 2 - subtitle.get_width() // 2,
                            GAME_HEIGHT // 3 - 10))


def draw_difficulty_screen(surface):
    surface.fill(BG_COLOR)
    title = big_font.render("DIFFICULTY", True, (255, 200, 50))
    surface.blit(title, (GAME_WIDTH // 2 - title.get_width() // 2,
                         GAME_HEIGHT // 4 - 30))


def draw_high_scores_screen(surface):
    surface.fill(BG_COLOR)
    title = big_font.render("HIGH SCORES", True, (255, 200, 50))
    surface.blit(title, (GAME_WIDTH // 2 - title.get_width() // 2, 40))
    scores = load_high_scores()
    if not scores:
        ns = medium_font.render("No scores yet!", True, (150, 150, 150))
        surface.blit(ns, (GAME_WIDTH // 2 - ns.get_width() // 2,
                          GAME_HEIGHT // 2 - 20))
    else:
        for i, entry in enumerate(scores[:10]):
            color = (255, 215, 0) if i == 0 else (
                (200, 200, 200) if i < 3 else (150, 150, 150))
            line = "{:>3}.  {:>6}    {}".format(
                i + 1, entry["score"], entry.get("difficulty", "---"))
            ts = hud_font.render(line, True, color)
            surface.blit(ts, (GAME_WIDTH // 2 - 140, 110 + i * 35))


def draw_hud(surface):
    score_surf = hud_font.render("Score: {}".format(score),
                                 True, (255, 255, 100))
    surface.blit(score_surf, (10, 10))
    lives_surf = hud_font.render("Lives: {}".format(lives),
                                 True, (255, 100, 100))
    surface.blit(lives_surf,
                 (GAME_WIDTH // 2 - lives_surf.get_width() // 2, 10))
    level_surf = hud_font.render("Level: {}".format(level),
                                 True, (100, 200, 255))
    surface.blit(level_surf,
                 (GAME_WIDTH - level_surf.get_width() - 10, 10))
    # Status indicators
    status_parts = []
    if ball.heavy:
        status_parts.append("HEAVY BALL {:.1f}s".format(ball.heavy_timer))
    if paddle.wide_timer > 0:
        status_parts.append("WIDE PADDLE {:.1f}s".format(paddle.wide_timer))
    if status_parts:
        status_text = " | ".join(status_parts)
        st = small_font.render(status_text, True, (255, 200, 100))
        surface.blit(st, (GAME_WIDTH // 2 - st.get_width() // 2, 30))
    # Legend
    legend = small_font.render(
        "Red=Accel  Blue=Decel  Green=Heavy  Drops=PowerUps",
        True, (80, 80, 80))
    surface.blit(legend, (10, 30))


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


def draw_game_scene(surface):
    surface.fill(BG_COLOR)
    bricks.draw(surface)
    for anim in shatter_animations:
        anim.draw(surface)
    ball_trail.draw(surface)
    paddle_glow.draw(surface, paddle.rect)
    paddle_group.draw(surface)
    ball_group.draw(surface)
    # Draw power-ups
    for pu in powerups:
        pu.draw(surface)
    particles.draw(surface)
    draw_hud(surface)


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

running = True
while running:
    dt = clock.tick(FPS) / 1000.0
    raw_mx, raw_my = pygame.mouse.get_pos()
    mouse_pos = scr_mgr.window_to_game(raw_mx, raw_my)
    mouse_click = False
    gs = scr_mgr.game_surface

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        if event.type == pygame.VIDEORESIZE:
            scr_mgr.handle_resize(event)
        if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
            mouse_click = True
        if event.type == pygame.KEYDOWN:
            if event.key == pygame.K_F11:
                scr_mgr.toggle_fullscreen()
            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 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 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

    # -- Button updates --
    if game_state == STATE_MENU:
        for btn in menu_buttons:
            btn.update(mouse_pos, mouse_click)
        if btn_start.clicked:
            play_sound(snd_click)
            start_game()
        elif btn_difficulty.clicked:
            play_sound(snd_click)
            game_state = STATE_DIFFICULTY
        elif btn_high_scores.clicked:
            play_sound(snd_click)
            game_state = STATE_HIGH_SCORES
        elif btn_quit.clicked:
            running = False
    elif game_state == STATE_DIFFICULTY:
        for btn in difficulty_buttons:
            btn.update(mouse_pos, mouse_click)
        if btn_easy.clicked:
            play_sound(snd_click)
            current_difficulty = DIFFICULTY_EASY
            game_state = STATE_MENU
        elif btn_normal.clicked:
            play_sound(snd_click)
            current_difficulty = DIFFICULTY_NORMAL
            game_state = STATE_MENU
        elif btn_hard.clicked:
            play_sound(snd_click)
            current_difficulty = DIFFICULTY_HARD
            game_state = STATE_MENU
        elif btn_diff_back.clicked:
            play_sound(snd_click)
            game_state = STATE_MENU
    elif game_state == STATE_HIGH_SCORES:
        btn_hs_back.update(mouse_pos, mouse_click)
        if btn_hs_back.clicked:
            play_sound(snd_click)
            game_state = STATE_MENU
    elif game_state == STATE_PAUSED:
        for btn in pause_buttons:
            btn.update(mouse_pos, mouse_click)
        if btn_resume.clicked:
            play_sound(snd_click)
            game_state = STATE_PLAYING
        elif btn_restart.clicked:
            play_sound(snd_click)
            start_game()
        elif btn_pause_quit.clicked:
            play_sound(snd_click)
            game_state = STATE_MENU

    # -- Update --
    if game_state == STATE_PLAYING:
        paddle.update(dt=dt, mouse_game_x=mouse_pos[0])
        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]

        # Update power-ups
        for pu in powerups:
            pu.update(dt)
        powerups = [pu for pu in powerups if pu.alive]

        # Power-up catch (paddle collision)
        for pu in powerups[:]:
            if pu.alive and pu.get_rect().colliderect(paddle.rect):
                pu.alive = False
                apply_powerup(pu)
                play_sound(snd_powerup)

        # Wall bounces
        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
            if lives <= 0:
                game_state = STATE_GAME_OVER
                play_sound(snd_game_over)
                add_high_score(score, current_difficulty)
            else:
                play_sound(snd_lose_life)
                ball.reset()
                ball.speed = DIFFICULTY_SPEEDS[current_difficulty]
                angle = math.radians(-60)
                ball.vx = ball.speed * math.cos(angle)
                ball.vy = ball.speed * math.sin(angle)
                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)
                # Special brick effects
                if brick.brick_type == BRICK_RED:
                    ball.accelerate(60)
                    play_sound(snd_speed_up)
                elif brick.brick_type == BRICK_BLUE:
                    ball.decelerate(40)
                    play_sound(snd_speed_down)
                elif brick.brick_type == BRICK_GREEN:
                    ball.make_heavy(5.0)
                # Possibly spawn power-up
                spawn_powerup(brick)
            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, current_difficulty)

    # -- Draw --
    if game_state == STATE_MENU:
        draw_menu(gs)
        for btn in menu_buttons:
            btn.draw(gs)
    elif game_state == STATE_DIFFICULTY:
        draw_difficulty_screen(gs)
        for btn in difficulty_buttons:
            btn.draw(gs)
    elif game_state == STATE_HIGH_SCORES:
        draw_high_scores_screen(gs)
        btn_hs_back.draw(gs)
    elif game_state == STATE_PLAYING:
        draw_game_scene(gs)
        info = small_font.render(
            "[M] Mouse  [P] Pause  [F1] Mute  [F11] Fullscreen",
            True, (150, 150, 150))
        gs.blit(info, (10, GAME_HEIGHT - 25))
    elif game_state == STATE_PAUSED:
        draw_game_scene(gs)
        overlay = pygame.Surface((GAME_WIDTH, GAME_HEIGHT), pygame.SRCALPHA)
        overlay.fill((0, 0, 0, 150))
        gs.blit(overlay, (0, 0))
        t = big_font.render("PAUSED", True, (200, 200, 255))
        gs.blit(t, (GAME_WIDTH // 2 - t.get_width() // 2,
                     GAME_HEIGHT // 2 - 120))
        for btn in pause_buttons:
            btn.draw(gs)
    elif game_state == STATE_GAME_OVER:
        draw_game_scene(gs)
        draw_overlay(gs, "GAME OVER", (255, 60, 60))
    elif game_state == STATE_YOU_WIN:
        draw_game_scene(gs)
        draw_overlay(gs, "YOU WIN!", (100, 255, 100))

    scr_mgr.present()

pygame.quit()
sys.exit()

Download

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