Tile maps
Represent levels as a 2D grid of tile IDs, render them by iterating the grid, and separate the collision layer from the visual layer.
- Define a tile-based level as a 2D list of integers
- Render a tile map by iterating the grid and blitting each tile
- Explain how a collision layer indicates which tiles are solid
Placing individual obstacle rects by hand works for a room with a few walls. For a full level — floors, ceilings, platforms, hazards, decorations — you need a better representation. Tile maps use a 2D grid of integer IDs to describe the level layout. Each ID maps to a tile type, and the renderer blits the corresponding image (or draws a coloured rect as a placeholder).
The grid
A tile map is a list of lists, where each integer is a tile ID:
TILE_SIZE = 40
TILEMAP = [
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
[1, 0, 0, 1, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0, 0, 1],
[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
[1, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 2, 0, 0, 1],
[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
]The ID convention is arbitrary — pick one and stick to it:
0— empty air (no tile)1— solid ground/wall2— hazard (spike, lava)
The number of rows times TILE_SIZE gives the world height in pixels. The
number of columns times TILE_SIZE gives the world width.
Rendering the map
Iterate the grid and draw each non-empty tile at its grid position:
TILE_COLORS = {
1: (120, 100, 80), # brown ground tile
2: (220, 60, 60), # red hazard tile
}
def draw_tilemap(surface, tilemap, camera_x=0, camera_y=0):
for row_idx, row in enumerate(tilemap):
for col_idx, tile_id in enumerate(row):
if tile_id == 0:
continue # empty — nothing to draw
color = TILE_COLORS.get(tile_id, (200, 200, 200))
rect = pygame.Rect(
col_idx * TILE_SIZE - camera_x,
row_idx * TILE_SIZE - camera_y,
TILE_SIZE,
TILE_SIZE,
)
pygame.draw.rect(surface, color, rect)The camera_x and camera_y offsets apply the camera pattern from the
previous module. Every tile is drawn relative to the camera, not the world
origin.
The collision layer
Not every tile needs to be solid. Decorative background tiles have no collision; floor tiles do. You can encode this in several ways:
Single grid with a solid-ID set:
SOLID_TILES = {1} # only ID 1 is solid
def get_solid_rects(tilemap):
rects = []
for row_idx, row in enumerate(tilemap):
for col_idx, tile_id in enumerate(row):
if tile_id in SOLID_TILES:
rects.append(pygame.Rect(
col_idx * TILE_SIZE,
row_idx * TILE_SIZE,
TILE_SIZE, TILE_SIZE))
return rectsBuild this list once at level load time, not every frame. Store the solid rects in a list and use them for player collision tests each frame.
Two separate grids — one for visual tiles (all decorative and solid), one for the collision layer (only solid tiles). This is common in level editors that export separate layers.
A 16x8 grid at 40x40 tiles gives a 640x320 world — small enough to prototype quickly. Scale up the grid or tile size when you need a larger level. The rendering and collision code does not change.
Where to go next
Next: the Sprites and Levels lab — combine a tile map, animated player sprite, platform collision, and a scrolling camera into a playable platformer level with a score/lives HUD.
Animated sprites
Implement a frame-cycling Sprite subclass using solid-colour rectangles as placeholder frames, with animation speed controlled by a frame counter.
Lab: Tile platformer
Build a scrolling tile-based level with an animated player sprite, platform collision, camera follow, and a score/lives HUD.