Learning Goals
3 minBy the end of this lesson you can:
- Integrate the
STATEstring variable pattern from PZ-31 ("start"→"play"→"over") into a multi-feature game. - Spawn, move, and wrap asteroid circles, detect bullet–asteroid collisions, and update score and lives accordingly.
- Call
load_scores/save_scoresfrom PZ-32 to display and persist the hi-score table on the game-over screen.
Warm-Up · Two Lessons in One
5 minThis lesson combines two earlier patterns. Quickly predict what each snippet does without running it:
# snippet A — from PZ-31 state = "start" if state == "start": screen.draw.text("Press SPACE", center=(300, 300), fontsize=36, color="white")
# snippet B — from PZ-32 import math scores = [120, 350, 80, 290] top3 = sorted(scores, reverse=True)[:3] print(top3)
Show the answers
Snippet A draws a white "Press SPACE" message when the state is "start". Snippet B prints [350, 290, 120] — the three highest scores in descending order.
New Concept · Layering Systems Together
12 minThink of a finished game like a layer cake. Each layer was baked separately and stacked in order: movement (PZ-33), states (PZ-31), and persistence (PZ-32). The trick is that each layer depends only on clean interfaces — not on each other's internals.
State-gating update and draw
Only run the game physics when the state is "play". The other states get their own draw branches:
STATE = "start" def update(): if STATE == "play": update_play() def draw(): screen.fill("black") if STATE == "start": draw_start() elif STATE == "play": draw_play() elif STATE == "over": draw_over()
Spawning drifting asteroids
Each asteroid is a dict with a position, velocity, and radius. Give them a random drift so they move every frame:
import random def make_asteroid(): return { "x": random.randint(0, WIDTH), "y": random.randint(0, HEIGHT), "vx": random.uniform(-1.5, 1.5), "vy": random.uniform(-1.5, 1.5), "r": random.randint(18, 36), }
Bullet–asteroid collision
A bullet is a small circle. Check if its centre is within the asteroid's radius using math.hypot:
def bullet_hits(bullet, asteroid): dist = math.hypot(bullet["x"] - asteroid["x"], bullet["y"] - asteroid["y"]) return dist < asteroid["r"] + 4
Worked Example · Asteroids Lite — Full Game
12 minAhmad puts it all together. This builds directly on asteroids1.py from PZ-33. Save as asteroids2.py:
# asteroids2.py — Part 2: states + asteroids + bullets + hi-scores import pgzrun import math import random WIDTH = 600 HEIGHT = 600 TITLE = "Asteroids Lite" SCORES_FILE = "asteroids_scores.txt" TURN_SPEED = 4 THRUST = 0.25 DRAG = 0.99 MAX_SPEED = 7 NUM_ASTEROIDS = 5
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") def wrap_pos(obj): if obj["x"] > WIDTH: obj["x"] = 0 elif obj["x"] < 0: obj["x"] = WIDTH if obj["y"] > HEIGHT: obj["y"] = 0 elif obj["y"] < 0: obj["y"] = HEIGHT def make_asteroid(): return { "x": random.randint(0, WIDTH), "y": random.randint(0, HEIGHT), "vx": random.uniform(-1.5, 1.5), "vy": random.uniform(-1.5, 1.5), "r": random.randint(18, 36), } def bullet_hits(bullet, asteroid): dist = math.hypot(bullet["x"] - asteroid["x"], bullet["y"] - asteroid["y"]) return dist < asteroid["r"] + 4
ship = Actor("ship", center=(300, 300)) vx = 0.0 vy = 0.0 bullets = [] asteroids = [] score = 0 lives = 3 STATE = "start" high_scores = load_scores(SCORES_FILE) def reset_game(): global vx, vy, bullets, asteroids, score, lives, ship ship.pos = (300, 300) ship.angle = 90 vx = vy = 0.0 bullets.clear() asteroids[:] = [make_asteroid() for _ in range(NUM_ASTEROIDS)] score = 0 lives = 3
def update_play(): global vx, vy, STATE, score, lives, high_scores if keyboard.left: ship.angle += TURN_SPEED if keyboard.right: ship.angle -= TURN_SPEED if keyboard.up: rad = math.radians(ship.angle) vx += math.cos(rad) * THRUST vy -= math.sin(rad) * THRUST speed = math.hypot(vx, vy) if speed > MAX_SPEED: vx = vx / speed * MAX_SPEED vy = vy / speed * MAX_SPEED vx *= DRAG vy *= DRAG ship.x += vx ship.y += vy ship_dict = {"x": ship.x, "y": ship.y} wrap_pos(ship_dict) ship.pos = (ship_dict["x"], ship_dict["y"]) for b in bullets: b["x"] += b["vx"] b["y"] += b["vy"] bullets[:] = [b for b in bullets if 0 < b["x"] < WIDTH and 0 < b["y"] < HEIGHT] for a in asteroids: a["x"] += a["vx"] a["y"] += a["vy"] wrap_pos(a) for b in list(bullets): for a in list(asteroids): if bullet_hits(b, a): bullets.remove(b) asteroids.remove(a) asteroids.append(make_asteroid()) score += 10 break for a in asteroids: dist = math.hypot(ship.x - a["x"], ship.y - a["y"]) if dist < a["r"] + 14: lives -= 1 ship.pos = (300, 300) vx = vy = 0.0 if lives <= 0: high_scores = load_scores(SCORES_FILE) high_scores.append(score) save_scores(SCORES_FILE, high_scores) high_scores = load_scores(SCORES_FILE) STATE = "over"
def on_key_down(key): global STATE if key == keys.SPACE: if STATE == "start": reset_game() STATE = "play" elif STATE == "play": rad = math.radians(ship.angle) bullets.append({ "x": ship.x, "y": ship.y, "vx": math.cos(rad) * 9, "vy": -math.sin(rad) * 9, }) elif STATE == "over": STATE = "start" def draw_start(): screen.draw.text("ASTEROIDS LITE", center=(300, 200), fontsize=48, color="white") screen.draw.text("Press SPACE to play", center=(300, 280), fontsize=28, color="grey") for i, s in enumerate(high_scores[:5]): screen.draw.text(f"{i + 1}. {s}", center=(300, 330 + i * 28), fontsize=22, color="gold") def draw_play(): ship.draw() for b in bullets: screen.draw.filled_circle((int(b["x"]), int(b["y"])), 4, "yellow") for a in asteroids: screen.draw.circle((int(a["x"]), int(a["y"])), a["r"], "white") screen.draw.text(f"Score: {score} Lives: {lives}", topleft=(10, 10), fontsize=22, color="white") def draw_over(): screen.draw.text("GAME OVER", center=(300, 180), fontsize=52, color="red") screen.draw.text(f"Score: {score}", center=(300, 250), fontsize=32, color="white") screen.draw.text("Top Scores:", center=(300, 300), fontsize=24, color="gold") for i, s in enumerate(high_scores[:5]): screen.draw.text(f"{i + 1}. {s}", center=(300, 330 + i * 26), fontsize=20, color="white") screen.draw.text("SPACE to restart", center=(300, 480), fontsize=22, color="grey") def update(): if STATE == "play": update_play() def draw(): screen.fill("black") if STATE == "start": draw_start() elif STATE == "play": draw_play() elif STATE == "over": draw_over() pgzrun.go()
asteroids_scores.txt.Try It Yourself
13 minRight now the player can fire unlimited bullets. Add a check so there are never more than 5 bullets on screen at once. Hint: check len(bullets) before appending.
if key == keys.SPACE and STATE == "play": if len(bullets) < 5: bullets.append({ ... })
Make make_asteroid accept a difficulty parameter so the max drift speed grows with score. Divide score by 50 (capped at 3) to compute difficulty.
def make_asteroid(difficulty=1): speed = 1.5 + difficulty * 0.5 return { "x": random.randint(0, WIDTH), "y": random.randint(0, HEIGHT), "vx": random.uniform(-speed, speed), "vy": random.uniform(-speed, speed), "r": random.randint(18, 36), }
Mini-Challenge — Add a Shield
8 minCombine the clock module (from PZ-26 or the cheat-sheet) with the collision system. Press S to activate a shield that lasts 2 seconds. While the shield is active, asteroid collisions don't reduce lives, and a circle is drawn around the ship.
It works if…
a white ring appears around the ship for 2 seconds after pressing S, and asteroid hits during that time don't reduce lives
Show one possible solution
shield_active = False def activate_shield(): global shield_active shield_active = False def on_key_down(key): global shield_active if key == keys.S and STATE == "play": shield_active = True clock.schedule(activate_shield, 2.0) # inside draw_play(): if shield_active: screen.draw.circle((int(ship.x), int(ship.y)), 28, "cyan") # inside the asteroid-collision check: if dist < a["r"] + 14 and not shield_active: lives -= 1
clock.schedule(fn, delay) calls fn once after delay seconds. Setting shield_active = False in that callback turns the shield off cleanly.
Recap
3 minAsteroids Lite is now a complete game. You stacked three lessons: Part-1 ship movement, PZ-31 game states, and PZ-32 persistent scores. Each piece was built and tested alone, then combined. That is the professional way to build any larger project — layer by layer, testing each seam.
Vocabulary Card
- game state machine
- A variable (here
STATE) that controls which screen and logic are active. Only one state is active at a time. - circle-circle collision
- Two circles overlap when the distance between their centres is less than the sum of their radii. Use
math.hypotfor the distance. - clock.schedule(fn, delay)
- Calls a function once after a delay in seconds — useful for timed power-ups, respawn delays, or countdown timers.
Homework
4 minAdd a level counter to Asteroids Lite. Every time the player destroys 10 asteroids, increase the level by 1, spawn one extra asteroid, and briefly flash the message "Level N!" in the centre of the screen for 1.5 seconds using clock.schedule. Bring the updated file to class.
Sample · level counter additions to asteroids2.py
# Add these globals alongside score and lives: level = 1 kills = 0 show_level_msg = False def hide_level_msg(): global show_level_msg show_level_msg = False # Inside the bullet-hits block, replace "score += 10" with: score += 10 kills += 1 if kills % 10 == 0: level += 1 asteroids.append(make_asteroid()) show_level_msg = True clock.schedule(hide_level_msg, 1.5) # Inside draw_play(), add: if show_level_msg: screen.draw.text(f"Level {level}!", center=(300, 300), fontsize=48, color="yellow")
Track kills separately from score so the level trigger is always "every 10 kills" regardless of scoring tweaks.