Effects & Systems
Creating a Particle System
A particle system draws many tiny sprites that move, fade, rotate, and disappear quickly. It is the classic way to create sparks, fire, smoke puffs, magic glows, explosions, fountains, and mouse trails without needing hundreds of separate textures. This tutorial builds a 2D MonoGame system that updates with delta time, draws with additive blending, and reuses particles from a fixed pool to avoid garbage collection spikes.
Download the runnable sample project
Use the verified ZIP bundle for this article to review the extracted example files, run the sample host, and repackage the sources without bin or obj folders.
The particle data
A particle is just a small record of state. The key fields are position, velocity, acceleration, colour, scale, rotation, age, lifetime, and alpha. Acceleration is useful for gravity, wind, or slow drifting forces. Age and lifetime let the system fade particles out and remove them when they expire.
using Microsoft.Xna.Framework;
public struct Particle
{
public Vector2 Position;
public Vector2 Velocity;
public Vector2 Acceleration;
public Color Colour;
public float Scale;
public float Rotation;
public float AngularVelocity;
public float Age;
public float Lifetime;
public float Alpha;
public bool IsAlive => Age < Lifetime;
}
💡 Keep the particle type small and predictable. A struct works well inside a fixed array because it avoids per-particle object allocations, but a lightweight class can also work if you pool the instances.
A pooled particle system
Creating and destroying hundreds of particles every frame can trigger garbage collection at exactly the wrong moment. Instead, allocate storage once, keep an activeCount, and compact the array when particles die. This pattern provides the same behaviour as a List<Particle> without growing the collection during gameplay.
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
public sealed class ParticleSystem
{
private const int MaxParticles = 2048;
private readonly Particle[] _particles = new Particle[MaxParticles];
private readonly Texture2D _texture;
private int _activeCount;
public ParticleSystem(Texture2D texture)
{
_texture = texture;
}
public void Add(Particle particle)
{
if (_activeCount >= MaxParticles)
return;
_particles[_activeCount] = particle;
_activeCount++;
}
public void Update(float dt)
{
for (int i = 0; i < _activeCount; i++)
{
Particle p = _particles[i];
p.Age += dt;
if (!p.IsAlive)
{
_activeCount--;
_particles[i] = _particles[_activeCount];
i--;
continue;
}
p.Velocity += p.Acceleration * dt;
p.Position += p.Velocity * dt;
p.Rotation += p.AngularVelocity * dt;
p.Alpha = 1f - MathHelper.Clamp(p.Age / p.Lifetime, 0f, 1f);
p.Scale *= 1f - (0.35f * dt);
_particles[i] = p;
}
}
public void Draw(SpriteBatch spriteBatch)
{
Vector2 origin = new Vector2(_texture.Width, _texture.Height) * 0.5f;
for (int i = 0; i < _activeCount; i++)
{
Particle p = _particles[i];
spriteBatch.Draw(
_texture,
p.Position,
null,
p.Colour * p.Alpha,
p.Rotation,
origin,
p.Scale,
SpriteEffects.None,
0f);
}
}
}
⚠️ Performance caution: Avoid new Particle() objects, LINQ, List<Particle>.Remove, or collection growth inside the hot update loop. Particle effects are short-lived and numerous, so small allocations become visible stutters.
Emitter shapes, bursts, and continuous flow
An emitter decides where particles appear and what their initial values are. A point emitter starts all particles at one position. A line, rectangle, circle, or ring emitter randomises the spawn position within a shape. A burst emits many particles at once, which is ideal for explosions. A continuous emitter uses a rate such as 80 particles per second for fountains, fire, rain, or a trail following the mouse.
private float _emitAccumulator;
private void EmitBurst(Vector2 position, int count)
{
for (int i = 0; i < count; i++)
SpawnParticle(position);
}
private void EmitContinuous(Vector2 position, float particlesPerSecond, float dt)
{
_emitAccumulator += particlesPerSecond * dt;
while (_emitAccumulator >= 1f)
{
SpawnParticle(position);
_emitAccumulator -= 1f;
}
}
- Explosion burst: spawn 120–200 particles from a point with random outward velocities and warm colours.
- Continuous fountain: spawn a few particles each frame from a fixed point with upward velocity and downward gravity.
- Mouse trail: emit small particles at the current mouse position as it moves, using low velocity and a short lifetime.
⚠️ Warning: Clamp or cap continuous emitters. If the frame rate drops and your accumulator tries to spawn thousands of particles in one update, cap the burst or the pool will fill immediately.
Additive drawing
For fire, magic, sparks, and glow effects, additive blending makes overlapping particles brighter. Start a separate sprite batch with BlendState.Additive, draw the particle system, and then end it before drawing normal UI or opaque sprites.
protected override void Draw(GameTime gameTime)
{
GraphicsDevice.Clear(Color.Black);
_spriteBatch.Begin(blendState: BlendState.Additive);
_particles.Draw(_spriteBatch);
_spriteBatch.End();
base.Draw(gameTime);
}
💡 Additive blending looks best on dark backgrounds. On bright backgrounds, particles can wash out quickly, so lower alpha, reduce spawn count, or use normal alpha blending for smoke and dust.
Complete example: explosion on mouse click
This complete Game1.cs creates a soft circular particle texture in code, so no asset file is required. Click the mouse to spawn an explosion burst. The system updates with delta time, applies gravity, fades alpha over the lifetime, removes dead particles by compacting the pool, and draws everything additively.
using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
public class Game1 : Game
{
private readonly GraphicsDeviceManager _graphics;
private readonly Random _random = new Random();
private SpriteBatch _spriteBatch;
private Texture2D _particleTexture;
private ParticleSystem _particles;
private MouseState _previousMouse;
public Game1()
{
_graphics = new GraphicsDeviceManager(this);
Content.RootDirectory = "Content";
IsMouseVisible = true;
}
protected override void LoadContent()
{
_spriteBatch = new SpriteBatch(GraphicsDevice);
_particleTexture = CreateCircleTexture(GraphicsDevice, 32);
_particles = new ParticleSystem(_particleTexture);
}
protected override void Update(GameTime gameTime)
{
if (Keyboard.GetState().IsKeyDown(Keys.Escape))
Exit();
float dt = (float)gameTime.ElapsedGameTime.TotalSeconds;
MouseState mouse = Mouse.GetState();
if (mouse.LeftButton == ButtonState.Pressed &&
_previousMouse.LeftButton == ButtonState.Released)
{
EmitExplosion(new Vector2(mouse.X, mouse.Y));
}
_particles.Update(dt);
_previousMouse = mouse;
base.Update(gameTime);
}
private void EmitExplosion(Vector2 position)
{
const int count = 160;
for (int i = 0; i < count; i++)
{
float angle = RandomFloat(0f, MathHelper.TwoPi);
float speed = RandomFloat(80f, 420f);
Vector2 direction = new Vector2(MathF.Cos(angle), MathF.Sin(angle));
Color colour = _random.Next(3) switch
{
0 => Color.OrangeRed,
1 => Color.Gold,
_ => Color.Yellow
};
_particles.Add(new Particle
{
Position = position,
Velocity = direction * speed,
Acceleration = new Vector2(0f, 260f),
Colour = colour,
Scale = RandomFloat(0.25f, 1.1f),
Rotation = RandomFloat(0f, MathHelper.TwoPi),
AngularVelocity = RandomFloat(-8f, 8f),
Age = 0f,
Lifetime = RandomFloat(0.45f, 1.2f),
Alpha = 1f
});
}
}
private float RandomFloat(float min, float max)
{
return min + ((float)_random.NextDouble() * (max - min));
}
private static Texture2D CreateCircleTexture(GraphicsDevice graphicsDevice, int size)
{
var texture = new Texture2D(graphicsDevice, size, size);
var pixels = new Color[size * size];
Vector2 centre = new Vector2(size - 1) * 0.5f;
float radius = size * 0.5f;
for (int y = 0; y < size; y++)
{
for (int x = 0; x < size; x++)
{
Vector2 offset = new Vector2(x, y) - centre;
float distance = offset.Length();
float alpha = 1f - MathHelper.Clamp(distance / radius, 0f, 1f);
alpha *= alpha;
pixels[(y * size) + x] = Color.White * alpha;
}
}
texture.SetData(pixels);
return texture;
}
protected override void Draw(GameTime gameTime)
{
GraphicsDevice.Clear(new Color(8, 10, 18));
_spriteBatch.Begin(blendState: BlendState.Additive);
_particles.Draw(_spriteBatch);
_spriteBatch.End();
base.Draw(gameTime);
}
}
public struct Particle
{
public Vector2 Position;
public Vector2 Velocity;
public Vector2 Acceleration;
public Color Colour;
public float Scale;
public float Rotation;
public float AngularVelocity;
public float Age;
public float Lifetime;
public float Alpha;
public bool IsAlive => Age < Lifetime;
}
public sealed class ParticleSystem
{
private const int MaxParticles = 2048;
private readonly Particle[] _particles = new Particle[MaxParticles];
private readonly Texture2D _texture;
private int _activeCount;
public ParticleSystem(Texture2D texture)
{
_texture = texture;
}
public void Add(Particle particle)
{
if (_activeCount >= MaxParticles)
return;
_particles[_activeCount] = particle;
_activeCount++;
}
public void Update(float dt)
{
for (int i = 0; i < _activeCount; i++)
{
Particle p = _particles[i];
p.Age += dt;
if (!p.IsAlive)
{
_activeCount--;
_particles[i] = _particles[_activeCount];
i--;
continue;
}
p.Velocity += p.Acceleration * dt;
p.Position += p.Velocity * dt;
p.Rotation += p.AngularVelocity * dt;
p.Alpha = 1f - MathHelper.Clamp(p.Age / p.Lifetime, 0f, 1f);
p.Scale = MathF.Max(0.05f, p.Scale - (0.45f * dt));
_particles[i] = p;
}
}
public void Draw(SpriteBatch spriteBatch)
{
Vector2 origin = new Vector2(_texture.Width, _texture.Height) * 0.5f;
for (int i = 0; i < _activeCount; i++)
{
Particle p = _particles[i];
spriteBatch.Draw(
_texture,
p.Position,
null,
p.Colour * p.Alpha,
p.Rotation,
origin,
p.Scale,
SpriteEffects.None,
0f);
}
}
}
Run it with dotnet run. Press Escape to quit. Click anywhere in the window to burst particles from the mouse position.
dotnet build
dotnet run
Common pitfalls
- Allocating during emission. Reuse a fixed array or pre-allocated
List<Particle>and keep an active count to avoid garbage collection spikes. - Using frame-based movement. Always multiply velocity, acceleration, rotation, age, and emitter rates by delta time.
- Drawing additive particles with normal blending. Fire and sparks usually need
BlendState.Additive; smoke and dust usually do not. - Letting continuous emitters run uncapped. Clamp spawn rates and respect the pool limit so low frame rates do not flood the system.
- Forgetting draw order. Additive particles are often drawn after the world but before UI, so interface text remains readable.
Next steps
🎓 Test Your Knowledge
Check your understanding of the key concepts from this tutorial.
What data does a single particle typically store?
✔ Correct!
✘ Not quite.
A minimal particle struct: Vector2 Position, Velocity; float Life, MaxLife; Color Color; float Scale;
How do you typically “kill” a particle when it expires?
✔ Correct!
✘ Not quite.
Using an object pool with an IsAlive flag avoids allocations and GC pressure.
What technique avoids GC pressure when spawning many particles?
✔ Correct!
✘ Not quite.
Pre-allocate Particle[] _pool = new Particle[MaxParticles]; and reuse inactive entries.
How can you create a “fade out” effect as a particle nears the end of its life?
✔ Correct!
✘ Not quite.
Color c = particle.Color; c.A = (byte)(255f * particle.Life / particle.MaxLife); spriteBatch.Draw(..., c);