Learning Goals
3 minBy the end of this lesson you can:
- Spawn obstacles procedurally using
clock.schedule_intervaland a list. - Detect a collision between the player rectangle and a rock, then trigger game over.
- Display and grow a distance-based score each frame.
Warm-Up · Part 1 Recap
5 minPart 1 gave us a scrolling ground and a jumping player. Before adding obstacles, predict what happens here:
rocks = [{"x": 600}, {"x": 720}] SPEED = 4 for rock in rocks: rock["x"] -= SPEED print([r["x"] for r in rocks])
Show the answer
Output
[596, 716]
Each rock's x position decreases by SPEED — exactly how they will slide left in the game.
Copy your runner.py from PZ-38 and add the new code below. Never start from a blank file in Part 2.
New Concept · Procedural Spawning
12 minThink of a car wash conveyor — new cars arrive at random gaps, not on a fixed schedule. We model this with clock.schedule_interval, which calls a spawn function regularly. Each rock is a small dictionary holding its position.
Spawn function
import random rocks = [] def spawn_rock(): height = random.randint(20, 40) rocks.append({ "x": WIDTH + 10, "h": height, })
Call this on a timer:
clock.schedule_interval(spawn_rock, 1.5) # new rock every 1.5 s
Simple rect collision
We store each rock as a Rect on the fly and compare it to the player rect:
player_rect = Rect((player_x - 15, player_y - 30), (30, 30)) rock_rect = Rect((rock["x"], GROUND_Y - rock["h"]), (20, rock["h"])) if player_rect.colliderect(rock_rect): game_over = True
Worked Example · Runner Part 2 — Complete Game
12 minWei Jie finishes the runner. Add these sections to your Part 1 file. Only the new/changed parts are shown — keep everything from PZ-38 unless noted.
New state variables (add after Part 1 constants)
rocks = [] score = 0 game_over = False
Updated draw()
def draw(): if game_over: screen.fill("black") screen.draw.text("GAME OVER", center=(WIDTH // 2, HEIGHT // 2 - 20), fontsize=56, color="red") screen.draw.text(f"Score: {score}", center=(WIDTH // 2, HEIGHT // 2 + 30), fontsize=32, color="white") return screen.fill("lightyellow") # ground (same as Part 1) screen.draw.filled_rect(Rect((ground_x, GROUND_Y), (WIDTH, 60)), "saddlebrown") screen.draw.filled_rect(Rect((ground_x + WIDTH, GROUND_Y), (WIDTH, 60)), "saddlebrown") # player screen.draw.filled_rect(Rect((player_x - 15, player_y - 30), (30, 30)), "royalblue") # rocks for rock in rocks: screen.draw.filled_rect( Rect((rock["x"], GROUND_Y - rock["h"]), (20, rock["h"])), "dimgray" ) # score screen.draw.text(f"Score: {score}", topleft=(10, 10), fontsize=24, color="black")
Updated update()
def update(): global player_y, vy, ground_x, score, game_over if game_over: return # gravity + landing (same as Part 1) vy += GRAVITY player_y += vy if player_y >= GROUND_Y: player_y = GROUND_Y vy = 0.0 # ground scroll ground_x -= SPEED if ground_x <= -WIDTH: ground_x = 0 # move rocks left for rock in rocks: rock["x"] -= SPEED # remove off-screen rocks rocks[:] = [r for r in rocks if r["x"] > -30] # collision p_rect = Rect((player_x - 12, player_y - 28), (24, 28)) for rock in rocks: r_rect = Rect((rock["x"] + 2, GROUND_Y - rock["h"]), (16, rock["h"])) if p_rect.colliderect(r_rect): game_over = True # score grows with distance score += 1
Spawn timer and restart (add before pgzrun.go())
def spawn_rock(): import random rocks.append({"x": WIDTH + 10, "h": random.randint(20, 40)}) def on_key_down(key): global vy, game_over, score, rocks, player_y if key == keys.SPACE: if game_over: # restart game_over = False score = 0 rocks.clear() globals()["player_y"] = GROUND_Y globals()["vy"] = 0.0 clock.schedule_interval(spawn_rock, 1.5) elif player_y >= GROUND_Y: vy = -JUMP_SPEED clock.schedule_interval(spawn_rock, 1.5) pgzrun.go()
What you will see
Try It Yourself
13 minEvery 200 frames, increase SPEED by 1 (cap it at 12). The game gets harder as the score climbs.
# in update(), after score += 1: if score % 200 == 0 and SPEED < 12: globals()["SPEED"] += 1
Add a high_score variable. After a game-over, update it if score > high_score. Display it in the game-over screen alongside the current score.
high_score = 0 # add to globals # in update(), when game_over becomes True: if score > high_score: globals()["high_score"] = score
Mini-Challenge · Priya's Vanishing Rocks
8 minPriya added a second rock type but her list filter is wrong — rocks are removed too early and the player can walk through them.
# priya_runner.py — buggy obstacle removal
def update():
global rocks
for rock in rocks:
rock["x"] -= SPEED
# removes rocks that are still on screen!
rocks[:] = [r for r in rocks if r["x"] < -30]It works if…
rocks only disappear once fully off the left edge of the screen
Show the fix
# priya_runner.py — fixed def update(): global rocks for rock in rocks: rock["x"] -= SPEED # keep rocks that are still on screen or approaching rocks[:] = [r for r in rocks if r["x"] > -30]
The condition was reversed: < -30 kept only off-screen rocks. The correct filter is > -30 — keep everything that has not yet left the left edge.
Recap
3 minEndless Runner Part 2 adds three things to Part 1: a clock.schedule_interval spawner that appends rocks to a list; a per-frame loop that moves and filters each rock; and a Rect collision check that sets game_over. The score counter increments every frame you survive.
Vocabulary Card
- procedural spawning
- Creating new game objects (rocks, enemies) automatically on a timer or at random intervals.
- clock.schedule_interval
- Calls a function repeatedly at a fixed time gap (e.g. every 1.5 seconds).
- list filter (rocks[:]= )
- Rebuilds the list in place, keeping only elements that satisfy a condition. Removes off-screen objects each frame.
- game_over flag
- A boolean that pauses all game logic and switches the draw function to show the end screen.
Homework
4 minAdd a second obstacle type: a low-flying bird that moves across the upper half of the screen. The bird is a small rectangle at a random height between 80 and 140 px. It spawns separately via its own clock.schedule_interval every 2.5 seconds. Colliding with a bird also ends the game. Bring a screenshot.
Sample · bird spawner & collision
import random birds = [] def spawn_bird(): birds.append({"x": WIDTH + 10, "y": random.randint(80, 140)}) clock.schedule_interval(spawn_bird, 2.5) # in update(), after rock collision: for bird in birds: bird["x"] -= SPEED birds[:] = [b for b in birds if b["x"] > -30] p_rect = Rect((player_x - 12, player_y - 28), (24, 28)) for bird in birds: b_rect = Rect((bird["x"], bird["y"]), (28, 14)) if p_rect.colliderect(b_rect): game_over = True # in draw(), inside the normal game branch: for bird in birds: screen.draw.filled_rect(Rect((bird["x"], bird["y"]), (28, 14)), "darkorange")
Birds use the same left-scroll pattern as rocks. The key difference is the y coordinate — birds float at mid-height, forcing the player to crouch (or duck) rather than just jump.