⚡ Intermediate Tutorial

Build Your First Pygame Game

Go step by step from a blank canvas to a complete asteroid dodge game — with real game states, collision detection, a lives system, and increasing difficulty.

⏱ ~60 min 🎮 Intermediate 🐍 Python 3

📋
Before you start: you should be comfortable with variables, loops, and functions in Python. If you haven't tried Pygame yet, start with the Getting Started guide first.

Step 1

What We're Building

We're going to build Asteroid Dodge — a complete arcade game where you pilot a ship at the bottom of the screen and dodge rocks falling from above. The longer you survive, the faster they fall and the higher your score.

Asteroid Dodge
Score: 480

By the end you'll have built all of these systems from scratch:

  • Player movement — smooth left/right control with screen boundaries
  • Enemy spawning — rocks appear at random positions and fall downward
  • Collision detection — using pygame.Rect.colliderect()
  • Score & lives HUD — text and shapes drawn each frame
  • Difficulty scaling — rocks speed up every 5 seconds
  • Game states — title screen → playing → game over → restart
🎮
Keep the editor open Open the Pygame Editor in a separate tab. Each step adds a new concept — paste the final complete code from Step 7 when you're ready to run the full game.

Step 2

Player & Movement

In Pygame, pygame.Rect is your best friend. It stores a position and size together, and comes with built-in collision methods. We'll use one to represent the player.

Python — player setup
import pygame
pygame.init()

WIDTH, HEIGHT = 640, 480
screen = pygame.display.set_mode((WIDTH, HEIGHT))
clock  = pygame.time.Clock()

ORANGE       = (225,  81,  10)
BLUE         = ( 46, 168, 255)
PLAYER_SPEED = 6

# A Rect holds (x, y, width, height)
player = pygame.Rect(WIDTH // 2 - 24, HEIGHT - 64, 48, 48)

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

    # Read all currently held keys
    keys = pygame.key.get_pressed()

    # Move left — but stop at the screen edge
    if keys[pygame.K_LEFT] and player.x > 0:
        player.x -= PLAYER_SPEED

    # Move right — stop before going off-screen
    if keys[pygame.K_RIGHT] and player.x < WIDTH - player.width:
        player.x += PLAYER_SPEED

    screen.fill((20, 20, 30))

    # Draw the ship body
    pygame.draw.rect(screen, ORANGE, player)
    # Draw a nose on top to make it look like a ship
    pygame.draw.rect(screen, BLUE, (player.x + 14, player.y - 12, 20, 14))

    pygame.display.flip()
    clock.tick(60)

pygame.quit()
💡
Why check the boundary? Without player.x > 0 the ship can slide off the left edge forever. player.width lets us check the right edge without hardcoding 48 — if we resize the ship later, the boundary check still works automatically.

Step 3

Falling Enemies

Rocks are stored in a plain Python list of Rects. Each frame we move every rock down by speed pixels, spawn a new one on a timer, and remove any that have fallen below the screen.

Python — enemy spawning & movement
import random

rocks       = []   # list of pygame.Rect
speed       = 4    # pixels per frame rocks fall
spawn_timer = 0
SPAWN_EVERY = 35   # spawn a new rock every 35 frames

# Inside the game loop:

# ── Spawn ──
spawn_timer += 1
if spawn_timer >= SPAWN_EVERY:
    spawn_timer = 0
    rx = random.randint(0, WIDTH - 40)        # random x position
    rocks.append(pygame.Rect(rx, -40, 40, 40)) # start above the screen

# ── Move & remove ──
for rock in rocks[:]:   # iterate a copy so we can safely remove
    rock.y += speed
    if rock.y > HEIGHT:
        rocks.remove(rock)

# ── Draw ──
GRAY = (80, 80, 90)
for rock in rocks:
    pygame.draw.ellipse(screen, GRAY, rock)
⚠️
Always iterate a copy when removing Writing for rock in rocks[:] loops over a snapshot of the list. If you remove items from rocks while iterating rocks directly, Python will skip elements and produce unpredictable bugs.

To ramp up difficulty, increase speed every 300 frames (5 seconds at 60 fps):

Python — difficulty scaling
frame += 1

# Every 300 frames (5 seconds), rocks fall 1 pixel/frame faster
if frame % 300 == 0:
    speed += 1

Step 4

Collision Detection

pygame.Rect has a built-in method called colliderect() that returns True when two rectangles overlap. This is all we need to detect when a rock hits the player.

Python — collision
# Check every rock against the player
for rock in rocks[:]:
    if player.colliderect(rock):
        rocks.remove(rock)   # destroy the rock
        lives -= 1           # lose a life
        if lives <= 0:
            state = "gameover"
💡
How colliderect works Two rectangles overlap when neither is completely to the left, right, above, or below the other. Pygame checks all four conditions in one call — no math required on your end. This is why representing everything as a Rect pays off.
🧩
Challenge idea Try adding a brief flash effect when the player is hit — draw the ship in red for the next 10 frames by tracking a hit_timer variable.

Step 5

Score & Lives HUD

The HUD (heads-up display) shows the score and remaining lives. We render text with a Font object and draw red circles for lives — one per life remaining.

Python — HUD setup
# Create fonts once — before the game loop
font_big   = pygame.font.Font(None, 64)
font_med   = pygame.font.Font(None, 36)
font_small = pygame.font.Font(None, 22)

# Score — increment each frame while playing
score += 1
Python — drawing the HUD
WHITE = (255, 255, 255)
RED   = (210,  50,  50)

# Score text — top left
sc_surf = font_small.render("Score: " + str(score), True, WHITE)
screen.blit(sc_surf, (10, 10))

# Lives — red circles, top right
for i in range(lives):
    pygame.draw.circle(screen, RED, (WIDTH - 18 - i * 28, 18), 10)
Create fonts before the loop Font objects are slow to create. If you call pygame.font.Font() inside the game loop it runs 60 times per second and tanks performance. Always create them once before the loop starts.

Step 6

Game States

Almost every real game uses a state machine — a variable that tracks which "mode" the game is in. We use a simple string: "title", "playing", or "gameover".

Each frame, the game loop checks the current state and runs the matching logic and drawing code.

Python — state machine
state = "title"  # starts here

# ── Event handling ──
for event in pygame.event.get():
    if event.type == pygame.QUIT:
        running = False
    if event.type == pygame.KEYDOWN:
        if state == "title" and event.key == pygame.K_SPACE:
            state = "playing"
        elif state == "gameover" and event.key == pygame.K_SPACE:
            reset()          # reset all variables
            state = "playing"

# ── Per-state logic & drawing ──
if state == "title":
    # draw title screen, wait for SPACE
    ...
elif state == "playing":
    # run all gameplay logic
    # draw the game
    ...
elif state == "gameover":
    # draw game over screen, wait for SPACE
    ...

The reset() function puts every variable back to its starting value so a fresh game can begin without restarting the program:

Python — reset function
def reset():
    global player, rocks, score, lives, speed, spawn_timer, frame
    player      = pygame.Rect(WIDTH // 2 - 24, HEIGHT - 64, 48, 48)
    rocks       = []
    score       = 0
    lives       = 3
    speed       = 4
    spawn_timer = 0
    frame       = 0

Step 7

The Complete Game

Here's the full game with every system from steps 2–6 assembled together. Copy it into the Pygame Editor and click Run — then click the canvas so keyboard input is captured.

🎮
Controls: Arrow Left / Arrow Right to move · SPACE to start or restart
Python — Asteroid Dodge (complete)
import pygame
import random

pygame.init()

# ── Setup ──────────────────────────────────────────────
WIDTH, HEIGHT = 640, 480
screen = pygame.display.set_mode((WIDTH, HEIGHT))
clock  = pygame.time.Clock()

# Colors
DGRAY  = ( 20,  20,  30)
WHITE  = (255, 255, 255)
ORANGE = (225,  81,  10)
BLUE   = ( 46, 168, 255)
RED    = (210,  50,  50)
YELLOW = (255, 215,   0)
GRAY   = ( 80,  80,  90)

# Fonts (create once, before the loop)
font_big   = pygame.font.Font(None, 64)
font_med   = pygame.font.Font(None, 36)
font_small = pygame.font.Font(None, 22)

# Constants
PLAYER_SPEED = 6
SPAWN_EVERY  = 35

# ── Reset / init ───────────────────────────────────────
def reset():
    global player, rocks, score, lives, speed, spawn_timer, frame
    player      = pygame.Rect(WIDTH // 2 - 24, HEIGHT - 64, 48, 48)
    rocks       = []
    score       = 0
    lives       = 3
    speed       = 4
    spawn_timer = 0
    frame       = 0

reset()
state = "title"

# ── Main loop ──────────────────────────────────────────
running = True
while running:

    # ── Events ──
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        if event.type == pygame.KEYDOWN:
            if state == "title" and event.key == pygame.K_SPACE:
                state = "playing"
            elif state == "gameover" and event.key == pygame.K_SPACE:
                reset()
                state = "playing"

    screen.fill(DGRAY)

    # ── Title screen ───────────────────────────────────
    if state == "title":
        t1 = font_big.render("ASTEROID DODGE", True, ORANGE)
        t2 = font_med.render("Dodge the falling rocks", True, WHITE)
        t3 = font_med.render("SPACE to play", True, YELLOW)
        t4 = font_small.render("Arrow keys to move", True, GRAY)
        screen.blit(t1, (55,  130))
        screen.blit(t2, (110, 220))
        screen.blit(t3, (210, 290))
        screen.blit(t4, (225, 340))

    # ── Playing ────────────────────────────────────────
    elif state == "playing":
        frame += 1
        score += 1

        if frame % 300 == 0:
            speed += 1

        keys = pygame.key.get_pressed()
        if keys[pygame.K_LEFT]  and player.x > 0:
            player.x -= PLAYER_SPEED
        if keys[pygame.K_RIGHT] and player.x < WIDTH - player.width:
            player.x += PLAYER_SPEED

        spawn_timer += 1
        if spawn_timer >= SPAWN_EVERY:
            spawn_timer = 0
            rx = random.randint(0, WIDTH - 40)
            rocks.append(pygame.Rect(rx, -40, 40, 40))

        for rock in rocks[:]:
            rock.y += speed
            if rock.y > HEIGHT:
                rocks.remove(rock)

        for rock in rocks[:]:
            if player.colliderect(rock):
                rocks.remove(rock)
                lives -= 1
                if lives <= 0:
                    state = "gameover"

        for rock in rocks:
            pygame.draw.ellipse(screen, GRAY, rock)

        pygame.draw.rect(screen, ORANGE, player)
        pygame.draw.rect(screen, BLUE, (player.x + 14, player.y - 12, 20, 14))

        sc = font_small.render("Score: " + str(score), True, WHITE)
        screen.blit(sc, (10, 10))
        for i in range(lives):
            pygame.draw.circle(screen, RED, (WIDTH - 18 - i * 28, 18), 10)

    # ── Game over ──────────────────────────────────────
    elif state == "gameover":
        g1 = font_big.render("GAME OVER", True, RED)
        g2 = font_med.render("Score: " + str(score), True, WHITE)
        g3 = font_med.render("SPACE to try again", True, YELLOW)
        screen.blit(g1, (160, 150))
        screen.blit(g2, (230, 240))
        screen.blit(g3, (165, 310))

    pygame.display.flip()
    clock.tick(60)

pygame.quit()

Things to try once it's running:

  • Lower SPAWN_EVERY to make rocks appear faster from the start
  • Change lives = 3 to 5 for a more forgiving game
  • Add a second rock size by spawning some Rects with width/height of 60 instead of 40
  • Draw the score larger on the game over screen using font_big