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.
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.
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
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.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.
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
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.
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))
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.
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
| Format | Structure | Best For |
|---|---|---|
| Hand-written Python list | 2D list literal | Tiny test levels, tutorials |
| Tiled CSV export | Comma-separated rows | Simple, human-readable diffs |
| Tiled JSON export | Flat array + metadata | Multiple layers, object data, custom properties |
| pytmx library | Parses .tmx directly | Large projects wanting less boilerplate |
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.
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))
max(0, min(...)) lines) stops the view from showing empty space past the map edges — a small detail that makes scrolling levels feel finished.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.
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
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.
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
level[row][col], what does the row index correspond to?Surface.subsurface() when slicing a tileset image?