Learning Goals
3 minBy the end of this lesson you can:
- Spawn falling objects as a list of dicts, each with its own position and velocity, and apply gravity each frame.
- Detect a catch using
colliderectbetween the falling object'sRectand the basketActor, updating score and lives correctly. - Build a complete game loop with score, lives, and game-over state that resets cleanly when the player presses a key.
Warm-Up · Gravity + Collision Recap
5 minTwo quick questions before we build. Predict the output of each snippet:
# Snippet A vy = 2.0 GRAVITY = 0.5 for _ in range(3): vy += GRAVITY print(round(vy, 1))
# Snippet B import pgzrun basket = Actor("basket", center=(300, 350)) fruit_rect = Rect((290, 340), (20, 20)) print(basket.colliderect(fruit_rect))
Show the answers
Snippet A output
3.5
Snippet B output
True
Snippet A: vy starts at 2.0 and increases by 0.5 three times → 3.5. Snippet B: the fruit Rect overlaps the basket Actor, so colliderect returns True.
New Concept · Falling Objects as a List
12 minThink of each falling kuih as a sticky note: it has a position, a vertical speed, and a size. We keep all of them in a list and loop through every frame to update and draw each one.
Spawning a falling object
import random ITEM_W = 28 ITEM_H = 28 ITEM_GRAVITY = 0.3 def make_item(): return { "x": float(random.randint(ITEM_W, WIDTH - ITEM_W)), "y": 0.0, "vy": random.uniform(1.5, 3.5), }
Updating all falling objects
Apply gravity, move, then check if the item fell off the bottom (missed):
def update_items(): global lives for item in list(items): item["vy"] += ITEM_GRAVITY item["y"] += item["vy"] if item["y"] > HEIGHT + ITEM_H: items.remove(item) lives -= 1
Catching with colliderect
colliderect needs a Rect. Build one from the item's position and size, then test against the basket Actor:
def check_catches(): global score for item in list(items): item_rect = Rect( (int(item["x"] - ITEM_W // 2), int(item["y"] - ITEM_H // 2)), (ITEM_W, ITEM_H), ) if basket.colliderect(item_rect): items.remove(item) score += 10
Worked Example · Gravity Catcher — Full Game
12 minNurul builds the full game. The basket is a wide rectangle Actor (save a wide basket PNG as images/basket.png, or any suitable image). Use clock.schedule_interval to spawn a new item every 1.5 seconds. Save as gravity_catcher.py:
# gravity_catcher.py — catch falling kuih with a basket import pgzrun import random WIDTH = 480 HEIGHT = 500 TITLE = "Gravity Catcher" BASKET_SPEED = 6 ITEM_W = 28 ITEM_H = 28 ITEM_GRAVITY = 0.25 MAX_LIVES = 3 SPAWN_INTERVAL = 1.4 COLOURS = ["gold", "orange", "crimson", "limegreen", "violet"]
basket = Actor("basket", center=(240, 450)) items = [] score = 0 lives = MAX_LIVES STATE = "play" def make_item(): return { "x": float(random.randint(ITEM_W, WIDTH - ITEM_W)), "y": 0.0, "vy": random.uniform(1.8, 3.5), "colour": random.choice(COLOURS), } def spawn_item(): if STATE == "play": items.append(make_item()) clock.schedule_interval(spawn_item, SPAWN_INTERVAL)
def check_catches(): global score for item in list(items): item_rect = Rect( (int(item["x"] - ITEM_W // 2), int(item["y"] - ITEM_H // 2)), (ITEM_W, ITEM_H), ) if basket.colliderect(item_rect): items.remove(item) score += 10 def update_items(): global lives, STATE for item in list(items): item["vy"] += ITEM_GRAVITY item["y"] += item["vy"] if item["y"] > HEIGHT + ITEM_H: items.remove(item) lives -= 1 if lives <= 0: STATE = "over" clock.unschedule(spawn_item)
def update(): if STATE != "play": return if keyboard.left: basket.x = max(basket.width // 2, basket.x - BASKET_SPEED) if keyboard.right: basket.x = min(WIDTH - basket.width // 2, basket.x + BASKET_SPEED) update_items() check_catches() def draw(): screen.fill("darkblue") if STATE == "play": basket.draw() for item in items: screen.draw.filled_rect( Rect( (int(item["x"] - ITEM_W // 2), int(item["y"] - ITEM_H // 2)), (ITEM_W, ITEM_H), ), item["colour"], ) screen.draw.text(f"Score: {score}", topleft=(10, 10), fontsize=26, color="white") for i in range(lives): screen.draw.filled_circle((WIDTH - 20 - i * 30, 22), 10, "red") elif STATE == "over": screen.draw.text("GAME OVER", center=(240, 200), fontsize=52, color="red") screen.draw.text(f"Score: {score}", center=(240, 270), fontsize=36, color="white") screen.draw.text("SPACE to play again", center=(240, 330), fontsize=24, color="grey") def on_key_down(key): global STATE, score, lives, items if key == keys.SPACE and STATE == "over": score = 0 lives = MAX_LIVES items.clear() basket.center = (240, 450) STATE = "play" clock.schedule_interval(spawn_item, SPAWN_INTERVAL) pgzrun.go()
Try It Yourself
13 minEvery time the score reaches a multiple of 50, decrease SPAWN_INTERVAL by 0.1 seconds (minimum 0.4) to make the game harder. Hint: check inside check_catches() after incrementing the score.
SPAWN_INTERVAL = 1.4 spawn_interval = SPAWN_INTERVAL # inside check_catches(), after score += 10: if score % 50 == 0: spawn_interval = max(0.4, spawn_interval - 0.1) clock.unschedule(spawn_item) clock.schedule_interval(spawn_item, spawn_interval)
Add a 20 % chance for a falling item to be a "poison" item (colour "black"). Catching it deducts 5 points. Adjust make_item() to add a "danger" key, and update check_catches() to check it.
def make_item(): is_danger = random.random() < 0.2 return { "x": float(random.randint(ITEM_W, WIDTH - ITEM_W)), "y": 0.0, "vy": random.uniform(1.8, 3.5), "colour": "black" if is_danger else random.choice(COLOURS), "danger": is_danger, }
Mini-Challenge · Add a Hi-Score Table
8 minCombine today's game with the score persistence from PZ-32. On game-over, save the current score to a file and display the top-5 scores on the game-over screen.
It works if…
the game-over screen shows a numbered top-5 list, and the list persists after closing and reopening the game
Show the key additions
SCORES_FILE = "catcher_scores.txt" def load_scores(filename): try: with open(filename, "r") as f: result = [int(line.strip()) for line in f if line.strip()] except FileNotFoundError: result = [] return result def save_scores(filename, scores): top5 = sorted(scores, reverse=True)[:5] with open(filename, "w") as f: for s in top5: f.write(str(s) + "\n") # when transitioning to "over": high_scores = load_scores(SCORES_FILE) high_scores.append(score) save_scores(SCORES_FILE, high_scores) high_scores = load_scores(SCORES_FILE) # inside draw_over(): for i, s in enumerate(high_scores[:5]): screen.draw.text(f"{i + 1}. {s}", center=(240, 370 + i * 26), fontsize=20, color="gold")
The load/save pattern is identical to PZ-32 — copy it in and call it at the right moments.
Recap
3 minGravity Catcher brings together everything from the physics lessons. Falling objects use the same vy += GRAVITY recipe from PZ-35. Collision uses colliderect with a Rect built from the object's position. Score and lives update on catch or miss. clock.schedule_interval handles spawning without cluttering update(). Each subsystem was already tested alone — combining them is straightforward.
Vocabulary Card
- clock.schedule_interval(fn, secs)
- Calls
fnrepeatedly everysecsseconds. Useclock.unschedule(fn)to stop it. - Rect((x, y), (w, h))
- A rectangle used for collision detection and drawing. The top-left corner is
(x, y); width and height are(w, h). - colliderect(rect)
- Returns
Trueif an Actor overlaps with the givenRect. The primary tool for catch-type collisions. - list(collection) loop trick
- Iterating over a copy of a list (
for item in list(items)) lets you safely remove items during the loop.
Homework
4 minAdd a combo multiplier to Gravity Catcher. Each consecutive catch increases a multiplier by 1 (up to ×5). Missing an item resets the multiplier to 1. Points scored = 10 × multiplier. Display the current multiplier next to the score. Save as catcher_combo.py.
Sample · combo multiplier additions to catcher_combo.py
# Add alongside score and lives: combo = 1 MAX_COMBO = 5 # In check_catches(), replace score += 10: score += 10 * combo combo = min(MAX_COMBO, combo + 1) # In update_items(), after lives -= 1: combo = 1 # reset combo on a miss # In draw() during play, update the score line: screen.draw.text( f"Score: {score} x{combo}", topleft=(10, 10), fontsize=26, color="white", )
Track combo as a separate global. Increment on catch, clamp at MAX_COMBO, reset to 1 on miss. Multiply the base points by combo when scoring.