Skip to main content

Particle Effects

Creating Magic with Particles

Particle effects bring your game to life with explosions, fire, smoke, sparks, and magical effects! They're the visual spice that makes actions feel impactful and worlds feel alive. Let's master the art of particle systems! āœØšŸŽ†

Understanding Particle Systems

šŸŽ† The Fireworks Analogy

Think of particle systems like fireworks:

graph TD A["Particle Systems"] --> B["Emitters"] A --> C["Particles"] A --> D["Physics"] A --> E["Rendering"] B --> F["Point/Area/Line"] B --> G["Burst/Continuous"] C --> H["Properties"] C --> I["Lifecycle"] D --> J["Forces"] D --> K["Collisions"] E --> L["Blending Modes"] E --> M["Optimization"]

Interactive Particle Effects Playground

Click to create effects! Explore different particle systems!

Effect: Explosion | Particles: 0

Basic Particle System

import pygame
import random
import math

class Particle:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.vx = random.uniform(-2, 2)
        self.vy = random.uniform(-5, -1)
        self.lifetime = random.uniform(0.5, 1.5)
        self.size = random.randint(2, 5)
        self.color = (255, random.randint(100, 255), 0)
        self.gravity = 0.2
    
    def update(self, dt):
        # Physics
        self.vx *= 0.99  # Air resistance
        self.vy += self.gravity
        
        # Movement
        self.x += self.vx
        self.y += self.vy
        
        # Age
        self.lifetime -= dt
        
        # Fade color
        fade_factor = max(0, self.lifetime)
        self.color = (
            int(255 * fade_factor),
            int(self.color[1] * fade_factor),
            0
        )
        
        return self.lifetime > 0
    
    def draw(self, screen):
        if self.lifetime > 0:
            pygame.draw.circle(screen, self.color, 
                             (int(self.x), int(self.y)), 
                             self.size)

class ParticleSystem:
    def __init__(self):
        self.particles = []
    
    def emit(self, x, y, count=10):
        """Create new particles"""
        for _ in range(count):
            self.particles.append(Particle(x, y))
    
    def update(self, dt):
        """Update all particles"""
        # Update particles and remove dead ones
        self.particles = [p for p in self.particles if p.update(dt)]
    
    def draw(self, screen):
        """Draw all particles"""
        for particle in self.particles:
            particle.draw(screen)
    
    def clear(self):
        """Remove all particles"""
        self.particles.clear()
    
    def count(self):
        """Get particle count"""
        return len(self.particles)

Advanced Particle Effects

# Configurable particle system
class ConfigurableParticle:
    def __init__(self, x, y, config):
        self.x = x
        self.y = y
        
        # Velocity with spread
        angle = random.uniform(config.angle - config.spread, 
                             config.angle + config.spread)
        speed = random.uniform(config.speed_min, config.speed_max)
        self.vx = math.cos(angle) * speed
        self.vy = math.sin(angle) * speed
        
        # Properties
        self.lifetime = random.uniform(config.lifetime_min, config.lifetime_max)
        self.max_lifetime = self.lifetime
        self.size = random.uniform(config.size_min, config.size_max)
        self.start_size = self.size
        
        # Color
        if config.color_variation:
            h = config.hue + random.randint(-config.hue_variation, config.hue_variation)
            s = config.saturation
            v = config.value
            self.color = self.hsv_to_rgb(h, s, v)
        else:
            self.color = config.color
        
        # Physics
        self.gravity = config.gravity
        self.damping = config.damping
        self.wind = config.wind
        
        # Behavior flags
        self.fade = config.fade
        self.shrink = config.shrink
        self.glow = config.glow
    
    def hsv_to_rgb(self, h, s, v):
        """Convert HSV to RGB"""
        import colorsys
        r, g, b = colorsys.hsv_to_rgb(h/360, s, v)
        return (int(r*255), int(g*255), int(b*255))
    
    def update(self, dt):
        # Apply forces
        self.vy += self.gravity * dt
        self.vx += self.wind * dt
        
        # Apply damping
        self.vx *= (1 - self.damping * dt)
        self.vy *= (1 - self.damping * dt)
        
        # Update position
        self.x += self.vx * dt
        self.y += self.vy * dt
        
        # Update lifetime
        self.lifetime -= dt
        
        # Update size
        if self.shrink:
            life_ratio = self.lifetime / self.max_lifetime
            self.size = self.start_size * life_ratio
        
        return self.lifetime > 0
    
    def draw(self, screen):
        # Calculate alpha
        alpha = 255
        if self.fade:
            alpha = int(255 * (self.lifetime / self.max_lifetime))
        
        # Draw glow effect
        if self.glow and alpha > 0:
            glow_surf = pygame.Surface((self.size * 4, self.size * 4), pygame.SRCALPHA)
            pygame.draw.circle(glow_surf, (*self.color, alpha // 4),
                             (self.size * 2, self.size * 2), self.size * 2)
            screen.blit(glow_surf, (self.x - self.size * 2, self.y - self.size * 2),
                       special_flags=pygame.BLEND_ADD)
        
        # Draw particle
        if alpha > 0:
            particle_surf = pygame.Surface((self.size * 2, self.size * 2), pygame.SRCALPHA)
            pygame.draw.circle(particle_surf, (*self.color, alpha),
                             (self.size, self.size), self.size)
            screen.blit(particle_surf, (self.x - self.size, self.y - self.size))

class ParticleConfig:
    """Configuration for particle behavior"""
    def __init__(self):
        # Emission
        self.angle = -math.pi / 2  # Upward
        self.spread = math.pi / 4  # 45 degree spread
        self.speed_min = 50
        self.speed_max = 150
        
        # Lifetime
        self.lifetime_min = 0.5
        self.lifetime_max = 1.5
        
        # Appearance
        self.size_min = 2
        self.size_max = 6
        self.color = (255, 100, 0)
        self.hue = 30  # Orange
        self.saturation = 1.0
        self.value = 1.0
        self.color_variation = True
        self.hue_variation = 30
        
        # Physics
        self.gravity = 100
        self.damping = 0.1
        self.wind = 0
        
        # Behavior
        self.fade = True
        self.shrink = True
        self.glow = False

# Particle emitter
class ParticleEmitter:
    def __init__(self, x, y, config, system):
        self.x = x
        self.y = y
        self.config = config
        self.system = system
        self.active = True
        
        # Emission settings
        self.emission_rate = 30  # particles per second
        self.burst_count = 0  # 0 for continuous
        self.emission_timer = 0
        
        # Lifetime
        self.lifetime = -1  # -1 for infinite
        self.age = 0
    
    def update(self, dt):
        if not self.active:
            return
        
        # Update age
        self.age += dt
        
        # Check lifetime
        if self.lifetime > 0 and self.age >= self.lifetime:
            self.active = False
            return
        
        # Emit particles
        if self.burst_count > 0:
            # Burst emission
            for _ in range(self.burst_count):
                self.emit_particle()
            self.active = False  # One-shot burst
        else:
            # Continuous emission
            self.emission_timer += dt
            particles_to_emit = int(self.emission_timer * self.emission_rate)
            
            if particles_to_emit > 0:
                for _ in range(particles_to_emit):
                    self.emit_particle()
                self.emission_timer -= particles_to_emit / self.emission_rate
    
    def emit_particle(self):
        """Create a single particle"""
        # Add position variation
        x = self.x + random.uniform(-5, 5)
        y = self.y + random.uniform(-5, 5)
        
        particle = ConfigurableParticle(x, y, self.config)
        self.system.particles.append(particle)

Specialized Particle Effects

# Fire effect
class FireEffect(ParticleEmitter):
    def __init__(self, x, y, system):
        config = ParticleConfig()
        config.angle = -math.pi / 2
        config.spread = math.pi / 6
        config.speed_min = 30
        config.speed_max = 60
        config.lifetime_min = 0.3
        config.lifetime_max = 0.8
        config.size_min = 3
        config.size_max = 8
        config.hue = 30
        config.hue_variation = 30
        config.gravity = -50  # Fire rises
        config.wind = random.uniform(-20, 20)
        config.fade = True
        config.shrink = True
        config.glow = True
        
        super().__init__(x, y, config, system)
        self.emission_rate = 50

# Smoke effect
class SmokeEffect(ParticleEmitter):
    def __init__(self, x, y, system):
        config = ParticleConfig()
        config.angle = -math.pi / 2
        config.spread = math.pi / 4
        config.speed_min = 10
        config.speed_max = 30
        config.lifetime_min = 1.0
        config.lifetime_max = 2.0
        config.size_min = 10
        config.size_max = 20
        config.color = (100, 100, 100)
        config.color_variation = False
        config.gravity = -20
        config.wind = 10
        config.fade = True
        config.shrink = False
        config.glow = False
        
        super().__init__(x, y, config, system)
        self.emission_rate = 20

# Explosion effect
class ExplosionEffect:
    def __init__(self, x, y, system):
        self.x = x
        self.y = y
        self.system = system
        self.create_explosion()
    
    def create_explosion(self):
        # Main explosion particles
        config = ParticleConfig()
        config.spread = math.pi * 2  # Full circle
        config.speed_min = 100
        config.speed_max = 300
        config.lifetime_min = 0.3
        config.lifetime_max = 0.6
        config.size_min = 3
        config.size_max = 8
        config.hue = 30
        config.hue_variation = 60
        config.gravity = 50
        config.damping = 0.5
        config.fade = True
        config.shrink = True
        config.glow = True
        
        # Create burst
        for _ in range(50):
            particle = ConfigurableParticle(self.x, self.y, config)
            self.system.particles.append(particle)
        
        # Add smoke
        smoke_config = ParticleConfig()
        smoke_config.spread = math.pi * 2
        smoke_config.speed_min = 20
        smoke_config.speed_max = 50
        smoke_config.lifetime_min = 1.0
        smoke_config.lifetime_max = 2.0
        smoke_config.size_min = 15
        smoke_config.size_max = 25
        smoke_config.color = (50, 50, 50)
        smoke_config.color_variation = False
        smoke_config.gravity = -10
        smoke_config.fade = True
        
        for _ in range(20):
            particle = ConfigurableParticle(self.x, self.y, smoke_config)
            self.system.particles.append(particle)

# Magic sparkles
class MagicSparkles(ParticleEmitter):
    def __init__(self, x, y, system):
        config = ParticleConfig()
        config.spread = math.pi * 2
        config.speed_min = 20
        config.speed_max = 50
        config.lifetime_min = 0.5
        config.lifetime_max = 1.5
        config.size_min = 1
        config.size_max = 3
        config.hue = 270  # Purple
        config.hue_variation = 60
        config.gravity = -20
        config.fade = True
        config.shrink = True
        config.glow = True
        
        super().__init__(x, y, config, system)
        self.emission_rate = 30
        
    def emit_particle(self):
        """Override to add spiral motion"""
        particle = ConfigurableParticle(self.x, self.y, self.config)
        
        # Add spiral motion
        angle = self.age * 5
        radius = 30
        offset_x = math.cos(angle) * radius
        offset_y = math.sin(angle) * radius
        
        particle.x += offset_x
        particle.y += offset_y
        
        self.system.particles.append(particle)

Complete Particle System Demo

import pygame
import random
import math

class ParticleSystemDemo:
    def __init__(self):
        pygame.init()
        self.screen = pygame.display.set_mode((800, 600))
        pygame.display.set_caption("Particle Effects Demo")
        self.clock = pygame.time.Clock()
        
        # Particle system
        self.particle_system = AdvancedParticleSystem()
        
        # Emitters
        self.emitters = []
        
        # Demo settings
        self.current_effect = 'fire'
        self.show_info = True
        
        # Create initial effects
        self.setup_demo_effects()
    
    def setup_demo_effects(self):
        """Create demonstration effects"""
        # Campfire
        fire = FireEffect(200, 400, self.particle_system)
        self.emitters.append(fire)
        
        # Magic fountain
        magic = MagicSparkles(600, 400, self.particle_system)
        self.emitters.append(magic)
    
    def handle_events(self):
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                return False
            elif event.type == pygame.MOUSEBUTTONDOWN:
                x, y = pygame.mouse.get_pos()
                
                if event.button == 1:  # Left click
                    self.create_effect(self.current_effect, x, y)
                elif event.button == 3:  # Right click
                    self.create_effect('explosion', x, y)
            elif event.type == pygame.KEYDOWN:
                if event.key == pygame.K_1:
                    self.current_effect = 'fire'
                elif event.key == pygame.K_2:
                    self.current_effect = 'smoke'
                elif event.key == pygame.K_3:
                    self.current_effect = 'explosion'
                elif event.key == pygame.K_4:
                    self.current_effect = 'magic'
                elif event.key == pygame.K_5:
                    self.current_effect = 'sparks'
                elif event.key == pygame.K_c:
                    self.particle_system.clear()
                    self.emitters.clear()
                elif event.key == pygame.K_i:
                    self.show_info = not self.show_info
        
        return True
    
    def create_effect(self, effect_type, x, y):
        """Create particle effect at position"""
        if effect_type == 'fire':
            emitter = FireEffect(x, y, self.particle_system)
            emitter.lifetime = 3  # 3 seconds
            self.emitters.append(emitter)
        elif effect_type == 'smoke':
            emitter = SmokeEffect(x, y, self.particle_system)
            emitter.lifetime = 3
            self.emitters.append(emitter)
        elif effect_type == 'explosion':
            ExplosionEffect(x, y, self.particle_system)
        elif effect_type == 'magic':
            emitter = MagicSparkles(x, y, self.particle_system)
            emitter.lifetime = 3
            self.emitters.append(emitter)
        elif effect_type == 'sparks':
            self.create_spark_burst(x, y)
    
    def create_spark_burst(self, x, y):
        """Create burst of sparks"""
        config = ParticleConfig()
        config.spread = math.pi * 2
        config.speed_min = 100
        config.speed_max = 200
        config.lifetime_min = 0.5
        config.lifetime_max = 1.0
        config.size_min = 1
        config.size_max = 2
        config.color = (255, 255, 100)
        config.gravity = 200
        config.fade = True
        config.glow = True
        
        for _ in range(30):
            particle = ConfigurableParticle(x, y, config)
            self.particle_system.particles.append(particle)
    
    def update(self, dt):
        # Update emitters
        for emitter in self.emitters[:]:
            emitter.update(dt)
            if not emitter.active:
                self.emitters.remove(emitter)
        
        # Update particle system
        self.particle_system.update(dt)
    
    def draw(self):
        # Clear screen
        self.screen.fill((20, 20, 30))
        
        # Draw ground
        pygame.draw.rect(self.screen, (40, 40, 40), (0, 450, 800, 150))
        
        # Draw particles
        self.particle_system.draw(self.screen)
        
        # Draw emitter positions
        for emitter in self.emitters:
            if emitter.active:
                pygame.draw.circle(self.screen, (255, 255, 0, 50),
                                 (int(emitter.x), int(emitter.y)), 5, 1)
        
        # Draw UI
        if self.show_info:
            self.draw_ui()
    
    def draw_ui(self):
        """Draw user interface"""
        font = pygame.font.Font(None, 24)
        
        # Effect selection
        effects = ['1: Fire', '2: Smoke', '3: Explosion', '4: Magic', '5: Sparks']
        y_offset = 10
        
        for i, text in enumerate(effects):
            color = (255, 255, 0) if text.split(':')[1].strip().lower() == self.current_effect else (200, 200, 200)
            rendered = font.render(text, True, color)
            self.screen.blit(rendered, (10, y_offset))
            y_offset += 30
        
        # Instructions
        instructions = [
            f"Current: {self.current_effect}",
            f"Particles: {self.particle_system.count()}",
            f"Emitters: {len(self.emitters)}",
            "",
            "Left Click: Create Effect",
            "Right Click: Explosion",
            "C: Clear All",
            "I: Toggle Info"
        ]
        
        y_offset = 10
        for text in instructions:
            rendered = font.render(text, True, (255, 255, 255))
            self.screen.blit(rendered, (600, y_offset))
            y_offset += 25
    
    def run(self):
        running = True
        dt = 0
        
        while running:
            running = self.handle_events()
            self.update(dt / 1000.0)  # Convert to seconds
            self.draw()
            pygame.display.flip()
            dt = self.clock.tick(60)
        
        pygame.quit()

class AdvancedParticleSystem:
    def __init__(self):
        self.particles = []
        self.max_particles = 5000
    
    def update(self, dt):
        """Update all particles"""
        # Update particles and remove dead ones
        self.particles = [p for p in self.particles 
                         if p.update(dt)]
        
        # Limit particle count
        if len(self.particles) > self.max_particles:
            self.particles = self.particles[-self.max_particles:]
    
    def draw(self, screen):
        """Draw all particles with blending"""
        # Create surface for additive blending
        particle_surf = pygame.Surface(screen.get_size())
        particle_surf.fill((0, 0, 0))
        
        # Draw particles to surface
        for particle in self.particles:
            particle.draw(particle_surf)
        
        # Blend with main screen
        screen.blit(particle_surf, (0, 0), special_flags=pygame.BLEND_ADD)
    
    def clear(self):
        """Remove all particles"""
        self.particles.clear()
    
    def count(self):
        """Get particle count"""
        return len(self.particles)

if __name__ == "__main__":
    demo = ParticleSystemDemo()
    demo.run()

Performance Optimization

⚔ Particle System Optimization

Practice Exercises

šŸŽÆ Particle Effect Challenges!

  1. Weather System: Create rain, snow, and fog effects
  2. Magic Spells: Design unique spell particle effects
  3. Destruction: Particle-based destruction system
  4. Trail System: Smooth trails behind moving objects
  5. Fluid Simulation: Simple water/smoke using particles
  6. Fireworks Show: Choreographed fireworks display

Key Takeaways

Congratulations! šŸŽ‰

šŸ† Section Complete!

You've completed the Sprite Management section! You've learned:

You now have all the tools to create visually rich, well-organized games with beautiful graphics and effects!

šŸ‹ļøā€ā™‚ļø Practice Exercise

šŸ‹ļøā€ā™‚ļø Exercise 1: Fountain of 200 — Per-Particle Random Spawn, Bool-Return Cull, Lifetime-Fade in One Pygame Window

Objective: Build a single ~70-line pygame program with an emitter at (400, 540) on an 800Ɨ600 screen. Each press of SPACE spawns a fountain BURST of 200 short-lived particles — each particle holds (x, y, vx, vy, lifetime, max_lifetime, color, size) with vx/vy drawn from an upward cone (vx ∈ [āˆ’150, 150], vy ∈ [āˆ’450, āˆ’250]), lifetime ∈ [0.6, 1.4] seconds, color HSV-randomized in the warm range (hue ∈ [0, 60]), and size ∈ [2, 5]. Three orthogonal multi-particle simulation disciplines must be visible per frame: (a) per-particle random attribute spawn (every particle is independent at spawn — the variation in (vx, vy, lifetime, color, size) is what makes the burst feel organic; particles are the degenerate case of chat-63 ai_flocking where N is large but rule-count is 1 and inter-entity interaction is 0); (b) boolean-return-enables-one-line-cull during update (each Particle.update(dt) ends with return self.lifetime > 0, and ParticleSystem.update rebuilds its list each tick via self.particles = [p for p in self.particles if p.update(dt)] — a single comprehension that updates AND culls in one pass, no separate find-dead loop, no modify-while-iterating bug); (c) lifetime/max_lifetime → alpha fade during draw (max_lifetime is captured once at spawn because lifetime -= dt mutates each tick; the ratio alpha = int(255 * (lifetime / max_lifetime)) maps from 1.0 fresh to 0.0 dying regardless of which random spawn-lifetime each particle drew — the same shape as chat-46 platformer_camera's smooth-follow exponential decay and chat-49 polish_tweening's easing-on-t at per-particle scope). Particles render through per-particle pygame.Surface(..., pygame.SRCALPHA) so the alpha channel composites correctly. Gravity = 900 px/s² is positive (Pygame down-Y — chat-43 game_mathematics_coordinates) so vy += GRAVITY*dt produces the parabolic fountain arc. HUD shows tick count, alive-particle count, total spawn count, and one alive particle's lifetime/max_lifetime ratio — three orthogonal multi-particle simulation disciplines visible per frame as concrete numbers and visibly-fading particle alphas. CLOSES the effects module 0/1 → 1/1 = 10th complete Phase-8 module joining pygame_basics + sprites + game_mathematics + physics + platformer + polish + networking + architecture + ai, advancing module-completeness 9/13 → 10/13.

Instructions:

  1. Set up an 800Ɨ600 Pygame window at 60 FPS with a near-black background (10, 10, 20) and a dark grey ground rect at y=555.
  2. Define a Particle class with attributes x, y, vx, vy, lifetime, max_lifetime, color, and size. Save self.max_lifetime = self.lifetime as a write-once spawn-time copy — the fade ratio in step 4 depends on it.
  3. Implement Particle.update(dt) to (a) apply gravity via self.vy += GRAVITY * dt with GRAVITY=900, (b) Euler-integrate position via self.x += self.vx * dt; self.y += self.vy * dt, (c) decrement lifetime via self.lifetime -= dt, and (d) return self.lifetime > 0 as the boolean alive-flag.
  4. Implement Particle.draw(dest) to render through a per-particle pygame.Surface((size*2, size*2), pygame.SRCALPHA) with alpha = int(255 * (self.lifetime / self.max_lifetime)) so the fade ratio drives the alpha channel; blit the per-particle surface onto the destination at (self.x - size, self.y - size) for centred placement.
  5. Define a ParticleSystem class holding self.particles = []. Implement ParticleSystem.update(dt) as a single line: self.particles = [p for p in self.particles if p.update(dt)] to update AND cull in one pass.
  6. Implement ParticleSystem.burst(x, y, n) to append n fresh Particle(x, y) instances; have the Particle's __init__ draw vx, vy, lifetime, color, and size from random.uniform / random.randint calls so each spawn is independent.
  7. In the main loop: handle QUIT and KEYDOWN K_SPACE (call system.burst(EMITTER_X, EMITTER_Y, 200)), call system.update(dt), fill the screen, draw the ground rect, call system.draw(screen), then render the HUD: tick count, alive-particle count, total-spawned count, and one alive particle's lifetime/max_lifetime ratio.
  8. Press SPACE multiple times rapidly to confirm bursts compound and decay independently — each burst's particles fade to alpha 0 over their own random spawn-lifetime, never via reset or extension; the HUD's alive-count climbs on each press and decays smoothly between presses as particles cull.
šŸ’” Hint

Three details that are easy to miss: (1) save self.max_lifetime = self.lifetime ONCE at spawn — lifetime mutates each tick (lifetime -= dt), so by draw time the original is gone; the ratio lifetime / max_lifetime only stays meaningful if max_lifetime is the spawn-time snapshot. (2) self.particles = [p for p in self.particles if p.update(dt)] UPDATES every particle (the comprehension calls p.update(dt) on each one) AND filters out dead ones (the if filter keeps only those whose update returned True) in a single pass — don't add a second find-dead loop. (3) per-particle fade requires a fresh pygame.Surface((s*2, s*2), pygame.SRCALPHA) per draw so the alpha channel composites correctly; calling pygame.draw.circle directly on the main screen surface ignores per-call alpha values because the screen surface is opaque RGB.

āœ… Example Solution
import pygame, random, colorsys

pygame.init()
SCREEN_W, SCREEN_H = 800, 600
EMITTER_X, EMITTER_Y = 400, 540
GRAVITY = 900           # px/s^2 (positive = falls in Pygame down-Y)
BURST = 200

screen = pygame.display.set_mode((SCREEN_W, SCREEN_H))
pygame.display.set_caption("Fountain of 200")
clock = pygame.time.Clock()
font = pygame.font.SysFont(None, 22)


def hsv_to_rgb(h, s, v):
    r, g, b = colorsys.hsv_to_rgb(h / 360.0, s, v)
    return (int(r * 255), int(g * 255), int(b * 255))


class Particle:
    def __init__(self, x, y):
        self.x, self.y = x, y
        self.vx = random.uniform(-150, 150)
        self.vy = random.uniform(-450, -250)
        self.lifetime = random.uniform(0.6, 1.4)
        self.max_lifetime = self.lifetime    # write-once spawn-time snapshot
        self.color = hsv_to_rgb(random.uniform(0, 60), 1.0, 1.0)
        self.size = random.randint(2, 5)

    def update(self, dt):
        self.vy += GRAVITY * dt              # positive gravity = falls
        self.x += self.vx * dt
        self.y += self.vy * dt
        self.lifetime -= dt
        return self.lifetime > 0             # boolean alive-flag

    def draw(self, dest):
        ratio = self.lifetime / self.max_lifetime    # 1.0 fresh, 0.0 dying
        alpha = max(0, min(255, int(255 * ratio)))
        s = self.size
        psurf = pygame.Surface((s * 2, s * 2), pygame.SRCALPHA)
        pygame.draw.circle(psurf, (*self.color, alpha), (s, s), s)
        dest.blit(psurf, (self.x - s, self.y - s))


class ParticleSystem:
    def __init__(self):
        self.particles = []
        self.spawned = 0

    def burst(self, x, y, n):
        for _ in range(n):
            self.particles.append(Particle(x, y))
        self.spawned += n

    def update(self, dt):
        # one-pass update + cull via list comprehension:
        self.particles = [p for p in self.particles if p.update(dt)]

    def draw(self, dest):
        for p in self.particles:
            p.draw(dest)


system = ParticleSystem()
tick = 0
running = True
while running:
    dt = clock.tick(60) / 1000.0
    tick += 1
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
            system.burst(EMITTER_X, EMITTER_Y, BURST)

    system.update(dt)
    screen.fill((10, 10, 20))
    pygame.draw.rect(screen, (40, 40, 40), (0, 555, SCREEN_W, 45))    # ground
    system.draw(screen)

    if system.particles:
        p0 = system.particles[0]
        sample = p0.lifetime / p0.max_lifetime
    else:
        sample = 0.0
    hud = f"tick {tick} | alive {len(system.particles)} | spawned {system.spawned} | sample ratio {sample:.2f}"
    screen.blit(font.render(hud, True, (220, 220, 220)), (10, 10))
    screen.blit(font.render("SPACE = burst 200 | close window to quit", True, (180, 180, 180)), (10, 30))

    pygame.display.flip()

pygame.quit()

šŸŽÆ Quick Quiz

Question 1: Each Particle.update(dt) ends with return self.lifetime > 0 after decrementing lifetime, and ParticleSystem.update calls it via self.particles = [p for p in self.particles if p.update(dt)]. What does this two-step pattern accomplish in one pass, and why is it preferred over the alternatives?

Question 2: The Particle.__init__ saves self.max_lifetime = self.lifetime ONCE at spawn, even though it never re-reads max_lifetime to extend or reset the lifetime. What role does this write-once snapshot play, and what would break without it?

Question 3: The lesson's FireEffect sets config.gravity = -50 # Fire rises while ExplosionEffect sets config.gravity = 50 for falling debris. What does the sign accomplish in the per-tick update line self.vy += self.gravity * dt, and why is it the OPPOSITE of math-class Newtonian intuition?

What's Next?

You've completed the Basic Module's Sprite Management section! With these skills, you're ready to move on to more advanced topics or start creating your own games with rich visual effects and polished graphics!