Learning Goals
3 minBy the end of this lesson you can:
- Apply screen shake, hit flash, and particle bursts to a game simultaneously without breaking its existing logic.
- Use
sounds.x.play()to add audio feedback on key events (hit, score milestone, death). - Explain in one sentence why each "juicy" effect makes the game feel better.
Warm-Up · Quick Concepts Check
5 minBefore the timer starts, answer these three questions — no looking at notes. Write them on paper or say them aloud to a partner.
- How many variables do you need for screen shake, and what do they do?
- What makes a hit-flash fade away automatically?
- In which function do you add a new particle to the list?
Check your answers
- Three:
shake_timer(counts down),oxandoy(the offset applied each frame). - A countdown timer —
flash_timerdecrements inupdate(); the flash rectangle is only drawn while it is above zero. - In whichever function handles the triggering event — usually
on_mouse_down()or the collision check insideupdate().
Skill Recap · The Juice Checklist
12 minGame developers call this "juice" — the layer of sensory detail that makes interactions feel weighty. Here is the checklist you will use during the timed challenge.
Screen shake
shake_timer = 0 ox, oy = 0, 0 def trigger_shake(frames=14): global shake_timer shake_timer = frames # in update(): if shake_timer > 0: shake_timer -= 1 ox, oy = random.randint(-7, 7), random.randint(-7, 7) else: ox, oy = 0, 0
Hit flash
flash_timer = 0 def trigger_flash(frames=8): global flash_timer flash_timer = frames # in update(): if flash_timer > 0: flash_timer -= 1 # in draw() after the sprite: if flash_timer > 0: r = Rect((sprite.x - 28, sprite.y - 28), (56, 56)) screen.draw.filled_rect(r, (255, 255, 255))
Particle burst
particles = [] def spawn_burst(cx, cy, count=12): for _ in range(count): particles.append({ "x": cx, "y": cy, "vx": random.uniform(-4, 4), "vy": random.uniform(-5, 1), "life": random.randint(10, 20), }) # 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]
Sound feedback
# Place hit.wav in a sounds/ folder next to your script, then: sounds.hit.play() # on hit event # For a score milestone: if score % 5 == 0: sounds.fanfare.play()
Worked Example · Before & After
12 minBefore — plain catcher (no juice)
# plain_catcher.py — no juice import pgzrun import random WIDTH = 480 HEIGHT = 320 ball_x = 240 ball_y = -30 score = 0 def update(): global ball_y, ball_x ball_y += 4 if ball_y > HEIGHT + 30: ball_y = -30 ball_x = random.randint(30, WIDTH - 30) def on_mouse_down(pos, button): global score, ball_y, ball_x dist = ((pos[0]-ball_x)**2 + (pos[1]-ball_y)**2)**0.5 if dist < 28: score += 1 ball_y = -30 ball_x = random.randint(30, WIDTH - 30) def draw(): screen.fill("black") screen.draw.filled_circle((ball_x, ball_y), 26, "crimson") screen.draw.text(f"Score:{score}", topleft=(10,10), fontsize=22, color="white") pgzrun.go()
After — juiced catcher (shake + flash + particles)
# juicy_catcher.py — fully juiced import pgzrun import random WIDTH = 480 HEIGHT = 320 ball_x, ball_y = 240, -30 score = 0 shake_timer = 0 flash_timer = 0 ox, oy = 0, 0 particles = []
def spawn_burst(cx, cy): for _ in range(14): particles.append({ "x": cx, "y": cy, "vx": random.uniform(-5, 5), "vy": random.uniform(-6, 1), "life": random.randint(8, 18), }) def update(): global ball_y, ball_x, shake_timer, flash_timer, ox, oy ball_y += 4 if ball_y > HEIGHT + 30: ball_y = -30 ball_x = random.randint(30, WIDTH - 30) 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 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_mouse_down(pos, button): global score, ball_y, ball_x, shake_timer, flash_timer dist = ((pos[0]-ball_x)**2 + (pos[1]-ball_y)**2)**0.5 if dist < 28: score += 1 spawn_burst(ball_x, ball_y) shake_timer = 14 flash_timer = 8 ball_y = -30 ball_x = random.randint(30, WIDTH - 30) def draw(): screen.fill("black") bx, by = ball_x + ox, ball_y + oy screen.draw.filled_circle((bx, by), 26, "crimson") if flash_timer > 0: r = Rect((bx-28, by-28), (56, 56)) screen.draw.filled_rect(r, (255, 255, 255)) for p in particles: alpha = max(0, p["life"] * 13) screen.draw.filled_circle( (int(p["x"] + ox), int(p["y"] + oy)), 4, (255, 200, 0)) screen.draw.text(f"Score:{score}", topleft=(10,10), fontsize=22, color="white") pgzrun.go()
Try It Yourself
13 minCopy juicy_catcher.py and change the particle colour from gold to a mix: half spawn as "white" and half as "orange". Use random.choice.
Hint
color = random.choice(["white", "orange"]) particles.append({ "x": cx, "y": cy, "vx": random.uniform(-5, 5), "vy": random.uniform(-6, 1), "life": random.randint(8, 18), "color": color, }) # in draw(): screen.draw.filled_circle( (int(p["x"] + ox), int(p["y"] + oy)), 4, p["color"])
Track a streak counter that increases each time the player hits the ball without missing. Make the shake strength grow with the streak: min(streak * 2, 20) pixels of maximum offset.
Hint
streak = 0 # on hit: streak += 1 mag = min(streak * 2, 20) ox = random.randint(-mag, mag) oy = random.randint(-mag, mag) # on miss (ball exits bottom): streak = 0
🔥 Timed Challenge · 30-Minute Juice Sprint
8 minOpen any game you built earlier in this module. Your brief:
- Add screen shake on at least one impactful event.
- Add a hit flash on the affected sprite.
- Add a particle burst at the hit location.
- Bonus: Add one sound effect using
sounds.x.play().
Success criteria
1. Hitting a target produces visible screen shake (≥ 10 frames). 2. The hit sprite flashes white for ≥ 6 frames. 3. At least 8 particles appear at the hit location and fade out. 4. (Bonus) A sound plays on the event.
Timer: 30 minutes. Save as juicy_<gamename>.py.
Show the juice integration checklist (reveal after the timer)
- Add
shake_timer = 0,ox, oy = 0, 0to your globals. - Add
flash_timer = 0to your globals. - Add
particles = []to your globals. - In
update(): tick down both timers; update particle positions; remove dead particles. - In the hit event: call
trigger_shake(14),trigger_flash(8),spawn_burst(cx, cy), optionallysounds.hit.play(). - In
draw(): shift all positions byox, oy; draw particles; draw flash overlay.
Six steps. Most of the code is copy-and-paste from the worked example — the skill is knowing where to slot each piece into your specific game.
Recap
3 min"Juice" is not one trick — it is a stack of small, cheap effects that fire together on impact: screen shake, hit flash, and a particle burst. Each effect follows the same pattern: a timer you count down in update() and a drawing call you guard with an if timer > 0 check in draw(). Stacking them makes every action feel powerful without changing the actual game rules.
Vocabulary Card
- juice
- The collection of visual, audio, and haptic reactions layered on top of game logic to make actions feel satisfying.
- effect timer
- A countdown variable (e.g.
shake_timer,flash_timer) that enables an effect while positive and disables it when it reaches zero. - particle burst
- A group of short-lived moving dots spawned at an impact point, each with a random velocity and a life counter.
- sounds.x.play()
- Plays the file
sounds/x.wavonce. Combine with effect timers so sound and visuals fire at the same moment.
Homework
4 minIf you did not finish the 30-minute challenge in class, complete it at home. Bring juicy_<gamename>.py to the next lesson — you will use it as the starting point for the capstone project in PZ-47.
Reflection. Write two sentences in a comment at the top of your file: which effect had the biggest impact on the feel of the game, and why?
Sample · juicy_asteroids.py (reflection comment)
# juicy_asteroids.py — asteroid destroyer with full juice # Reflection: screen shake had the biggest impact because it makes every # explosion feel like a real physical force, not just a sprite disappearing. # The particle burst looks great but the shake is what makes you feel it. import pgzrun import random # ... rest of the game ... pgzrun.go()
The reflection is personal — there is no single correct answer. The point is to think about why an effect works, not just how to code it.