Code of the Day
AdvancedVisual Effects

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.

Game DevAdvanced12 min read
Recommended first
By the end of this lesson you will be able to:
  • 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.

Finished reading? Mark it complete to track your progress.

On this page