Particle system in code
Implement a burst particle emitter in pygame — random velocity spread, lifetime tracking, and alpha fade-out rendered with pygame.draw.circle.
- Implement a Particle dataclass with pos, vel, lifetime, and colour fields
- Write an Emitter that spawns N particles with randomised direction and speed
- Age particles each frame and remove those with expired lifetimes
- Render each particle as a circle scaled by remaining life fraction
Click anywhere in the window to trigger a burst explosion at that point. Each click spawns a fresh batch of particles that spread outward, slow down under optional gravity, and fade out over their lifetime.
Pyodide (the in-browser Python runner) does not support pygame's display
system. Read through this code carefully in the browser, then run it locally:
pip install pygame followed by python particles.py.
Implementation
import pygame
import random
import math
import sys
from dataclasses import dataclass, field
pygame.init()
screen = pygame.display.set_mode((640, 480))
pygame.display.set_caption("Particle emitter")
clock = pygame.time.Clock()
Vector2 = pygame.math.Vector2
# ── Particle ──────────────────────────────────────────────────────────────────
@dataclass
class Particle:
pos: Vector2
vel: Vector2
lifetime: float # seconds remaining
max_life: float # total lifetime at birth (for normalisation)
colour: tuple # (R, G, B)
radius: float # base radius in pixels
def life_frac(self):
"""0.0 = just died, 1.0 = freshly spawned."""
return max(0.0, self.lifetime / self.max_life)
def update(self, dt):
# Optional gravity pull
self.vel.y += 120 * dt
self.pos += self.vel * dt
self.lifetime -= dt
def draw(self, surface):
frac = self.life_frac()
r = max(1, int(self.radius * frac))
# Fade colour toward dark by multiplying channels by frac
colour = tuple(int(c * frac) for c in self.colour)
# Draw onto a temporary surface to support per-particle alpha
tmp = pygame.Surface((r * 2, r * 2), pygame.SRCALPHA)
alpha = int(220 * frac)
pygame.draw.circle(tmp, (*colour, alpha), (r, r), r)
surface.blit(tmp, (int(self.pos.x) - r, int(self.pos.y) - r))
# ── Emitter ───────────────────────────────────────────────────────────────────
PARTICLE_COLOURS = [
(255, 200, 60), # bright yellow
(255, 140, 20), # orange
(220, 60, 20), # red-orange
(255, 255, 180), # white-yellow
]
def emit_burst(x, y, count=40):
"""
Spawn `count` particles at (x, y) with uniformly random directions
and speeds in [MIN_SPEED, MAX_SPEED].
"""
MIN_SPEED = 60
MAX_SPEED = 240
particles = []
for _ in range(count):
angle = random.uniform(0, 2 * math.pi)
speed = random.uniform(MIN_SPEED, MAX_SPEED)
vel = Vector2(math.cos(angle) * speed, math.sin(angle) * speed)
lifetime = random.uniform(0.6, 1.4)
colour = random.choice(PARTICLE_COLOURS)
radius = random.uniform(2.5, 5.5)
particles.append(Particle(
pos = Vector2(x, y),
vel = vel,
lifetime = lifetime,
max_life = lifetime,
colour = colour,
radius = radius,
))
return particles
# ── Main loop ─────────────────────────────────────────────────────────────────
all_particles = []
running = True
while running:
dt = min(clock.tick(60) / 1000.0, 0.05)
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
if event.type == pygame.MOUSEBUTTONDOWN:
mx, my = pygame.mouse.get_pos()
all_particles.extend(emit_burst(mx, my))
# Update and cull dead particles
all_particles = [p for p in all_particles if p.lifetime > 0]
for p in all_particles:
p.update(dt)
screen.fill((15, 15, 25))
for p in all_particles:
p.draw(screen)
font = pygame.font.SysFont(None, 24)
hud = font.render(
f"Click to explode particles: {len(all_particles)}", True, (180, 180, 180))
screen.blit(hud, (10, 10))
pygame.display.flip()
pygame.quit()
sys.exit()Design notes
life_frac() normalises the remaining lifetime to 0–1. This single value
drives both the radius shrink and the colour fade. Any visual attribute that
should taper over a particle's lifetime can multiply by life_frac().
Per-particle SRCALPHA surface for alpha. pygame.draw.circle does not
accept a per-pixel alpha argument directly. Drawing onto a small SRCALPHA
surface and then blitting it allows clean fade-out. For large particle counts,
pre-render a set of circle surfaces at various sizes and cache them.
Culling with a list comprehension — [p for p in ... if p.lifetime > 0] —
creates a new list each frame, which allocates and discards memory. For moderate
counts (< 200 alive particles) this is fine. For heavy effects, maintain an
index into a pre-allocated array and skip dead entries.
Gravity is optional. Setting the vel.y += 120 * dt line to 0 produces
a pure radial burst. Increasing it to 400+ creates sparks that arc quickly
downward. Adjust per-effect.
Each particle draws to its own temporary SRCALPHA surface. This is clean
but not the fastest approach. If you need hundreds of particles per frame,
look into pygame.surfarray for batch rendering, or reduce particle count
and increase radius to maintain visual density.
Where to go next
Next: screen effects — screen shake, hit flash, and fade-to-black transitions — three cheap techniques that dramatically improve game feel.
Particle systems
A particle system is an emitter that spawns, moves, ages, and culls short-lived visual elements — explosions, smoke, sparks, and rain all follow the same pattern.
Screen effects
Screen shake, hit flash, and fade-to-black are three cheap techniques that dramatically improve game feel without touching game logic.