Managing Multiple Obstacles

Introduction

Welcome to Creating the Core Game Mechanics with Pygame! In the previous course, we built the skeleton of our dodge game: an 800 × 600 window, a game loop that handles quitting, a dark blue background, a single red obstacle that falls and recycles, and a green player that responds to the arrow keys. That scene works, but it is not yet a game: one obstacle is easy to sidestep forever.

This is the first of four lessons in this course, and our job here is to make the screen feel alive with several obstacles falling at once, each starting at its own random position and recycling forever. Here is roughly where we left off:

Python
    obstacle = pygame.Rect(100, -40, 40, 40)
    obstacle_speed = 4

Three tools will improve that setup: an OBSTACLE_SIZE constant for the shared dimensions, a Python list to hold many Rects, and a helper function that builds them for us.

Replacing Repeated Dimensions with a Constant

Every obstacle is a 40 × 40 square. Instead of repeating the number 40 wherever an obstacle is created or positioned, we define it once with the other constants:

Python
WIDTH, HEIGHT = 800, 600
FPS = 60
OBSTACLE_SIZE = 40

Now, changing the obstacle size later only requires editing one value. The same constant can control both dimensions and the horizontal spawning boundary.

Why a List Beats Copy-Pasting Variables

Imagine we wanted five obstacles and reached for the most obvious solution: five variables named obstacle1 through obstacle5. Every single piece of obstacle logic would then need to be written five times over:

  • five lines to create the Rects;
  • five lines to move them down each frame;
  • five recycle checks with five pairs of position resets;
  • five calls to pygame.draw.rect.

That creates many near-identical lines. If we later change the obstacle size or behavior, we must edit every copy and hope we do not miss any. A list solves this neatly: we store all the Rects in one place, then write each rule once and let a for loop apply it to every item.

Writing the spawn_obstacles Helper Function

Creating those Rects is a standalone task, so we give it its own function. It sits outside main(), at the top level of the file:

Python
def spawn_obstacles(count):
    new_obstacles = []
    for _ in range(count):
        x = random.randint(0, WIDTH - OBSTACLE_SIZE)
        y = random.randint(-HEIGHT, 0)
        new_obstacles.append(
            pygame.Rect(x, y, OBSTACLE_SIZE, OBSTACLE_SIZE)
        )
    return new_obstacles

Let us walk through it:

  • new_obstacles = [] initializes an empty list.
  • for _ in range(count) runs exactly count times. The underscore shows that we do not need the loop's index value.
  • random.randint(0, WIDTH - OBSTACLE_SIZE) picks the left edge. We subtract the obstacle width because a Rect grows to the right from its x.
  • random.randint(-HEIGHT, 0) places each obstacle in the invisible strip above the window.
  • pygame.Rect(x, y, OBSTACLE_SIZE, OBSTACLE_SIZE) uses the shared constant for both dimensions.
  • return new_obstacles hands the finished list back to the caller.

Keeping this logic outside the game loop means we can call it whenever we need a fresh batch of obstacles, including when a future version of the game restarts.

Calling the Helper and Setting Up Game Variables

We use the helper inside main(), replacing the old single-obstacle setup. Since it relies on random values, import random belongs at the top of the file:

Python
    player = pygame.Rect(375, 540, 50, 40)
    player_speed = 5

    obstacles = spawn_obstacles(5)
    obstacle_speed = 4

spawn_obstacles(5) runs once before the while loop starts. Calling it inside the loop would create five new obstacles every frame. Each obstacle has its own position, while obstacle_speed remains one shared number.

Moving Every Obstacle with a for Loop

With the list in place, movement becomes a small loop inside the game loop:

Python
        for obstacle in obstacles:
            obstacle.y += obstacle_speed

Every frame, the loop visits each Rect and moves it down. The loop variable refers to the same Rect stored in the list, so changing obstacle.y updates the actual game object.

Recycling Obstacles Back to the Top

An obstacle that leaves the bottom is repositioned above the window and reused:

Python
        for obstacle in obstacles:
            obstacle.y += obstacle_speed
            if obstacle.y > HEIGHT:
                obstacle.x = random.randint(
                    0, WIDTH - OBSTACLE_SIZE
                )
                obstacle.y = random.randint(-100, 0)

Re-randomizing x gives the obstacle a new lane. The tighter vertical range of -100 to 0 returns it to play quickly, while the wider initial range of -HEIGHT to 0 staggers the opening wave.

Drawing All the Obstacles

Updating and drawing remain separate tasks:

Python
        screen.fill(DARK_BLUE)
        pygame.draw.rect(screen, GREEN, player)
        for obstacle in obstacles:
            pygame.draw.rect(screen, RED, obstacle)
        pygame.display.flip()

The background is cleared first, followed by the player and each obstacle. pygame.display.flip() then presents the completed frame.

The Complete Program

Python
import asyncio
import random
import pygame

WIDTH, HEIGHT = 800, 600
FPS = 60
OBSTACLE_SIZE = 40
DARK_BLUE = (20, 28, 48)
GREEN = (87, 215, 170)
RED = (220, 50, 50)

def spawn_obstacles(count):
    new_obstacles = []
    for _ in range(count):
        x = random.randint(0, WIDTH - OBSTACLE_SIZE)
        y = random.randint(-HEIGHT, 0)
        new_obstacles.append(
            pygame.Rect(x, y, OBSTACLE_SIZE, OBSTACLE_SIZE)
        )
    return new_obstacles

async def main():
    pygame.init()
    screen = pygame.display.set_mode((WIDTH, HEIGHT))
    pygame.display.set_caption("Dodge Game")
    clock = pygame.time.Clock()

    player = pygame.Rect(375, 540, 50, 40)
    player_speed = 5

    obstacles = spawn_obstacles(5)
    obstacle_speed = 4

    running = True
    while running:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False

        keys = pygame.key.get_pressed()
        if keys[pygame.K_LEFT]: player.x -= player_speed
        if keys[pygame.K_RIGHT]: player.x += player_speed
        if keys[pygame.K_UP]: player.y -= player_speed
        if keys[pygame.K_DOWN]: player.y += player_speed
        player.clamp_ip(screen.get_rect())

        for obstacle in obstacles:
            obstacle.y += obstacle_speed
            if obstacle.y > HEIGHT:
                obstacle.x = random.randint(
                    0, WIDTH - OBSTACLE_SIZE
                )
                obstacle.y = random.randint(-100, 0)

        screen.fill(DARK_BLUE)
        pygame.draw.rect(screen, GREEN, player)
        for obstacle in obstacles:
            pygame.draw.rect(screen, RED, obstacle)
        pygame.display.flip()
        clock.tick(FPS)
        await asyncio.sleep(0)

    pygame.quit()

asyncio.run(main())

Running this program shows five red squares drifting into view at scattered moments and lanes, falling steadily, and reappearing above the screen after passing the bottom.

Conclusion and Next Steps

Excellent work: our scene finally behaves like an arcade game. A constant keeps shared dimensions consistent, a list stores many game objects, a for loop applies one rule to all of them, and a helper function keeps object creation tidy and reusable.

There is one glaring gap, though: the squares pass straight through the player. In the next lesson, we fix that with pygame.Rect.colliderect().

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal