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:
- Tiles: Individual LEGO pieces
- Tilemap: The complete LEGO construction
- Tilesets: Your collection of different pieces
- Grid: The baseplate everything sits on
- Layers: Building multiple levels
- Collision Map: Which blocks are solid
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
- Viewport Culling: Only render visible tiles
- Tile Atlases: Pack all tiles in one texture
- Layer System: Separate visual and collision layers
- Chunk Loading: Load large maps in sections
- Auto-tiling: Automatically select appropriate tile variants
- Tile Properties: Store metadata with tiles
- Compression: Use run-length encoding for storage
- Object Layers: Separate tiles from game objects
Key Takeaways
- 🗺️ Tile-based maps efficiently represent 2D worlds
- 🎨 Tilesets provide reusable graphics
- 📐 Grid systems simplify collision detection
- 📑 Multiple layers add depth and detail
- ⚡ Viewport culling optimizes rendering
- 💾 JSON format enables easy map sharing
- 🔧 Auto-tiling speeds up level creation
- 🎮 Tile properties enable gameplay features
🏋️♂️ 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:
- 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). - Build the world as a 2D Python list
tiles[y][x]with three tile types —EMPTY = 0,GROUND = 1,COIN = 2— and aSOLID = {GROUND}set used byis_solid. Fill the bottom 5 rows with GROUND, add 1–2 floating platforms with GROUND tiles, and scatter ~6 COIN tiles in reachable positions. - 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'sTilemap.world_to_tilepattern verbatim). - Implement
is_solid(tx, ty)following the lesson's pattern: in-bounds returnstiles[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. - Implement
surrounding_tiles(wx, wy, radius=1)following the lesson'sget_surrounding_tilespattern: convert(wx, wy)to a center tile viaworld_to_tile, then return all(cx+dx, cy+dy)fordx, dy in [-radius..radius]. With radius=1 this is 9 tiles — the broad-phase set. - 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, snapplayer.right = tile.left(rightward),player.left = tile.right(leftward),player.bottom = tile.top(downward, also setsgrounded = True), orplayer.top = tile.bottom(upward). - Coin collection: walk the same surrounding-tiles set; if any cell is COIN, set
tiles[ty][tx] = EMPTYand increment score. - 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. - Viewport culling in render: derive
start_x = max(0, int(camera_x // TILE))andend_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 ONLYstart_x..end_x, not0..MAP_W— countingrenderedtiles each frame proves the optimization is real. - 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!