Code of the Day
IntermediateSprites and Levels

Lab: Tile platformer

Build a scrolling tile-based level with an animated player sprite, platform collision, camera follow, and a score/lives HUD.

Lab · optionalGame DevIntermediate40 min
Recommended first
By the end of this lesson you will be able to:
  • 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.Sprite subclass 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.

Finished reading? Mark it complete to track your progress.

On this page