Learning Goals
3 minBy the end of this lesson you can:
- Reverse a velocity variable (
vxorvy) to make a moving object bounce off a surface. - Remove an object from a list to "destroy" it when a collision happens.
- Increment a score variable and display it whenever a collision is detected.
Warm-Up · What Should Happen?
5 minIn PZ-20 you learnt to detect a collision. Today is about the reaction. Predict what this prints each frame while vy is 5 and the ball is moving down:
ball_y = 350 vy = 5 HEIGHT = 400 ball_y += vy if ball_y >= HEIGHT: vy = -vy # bounce! print(ball_y, vy)
Show the answer
Output
400 -5
The ball reaches the bottom edge, so vy flips from +5 to -5. Next frame the ball moves upward.
A collision reaction is just an if block. The three most useful ones are: flip velocity (bounce), call .remove() (destroy), increment a counter (score).
New Concept · Three Reactions
12 minThink of collision reactions like bumper rules in a bowling lane. The ball can ricochet off the wall (bounce), disappear into a pocket (destroy), or trigger the scoreboard lights (score). Each is a tiny code action.
Reaction 1 · Bounce (reverse velocity)
# flip horizontal speed on left/right walls if ball_x <= 0 or ball_x >= WIDTH: vx = -vx # flip vertical speed on top/bottom walls if ball_y <= 0 or ball_y >= HEIGHT: vy = -vy
Reaction 2 · Destroy (remove from list)
Keep your game objects in a list. When a collision happens, remove the object with .remove(). Iterate over a copy (the [:] slice) so removing mid-loop is safe.
coins = [Actor("coin", pos=(x * 80, 100)) for x in range(5)] for coin in coins[:]: # iterate over a copy if ball.colliderect(coin): coins.remove(coin) # destroy the coin
Reaction 3 · Score (increment counter)
score = 0 for coin in coins[:]: if ball.colliderect(coin): coins.remove(coin) score += 10 # reward the player
Worked Example · Bouncing Ball Collects Stars
12 minThe story
Mei Ling wants a game where a ball bounces around the screen and collects glowing stars. Save as star_collector.py. Stars are drawn as circles (no image needed).
# star_collector.py — bounce + destroy + score import pgzrun WIDTH = 600 HEIGHT = 400 TITLE = "Star Collector" ball_x, ball_y = 300, 200 vx, vy = 4, 3 BALL_R = 14 score = 0 STARS = [ [80, 80], [200, 300], [400, 100], [520, 280], [150, 200], ]
def draw(): screen.fill("black") for sx, sy in STARS: screen.draw.filled_circle((sx, sy), 10, "yellow") screen.draw.filled_circle((int(ball_x), int(ball_y)), BALL_R, "deepskyblue") screen.draw.text(f"Score: {score}", topleft=(8, 8), fontsize=30, color="white") def update(): global ball_x, ball_y, vx, vy, score ball_x += vx ball_y += vy if ball_x <= BALL_R or ball_x >= WIDTH - BALL_R: vx = -vx if ball_y <= BALL_R or ball_y >= HEIGHT - BALL_R: vy = -vy for star in STARS[:]: sx, sy = star dist = ((ball_x - sx) ** 2 + (ball_y - sy) ** 2) ** 0.5 if dist < BALL_R + 10: STARS.remove(star) score += 10 pgzrun.go()
What you'll see
Try It Yourself
13 minWhen all stars are gone, display "Tahniah!" (congratulations) in the centre of the screen. Use an if not STARS: check inside draw().
def draw(): # existing drawing code... if not STARS: screen.draw.text("Tahniah!", center=(WIDTH // 2, HEIGHT // 2), fontsize=60, color="gold")
Each time the ball destroys a star, increase vx and vy by 0.3 (in the same if dist < ... block). Cap the speed so it never exceeds 10.
# inside the collision block: vx_sign = 1 if vx > 0 else -1 vy_sign = 1 if vy > 0 else -1 vx = min(abs(vx) + 0.3, 10) * vx_sign vy = min(abs(vy) + 0.3, 10) * vy_sign
Mini-Challenge · Debug the Destroyer
8 minArjun wrote a coin-collecting game but it crashes with ValueError: list.remove(x): x not in list after a few seconds. Find the bug.
# arjun_coins.py — buggy
import pgzrun
WIDTH = 500
HEIGHT = 400
ball_x, ball_y = 250, 200
vx, vy = 3, 2
score = 0
coins = [[100, 100], [300, 300], [400, 150]]
def draw():
screen.fill("navy")
for cx, cy in coins:
screen.draw.filled_circle((cx, cy), 12, "gold")
screen.draw.filled_circle((int(ball_x), int(ball_y)), 14, "white")
screen.draw.text(f"Score: {score}", topleft=(8, 8),
fontsize=28, color="white")
def update():
global ball_x, ball_y, vx, vy, score
ball_x += vx
ball_y += vy
if ball_x <= 0 or ball_x >= WIDTH:
vx = -vx
if ball_y <= 0 or ball_y >= HEIGHT:
vy = -vy
for coin in coins: # BUG: not a copy!
cx, cy = coin
if ((ball_x - cx) ** 2 + (ball_y - cy) ** 2) ** 0.5 < 26:
coins.remove(coin)
score += 5
pgzrun.go()It works if…
coins disappear cleanly on contact and no ValueError appears
Show the fix
# fix: iterate over a copy with [:] for coin in coins[:]: cx, cy = coin if ((ball_x - cx) ** 2 + (ball_y - cy) ** 2) ** 0.5 < 26: coins.remove(coin) score += 5
Removing items from a list while iterating it confuses Python's loop counter. Always loop over coins[:] (a copy) and remove from the original coins.
Recap
3 minThree reactions cover most games: flip vx or vy to bounce, call list.remove() to destroy, and increment a counter to score. When removing objects during a loop, always iterate over a copy (my_list[:]) to avoid a ValueError.
Vocabulary Card
- bounce
- Reversing a velocity component (
vx = -vx) so an object moves in the opposite direction along one axis. - destroy
- Removing an object from the game's list with
list.remove(obj)so it is no longer drawn or checked. - score
- A running integer that goes up (or down) when a collision condition is met.
- safe iteration copy
for item in my_list[:]:— iterates a snapshot of the list so items can be removed from the original without aValueError.
Homework
4 minBuild a "Roti Canai Catcher" mini-game. A plate (rectangle or actor) slides left and right with keyboard.left / keyboard.right. Roti canai pieces (small circles) fall from the top at different x positions. When the plate touches a piece, destroy it and score +5. If a piece reaches the bottom without being caught, deduct 3 from the score (but never go below 0). Save as roti_catcher.py and bring a screenshot.
Sample · roti_catcher.py
# roti_catcher.py — catch the roti canai! import pgzrun import random WIDTH = 500 HEIGHT = 400 TITLE = "Roti Canai Catcher" plate_x = 250 PLATE_Y = 370 PLATE_W = 80 PLATE_H = 14 PLATE_SPEED = 5 score = 0 pieces = [] def spawn(): pieces.append([random.randint(20, WIDTH - 20), 0]) clock_ref = None def draw(): screen.fill("saddlebrown") screen.draw.filled_rect( Rect((plate_x - PLATE_W // 2, PLATE_Y), (PLATE_W, PLATE_H)), "wheat", ) for px, py in pieces: screen.draw.filled_circle((px, int(py)), 10, "lightyellow") screen.draw.text(f"Score: {score}", topleft=(8, 8), fontsize=28, color="white") def update(): global plate_x, score if keyboard.left and plate_x > PLATE_W // 2: plate_x -= PLATE_SPEED if keyboard.right and plate_x < WIDTH - PLATE_W // 2: plate_x += PLATE_SPEED for piece in pieces[:]: piece[1] += 3 px, py = piece in_x = abs(px - plate_x) < PLATE_W // 2 + 10 in_y = abs(py - PLATE_Y) < PLATE_H + 10 if in_x and in_y: pieces.remove(piece) score += 5 elif py > HEIGHT: pieces.remove(piece) score = max(0, score - 3) clock.schedule_interval(spawn, 1.5) pgzrun.go()
Your layout and speeds will differ. Key points: spawn pieces on a timer, check overlap manually (or with colliderect if using Actors), and use max(0, score - 3) to avoid a negative score.