Learning Goals
3 minBy the end of this lesson you can:
- Represent a particle as a dictionary with
x,y,vx,vy, andlifekeys. - Update every particle each frame: move by velocity, reduce life, and remove dead ones.
- Spawn an explosion burst of 20 particles at a point and watch them spread and fade.
Warm-Up · List Filter Recap
5 minRemoving dead particles uses the same list filter you used for rocks in PZ-39. Predict the output:
sparks = [{"life": 3}, {"life": 0}, {"life": 1}, {"life": 0}] sparks[:] = [s for s in sparks if s["life"] > 0] print(len(sparks))
Show the answer
Output
2
Two sparks had life > 0; the two dead ones were removed. This one-liner is the core of every particle system.
New Concept · The Particle Life Cycle
12 minThink of a sparkler. The moment you light it, sparks shoot out in all directions. Each spark moves, slows, dims, and finally disappears. A particle system works the same way: we spawn, update, draw, and remove.
Particle data structure
# one particle = a dict { "x": 100.0, # current x position "y": 200.0, # current y position "vx": -2.5, # velocity: pixels per frame, horizontal "vy": -4.0, # velocity: pixels per frame, vertical "life": 30, # frames remaining before removal "colour": "orange", }
Spawning an explosion
import random import math def explode(cx, cy): for _ in range(20): angle = random.uniform(0, 2 * math.pi) speed = random.uniform(1.5, 5.0) particles.append({ "x": float(cx), "y": float(cy), "vx": math.cos(angle) * speed, "vy": math.sin(angle) * speed, "life": random.randint(15, 40), "colour": random.choice(["orange", "yellow", "red"]), })
Update and remove
def update(): for p in particles: p["x"] += p["vx"] p["y"] += p["vy"] p["vy"] += 0.1 # gentle gravity pulls sparks down p["life"] -= 1 particles[:] = [p for p in particles if p["life"] > 0]
Drawing — radius shrinks with life
def draw(): for p in particles: radius = max(1, p["life"] // 8) screen.draw.filled_circle( (int(p["x"]), int(p["y"])), radius, p["colour"] )
Worked Example · Click-to-Explode Demo
12 minAnjali wants sparks every time she clicks. Save as particles.py:
Part A — setup and explode
# particles.py — click for explosions import pgzrun import random import math WIDTH = 600 HEIGHT = 400 TITLE = "Particles" particles = [] def explode(cx, cy): for _ in range(25): angle = random.uniform(0, 2 * math.pi) speed = random.uniform(2.0, 6.0) particles.append({ "x": float(cx), "y": float(cy), "vx": math.cos(angle) * speed, "vy": math.sin(angle) * speed, "life": random.randint(20, 50), "colour": random.choice(["orange", "yellow", "red", "white"]), })
Part B — draw, update, events
def draw(): screen.fill("black") screen.draw.text( "Click anywhere to explode!", topleft=(10, 10), fontsize=20, color="gray", ) for p in particles: radius = max(1, p["life"] // 10) screen.draw.filled_circle( (int(p["x"]), int(p["y"])), radius, p["colour"] ) def update(): for p in particles: p["x"] += p["vx"] p["y"] += p["vy"] p["vy"] += 0.15 p["life"] -= 1 particles[:] = [p for p in particles if p["life"] > 0] def on_mouse_down(pos, button): explode(pos[0], pos[1]) pgzrun.go()
What you will see
Try It Yourself
13 minAdd a trail: every frame the mouse moves, spawn 1 small particle at the cursor position. Use on_mouse_move(pos). Set life = 15 and a single colour like "cyan".
def on_mouse_move(pos): particles.append({ "x": float(pos[0]), "y": float(pos[1]), "vx": 0.0, "vy": -0.5, "life": 15, "colour": "cyan", })
Make each particle fade from bright yellow to dark red as its life drops. Compute the colour as an RGB tuple: red is always 255, green is proportional to life divided by max life.
# in draw(), replace colour lookup: ratio = p["life"] / 50 # 50 = max life you assigned green = int(255 * ratio) colour = (255, green, 0) screen.draw.filled_circle((int(p["x"]), int(p["y"])), radius, colour)
Mini-Challenge · Arjun's Frozen Particles
8 minArjun's particles appear but never move and never disappear. Find the three bugs.
# arjun_particles.py — buggy
import pgzrun
import random, math
WIDTH = 600
HEIGHT = 400
particles = []
def explode(cx, cy):
for _ in range(10):
angle = random.uniform(0, 2 * math.pi)
particles.append({
"x": cx, "y": cy,
"vx": math.cos(angle),
"vy": math.sin(angle),
"life": 30,
})
def update():
for p in particles:
p["x"] += p["vx"]
p["y"] += p["vy"]
# bug 1: life is never decreased
# bug 2: dead particles never removed
def draw():
screen.fill("black")
for p in particles:
screen.draw.circle( # bug 3: outline only, hard to see on black
(p["x"], p["y"]), 3, "orange"
)
def on_mouse_down(pos, button):
explode(pos[0], pos[1])
pgzrun.go()It works if…
particles spread outward, shrink, then vanish after ~30 frames
Show the fix
# arjun_particles.py — fixed import pgzrun import random, math WIDTH = 600 HEIGHT = 400 particles = [] def explode(cx, cy): for _ in range(10): angle = random.uniform(0, 2 * math.pi) particles.append({ "x": float(cx), "y": float(cy), "vx": math.cos(angle) * 3, "vy": math.sin(angle) * 3, "life": 30, }) def update(): for p in particles: p["x"] += p["vx"] p["y"] += p["vy"] p["life"] -= 1 # fix 1: decrement life particles[:] = [p for p in particles if p["life"] > 0] # fix 2: remove dead def draw(): screen.fill("black") for p in particles: radius = max(1, p["life"] // 8) screen.draw.filled_circle( # fix 3: filled circle is visible on black (int(p["x"]), int(p["y"])), radius, "orange" ) def on_mouse_down(pos, button): explode(pos[0], pos[1]) pgzrun.go()
Bug 1: life was never decreased, so particles lived forever. Bug 2: the filter line was missing entirely. Bug 3: screen.draw.circle draws an outline — too thin to see on black; filled_circle is required.
Recap
3 minA particle is a small dictionary with position, velocity, and a life counter. Each frame: move by velocity, decrease life by 1, draw a circle whose radius shrinks with life, and filter out dead particles. An explosion spawns many particles at once with random angles and speeds using math.cos and math.sin.
Vocabulary Card
- particle
- A tiny visual element with position, velocity and a limited lifespan. Many particles together create effects like fire, explosions, and trails.
- vx / vy
- Horizontal and vertical velocity — how many pixels the particle moves per frame in each direction.
- life counter
- An integer that decrements each frame. When it reaches zero, the particle is removed from the list.
- math.cos / math.sin
- Convert an angle (in radians) into x and y components of a unit vector — the key to spreading particles in all directions.
Homework
4 minAdd a particle explosion to the endless runner from PZ-39. When a rock collision occurs, call explode(rock["x"] + 10, GROUND_Y) before setting game_over = True. The explosion should be visible for at least half a second before the game-over screen appears. Use a short delay — a death_timer counter that counts down in update() before game_over is set — so the player can see the burst. Bring a screenshot of the explosion frame.
Sample · death delay with explosion
import math, random particles = [] death_timer = 0 # frames until game_over is set def explode(cx, cy): for _ in range(20): angle = random.uniform(0, 2 * math.pi) spd = random.uniform(2.0, 5.0) particles.append({ "x": float(cx), "y": float(cy), "vx": math.cos(angle) * spd, "vy": math.sin(angle) * spd, "life": random.randint(20, 40), "colour": random.choice(["orange", "yellow", "red"]), }) # in update(), replace instant game_over with: for rock in rocks: r_rect = Rect((rock["x"] + 2, GROUND_Y - rock["h"]), (16, rock["h"])) if p_rect.colliderect(r_rect) and death_timer == 0: explode(rock["x"] + 10, GROUND_Y) globals()["death_timer"] = 40 # ~0.67 s at 60 fps if death_timer > 0: death_timer -= 1 if death_timer == 0: globals()["game_over"] = True # in update(), also update particles: for p in particles: p["x"] += p["vx"] p["y"] += p["vy"] p["vy"] += 0.15 p["life"] -= 1 particles[:] = [p for p in particles if p["life"] > 0] # in draw(), after drawing rocks: for p in particles: r = max(1, p["life"] // 8) screen.draw.filled_circle((int(p["x"]), int(p["y"])), r, p["colour"])
The death_timer gap lets the explosion play out visually before the game-over screen covers it. This pattern — "show effect, then change state" — appears in almost every professional game.