Scenes, Audio, and Tilemaps

Pygame Game Development Course

Felipe A. Moreno-Vera

πŸ—ΊοΈ Tilemaps & Scrolling

What is a Tilemap?

A tilemap is a 2D grid where each cell holds a small integer (tile ID) that maps to a tile image. This is how almost every 2D game stores its levels β€” from Super Mario Bros to modern indie games.

What is a Tilemap?

A tilemap is a 2D grid where each cell holds a small integer (tile ID) that maps to a tile image. This is how almost every 2D game stores its levels β€” from Super Mario Bros to modern indie games.

Level data (2D list of ints):     Rendered result:
0 = air   1 = grass   2 = stone

 0  0  0  0  0  0  0  0            β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘
 0  0  0  1  1  0  0  0            β–‘β–‘β–‘πŸŒΏπŸŒΏβ–‘β–‘β–‘
 0  0  0  0  0  0  0  0            β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘
 1  1  1  2  2  1  1  1            🌿🌿🌿πŸͺ¨πŸͺ¨πŸŒΏπŸŒΏπŸŒΏ
 2  2  2  2  2  2  2  2            πŸͺ¨πŸͺ¨πŸͺ¨πŸͺ¨πŸͺ¨πŸͺ¨πŸͺ¨πŸͺ¨

What is a Tilemap?

A tilemap is a 2D grid where each cell holds a small integer (tile ID) that maps to a tile image. This is how almost every 2D game stores its levels β€” from Super Mario Bros to modern indie games.

Level data (2D list of ints):     Rendered result:
0 = air   1 = grass   2 = stone

 0  0  0  0  0  0  0  0            β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘
 0  0  0  1  1  0  0  0            β–‘β–‘β–‘πŸŒΏπŸŒΏβ–‘β–‘β–‘
 0  0  0  0  0  0  0  0            β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘
 1  1  1  2  2  1  1  1            🌿🌿🌿πŸͺ¨πŸͺ¨πŸŒΏπŸŒΏπŸŒΏ
 2  2  2  2  2  2  2  2            πŸͺ¨πŸͺ¨πŸͺ¨πŸͺ¨πŸͺ¨πŸͺ¨πŸͺ¨πŸͺ¨

Benefits of tilemaps: - Levels are compact β€” a 100Γ—50 map is just 5,000 integers - Easy to serialize (save/load levels as text files) - Efficient rendering β€” draw only visible tiles (culling) - Reusable assets β€” 10 tile images can build infinite levels

TileMap Class

class TileMap:
    def __init__(self, level_data, tile_size):
        self.data      = level_data
        self.tile_size = tile_size
        self.rows      = len(data)
        self.cols      = len(data[0])

    def get_tile(self, col, row):
        if 0 <= row < self.rows and 0 <= col < self.cols:
            return self.data[row][col]
        return -1   # Out of bounds

    def set_tile(self, col, row, tile_id):
        if 0 <= row < self.rows and 0 <= col < self.cols:
            self.data[row][col] = tile_id

    def world_to_tile(self, world_x, world_y):
        return int(world_x // self.tile_size), int(world_y // self.tile_size)

    def draw(self, surface, camera_x, camera_y):
        ts = self.tile_size

        # CULLING: only draw tiles visible in the viewport
        col_start = max(0, int(camera_x // ts))
        col_end   = min(self.cols, int((camera_x + SCREEN_W) // ts) + 1)
        row_start = max(0, int(camera_y // ts))
        row_end   = min(self.rows, int((camera_y + SCREEN_H) // ts) + 1)

        for row in range(row_start, row_end):
            for col in range(col_start, col_end):
                tile_id  = self.data[row][col]
                if tile_id == 0: continue
                screen_x = col * ts - camera_x
                screen_y = row * ts - camera_y
                surface.blit(tile_images[tile_id], (screen_x, screen_y))
Culling savings β€” tiles skipped vs drawn each frame
============================================================
  Map size         Total    Visible    Skipped    Saved
  --------------------------------------------------------
  Small (20Γ—15)    300      300        0          0%
  Medium (50Γ—30)   1500     408        1092       73%
  Large (100Γ—50)   5000     408        4592       92%
  Huge (200Γ—80)    16000    408        15592      97%

The Camera

The camera represents the viewport into the world. Everything is drawn in world space and converted to screen space for rendering.

class Camera:
    def __init__(self, view_w, view_h):
        self.x    = 0.0   # Camera top-left in world space
        self.y    = 0.0
        self.zoom = 1.0

    def world_to_screen(self, wx, wy):
        """What screen pixel does this world coordinate map to?"""
        sx = (wx - self.x) * self.zoom
        sy = (wy - self.y) * self.zoom
        return int(sx), int(sy)

    def screen_to_world(self, sx, sy):
        """What world coordinate is under this screen pixel?"""
        wx = sx / self.zoom + self.x
        wy = sy / self.zoom + self.y
        return wx, wy

    def follow(self, target_x, target_y, dt, world_w, world_h):
        """Smooth follow with lerp."""
        desired_x = target_x - self.view_w / (2 * self.zoom)
        desired_y = target_y - self.view_h / (2 * self.zoom)
        self.x += (desired_x - self.x) * 6.0 * dt  # Lerp
        # Clamp to world bounds
        self.x = max(0, min(self.x, world_w - self.view_w / self.zoom))
        self.y = max(0, min(self.y, world_h - self.view_h / self.zoom))

Camera Dead Zone

A dead zone is a region around the screen center where the camera doesn’t move. The player can walk freely within the dead zone; the camera only moves when the player hits its edge.

Screen:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                                  β”‚
β”‚      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”            β”‚
β”‚      β”‚  DEAD ZONE   β”‚            β”‚
β”‚      β”‚   Player ●   β”‚ ← camera   β”‚
β”‚      β”‚  moves here  β”‚   doesn't  β”‚
β”‚      β”‚  freely      β”‚   move     β”‚
β”‚      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜            β”‚
β”‚                                  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Camera Shake

def shake(self, strength=8.0, duration=0.4):
    self._shake_strength = strength
    self._shake_timer    = duration

def update_shake(self, dt):
    if self._shake_timer > 0:
        self._shake_timer -= dt
        pct = self._shake_timer / 0.4
        self._shake_offset = (
            random.uniform(-1, 1) * self._shake_strength * pct,
            random.uniform(-1, 1) * self._shake_strength * pct,
        )
    else:
        self._shake_offset = (0, 0)

# Apply offset in world_to_screen():
sx = (wx - self.x) * self.zoom + self._shake_offset[0]

Parallax Scrolling

Parallax makes the world feel deep by scrolling different background layers at different speeds.

Layer           Speed (% of camera)   Perceived depth
─────           ───────────────────   ───────────────
Stars              5%                   Very far away
Distant mountains  15%                  Far
Near mountains     25%                  Medium
Clouds            30–50%                Near background
class ParallaxLayer:
    """
    factor = 0.0 β†’ stationary (sky)
    factor = 1.0 β†’ moves with the world (foreground)
    """
    def __init__(self, factor):
        self.factor = factor

    def draw(self, surface, camera_x):
        offset_x = int(camera_x * self.factor)
        for element in self.elements:
            screen_x = element.x - offset_x
            # Seamless horizontal wrap
            screen_x = screen_x % (WORLD_W + SCREEN_W) - SCREEN_W
            # Draw at screen_x...

Parallax Scrolling

Camera scrolled 500px to the right

  Layer                  Factor   Scrolled     Illusion
  --------------------------------------------------------
  Stars (far)            0.05     25           Stationary
  Distant mountains      0.15     75           Very far
  Near mountains         0.25     125          Far
  Far clouds             0.30     150          Far
  Near clouds            0.50     250          Medium
  Background trees       0.70     350          Same as world
  World objects          1.00     500          Same as world

Tilemaps & Camera β€” Summary

  • Tilemap = 2D list of ints β†’ compact, serializable, efficient
  • Culling: only draw tiles within the viewport β€” critical for large maps
  • Camera converts world β†”οΈŽ screen coordinates for every object
  • Smooth follow: lerp toward desired camera position each frame
  • Dead zone: player moves freely in a central region before camera tracks
  • Parallax: background layers scrolled at camera_x * factor; lower = farther