Skip to main content

Tile-based Maps

Building Efficient Tile-based Worlds

Tile-based maps are the foundation of 2D platformer games! Learn how to create, render, and manage tilemap systems, implement collision detection, and build expansive game worlds efficiently! ๐Ÿ—บ๏ธ๐ŸŽฎ๐Ÿ—๏ธ

Understanding Tile-based Systems

๐Ÿงฑ The LEGO Block Analogy

Think of tile-based maps like building with LEGO blocks:

graph TD A["Tile System"] --> B["Tilemap Data"] A --> C["Tileset Graphics"] A --> D["Rendering"] B --> E["2D Array"] B --> F["Tile Properties"] B --> G["Collision Data"] C --> H["Sprite Sheet"] C --> I["Tile Types"] C --> J["Animation Frames"] D --> K["Viewport Culling"] D --> L["Batch Rendering"] D --> M["Layer Ordering"] N["Map Features"] --> O["Multiple Layers"] N --> P["Auto-tiling"] N --> Q["Chunk Loading"]
A nine-column by six-row grid of tiles with column and row indices. The bottom row is green ground, a short amber brick platform sits three rows up, and the rest is blue sky; sample cells are marked with their ID โ€” 0 for sky, 1 for ground, 2 for brick. A dashed amber rectangle marks the camera view over the middle columns and rows, and the tiles outside it are dimmed to show they are culled and never drawn. Formulas on the right convert grid coordinates to pixels: world_x = col ร— TILE, world_y = row ร— TILE, and the inverse col = world_x รท TILE.
A tilemap stores an entire level as a plain 2D array of small integers — one ID per cell — which is tiny to keep in memory and instant to look up. Two multiplications convert any grid cell to its pixel position and back, and because you only draw the tiles that overlap the camera, a 1000×1000 world costs the same to render as a single screen. That’s how sprawling platformer levels stay fast on modest hardware.

Interactive Tilemap Editor Demo

Click to place tiles! Use the tools to build your platformer level!

Map Size: 30x20 | Tiles Placed: 0 | Current Tile: Ground | Mouse: (0, 0)

Tilemap Implementation

import pygame
import json
from typing import Dict, List, Tuple, Optional

class Tile:
    """Individual tile in the tilemap"""
    def __init__(self, tile_id: int, solid: bool = False):
        self.id = tile_id
        self.solid = solid
        self.animated = False
        self.animation_frames = []
        self.current_frame = 0
        self.animation_speed = 0.1
        self.animation_time = 0

class Tileset:
    """Manages tile graphics and properties"""
    def __init__(self, image_path: str, tile_size: int):
        self.image = pygame.image.load(image_path)
        self.tile_size = tile_size
        self.tiles = {}
        
        # Calculate tiles in tileset
        self.tiles_wide = self.image.get_width() // tile_size
        self.tiles_high = self.image.get_height() // tile_size
        
        # Create tile surfaces
        self.create_tile_surfaces()
    
    def create_tile_surfaces(self):
        """Extract individual tile surfaces"""
        for y in range(self.tiles_high):
            for x in range(self.tiles_wide):
                tile_id = y * self.tiles_wide + x
                rect = pygame.Rect(
                    x * self.tile_size,
                    y * self.tile_size,
                    self.tile_size,
                    self.tile_size
                )
                self.tiles[tile_id] = self.image.subsurface(rect)
    
    def get_tile(self, tile_id: int) -> pygame.Surface:
        """Get tile surface by ID"""
        return self.tiles.get(tile_id)

class Tilemap:
    """2D tile-based map"""
    def __init__(self, width: int, height: int, tile_size: int):
        self.width = width
        self.height = height
        self.tile_size = tile_size
        
        # Map layers
        self.layers = {
            'background': [[0 for _ in range(width)] for _ in range(height)],
            'main': [[0 for _ in range(width)] for _ in range(height)],
            'foreground': [[0 for _ in range(width)] for _ in range(height)]
        }
        
        # Collision map
        self.collision_map = [[False for _ in range(width)] for _ in range(height)]
        
        # Tile properties
        self.tile_properties = {}
        
        # Tileset
        self.tileset = None
    
    def set_tileset(self, tileset: Tileset):
        """Set the tileset for rendering"""
        self.tileset = tileset
    
    def set_tile(self, x: int, y: int, tile_id: int, layer: str = 'main'):
        """Set a tile at position"""
        if 0 <= x < self.width and 0 <= y < self.height:
            self.layers[layer][y][x] = tile_id
            
            # Update collision map
            if layer == 'main':
                tile_props = self.tile_properties.get(tile_id, {})
                self.collision_map[y][x] = tile_props.get('solid', False)
    
    def get_tile(self, x: int, y: int, layer: str = 'main') -> int:
        """Get tile at position"""
        if 0 <= x < self.width and 0 <= y < self.height:
            return self.layers[layer][y][x]
        return 0
    
    def is_solid(self, x: int, y: int) -> bool:
        """Check if tile is solid"""
        if 0 <= x < self.width and 0 <= y < self.height:
            return self.collision_map[y][x]
        return True  # Out of bounds is solid
    
    def world_to_tile(self, world_x: float, world_y: float) -> Tuple[int, int]:
        """Convert world coordinates to tile coordinates"""
        tile_x = int(world_x // self.tile_size)
        tile_y = int(world_y // self.tile_size)
        return tile_x, tile_y
    
    def tile_to_world(self, tile_x: int, tile_y: int) -> Tuple[float, float]:
        """Convert tile coordinates to world coordinates"""
        world_x = tile_x * self.tile_size
        world_y = tile_y * self.tile_size
        return world_x, world_y
    
    def get_collision_rect(self, tile_x: int, tile_y: int) -> pygame.Rect:
        """Get collision rectangle for a tile"""
        world_x, world_y = self.tile_to_world(tile_x, tile_y)
        return pygame.Rect(world_x, world_y, self.tile_size, self.tile_size)
    
    def get_surrounding_tiles(self, world_x: float, world_y: float, 
                            radius: int = 1) -> List[Tuple[int, int]]:
        """Get tiles surrounding a world position"""
        center_x, center_y = self.world_to_tile(world_x, world_y)
        tiles = []
        
        for dy in range(-radius, radius + 1):
            for dx in range(-radius, radius + 1):
                tile_x = center_x + dx
                tile_y = center_y + dy
                
                if 0 <= tile_x < self.width and 0 <= tile_y < self.height:
                    tiles.append((tile_x, tile_y))
        
        return tiles
    
    def render(self, screen: pygame.Surface, camera_x: int = 0, camera_y: int = 0):
        """Render tilemap with camera offset"""
        if not self.tileset:
            return
        
        # Calculate visible tile range
        start_x = max(0, camera_x // self.tile_size)
        start_y = max(0, camera_y // self.tile_size)
        end_x = min(self.width, (camera_x + screen.get_width()) // self.tile_size + 2)
        end_y = min(self.height, (camera_y + screen.get_height()) // self.tile_size + 2)
        
        # Render each layer
        for layer_name in ['background', 'main', 'foreground']:
            layer = self.layers[layer_name]
            
            for y in range(start_y, end_y):
                for x in range(start_x, end_x):
                    tile_id = layer[y][x]
                    
                    if tile_id != 0:  # 0 is empty
                        tile_surface = self.tileset.get_tile(tile_id)
                        if tile_surface:
                            screen_x = x * self.tile_size - camera_x
                            screen_y = y * self.tile_size - camera_y
                            screen.blit(tile_surface, (screen_x, screen_y))
    
    def save_to_file(self, filename: str):
        """Save tilemap to JSON file"""
        data = {
            'width': self.width,
            'height': self.height,
            'tile_size': self.tile_size,
            'layers': self.layers,
            'tile_properties': self.tile_properties
        }
        
        with open(filename, 'w') as f:
            json.dump(data, f, indent=2)
    
    def load_from_file(self, filename: str):
        """Load tilemap from JSON file"""
        with open(filename, 'r') as f:
            data = json.load(f)
        
        self.width = data['width']
        self.height = data['height']
        self.tile_size = data['tile_size']
        self.layers = data['layers']
        self.tile_properties = data.get('tile_properties', {})
        
        # Rebuild collision map
        self.rebuild_collision_map()

Best Practices

โšก Tilemap Tips

Key Takeaways

๐Ÿ‹๏ธโ€โ™‚๏ธ Practice Exercise

๐Ÿ‹๏ธโ€โ™‚๏ธ Exercise 1: Tile World Navigator โ€” Coords + Culling + Broad-Phase in One Loop

Objective: Build a runnable pygame mini-platformer that exercises the three pillar tilemap patterns from the lesson โ€” world_to_tile for grid-from-world conversion, viewport culling for rendering, and get_surrounding_tiles as a broad-phase for collision โ€” in one ~80-line program. The world (40ร—25 tiles) is much larger than the screen so the camera scrolls, making both culling and broad-phase pay off visibly. A live HUD reports collision-checked tiles vs total and rendered tiles vs total each frame, turning the abstract optimizations into concrete percentage savings.

Instructions:

  1. Set up a 640ร—480 pygame window with TILE = 24, MAP_W, MAP_H = 40, 25 (so the world is 960ร—600 โ€” wider AND taller than the screen, forcing camera scroll on both axes if you build vertical level features).
  2. Build the world as a 2D Python list tiles[y][x] with three tile types โ€” EMPTY = 0, GROUND = 1, COIN = 2 โ€” and a SOLID = {GROUND} set used by is_solid. Fill the bottom 5 rows with GROUND, add 1โ€“2 floating platforms with GROUND tiles, and scatter ~6 COIN tiles in reachable positions.
  3. Implement world_to_tile(wx, wy) with integer division: return int(wx // TILE), int(wy // TILE). The integer floor is what maps continuous world positions to discrete grid indices (the lesson's Tilemap.world_to_tile pattern verbatim).
  4. Implement is_solid(tx, ty) following the lesson's pattern: in-bounds returns tiles[ty][tx] in SOLID; out-of-bounds returns True so the void surrounding the level acts as an invisible wall, removing the need for a separate bounds check at every collision call site.
  5. Implement surrounding_tiles(wx, wy, radius=1) following the lesson's get_surrounding_tiles pattern: convert (wx, wy) to a center tile via world_to_tile, then return all (cx+dx, cy+dy) for dx, dy in [-radius..radius]. With radius=1 this is 9 tiles โ€” the broad-phase set.
  6. Player movement: arrow keys for horizontal, SPACE for jump; gravity adds to vy each frame. For collision resolution, do TWO passes (X then Y), and for each pass only iterate the 9 surrounding tiles from surrounding_tiles(player_center) โ€” NOT all 1000 tiles in the map. On collision, snap player.right = tile.left (rightward), player.left = tile.right (leftward), player.bottom = tile.top (downward, also sets grounded = True), or player.top = tile.bottom (upward).
  7. Coin collection: walk the same surrounding-tiles set; if any cell is COIN, set tiles[ty][tx] = EMPTY and increment score.
  8. Camera follow: camera_x = clamp(player_x - SCREEN_W/2, 0, MAP_W*TILE - SCREEN_W) so the player stays centered horizontally and the camera doesn't show off-world gray bars at level edges.
  9. Viewport culling in render: derive start_x = max(0, int(camera_x // TILE)) and end_x = min(MAP_W, int((camera_x + SCREEN_W) // TILE) + 2) (the +2 is the off-screen edge buffer that prevents pop-in as the camera pans by sub-tile amounts). Iterate ONLY start_x..end_x, not 0..MAP_W โ€” counting rendered tiles each frame proves the optimization is real.
  10. HUD shows three live numbers per frame: player tile coord (tx, ty) + score; collision-checked tiles count + percentage of total; rendered tiles count + percentage of total. With a 40ร—25 = 1000-tile world, expect ~9/1000 = 0.9% checked โ€” a genuine 99%+ reduction in collision work. Rendering saves less: this cull trims only the X axis (it still walks all 25 rows of the visible ~28-column strip), so the HUD shows roughly ~150/1000 โ‰ˆ 15% rendered, dominated by the 5-row ground floor. A full 2-axis cull that also skipped off-screen rows would cut that toward ~3%.
๐Ÿ’ก Hint

The two-pass collision resolution (move X, resolve X collisions; then move Y, resolve Y collisions) is what avoids the corner-snag bug where a player approaching a corner gets snapped to the wrong axis. Resolving both axes simultaneously from one combined position update would arbitrate ties incorrectly โ€” separating into two passes lets gravity-versus-walk resolutions happen independently. The pattern is identical to M4's reflection-only-when-approaching gate: do one transform, resolve, do the next transform.

โœ… Example Solution
"""Tile World Navigator โ€” world_to_tile + viewport culling + broad-phase collision."""
import pygame, sys

pygame.init()
SCREEN_W, SCREEN_H = 640, 480
TILE = 24
MAP_W, MAP_H = 40, 25
SCREEN = pygame.display.set_mode((SCREEN_W, SCREEN_H))
pygame.display.set_caption("Tile World Navigator")
FONT = pygame.font.SysFont("monospace", 13)
CLOCK = pygame.time.Clock()

EMPTY, GROUND, COIN = 0, 1, 2
SOLID = {GROUND}

# Build the world (40 wide, 25 tall = 1000 tiles)
tiles = [[EMPTY] * MAP_W for _ in range(MAP_H)]
for x in range(MAP_W):                     # ground floor (rows 20-24)
    for y in range(20, 25):
        tiles[y][x] = GROUND
for x in range(8, 14):  tiles[15][x] = GROUND   # floating platform 1
for x in range(20, 27): tiles[12][x] = GROUND   # floating platform 2
for x in range(30, 35): tiles[16][x] = GROUND   # floating platform 3
for (cx, cy) in [(10, 14), (12, 14), (23, 11), (25, 11), (32, 15), (5, 19)]:
    tiles[cy][cx] = COIN

def world_to_tile(wx, wy):
    return int(wx // TILE), int(wy // TILE)

def is_solid(tx, ty):
    if 0 <= tx < MAP_W and 0 <= ty < MAP_H:
        return tiles[ty][tx] in SOLID
    return True  # out-of-bounds is solid (invisible wall around level)

def surrounding_tiles(wx, wy, radius=1):
    cx, cy = world_to_tile(wx, wy)
    return [(cx + dx, cy + dy)
            for dy in range(-radius, radius + 1)
            for dx in range(-radius, radius + 1)]

# Player + camera
P_W = P_H = 18
px, py = 100.0, 400.0
vx, vy = 0.0, 0.0
SPEED, GRAV, JUMP = 240.0, 1400.0, 520.0
grounded = False
camera_x = 0.0
score = 0

while True:
    dt = CLOCK.tick(60) / 1000.0
    for e in pygame.event.get():
        if e.type == pygame.QUIT:
            pygame.quit(); sys.exit()
        if e.type == pygame.KEYDOWN and e.key == pygame.K_SPACE and grounded:
            vy = -JUMP; grounded = False

    keys = pygame.key.get_pressed()
    vx = ((1 if keys[pygame.K_RIGHT] or keys[pygame.K_d] else 0)
        - (1 if keys[pygame.K_LEFT]  or keys[pygame.K_a] else 0)) * SPEED
    vy += GRAV * dt

    # X-axis movement + broad-phase collision
    px += vx * dt
    rect = pygame.Rect(int(px), int(py), P_W, P_H)
    cx_world, cy_world = px + P_W / 2, py + P_H / 2
    for tx, ty in surrounding_tiles(cx_world, cy_world):
        if is_solid(tx, ty):
            tile_rect = pygame.Rect(tx * TILE, ty * TILE, TILE, TILE)
            if rect.colliderect(tile_rect):
                if vx > 0:   rect.right = tile_rect.left
                elif vx < 0: rect.left  = tile_rect.right
                px = float(rect.x); vx = 0

    # Y-axis movement + broad-phase collision
    py += vy * dt
    rect = pygame.Rect(int(px), int(py), P_W, P_H)
    cx_world, cy_world = px + P_W / 2, py + P_H / 2
    grounded = False
    for tx, ty in surrounding_tiles(cx_world, cy_world):
        if is_solid(tx, ty):
            tile_rect = pygame.Rect(tx * TILE, ty * TILE, TILE, TILE)
            if rect.colliderect(tile_rect):
                if vy > 0:
                    rect.bottom = tile_rect.top; grounded = True
                elif vy < 0:
                    rect.top = tile_rect.bottom
                py = float(rect.y); vy = 0

    # Coin pickup (also via broad-phase)
    cx_world, cy_world = px + P_W / 2, py + P_H / 2
    for tx, ty in surrounding_tiles(cx_world, cy_world):
        if 0 <= tx < MAP_W and 0 <= ty < MAP_H and tiles[ty][tx] == COIN:
            prect = pygame.Rect(int(px), int(py), P_W, P_H)
            crect = pygame.Rect(tx * TILE, ty * TILE, TILE, TILE)
            if prect.colliderect(crect):
                tiles[ty][tx] = EMPTY; score += 1

    # Camera follow
    camera_x = max(0, min(MAP_W * TILE - SCREEN_W, px - SCREEN_W / 2))

    # VIEWPORT CULLING โ€” render only tiles in screen window
    SCREEN.fill((135, 206, 235))
    start_x = max(0, int(camera_x // TILE))
    end_x   = min(MAP_W, int((camera_x + SCREEN_W) // TILE) + 2)
    rendered = 0
    for y in range(MAP_H):
        for x in range(start_x, end_x):
            t = tiles[y][x]
            if t == EMPTY: continue
            sx, sy = x * TILE - camera_x, y * TILE
            if t == GROUND:
                pygame.draw.rect(SCREEN, (139, 69, 19), (sx, sy, TILE, TILE))
            elif t == COIN:
                pygame.draw.circle(SCREEN, (255, 215, 0),
                                   (int(sx + TILE / 2), int(sy + TILE / 2)), TILE // 3)
            rendered += 1
    pygame.draw.rect(SCREEN, (240, 200, 80), (px - camera_x, py, P_W, P_H))

    # HUD โ€” make the optimizations concrete
    total = MAP_W * MAP_H
    checked = 9  # surrounding_tiles with radius=1 always returns 9
    ptx, pty = world_to_tile(cx_world, cy_world)
    hud = [
        f"Player tile: ({ptx}, {pty})   Score: {score}",
        f"Checked: {checked:3d} / {total} tiles  ({checked * 100 / total:5.2f}%)",
        f"Rendered: {rendered:3d} / {total} tiles  ({rendered * 100 / total:5.2f}%)",
    ]
    for i, line in enumerate(hud):
        SCREEN.blit(FONT.render(line, True, (0, 0, 0)), (8, 8 + i * 17))
    pygame.display.flip()

๐ŸŽฏ Quick Quiz

Question 1: The lesson's Tilemap.world_to_tile method computes tile_x = int(world_x // self.tile_size) using integer division (//) rather than regular division (/). Why?

Question 2: The lesson's Tilemap.render method culls to a viewport: start_x = max(0, camera_x // tile_size) and end_x = min(width, (camera_x + screen.get_width()) // tile_size + 2), then loops only start_x..end_x. Why is viewport culling done in the rendering loop every frame, instead of once at level load by trimming the tilemap to viewport-sized regions?

Question 3: The lesson's Tilemap.is_solid method ends with return True # Out of bounds is solid. Why return True for tile coordinates outside the tilemap, instead of returning False or raising an exception?

What's Next?

Now that you understand tile-based maps, next we'll implement camera and viewport systems to navigate through your game worlds!