What you will learn

In this chapter we load levels from CSV files. Instead of a code-generated brick field, each level is designed in a text file. The player progresses through multiple levels, and victory comes when all levels are completed.

The concept

A tile map is a grid where each cell represents a world element. In our case, each cell is either empty (0) or a colored brick (1 to 6). The CSV file is easy to edit with a text editor or spreadsheet.

Here is an example level1.csv:

text
1,1,1,1,1,1,1,1,1,1
2,2,2,2,2,2,2,2,2,2
3,3,3,3,3,3,3,3,3,3
4,4,4,4,4,4,4,4,4,4
5,5,5,5,5,5,5,5,5,5
6,6,6,6,6,6,6,6,6,6

The color codes are:

  • 1 = Red
  • 2 = Orange
  • 3 = Yellow
  • 4 = Green
  • 5 = Blue
  • 6 = Purple

Step by step

1. The color dictionary

We define a dictionary that maps each number to an RGB color:

python
BRICK_COLORS = {
    1: (255, 50, 50),     # Red
    2: (255, 165, 0),     # Orange
    3: (255, 255, 50),    # Yellow
    4: (50, 255, 50),     # Green
    5: (50, 150, 255),    # Blue
    6: (180, 50, 255),    # Purple
}

2. Loading a CSV file

The load_level_from_csv() function reads the file line by line. Each non-empty, non-zero cell creates a brick at the right position in the grid.

python
def load_level_from_csv(filepath):
    group = pygame.sprite.Group()
    with open(filepath, newline="") as f:
        reader = csv.reader(f)
        for row_idx, row_data in enumerate(reader):
            for col_idx, cell in enumerate(row_data):
                cell = cell.strip()
                if not cell or cell == "0":
                    continue
                brick_type = int(cell)
                color = BRICK_COLORS[brick_type]
                x = BRICK_OFFSET_X + col_idx * (...)
                y = BRICK_OFFSET_Y + row_idx * (...)
                group.add(Brick(x, y, color, brick_type))
    return group

3. Level progression

When all bricks are destroyed, the advance_level() function loads the next level. If all levels are done, we transition to the YOU_WIN state.

python
def advance_level():
    global current_level, bricks, game_state
    current_level += 1
    if current_level < len(LEVEL_FILES):
        bricks = load_level(current_level)
        ball.reset()
        game_state = STATE_LEVEL_CLEAR
    else:
        game_state = STATE_YOU_WIN

4. Creating your own levels

To create a new level, just create a CSV file and add it to the LEVEL_FILES list. Each line in the CSV represents a row of bricks, and each number is a brick (or 0 for empty space).

Key takeaways

Tip: Python's csv module handles all edge cases (quotes, commas in values). Use it instead of a simple split(',').

Common pitfall: forgetting os.path.abspath when building file paths. If the script is run from a different directory, relative paths will not work.

Possible evolution: replace CSV files with the TMX format (Tiled Map Editor) for more complex levels with multiple layers.

Complete code

python
"""Brick Breaker - Chapter 13: Tile Maps"""
import pygame
import sys
import math
import array
import csv
import os

pygame.init()
pygame.mixer.init(frequency=44100, size=-16, channels=1, buffer=512)

SCREEN_WIDTH = 800
SCREEN_HEIGHT = 600
FPS = 60
BG_COLOR = (20, 20, 40)

PADDLE_WIDTH = 100
PADDLE_HEIGHT = 15
PADDLE_SPEED = 8

BALL_RADIUS = 8
BALL_BASE_SPEED = 300
BALL_SPEED_INCREMENT = 10

BRICK_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_LEVEL_CLEAR = 5

# Brick color map: number -> (R, G, B)
BRICK_COLORS = {
    1: (255, 50, 50),     # Red
    2: (255, 165, 0),     # Orange
    3: (255, 255, 50),    # Yellow
    4: (50, 255, 50),     # Green
    5: (50, 150, 255),    # Blue
    6: (180, 50, 255),    # Purple
}

# Level files (relative to this script)
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
LEVEL_FILES = [
    os.path.join(SCRIPT_DIR, "level1.csv"),
    os.path.join(SCRIPT_DIR, "level2.csv"),
    os.path.join(SCRIPT_DIR, "level3.csv"),
]


# ── 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_level_clear = generate_beep(frequency=660, duration_ms=300, volume=0.4)


# ── Sprite classes ──────────────────────────────────────────────────

class Paddle(pygame.sprite.Sprite):
    def __init__(self):
        super().__init__()
        self.image = pygame.Surface((PADDLE_WIDTH, PADDLE_HEIGHT), pygame.SRCALPHA)
        for y in range(PADDLE_HEIGHT):
            brightness = 200 + int(55 * (1 - y / PADDLE_HEIGHT))
            pygame.draw.line(self.image, (brightness, brightness, brightness),
                             (0, y), (PADDLE_WIDTH, y))
        pygame.draw.rect(self.image, (0, 0, 0, 0), (0, 0, 3, 3))
        pygame.draw.rect(self.image, (0, 0, 0, 0), (PADDLE_WIDTH - 3, 0, 3, 3))
        self.image = self.image.convert_alpha()
        self.rect = self.image.get_rect()
        self.rect.centerx = SCREEN_WIDTH // 2
        self.rect.y = SCREEN_HEIGHT - 40
        self.use_mouse = False

    def update(self):
        if self.use_mouse:
            mx, _ = pygame.mouse.get_pos()
            self.rect.centerx = mx
        else:
            keys = pygame.key.get_pressed()
            if keys[pygame.K_LEFT] or keys[pygame.K_a]:
                self.rect.x -= PADDLE_SPEED
            if keys[pygame.K_RIGHT] or keys[pygame.K_d]:
                self.rect.x += PADDLE_SPEED
        self.rect.left = max(0, self.rect.left)
        self.rect.right = min(SCREEN_WIDTH, self.rect.right)


class Ball(pygame.sprite.Sprite):
    def __init__(self):
        super().__init__()
        size = BALL_RADIUS * 4
        self.image = pygame.Surface((size, size), pygame.SRCALPHA)
        center = size // 2
        for r in range(BALL_RADIUS * 2, BALL_RADIUS, -1):
            alpha = int(100 * (1 - (r - BALL_RADIUS) / BALL_RADIUS))
            pygame.draw.circle(self.image, (100, 100, 255, alpha), (center, center), r)
        pygame.draw.circle(self.image, (255, 255, 255), (center, center), BALL_RADIUS)
        pygame.draw.circle(self.image, (255, 255, 255, 200),
                           (center - 2, center - 2), BALL_RADIUS // 3)
        self.image = self.image.convert_alpha()
        self.rect = self.image.get_rect()
        self.float_x = float(SCREEN_WIDTH // 2)
        self.float_y = float(SCREEN_HEIGHT // 2)
        self.rect.center = (int(self.float_x), int(self.float_y))
        self.speed = BALL_BASE_SPEED
        angle = math.radians(-60)
        self.vx = self.speed * math.cos(angle)
        self.vy = self.speed * math.sin(angle)

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

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

    def bounce_wall(self):
        bounced = False
        if self.float_x - BALL_RADIUS <= 0:
            self.float_x = BALL_RADIUS
            self.vx = abs(self.vx)
            self.speed += BALL_SPEED_INCREMENT
            bounced = True
        if self.float_x + BALL_RADIUS >= SCREEN_WIDTH:
            self.float_x = SCREEN_WIDTH - BALL_RADIUS
            self.vx = -abs(self.vx)
            self.speed += BALL_SPEED_INCREMENT
            bounced = True
        if self.float_y - BALL_RADIUS <= 0:
            self.float_y = BALL_RADIUS
            self.vy = abs(self.vy)
            self.speed += BALL_SPEED_INCREMENT
            bounced = True
        if bounced:
            self.rect.center = (int(self.float_x), int(self.float_y))
        return bounced

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

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


class Brick(pygame.sprite.Sprite):
    def __init__(self, x, y, color, row):
        super().__init__()
        self.image = pygame.Surface((BRICK_WIDTH, BRICK_HEIGHT), pygame.SRCALPHA)
        pygame.draw.rect(self.image, color, (0, 0, BRICK_WIDTH, BRICK_HEIGHT))
        highlight = tuple(min(255, c + 50) for c in color)
        pygame.draw.line(self.image, highlight, (1, 1), (BRICK_WIDTH - 2, 1))
        pygame.draw.line(self.image, highlight, (1, 1), (1, BRICK_HEIGHT - 2))
        shadow = tuple(max(0, c - 50) for c in color)
        pygame.draw.line(self.image, shadow, (1, BRICK_HEIGHT - 1),
                         (BRICK_WIDTH - 1, BRICK_HEIGHT - 1))
        pygame.draw.line(self.image, shadow, (BRICK_WIDTH - 1, 1),
                         (BRICK_WIDTH - 1, BRICK_HEIGHT - 1))
        self.image = self.image.convert_alpha()
        self.rect = self.image.get_rect(topleft=(x, y))
        self.row = row
        self.color = color

    def score_value(self):
        return (7 - min(self.row, 6)) * 10


# ── Level loading from CSV ──────────────────────────────────────────

def load_level_from_csv(filepath):
    """Load a brick layout from a CSV file.

    Each cell contains a number:
      0 = empty
      1-6 = brick color
    Returns a pygame.sprite.Group.
    """
    group = pygame.sprite.Group()
    if not os.path.exists(filepath):
        return group

    with open(filepath, newline="") as f:
        reader = csv.reader(f)
        for row_idx, row_data in enumerate(reader):
            for col_idx, cell in enumerate(row_data):
                cell = cell.strip()
                if not cell or cell == "0":
                    continue
                brick_type = int(cell)
                if brick_type not in BRICK_COLORS:
                    continue
                color = BRICK_COLORS[brick_type]
                x = BRICK_OFFSET_X + col_idx * (BRICK_WIDTH + BRICK_PADDING)
                y = BRICK_OFFSET_Y + row_idx * (BRICK_HEIGHT + BRICK_PADDING)
                group.add(Brick(x, y, color, brick_type))
    return group


# ── Game setup ──────────────────────────────────────────────────────

screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
pygame.display.set_caption("Brick Breaker - Chapter 13")
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)

paddle = Paddle()
ball = Ball()
bricks = pygame.sprite.Group()

paddle_group = pygame.sprite.GroupSingle(paddle)
ball_group = pygame.sprite.GroupSingle(ball)

lives = MAX_LIVES
score = 0
current_level = 0
game_state = STATE_MENU
master_volume = 0.5
sound_muted = False
menu_blink_timer = 0.0
level_clear_timer = 0.0
LEVEL_CLEAR_DELAY = 2.0


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


def load_level(level_index):
    """Load a level by index. Returns the brick group."""
    if level_index < len(LEVEL_FILES):
        return load_level_from_csv(LEVEL_FILES[level_index])
    else:
        return pygame.sprite.Group()


def start_game():
    global lives, score, current_level, bricks, game_state
    lives = MAX_LIVES
    score = 0
    current_level = 0
    bricks = load_level(current_level)
    ball.reset()
    game_state = STATE_PLAYING


def advance_level():
    """Move to next level or win the game."""
    global current_level, bricks, game_state, level_clear_timer
    current_level += 1
    if current_level < len(LEVEL_FILES):
        bricks = load_level(current_level)
        ball.reset()
        game_state = STATE_LEVEL_CLEAR
        level_clear_timer = 0.0
        play_sound(snd_level_clear)
    else:
        game_state = STATE_YOU_WIN
        play_sound(snd_win)


# ── Draw helpers ────────────────────────────────────────────────────

def draw_menu():
    global menu_blink_timer
    menu_blink_timer += clock.get_time() / 1000.0
    screen.fill(BG_COLOR)

    title = big_font.render("BRICK BREAKER", True, (255, 200, 50))
    screen.blit(title, (SCREEN_WIDTH // 2 - title.get_width() // 2,
                        SCREEN_HEIGHT // 3 - 30))

    subtitle = medium_font.render("Tile Map Edition", True, (200, 200, 255))
    screen.blit(subtitle, (SCREEN_WIDTH // 2 - subtitle.get_width() // 2,
                           SCREEN_HEIGHT // 3 + 30))

    if int(menu_blink_timer * 2) % 2 == 0:
        start_text = medium_font.render("Press SPACE to start", True, (255, 255, 255))
        screen.blit(start_text, (SCREEN_WIDTH // 2 - start_text.get_width() // 2,
                                 SCREEN_HEIGHT // 2 + 20))

    levels_info = small_font.render(
        "{} levels loaded from CSV files".format(len(LEVEL_FILES)),
        True, (150, 200, 150))
    screen.blit(levels_info, (SCREEN_WIDTH // 2 - levels_info.get_width() // 2,
                              SCREEN_HEIGHT // 2 + 60))


def draw_hud():
    score_surf = hud_font.render("Score: {}".format(score), True, (255, 255, 100))
    screen.blit(score_surf, (10, 10))
    lives_surf = hud_font.render("Lives: {}".format(lives), True, (255, 100, 100))
    screen.blit(lives_surf, (SCREEN_WIDTH // 2 - lives_surf.get_width() // 2, 10))
    level_surf = hud_font.render(
        "Level: {}/{}".format(current_level + 1, len(LEVEL_FILES)),
        True, (100, 200, 255))
    screen.blit(level_surf, (SCREEN_WIDTH - level_surf.get_width() - 10, 10))
    vol_label = "MUTED" if sound_muted else "Vol: {}".format(int(master_volume * 100))
    vol_color = (255, 80, 80) if sound_muted else (150, 150, 150)
    vol_surf = small_font.render(vol_label, True, vol_color)
    screen.blit(vol_surf, (SCREEN_WIDTH - vol_surf.get_width() - 10, 30))


def draw_overlay(title_text, title_color, sub_text="Press SPACE for menu"):
    overlay = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT), pygame.SRCALPHA)
    overlay.fill((0, 0, 0, 150))
    screen.blit(overlay, (0, 0))
    t = big_font.render(title_text, True, title_color)
    screen.blit(t, (SCREEN_WIDTH // 2 - t.get_width() // 2, SCREEN_HEIGHT // 2 - 40))
    fs = medium_font.render("Score: {}".format(score), True, (255, 255, 255))
    screen.blit(fs, (SCREEN_WIDTH // 2 - fs.get_width() // 2, SCREEN_HEIGHT // 2 + 20))
    r = medium_font.render(sub_text, True, (200, 200, 200))
    screen.blit(r, (SCREEN_WIDTH // 2 - r.get_width() // 2, SCREEN_HEIGHT // 2 + 60))


# ── Main loop ───────────────────────────────────────────────────────

running = True
while running:
    dt = clock.tick(FPS) / 1000.0

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        if event.type == pygame.KEYDOWN:
            if event.key == pygame.K_ESCAPE:
                running = False
            if event.key == pygame.K_F1:
                sound_muted = not sound_muted
            if event.key in (pygame.K_PLUS, pygame.K_KP_PLUS, pygame.K_EQUALS):
                master_volume = min(1.0, master_volume + 0.1)
            if event.key in (pygame.K_MINUS, pygame.K_KP_MINUS):
                master_volume = max(0.0, master_volume - 0.1)

            if game_state == STATE_MENU:
                if event.key == pygame.K_SPACE:
                    start_game()
            elif game_state == STATE_PLAYING:
                if event.key == pygame.K_m:
                    paddle.use_mouse = not paddle.use_mouse
                if event.key == pygame.K_p:
                    game_state = STATE_PAUSED
            elif game_state == STATE_PAUSED:
                if event.key == pygame.K_p:
                    game_state = STATE_PLAYING
            elif game_state in (STATE_GAME_OVER, STATE_YOU_WIN):
                if event.key == pygame.K_SPACE:
                    game_state = STATE_MENU

    # ── Update ──────────────────────────────────────────────────
    if game_state == STATE_PLAYING:
        paddle_group.update()
        ball.update(dt)

        if ball.bounce_wall():
            play_sound(snd_wall)

        if ball.fell_off_bottom():
            lives -= 1
            if lives <= 0:
                game_state = STATE_GAME_OVER
                play_sound(snd_game_over)
            else:
                play_sound(snd_lose_life)
                ball.reset()

        if ball.bounce_off_paddle(paddle):
            play_sound(snd_paddle)

        hit_bricks = pygame.sprite.spritecollide(ball, bricks, True)
        if hit_bricks:
            for brick in hit_bricks:
                score += brick.score_value()
            ball.vy = -ball.vy
            ball.speed += BALL_SPEED_INCREMENT
            play_sound(snd_brick)

        if len(bricks) == 0:
            advance_level()

    elif game_state == STATE_LEVEL_CLEAR:
        level_clear_timer += dt
        if level_clear_timer >= LEVEL_CLEAR_DELAY:
            game_state = STATE_PLAYING

    # ── Draw ────────────────────────────────────────────────────
    if game_state == STATE_MENU:
        draw_menu()

    elif game_state == STATE_PLAYING:
        screen.fill(BG_COLOR)
        bricks.draw(screen)
        paddle_group.draw(screen)
        ball_group.draw(screen)
        draw_hud()
        info = small_font.render(
            "[M] Mouse  [P] Pause  [F1] Mute  [+/-] Volume  [ESC] Quit",
            True, (150, 150, 150))
        screen.blit(info, (10, SCREEN_HEIGHT - 25))

    elif game_state == STATE_PAUSED:
        screen.fill(BG_COLOR)
        bricks.draw(screen)
        paddle_group.draw(screen)
        ball_group.draw(screen)
        draw_hud()
        draw_overlay("PAUSED", (200, 200, 255), "Press P to resume")

    elif game_state == STATE_LEVEL_CLEAR:
        screen.fill(BG_COLOR)
        bricks.draw(screen)
        paddle_group.draw(screen)
        ball_group.draw(screen)
        draw_hud()
        draw_overlay(
            "LEVEL {} CLEAR!".format(current_level),
            (255, 255, 100),
            "Next level starting..."
        )

    elif game_state == STATE_GAME_OVER:
        screen.fill(BG_COLOR)
        bricks.draw(screen)
        paddle_group.draw(screen)
        ball_group.draw(screen)
        draw_hud()
        draw_overlay("GAME OVER", (255, 60, 60))

    elif game_state == STATE_YOU_WIN:
        screen.fill(BG_COLOR)
        paddle_group.draw(screen)
        ball_group.draw(screen)
        draw_hud()
        draw_overlay("ALL LEVELS CLEAR!", (100, 255, 100))

    pygame.display.flip()

pygame.quit()
sys.exit()

Download

Download the source code for this chapter (includes 3 CSV level files): brick-breaker-ch13.zip