Learning Goals
3 minBy the end of this lesson you can:
- Apply the full juice stack (particles, shake, flash, sound) to your own arcade game from Part 1.
- Add a polished title screen and game-over screen with the four-state machine from PZ-45.
- Share a playable
.pyfile with a classmate and explain one design decision you are proud of.
Warm-Up · Prototype Review
5 minOpen your capstone.py from PZ-47. Run it. Ask yourself three questions:
- Does the core mechanic work without bugs?
- Do score and lives display correctly?
- Does restarting from the game-over screen reset everything?
Fix any "no" answers before adding polish — juice on a broken game is wasted effort.
Add effects in this order: title screen → game-over screen → particles → shake → flash → sound. Each layer builds on a stable base. Stop when time is up — a half-juiced game still ships.
New Concept · The Polish Checklist
12 minYou have seen every one of these techniques in earlier lessons. This section puts them all on one page as a copy-and-paste reference to use during your build today.
1 · Polished title screen
def draw_title(): screen.fill("black") # large title screen.draw.text(TITLE, center=(WIDTH // 2, HEIGHT // 3), fontsize=56, color="gold") # subtitle / tagline screen.draw.text("Catch the roti before it hits the floor!", center=(WIDTH // 2, HEIGHT // 2), fontsize=20, color="white") # call to action screen.draw.text("Press SPACE to start", center=(WIDTH // 2, HEIGHT * 2 // 3), fontsize=22, color="grey")
2 · Polished game-over screen with high score
best_score = 0 # where lives reach zero: if score > best_score: best_score = score state = "game_over" def draw_game_over(): screen.fill((30, 0, 0)) screen.draw.text("GAME OVER", center=(WIDTH // 2, HEIGHT // 4), fontsize=52, color="crimson") screen.draw.text(f"Score: {score}", center=(WIDTH // 2, HEIGHT // 2 - 20), fontsize=34, color="white") screen.draw.text(f"Best: {best_score}", center=(WIDTH // 2, HEIGHT // 2 + 20), fontsize=28, color="gold") screen.draw.text("Press R to play again", center=(WIDTH // 2, HEIGHT * 3 // 4), fontsize=22, color="grey")
3 · Particle burst (from PZ-43 / PZ-46)
particles = [] def spawn_burst(cx, cy, count=14): for _ in range(count): particles.append({ "x": cx, "y": cy, "vx": random.uniform(-5, 5), "vy": random.uniform(-6, 0), "life": random.randint(10, 22), "color": random.choice(["gold", "white", "orange"]), }) # in update(): for p in particles: p["x"] += p["vx"] p["y"] += p["vy"] p["life"] -= 1 particles[:] = [p for p in particles if p["life"] > 0]
4 · Screen shake + hit flash (from PZ-44)
shake_timer = 0 flash_timer = 0 ox, oy = 0, 0 # trigger on impact: shake_timer = 14 flash_timer = 8 # in update(): if shake_timer > 0: shake_timer -= 1 ox, oy = random.randint(-7, 7), random.randint(-7, 7) else: ox, oy = 0, 0 if flash_timer > 0: flash_timer -= 1
5 · Sound effects
# Place .wav files in a sounds/ folder beside your script. # On a hit: sounds.catch.play() # On a miss: sounds.miss.play() # On level-up: sounds.levelup.play() # If no .wav files are available, wrap in try/except: try: sounds.catch.play() except Exception: pass # skip silently if the file is missing
Worked Example · Roti Canai Rain — Fully Polished
12 minThe story
Aiman takes the Part 1 prototype and adds every polish layer. The additions are marked with # NEW comments so you can spot them quickly.
# roti_rain_polished.py — capstone Part 2 (full polish) import pgzrun import random WIDTH = 480 HEIGHT = 320 TITLE = "Roti Canai Rain" state = "title" score = 0 lives = 3 best_score = 0 # NEW tray_x = 240 TRAY_Y = 290 TRAY_W = 70 roti_x = 240 roti_y = -30 BASE_SPEED = 4 shake_timer = 0 # NEW flash_timer = 0 # NEW ox, oy = 0, 0 # NEW particles = [] # NEW
def current_speed(): return BASE_SPEED + score // 10 def reset_game(): global score, lives, tray_x, roti_x, roti_y global shake_timer, flash_timer, ox, oy score = 0 lives = 3 tray_x = 240 roti_x = random.randint(30, WIDTH - 30) roti_y = -30 shake_timer = 0 flash_timer = 0 ox, oy = 0, 0 particles.clear() def spawn_burst(cx, cy): # NEW for _ in range(14): particles.append({ "x": cx, "y": cy, "vx": random.uniform(-5, 5), "vy": random.uniform(-6, 0), "life": random.randint(10, 22), "color": random.choice(["gold", "white", "orange"]), })
def update_play(): global roti_y, roti_x, lives, state, score global shake_timer, flash_timer, ox, oy, best_score if keyboard.left and tray_x > TRAY_W // 2 + 5: tray_x -= 5 if keyboard.right and tray_x < WIDTH - TRAY_W // 2 - 5: tray_x += 5 roti_y += current_speed() caught = (abs(roti_x - tray_x) < TRAY_W // 2 + 14 and abs(roti_y - TRAY_Y) < 22) if caught: score += 1 spawn_burst(roti_x, int(roti_y)) # NEW shake_timer = 10 # NEW flash_timer = 8 # NEW try: sounds.catch.play() # NEW except Exception: pass roti_x = random.randint(30, WIDTH - 30) roti_y = -30 elif roti_y > HEIGHT + 30: lives -= 1 shake_timer = 18 # NEW — bigger shake on miss try: sounds.miss.play() # NEW except Exception: pass roti_x = random.randint(30, WIDTH - 30) roti_y = -30 if lives <= 0: if score > best_score: # NEW best_score = score state = "game_over" # update shake and flash # NEW block if shake_timer > 0: shake_timer -= 1 ox, oy = random.randint(-7, 7), random.randint(-7, 7) else: ox, oy = 0, 0 if flash_timer > 0: flash_timer -= 1 # update particles # NEW block for p in particles: p["x"] += p["vx"] p["y"] += p["vy"] p["life"] -= 1 particles[:] = [p for p in particles if p["life"] > 0]
def on_key_down(key): global state if state == "title" and key == keys.SPACE: reset_game() state = "play" elif state == "play" and key == keys.P: state = "pause" elif state == "pause" and key == keys.P: state = "play" elif state == "game_over" and key == keys.R: state = "title" def draw_title(): screen.fill("black") screen.draw.text("ROTI CANAI RAIN", center=(240, 100), fontsize=44, color="gold") screen.draw.text("Catch the roti before it hits the floor!", center=(240, 160), fontsize=18, color="white") screen.draw.text("LEFT / RIGHT to move tray · P to pause", center=(240, 195), fontsize=16, color="grey") screen.draw.text("Press SPACE to start", center=(240, 230), fontsize=22, color="wheat") def draw_play(): bx, by = int(roti_x + ox), int(roti_y + oy) screen.draw.filled_circle((bx, by), 20, "wheat") if flash_timer > 0: # NEW — hit flash r = Rect((bx - 22, by - 22), (44, 44)) screen.draw.filled_rect(r, (255, 255, 255)) for p in particles: # NEW — particles screen.draw.filled_circle( (int(p["x"] + ox), int(p["y"] + oy)), 4, p["color"]) tray_r = Rect((tray_x + ox - TRAY_W//2, TRAY_Y + oy - 8), (TRAY_W, 16)) screen.draw.filled_rect(tray_r, (180, 120, 60)) screen.draw.text(f"Score:{score} Lives:{lives}", topleft=(10, 10), fontsize=22, color="white") def draw_game_over(): screen.fill((30, 0, 0)) screen.draw.text("GAME OVER", center=(240, 80), fontsize=52, color="crimson") screen.draw.text(f"Score: {score}", center=(240, 155), fontsize=34, color="white") screen.draw.text(f"Best: {best_score}", center=(240, 195), fontsize=26, color="gold") screen.draw.text("Press R to play again", center=(240, 240), fontsize=22, color="grey") def update(): if state == "play": update_play() def draw(): screen.fill("black") if state == "title": draw_title() elif state == "play": draw_play() elif state == "pause": draw_play() screen.draw.text("PAUSED — P to resume", center=(240, 160), fontsize=28, color="white") elif state == "game_over": draw_game_over() pgzrun.go()
Try It Yourself
13 minWork through the Polish Checklist in the Concept section. Add at least three of the five layers (title, game-over, particles, shake/flash, sound) to your own capstone.py.
Hint — where to start
Copy the globals block first (shake_timer, flash_timer, particles, best_score). Add them to reset_game(). Then add the particle spawn and timer ticks to the event that matters most in your game. Finally update draw() to render particles and the flash overlay.
Use the file-I/O skills from Level 2 to save best_score to a text file when the game ends, and load it at startup so the best score persists between sessions.
Hint
import os SAVE_FILE = "best_score.txt" def load_best(): if os.path.exists(SAVE_FILE): with open(SAVE_FILE, "r") as f: return int(f.read().strip()) return 0 def save_best(value): with open(SAVE_FILE, "w") as f: f.write(str(value)) best_score = load_best() # call at module level on startup
Mini-Challenge · Share & Play
8 minSwap computers (or share your file via USB / AirDrop / Google Drive) with a classmate. Play each other's games for two minutes. Then answer these three questions about the game you just played:
- What is the core mechanic? Does it feel clear within 10 seconds?
- Which juice effect had the most impact on the feel?
- One thing you would change or add if you were the developer.
Success criteria
Your classmate can start, play, die, and restart without any help from you. They can answer all three questions from memory after playing.
Show a sample peer-feedback script
These are the same questions professional game studios ask in playtests. If a player cannot answer question 1 within the first 10 seconds, the title screen or first few seconds of gameplay need more signposting. If no juice effect stood out in question 2, the effects may be too subtle — try increasing the shake magnitude or particle count.
Playtesting is a skill. You are not just playing — you are observing. Notice where your classmate hesitates or gets confused, not just where they have fun.
Recap · The Whole Journey
3 minYou started this module with a blank window and three lines of code. Over 48 lessons you built a complete game-development toolkit: actors and sprites, animation, collision detection, sound, physics, particle systems, screen shake, state machines, score persistence, and now a fully polished, shareable arcade game. That is the same toolkit professional indie developers use — you just learned it without the boilerplate.
Every technique you applied today started as a concept in an earlier lesson. The capstone is proof that you can combine them all when it matters.
Vocabulary Card
- polish
- The layer of visual, audio, and UX detail added after a prototype is proven fun. Polish never fixes a broken mechanic — it amplifies a working one.
- playtest
- Watching someone else play your game and noting where they succeed, fail, hesitate, or smile — without giving them any instructions.
- best score persistence
- Saving the highest score to a file so it survives between sessions. Implemented with
open()andos.path.exists()from Level 2. - game feel
- The overall sensation of playing a game — how responsive it is, how satisfying the feedback is, how fair it feels. Shaped by every technique in this module.
Homework
4 minThis is the final Pygame Zero lesson. Your homework is to finish and present your capstone game.
- Apply all five polish layers if you have not already.
- Add a comment block at the top listing your name, the game title, and three things you learned from this module.
- Share the finished
capstone.pywith your teacher as a file or screenshot.
Optional extension. Record a 30-second screen video of someone else playing your game. Watch it back — do they understand the goal immediately? Use what you see to make one final improvement.
Sample · capstone.py (header comment block)
# capstone.py # Developer: Aiman # Game title: Roti Canai Rain # Genre: Catcher # # Three things I learned in this module: # 1. Screen shake and particles make even a simple game feel dramatic. # 2. A state machine keeps code organised as the game grows. # 3. Playtesting reveals problems I never noticed when writing the code. # # All five polish layers applied: # - Polished title screen with tagline and controls # - Game-over screen with best score display # - Particle burst on catch # - Screen shake (small on catch, large on miss) # - Best score saved to best_score.txt import pgzrun import random import os # ... (full game code follows) ... pgzrun.go()
The comment block is the most important part of this homework. Reflecting on what you learned is how you make the knowledge stick. Every student's three lessons will be different — there is no single correct answer.