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