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"]

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 and ~28/1000 = 2.8% rendered — a 99%+ work reduction made concrete on screen.
💡 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 chat-44 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!