What you will learn

In this chapter we add screen management: F11 fullscreen toggle, resolution scaling, window resize handling, and aspect ratio preservation with letterboxing and pillarboxing.

The concept

The game always renders to a fixed surface (800x600). That surface is then scaled to fit the window, regardless of its size. The 4:3 ratio is preserved automatically.

This virtual resolution technique is used by many professional games to ensure consistent rendering across all screens.

Step by step

1. The ScreenManager class

The ScreenManager encapsulates all screen logic. It holds a fixed-size game_surface and computes a destination rectangle for scaling.

python
class ScreenManager:
    def __init__(self):
        self.game_surface = pygame.Surface(
            (GAME_WIDTH, GAME_HEIGHT))
        self.fullscreen = False
        self.screen = pygame.display.set_mode(
            (GAME_WIDTH, GAME_HEIGHT),
            pygame.RESIZABLE)

2. Aspect ratio calculation

The _update_scaling() method compares the window ratio to the game ratio to determine whether vertical (pillarbox) or horizontal (letterbox) bars are needed.

3. Mouse coordinate conversion

Mouse coordinates in the window must be converted to game surface coordinates. The window_to_game() method performs this calculation, accounting for the position and size of the scaled rectangle.

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

4. Fullscreen

F11 toggles between windowed and fullscreen modes. Pygame provides the FULLSCREEN flag which uses the entire display. The scaling calculation is then updated.

5. Display with smoothscale

The present() method uses pygame.transform.smoothscale for smooth rendering. The screen is first filled with black (for the bars), then the scaled game surface is blitted to the centre.

Key takeaways

Tip: always draw to the fixed game surface, never directly to the screen. This simplifies gameplay code.

Common pitfall: forgetting to convert mouse coordinates causes misaligned clicks when the window is resized.

Complete code

python
"""Brick Breaker - Chapter 17: Screen Management"""
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)

# Internal (logical) resolution -- the game always renders to this size
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]

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


# -- 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 management ------------------------------------------------------

class ScreenManager:
    """Manages window, fullscreen toggle, and resolution scaling.

    The game always renders to a fixed-size surface (GAME_WIDTH x GAME_HEIGHT).
    That surface is then scaled to fit the window while keeping the aspect
    ratio. Black bars (letter-boxing / pillar-boxing) fill any extra space.
    """

    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 17")
        self._update_scaling()

    def _update_scaling(self):
        """Recalculate the scaled rect that keeps the aspect ratio."""
        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:
            # Window is wider than game -- pillar-box
            scaled_h = win_h
            scaled_w = int(win_h * game_aspect)
        else:
            # Window is taller than game -- letter-box
            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):
        """Call when a VIDEORESIZE event is received."""
        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):
        """Scale the game surface to the window and flip."""
        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):
        """Convert window coordinates to game-surface coordinates."""
        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)


# -- 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 = GAME_WIDTH // 2
        self.rect.y = GAME_HEIGHT - 40
        self.use_mouse = False

    def update(self, mouse_game_x=None):
        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)

    def reset(self):
        self.float_x = float(GAME_WIDTH // 2)
        self.float_y = float(GAME_HEIGHT // 2)
        self.rect.center = (int(self.float_x), int(self.float_y))
        self.speed = BALL_BASE_SPEED
        angle = math.radians(-60)
        self.vx = self.speed * math.cos(angle)
        self.vy = self.speed * math.sin(angle)

    def update(self, dt):
        self.float_x += self.vx * dt
        self.float_y += self.vy * dt
        self.rect.center = (int(self.float_x), int(self.float_y))

    def bounce_wall(self):
        bounced = False
        wall_direction = None
        if self.float_x - BALL_RADIUS <= 0:
            self.float_x = BALL_RADIUS
            self.vx = abs(self.vx)
            self.speed += BALL_SPEED_INCREMENT
            bounced = True
            wall_direction = "left"
        if self.float_x + BALL_RADIUS >= GAME_WIDTH:
            self.float_x = GAME_WIDTH - BALL_RADIUS
            self.vx = -abs(self.vx)
            self.speed += BALL_SPEED_INCREMENT
            bounced = True
            wall_direction = "right"
        if self.float_y - BALL_RADIUS <= 0:
            self.float_y = BALL_RADIUS
            self.vy = abs(self.vy)
            self.speed += BALL_SPEED_INCREMENT
            bounced = True
            wall_direction = "top"
        if bounced:
            self.rect.center = (int(self.float_x), int(self.float_y))
        return bounced, wall_direction

    def fell_off_bottom(self):
        return self.float_y - BALL_RADIUS > GAME_HEIGHT

    def bounce_off_paddle(self, paddle):
        if self.rect.colliderect(paddle.rect) and self.vy > 0:
            self.float_y = paddle.rect.top - BALL_RADIUS
            hit_pos = (self.float_x - paddle.rect.left) / PADDLE_WIDTH
            angle = 150 - hit_pos * 120
            rad = math.radians(angle)
            self.speed += BALL_SPEED_INCREMENT
            self.vx = self.speed * math.cos(rad)
            self.vy = -abs(self.speed * math.sin(rad))
            self.rect.center = (int(self.float_x), int(self.float_y))
            return True
        return False


class Brick(pygame.sprite.Sprite):
    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.row = row
        self.color = color

    def score_value(self):
        return (BRICK_ROWS - self.row) * 10


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


# -- 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()

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 start_game():
    global lives, score, level, bricks, game_state, shatter_animations
    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()
    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("Screen Management", True, (200, 200, 255))
    surface.blit(subtitle, (GAME_WIDTH // 2 - subtitle.get_width() // 2,
                            GAME_HEIGHT // 3 - 10))
    diff_text = small_font.render(
        "Difficulty: {}".format(DIFFICULTY_NAMES[current_difficulty]),
        True, (180, 180, 180))
    surface.blit(diff_text,
                 (GAME_WIDTH // 2 - diff_text.get_width() // 2,
                  GAME_HEIGHT // 3 + 20))
    fs_hint = small_font.render("[F11] Toggle fullscreen", True,
                                (100, 100, 100))
    surface.blit(fs_hint, (GAME_WIDTH // 2 - fs_hint.get_width() // 2,
                           GAME_HEIGHT - 30))


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))
    current = small_font.render(
        "Current: {}".format(DIFFICULTY_NAMES[current_difficulty]),
        True, (200, 200, 200))
    surface.blit(current, (GAME_WIDTH // 2 - current.get_width() // 2,
                           GAME_HEIGHT // 4 + 20))


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:
        header = hud_font.render("  #   Score    Difficulty", True,
                                 (200, 200, 200))
        surface.blit(header, (GAME_WIDTH // 2 - 140, 110))
        pygame.draw.line(surface, (100, 100, 100),
                         (GAME_WIDTH // 2 - 140, 135),
                         (GAME_WIDTH // 2 + 140, 135))
        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, 145 + 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))
    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)
    surface.blit(vol_surf,
                 (GAME_WIDTH - vol_surf.get_width() - 10, 30))
    # Show window size info
    win_w, win_h = scr_mgr.screen.get_size()
    res_text = "Window: {}x{} | Game: {}x{}{}".format(
        win_w, win_h, GAME_WIDTH, GAME_HEIGHT,
        " [FS]" if scr_mgr.fullscreen else "")
    res_surf = small_font.render(res_text, True, (100, 100, 100))
    surface.blit(res_surf, (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)
    particles.draw(surface)
    draw_hud(surface)


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

running = True
while running:
    dt = clock.tick(FPS) / 1000.0
    # Convert mouse position to game coordinates
    raw_mx, raw_my = pygame.mouse.get_pos()
    mouse_pos = scr_mgr.window_to_game(raw_mx, raw_my)
    mouse_click = False

    # Alias the game surface for drawing
    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
                elif game_state in (STATE_MENU, STATE_DIFFICULTY,
                                    STATE_HIGH_SCORES):
                    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(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]

        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)
            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 (to game surface) --
    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))

    # Scale and present
    scr_mgr.present()

pygame.quit()
sys.exit()

Download

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