Code of the Day
IntermediateSprites and Levels

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.

Game DevIntermediate6 min read
By the end of this lesson you will be able to:
  • 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/wall
  • 2 — 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 rects

Build 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.

Finished reading? Mark it complete to track your progress.

On this page