Module 13 of 20 Intermediate-Advanced Phase 3 · Advanced Systems

Tile Maps: Grids, Tilesets
& Scrolling Levels

Every classic 2D game — from platformers to Zelda-style dungeons — is built on a grid of reusable tiles instead of hand-placed sprites. In this module you'll represent levels as 2D arrays, slice a tileset image into individual tiles, render huge maps efficiently, import real levels from the Tiled editor, and build scrolling cameras with proper collision layers.

📚
12 Lessons
Grids to collision layers
⏱️
4–5 Hours
Estimated time
📈
Intermediate+
Difficulty level
🧩
Tiled Editor
Primary tool
Section 1

Representing a Level as a Grid

Before you draw a single pixel, a tile-based level is just data: a 2D list of integers, where every integer is a "tile ID" pointing at one image in a tileset. Row and column indices map directly to screen positions once you multiply by a fixed TILE_SIZE (commonly 16, 32, or 64 pixels).

This separation of data from rendering is the single most important idea in this module. The grid never touches Pygame's drawing functions — it's plain Python. That means you can generate levels procedurally, save them to a file, run collision checks against them, and swap tilesets, all without changing your level layout.

level_data.py
TILE_SIZE = 32

# 0 = empty, 1 = wall, 2 = grass, 3 = water
level = [
    [1, 1, 1, 1, 1, 1, 1, 1],
    [1, 0, 0, 0, 0, 0, 0, 1],
    [1, 0, 2, 2, 0, 0, 0, 1],
    [1, 0, 0, 0, 0, 3, 0, 1],
    [1, 1, 1, 1, 1, 1, 1, 1],
]

def tile_at(level, col, row):
    return level[row][col]

def world_to_grid(x, y, tile_size=TILE_SIZE):
    return x // tile_size, y // tile_size
💡
Key insight: Notice the grid is indexed level[row][col], not level[col][row] — rows go top-to-bottom (y-axis), columns go left-to-right (x-axis). Mixing this up is the #1 source of "my map looks rotated" bugs.
Section 2

Loading a Tileset and Slicing It into Tiles

A tileset is a single spritesheet image arranged as a uniform grid of same-sized tiles. To use it, you load the whole image once, then carve it into individual tile surfaces using Surface.subsurface() — a fast operation because it shares pixel memory with the source image instead of copying it (call .copy() if you need an independent surface you can modify later).

Slice the tileset once at load time and store every tile in a list, indexed by row * cols + col — this matches how Tiled and most map formats number tile IDs, which makes importing real map files far simpler later in this module.

tileset.py
import pygame

def load_tileset(path, tile_size=32):
    sheet = pygame.image.load(path).convert_alpha()
    sheet_w, sheet_h = sheet.get_size()
    cols = sheet_w // tile_size
    rows = sheet_h // tile_size

    tiles = []
    for row in range(rows):
        for col in range(cols):
            rect = pygame.Rect(col * tile_size, row * tile_size, tile_size, tile_size)
            tile_image = sheet.subsurface(rect).copy()
            tiles.append(tile_image)
    return tiles  # tiles[0] is the top-left tile of the sheet
🖼️
One Image, Many Tiles
A 256×256px sheet with 32px tiles yields an 8×8 grid — 64 reusable tiles from a single file.
Slice Once, Reuse Forever
Slicing at load time (not every frame) keeps rendering fast — the tile list is built once, then indexed.
🔢
Index = Tile ID
Matching your slicing order to Tiled's numbering means Tiled exports work with zero remapping.
Section 3

Rendering a Tilemap from a 2D Array

Rendering is a nested loop: walk every row and column of the level array, look up the tile image for that ID, and blit it at the pixel position (col * TILE_SIZE, row * TILE_SIZE). Skipping tile ID 0 (empty) avoids drawing anything for open floor space, which also happens to be a free performance win.

render.py
def draw_level(surface, level, tiles, tile_size=32):
    for row_index, row in enumerate(level):
        for col_index, tile_id in enumerate(row):
            if tile_id == 0:
                continue  # 0 = empty, nothing to draw
            x = col_index * tile_size
            y = row_index * tile_size
            surface.blit(tiles[tile_id], (x, y))
🔢
Grid Data
level[row][col]
🔍
Lookup
tiles[tile_id]
📐
Offset
col*size, row*size
🖥️
Draw
surface.blit()
Section 4

The Tiled Map Editor & Importing Real Maps

Tiled is a free, open-source map editor built exactly for this workflow: you paint tiles visually onto a canvas and export the result as JSON or CSV. Hand-typing every level array does not scale past a couple of test rooms — Tiled is how real games build dozens of levels quickly.

A Tiled JSON export stores each layer's tiles as one flat list of tile GIDs rather than a 2D array, plus a width field telling you how many columns to wrap at. Reconstructing your familiar 2D grid takes one line of Python slicing.

load_tiled.py
import json

def load_tiled_json(path):
    with open(path) as f:
        data = json.load(f)

    width = data["width"]
    flat_tiles = data["layers"][0]["data"]  # flat list of tile GIDs

    level = [flat_tiles[i:i + width] for i in range(0, len(flat_tiles), width)]
    return level
FormatStructureBest For
Hand-written Python list2D list literalTiny test levels, tutorials
Tiled CSV exportComma-separated rowsSimple, human-readable diffs
Tiled JSON exportFlat array + metadataMultiple layers, object data, custom properties
pytmx libraryParses .tmx directlyLarge projects wanting less boilerplate
Section 5

Camera-Based Scrolling Maps

Real levels are usually far bigger than the screen. A camera tracks a target (typically the player) and every tile is drawn at world_position - camera_position. Crucially, you should only loop over the rows and columns that are actually visible — culling off-screen tiles is what makes a 500×500 tile map run just as fast as a 20×20 one.

camera_scroll.py
class Camera:
    def __init__(self, width, height):
        self.x = 0
        self.y = 0
        self.width = width
        self.height = height

    def follow(self, target_rect, level_w_px, level_h_px):
        self.x = target_rect.centerx - self.width // 2
        self.y = target_rect.centery - self.height // 2
        self.x = max(0, min(self.x, level_w_px - self.width))
        self.y = max(0, min(self.y, level_h_px - self.height))


def draw_level_scrolled(surface, level, tiles, camera, tile_size=32):
    first_col = camera.x // tile_size
    first_row = camera.y // tile_size
    cols_visible = camera.width // tile_size + 2
    rows_visible = camera.height // tile_size + 2

    for row in range(first_row, min(first_row + rows_visible, len(level))):
        for col in range(first_col, min(first_col + cols_visible, len(level[0]))):
            tile_id = level[row][col]
            if tile_id == 0:
                continue
            x = col * tile_size - camera.x
            y = row * tile_size - camera.y
            surface.blit(tiles[tile_id], (x, y))
Performance tip: Clamping the camera to the level bounds (the max(0, min(...)) lines) stops the view from showing empty space past the map edges — a small detail that makes scrolling levels feel finished.
Section 6

Collision Layers & Loading Levels from CSV

Not every tile ID should block the player. Define a set of "solid" tile IDs — walls, rocks, water — and check the player's bounding box against the tiles it currently overlaps before allowing movement. This is far cheaper than per-pixel collision and scales to enormous maps effortlessly.

Loading levels from an external CSV file (rather than hard-coding them in Python) means level designers can edit maps in a spreadsheet or export straight from Tiled without ever touching your game code.

collision_layer.py
import csv

SOLID_TILES = {1, 4, 5}  # tile IDs that block movement

def load_level_csv(path):
    level = []
    with open(path, newline="") as f:
        reader = csv.reader(f)
        for row in reader:
            level.append([int(value) for value in row])
    return level

def is_solid(level, col, row):
    if row < 0 or row >= len(level) or col < 0 or col >= len(level[0]):
        return True  # treat out-of-bounds as solid
    return level[row][col] in SOLID_TILES

def can_move_to(level, rect, tile_size=32):
    left = rect.left // tile_size
    right = (rect.right - 1) // tile_size
    top = rect.top // tile_size
    bottom = (rect.bottom - 1) // tile_size
    for row in range(top, bottom + 1):
        for col in range(left, right + 1):
            if is_solid(level, col, row):
                return False
    return True
🧱
Collision Layer
A separate logical layer — often the same grid, sometimes a dedicated one — whose only job is answering "is this tile blocking?"
📄
Data-Driven Levels
Loading from CSV/JSON keeps level design decoupled from game logic — new levels ship as data files, not code changes.
Section 7

Visualizing Row/Column Indexing

Every tile's on-screen pixel position comes from multiplying its column and row index by TILE_SIZE. The grid below is a 5×4 example (matching the level_data.py sample) with one tile highlighted to show the math.

col → row ↓ 0 1 2 3 4 0 1 2 3 col 3, row 1 (3×32, 1×32) = (96, 32)
Grid coordinates are indexed level[row][col] — the highlighted tile at column 3, row 1 sits at pixel position (96, 32) once multiplied by a 32px TILE_SIZE.

🧠 Quick Knowledge Check

1. In a level grid accessed as level[row][col], what does the row index correspond to?
2. Why use Surface.subsurface() when slicing a tileset image?
3. What does the Tiled editor export a tile layer as?
4. Why should a scrolling tilemap renderer only loop over visible rows/columns instead of the whole level?
5. In a collision-layer system, what determines whether a tile blocks the player?
Finished Module 13?

Mark it complete to track your progress through the Pygame Mastery course.

🗒 Cheat Sheet 📝 Worksheet