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.
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
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.
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()
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.
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)
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):
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.
# 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"
Rect pays off.
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.
# 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
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)
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.
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:
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.
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_EVERYto make rocks appear faster from the start - Change
lives = 3to5for 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