Lab: Tile platformer
Build a scrolling tile-based level with an animated player sprite, platform collision, camera follow, and a score/lives HUD.
- Render a tile map and build the solid-rect collision list at load time
- Implement platform collision so the player can stand on tile rows
- Add an animated player sprite using placeholder coloured frames
- Apply a camera offset so the level scrolls as the player moves right
- Display a score and lives counter in a HUD
This lab combines the tile map, sprite animation, and camera lessons into a
small but complete scrolling platformer. The level is wider than the screen,
the player has an idle/run animation, and coin tiles increment the score when
touched. No external assets are needed — everything is drawn with coloured
rectangles and pygame.draw.
Pyodide (the in-browser Python runner) does not support pygame's display
system. Work through this lab locally: pip install pygame then run
python platformer.py after each checkpoint.
The complete game
Save this as platformer.py and run it. The checkpoints below explain each
section so you can extend it confidently.
import pygame
import sys
pygame.init()
SCREEN_W, SCREEN_H = 640, 480
TILE_SIZE = 40
screen = pygame.display.set_mode((SCREEN_W, SCREEN_H))
pygame.display.set_caption("Tile platformer")
clock = pygame.time.Clock()
font = pygame.font.SysFont(None, 30)
# ── Tile IDs ──────────────────────────────────────────────────────────────────
AIR = 0
GROUND = 1
COIN = 2
TILE_COLORS = {
GROUND: (120, 100, 80),
COIN: (255, 210, 50),
}
SOLID_TILES = {GROUND}
# ── Level definition ──────────────────────────────────────────────────────────
# 20 columns × 12 rows = 800×480 world (wider than the 640px window)
TILEMAP = [
[1,1,1,1,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,0,0,0,0,1],
[1,0,0,2,0,0,0,2,0,0,0,2,0,0,0,2,0,0,0,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,0,0,1,1,0,0,0,1,1,0,0,0,1,1,0,0,0,0,1],
[1,0,0,0,0,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,0,0,0,0,1],
[1,1,0,0,0,1,1,0,0,0,1,1,0,0,0,1,1,0,0,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,0,2,0,0,0,2,0,0,0,2,0,0,0,2,0,0,0,2,1],
[1,0,0,0,0,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,1,1,1,1],
]
WORLD_W = len(TILEMAP[0]) * TILE_SIZE # 800
WORLD_H = len(TILEMAP) * TILE_SIZE # 480
# ── Build collision and coin rects at load time ───────────────────────────────
solid_rects = []
coin_rects = {} # rect → (row, col) for removal
for row_idx, row in enumerate(TILEMAP):
for col_idx, tile_id in enumerate(row):
r = pygame.Rect(col_idx * TILE_SIZE, row_idx * TILE_SIZE,
TILE_SIZE, TILE_SIZE)
if tile_id in SOLID_TILES:
solid_rects.append(r)
elif tile_id == COIN:
coin_rects[r] = (row_idx, col_idx)
# ── Animated player sprite ────────────────────────────────────────────────────
def make_frames(colors):
frames = []
for c in colors:
s = pygame.Surface((32, 40))
s.fill(c)
frames.append(s)
return frames
ANIMATIONS = {
"idle": make_frames([(80, 180, 90), (100, 210, 110)]),
"run": make_frames([(60,160,70),(100,210,110),(140,230,140),(100,210,110)]),
"jump": make_frames([(160, 230, 170)]),
}
class Player(pygame.sprite.Sprite):
SPEED = 4
GRAVITY = 0.5
JUMP_VEL = -10
def __init__(self, x, y, *groups):
super().__init__(*groups)
self.state = "idle"
self.frame_index = 0.0
self.image = ANIMATIONS["idle"][0]
self.rect = self.image.get_rect(topleft=(x, y))
self.vy = 0
self.on_ground = False
def _set_state(self, s):
if s != self.state:
self.state = s
self.frame_index = 0.0
def update(self, keys, solids):
# Horizontal movement
dx = 0
if keys[pygame.K_LEFT]: dx = -self.SPEED
if keys[pygame.K_RIGHT]: dx = self.SPEED
self.rect.x += dx
for solid in solids:
if self.rect.colliderect(solid):
if dx > 0: self.rect.right = solid.left
if dx < 0: self.rect.left = solid.right
# Vertical movement with gravity
self.vy += self.GRAVITY
self.rect.y += int(self.vy)
self.on_ground = False
for solid in solids:
if self.rect.colliderect(solid):
if self.vy > 0:
self.rect.bottom = solid.top
self.on_ground = True
elif self.vy < 0:
self.rect.top = solid.bottom
self.vy = 0
# Jump
if keys[pygame.K_SPACE] and self.on_ground:
self.vy = self.JUMP_VEL
# Clamp to world
self.rect.clamp_ip(pygame.Rect(0, 0, WORLD_W, WORLD_H))
# Animation state
if not self.on_ground:
self._set_state("jump")
elif dx != 0:
self._set_state("run")
else:
self._set_state("idle")
frames = ANIMATIONS[self.state]
self.frame_index = (self.frame_index + 0.15) % len(frames)
self.image = frames[int(self.frame_index)]
# ── Game state ────────────────────────────────────────────────────────────────
all_sprites = pygame.sprite.Group()
player = Player(TILE_SIZE, WORLD_H - TILE_SIZE * 2, all_sprites)
score = 0
lives = 3
camera_x = 0
# ── Helpers ───────────────────────────────────────────────────────────────────
def update_camera():
global camera_x
target = player.rect.centerx - SCREEN_W // 2
camera_x = max(0, min(target, WORLD_W - SCREEN_W))
def draw_tilemap(surface):
for row_idx, row in enumerate(TILEMAP):
for col_idx, tile_id in enumerate(row):
if tile_id == AIR:
continue
color = TILE_COLORS.get(tile_id, (200, 200, 200))
rect = pygame.Rect(
col_idx * TILE_SIZE - camera_x,
row_idx * TILE_SIZE,
TILE_SIZE, TILE_SIZE,
)
pygame.draw.rect(surface, color, rect)
def draw_hud(surface):
surf = font.render(
f"Score: {score} Lives: {lives}", True, (255, 255, 255))
surface.blit(surf, (10, 10))
# ── Main loop ─────────────────────────────────────────────────────────────────
running = True
while running:
events = pygame.event.get()
for event in events:
if event.type == pygame.QUIT:
running = False
keys = pygame.key.get_pressed()
player.update(keys, solid_rects)
# Coin collection
collected = [r for r in list(coin_rects) if player.rect.colliderect(r)]
for r in collected:
row_idx, col_idx = coin_rects.pop(r)
TILEMAP[row_idx][col_idx] = AIR
score += 1
update_camera()
screen.fill((30, 30, 50))
draw_tilemap(screen)
# Draw player with camera offset applied
draw_rect = player.rect.move(-camera_x, 0)
screen.blit(player.image, draw_rect)
draw_hud(screen)
pygame.display.flip()
clock.tick(60)
pygame.quit()
sys.exit()Checkpoint walkthrough
Checkpoint 1 — Tile map and collision list. The TILEMAP grid and the
solid_rects/coin_rects builds happen before the game loop. Building once
at load time is much faster than rebuilding every frame.
Checkpoint 2 — Platform collision. Player.update() handles horizontal
and vertical movement separately. Horizontal first: move, then push out of any
solid rect. Vertical second: apply gravity, move, then resolve. This order
prevents diagonal corner-catching.
Checkpoint 3 — Animation state. The player picks "jump", "run", or
"idle" based on physics state and input. _set_state() resets frame_index
only on a state change.
Checkpoint 4 — Camera. update_camera() centres on the player and clamps
to the world. The tile map draw uses camera_x; the player sprite is blitted
using player.rect.move(-camera_x, 0). Everything is consistent.
Checkpoint 5 — Coin collection. Coin rects are stored in a dict keyed by
pygame.Rect. On collision, the coin is removed from the dict and the
TILEMAP cell is cleared so the tile no longer renders.
The level is 800 pixels wide and the window is 640 pixels. Walk right and
watch the camera follow. When the player is near the left or right world edge
the camera stops, showing the border wall tiles. This is the clamping from
camera-and-viewport in action.
What you have now
This game demonstrates every concept from the Sprites and Levels module in a single working program:
- A tile map defining the level as a data structure, not hand-placed rects.
- Efficient solid-rect collision built once at load time.
- A
pygame.sprite.Spritesubclass with frame-cycling animation. - A camera that follows the player through a scrolling world.
- A live HUD showing score and lives.
From here you can add enemies (Sprite subclass, their own group), additional tile types (moving platforms, doors), and more animation states — the architecture supports all of it without restructuring.