Scientific ComputingIntermediate

Particle System Simulation in Python

Build a particle system in Python with NumPy and Matplotlib. Simulate fireworks, gravity, and emitters from scratch — runnable instantly in your browser.

Try it yourself

Run this code directly in your browser. Click "Open in full editor" to experiment further.

Loading...

Click Run to see output

Or press Ctrl + Enter

How it works

A particle system is one of those ideas where the implementation is short but the visual payoff is huge. Every fire effect, every smoke trail, every magic spell, every star field in every game and movie you've ever seen is some variation of "a few thousand dots, each following its own version of the same physics rules". Once you've built one, you can build all of them.

The Mental Model

A particle is just a struct with a few numbers:

  • position (x, y)
  • velocity (vx, vy)
  • life (frames remaining before it disappears)
  • color
  • A particle system is a bag of particles. Every frame, you do the same thing to all of them: apply forces, move them, age them, draw them. Then on certain frames you emit new particles to replace the ones that died.

    That's the whole architecture. The art is in choosing emit patterns and force rules.

    Why You Vectorize

    The naive way to write this is one Python object per particle, with a for particle in particles: particle.update() loop. That works for 100 particles. It crawls at 10,000.

    The NumPy way stores every particle's data in a 2D array and updates all of them in one expression:

    self.vel[alive, 1] += gravity      # apply gravity to every alive particle at once
    self.pos[alive]    += self.vel[alive]   # move every alive particle at once

    This is the same trick that makes the Mandelbrot snippet fast. NumPy is ~50x faster than a Python loop here, which is the difference between a particle system that holds 500 particles and one that holds 50,000.

    The Recycling Trick

    Notice the snippet pre-allocates a fixed pool of n=4000 particles up front, then "emits" by finding particles whose life <= 0 and resetting their fields. It never grows the array.

    This matters because:

  • Allocation in a hot loop kills performance. Creating new objects every frame causes garbage collection hiccups.
  • Fixed-size arrays vectorize cleanly. Variable-length arrays force you back into Python loops.
  • You get a natural particle cap for free. If you try to spawn more particles than the pool has slots for, the excess just doesn't appear — graceful degradation.
  • This is exactly how production game engines do it. The pattern is called object pooling.

    The Physics, In Three Lines

    The step() method has three operations and that's it:

    self.vel[alive, 1] += gravity     # forces accelerate the particle
    self.vel[alive]   *= drag          # drag slows it down
    self.pos[alive]   += self.vel[alive]   # velocity moves the position

    This is the simplest possible numerical integration — Euler integration. It's not the most accurate (energy slowly creeps up over time), but for visual effects nobody cares. For a physics simulation where accuracy matters (planets, molecules), you'd use velocity Verlet or RK4 instead.

    Drag Is Underrated

    Adding vel *= drag (with drag = 0.99) makes the difference between particles that fly off the screen and particles that arc and float gracefully. Each frame, every particle keeps 99% of its velocity. After 60 frames, it's down to ~55%. After 200 frames, ~13%. That natural deceleration is what makes smoke look like smoke and explosions look like explosions instead of straight-line missiles.

    Emit Patterns Are The Whole Personality

    The step() method is identical across every particle effect you'll ever write. The only thing that changes is how new particles are spawned:

    EffectEmit pattern
    FireworksBig burst at one point, particles spray uniformly in a circle
    FountainContinuous trickle, narrow cone aimed up
    Smoke trailContinuous trickle, low velocity, slow upward drift, gray fading to transparent
    Sparks from grindingContinuous wide cone, high speed, short life
    Magic spell trailParticles emit behind a moving point, curling outward
    RainParticles spawn across the top of the screen, all moving down
    SnowSame as rain but with sideways drift and lower speed

    Every one of these uses the same step() and the same render function.

    Fading And Resizing With Life

    The fade = life / max_life trick is a tiny but huge detail. Each particle's transparency and size follows its remaining life:

  • New particles are bright and large.
  • Old particles fade and shrink before disappearing.
  • Without this, particles pop in and out of existence — which looks terrible. With it, the system breathes.

    What's Hard About This In Pyodide

    The snippet above renders static snapshots at chosen frames rather than running a true animation. The reason: this code runs in a Web Worker, which doesn't have a real-time canvas to draw to. For a real animated version, you'd:

  • Move the simulation to the main thread, or
  • Stream frames from the worker and re-render with matplotlib.animation, or
  • Use Pygame-CE (which has a Pyodide build but needs main-thread canvas access).
  • The four-frame fireworks panel and the steady-state fountain shot capture the essence of what an animation would look like — particles in different stages of their life, fading, falling, dispersing.

    What To Try Next

  • Add collisions — bounce particles off the floor with vel[hit_floor, 1] *= -0.6 (the 0.6 is energy lost on bounce).
  • Add windvel[alive, 0] += wind_x is a global horizontal force.
  • Add attractors — compute a vector from each particle to a target point and push them toward it. Instant magic effect.
  • Add color over life — interpolate from yellow → orange → red over a particle's lifetime to make fire.
  • Spatial hashing — once you have particles interacting (collisions between particles, not just with the floor), you need a grid to avoid O(n²) checks.
  • Run the snippet above and you'll see fireworks captured across four moments — the bursts firing, the trails arcing, the embers fading — and a continuous blue fountain spraying upward in steady state, all driven by the same three-line physics loop.

    Related examples