Chapter 21

Particle Effects

Add visual "juice" to your game with particle effects — coin bursts, crash explosions, speed lines, and player trails that make every interaction feel satisfying.

Why Particles Matter

Play any top-tier mobile game — Subway Surfers, Temple Run, Crossy Road — and pay attention to what happens when you collect a coin. There is a satisfying burst of sparkles. When you crash, debris flies everywhere. When you are running fast, streaks rush past the camera. These are particle effects, and they are a massive part of what game designers call "juice" or "game feel."

Without particles, your game feels flat. A coin disappears and the score goes up — functional, but boring. With particles, the coin explodes into a shower of gold sparkles, and the player's brain gets a tiny dopamine hit. That feedback loop is what keeps players coming back.

What Is "Juice"?

"Juice" is a game design term for all the little effects that make a game feel good — screen shakes, particle bursts, sound effects, animations, color flashes. Individually, each is subtle. Together, they transform a prototype into a polished product. Particles are one of the most impactful forms of juice because they are highly visual and relatively easy to implement.

Unity Particle System Basics

Unity has a powerful built-in Particle System component (sometimes called "Shuriken"). A Particle System emits many small sprites or meshes (called "particles") that move, change color, shrink, and fade over time. By tweaking a handful of settings, you can create fire, smoke, sparks, rain, magic spells — or in our case, coin bursts and crash effects.

Creating Your First Particle System

  1. In the Hierarchy window, right-click and select Effects > Particle System.
  2. Unity creates a new GameObject with a Particle System component. You will immediately see white particles spraying upward in the Scene view.
  3. Select the Particle System object to see its Inspector — there are many modules listed on the left side.

Key Modules Explained

The Particle System has dozens of modules, but we only need a handful for our infinite runner. Here are the ones that matter most:

ModuleWhat It ControlsKey Settings
MainOverall behavior — duration, looping, start lifetime, start speed, start size, start color, gravity.Duration, Start Lifetime, Start Speed, Start Size, Start Color, Max Particles
EmissionHow many particles are emitted and when. "Rate over Time" for continuous effects, "Bursts" for one-shot explosions.Rate over Time, Bursts (Count, Time)
ShapeThe shape of the emitter — sphere, cone, box, etc. Controls the initial direction and spread of particles.Shape type, Radius, Angle
Color over LifetimeChanges particle color as it ages. Great for fading out (set alpha to 0 at the end).Gradient editor
Size over LifetimeChanges particle size as it ages. Shrinking to zero before death looks clean.Curve editor
RendererHow particles are drawn — billboard (always facing camera), stretched billboard, mesh.Render Mode, Material
Looping vs. One-Shot

For continuous effects like speed lines or trails, set Looping to true. For one-shot effects like a coin burst or crash explosion, uncheck Looping and set Play On Awake to false — you will trigger them from code using Play(). Also set Stop Action to "Disable" so the system automatically deactivates when it finishes, making it ready to be returned to a pool.

Coin Collect Effect

When the player collects a coin, we want a satisfying burst of gold sparkles at the coin's position. This is a classic "one-shot" particle effect.

Step-by-Step Inspector Setup

  1. Create a new Particle System: right-click in the Hierarchy > Effects > Particle System. Name it CoinCollectVFX.
  2. Main Module:
    • Duration: 0.5
    • Looping: Unchecked (one-shot)
    • Start Lifetime: 0.3 to 0.6 (random between two constants)
    • Start Speed: 3 to 6 (random between two constants)
    • Start Size: 0.1 to 0.25 (random between two constants)
    • Start Color: Gold/yellow (e.g., #FFD700)
    • Simulation Space: World (so particles stay in place as the player moves away)
    • Play On Awake: Unchecked
    • Stop Action: Disable
    • Max Particles: 30
  3. Emission Module:
    • Rate over Time: 0 (we do not want continuous emission)
    • Click the + under Bursts to add a burst
    • Time: 0, Count: 20, Cycles: 1
  4. Shape Module:
    • Shape: Sphere
    • Radius: 0.3
  5. Color over Lifetime: Enable this module. Set a gradient from full-opacity gold on the left to zero-opacity gold on the right. This makes particles fade out before they die.
  6. Size over Lifetime: Enable this module. Set the curve to start at 1.0 and decrease to 0.0. This makes particles shrink as they age.
  7. Renderer Module:
    • Render Mode: Billboard
    • Material: Use the Default-Particle material, or create a simple unlit particle material
  8. Drag CoinCollectVFX into your Assets/Prefabs/VFX/ folder to create a prefab. Delete the instance from the scene.
Random Between Two Constants

For fields like Start Lifetime and Start Speed, click the small dropdown arrow next to the value and select Random Between Two Constants. This gives each particle a slightly different value, making the effect look more natural and less mechanical.

Death / Crash Effect

When the player crashes into an obstacle, we want a dramatic explosion of particles — bigger, more numerous, and more colorful than the coin effect.

Inspector Setup

  1. Create a new Particle System and name it CrashVFX.
  2. Main Module:
    • Duration: 1.0
    • Looping: Unchecked
    • Start Lifetime: 0.5 to 1.0
    • Start Speed: 5 to 10
    • Start Size: 0.2 to 0.5
    • Start Color: Random between orange (#FF6600) and red (#FF0000)
    • Gravity Modifier: 1.0 (particles fall after exploding outward)
    • Simulation Space: World
    • Play On Awake: Unchecked
    • Stop Action: Disable
    • Max Particles: 60
  3. Emission Module:
    • Rate over Time: 0
    • Add a burst: Time: 0, Count: 40, Cycles: 1
  4. Shape Module:
    • Shape: Sphere
    • Radius: 0.5
  5. Color over Lifetime: Gradient from orange (full opacity) to dark red (zero opacity).
  6. Size over Lifetime: Curve from 1.0 to 0.0.
  7. Save as a prefab in Assets/Prefabs/VFX/.
Gravity Modifier

Setting Gravity Modifier to 1.0 means particles are affected by Unity's gravity. After the initial burst pushes them outward, they arc downward and fall — exactly like real debris would. For the coin effect, we left gravity at 0 so the sparkles float freely. This subtle difference makes each effect feel distinct.

Speed Lines Effect

Speed lines are stretched particles that fly past the camera to convey a sense of velocity. As the player runs faster, more lines appear and they move faster. This is a looping effect attached to the camera.

Inspector Setup

  1. Create a new Particle System and name it SpeedLinesVFX.
  2. Main Module:
    • Duration: 1.0
    • Looping: Checked (continuous effect)
    • Start Lifetime: 0.3 to 0.5
    • Start Speed: 20 to 40
    • Start Size: 0.02 to 0.05
    • Start Color: White with slight transparency (alpha ~180)
    • Simulation Space: Local
    • Play On Awake: Unchecked
    • Max Particles: 100
  3. Emission Module:
    • Rate over Time: 30 (we will adjust this from code based on speed)
  4. Shape Module:
    • Shape: Box
    • Scale: (4, 3, 0) — wide and tall rectangle in front of the camera
    • Position: (0, 0, 10) — spawn 10 units ahead of the camera
  5. Renderer Module:
    • Render Mode: Stretched Billboard
    • Speed Scale: 0.5 (stretches particles in the direction of travel)
    • Length Scale: 5 (makes them long streaks)
  6. Color over Lifetime: White to transparent white.
  7. Make this a child of your Main Camera object, or position it in code relative to the camera.
Stretched Billboard

The Stretched Billboard render mode elongates particles along their direction of movement. Combined with a high Length Scale, particles become thin streaks that look like motion blur lines. This is the standard technique used in racing games and runners to convey speed.

Trail Effect on Player

A subtle trail behind the player adds visual flair and reinforces the sense of motion. You can achieve this with either a Trail Renderer component or a particle system with trails. We will use a Trail Renderer because it is simpler and more performant for this use case.

Setting Up a Trail Renderer

  1. Select your Player GameObject.
  2. Create a child empty GameObject named TrailPoint. Position it slightly behind and below the player (e.g., (0, 0.1, -0.5)).
  3. Add a Trail Renderer component to TrailPoint.
  4. Configure the Trail Renderer:
    • Time: 0.3 (how long the trail lingers, in seconds)
    • Width Curve: Start at 0.3, end at 0 (trail tapers from wide to nothing)
    • Color Gradient: Start with a bright color (e.g., cyan #00FFFF at full opacity), end fully transparent
    • Material: Create a simple unlit material with an additive or alpha-blended shader
    • Min Vertex Distance: 0.1 (lower values = smoother trail, but more vertices)
    • Corner Vertices: 3
    • End Cap Vertices: 3
Trail Renderer Gotcha

If you instantiate or reposition a Trail Renderer, it can draw a streak from the old position to the new one. To prevent this, call trailRenderer.Clear() immediately after teleporting or respawning the player. Otherwise, you will see an ugly line stretching across the entire screen.

The ParticleManager Script

Now let us build the system that manages all our particle effects. The ParticleManager uses object pooling (which we built in Chapter 12) to avoid Instantiate/Destroy overhead. It provides simple public methods like PlayCoinCollect(position) that any script can call.

ParticleManager.csC#
using System;
using System.Collections.Generic;
using UnityEngine;
using InfiniteRunner.Core;

namespace InfiniteRunner.VFX
{
    /// <summary>
    /// Manages all particle effects in the game.
    /// Pools particle system instances to avoid garbage collection spikes.
    /// Access via ParticleManager.Instance.
    /// </summary>
    public class ParticleManager : Singleton<ParticleManager>
    {
        // ─── Inspector Fields ──────────────────────────────────
        [Header("Particle Prefabs")]
        [Tooltip("Burst of gold sparkles when collecting a coin.")]
        [SerializeField] private ParticleSystem coinCollectPrefab;

        [Tooltip("Explosion of debris when the player crashes.")]
        [SerializeField] private ParticleSystem crashPrefab;

        [Tooltip("Stretched speed lines attached to the camera.")]
        [SerializeField] private ParticleSystem speedLinesPrefab;

        [Header("Pool Settings")]
        [Tooltip("How many of each effect to pre-create.")]
        [SerializeField] private int poolSizePerEffect = 5;

        // ─── Private Fields ────────────────────────────────────
        // Each effect type has its own pool (a queue of inactive instances)
        private Dictionary<string, Queue<ParticleSystem>> _pools;

        // Reference to the active speed lines instance (only one at a time)
        private ParticleSystem _activeSpeedLines;

        // Parent transform to keep the Hierarchy tidy
        private Transform _poolParent;

        // ─── Unity Lifecycle ────────────────────────────────────

        protected override void Awake()
        {
            base.Awake();
            InitializePools();
        }

        // ─── Pool Initialization ────────────────────────────────

        /// <summary>
        /// Creates pools for each particle prefab.
        /// Pre-instantiates instances so they are ready to use at runtime.
        /// </summary>
        private void InitializePools()
        {
            _pools = new Dictionary<string, Queue<ParticleSystem>>();

            // Create a parent object to keep pooled particles organized
            _poolParent = new GameObject("[ParticlePools]").transform;
            _poolParent.SetParent(transform);

            // Create pools for each effect type
            if (coinCollectPrefab != null)
            {
                CreatePool(coinCollectPrefab, poolSizePerEffect);
            }

            if (crashPrefab != null)
            {
                CreatePool(crashPrefab, poolSizePerEffect);
            }

            // Speed lines are a single persistent instance, not pooled
            if (speedLinesPrefab != null)
            {
                _activeSpeedLines = Instantiate(speedLinesPrefab, _poolParent);
                _activeSpeedLines.gameObject.SetActive(false);
            }
        }

        /// <summary>
        /// Creates a pool of inactive particle system instances for a given prefab.
        /// </summary>
        /// <param name="prefab">The particle system prefab to pool.</param>
        /// <param name="count">How many instances to pre-create.</param>
        private void CreatePool(ParticleSystem prefab, int count)
        {
            string key = prefab.name;
            _pools[key] = new Queue<ParticleSystem>();

            // Create a sub-parent for this effect type
            Transform subParent = new GameObject($"Pool_{key}").transform;
            subParent.SetParent(_poolParent);

            for (int i = 0; i < count; i++)
            {
                ParticleSystem instance = Instantiate(prefab, subParent);
                instance.gameObject.SetActive(false);
                _pools[key].Enqueue(instance);
            }
        }

        // ─── Pool Retrieval ─────────────────────────────────────

        /// <summary>
        /// Gets an inactive particle system from the pool.
        /// If the pool is empty, creates a new instance (grows the pool).
        /// </summary>
        /// <param name="prefab">The prefab to get an instance of.</param>
        /// <returns>A ready-to-use ParticleSystem instance.</returns>
        private ParticleSystem GetFromPool(ParticleSystem prefab)
        {
            string key = prefab.name;

            // If the pool does not exist yet, create it
            if (!_pools.ContainsKey(key))
            {
                CreatePool(prefab, poolSizePerEffect);
            }

            Queue<ParticleSystem> pool = _pools[key];

            // Try to find an inactive instance in the pool
            // If all are in use, create a new one (pool growth)
            ParticleSystem instance;

            if (pool.Count > 0)
            {
                instance = pool.Dequeue();
            }
            else
            {
                // Pool is exhausted — grow it by creating a new instance
                Debug.LogWarning(
                    $"[ParticleManager] Pool for '{key}' exhausted. " +
                    "Creating new instance. Consider increasing pool size.");
                instance = Instantiate(prefab, _poolParent);
            }

            return instance;
        }

        /// <summary>
        /// Returns a particle system instance to its pool after it finishes playing.
        /// Called automatically by our coroutine.
        /// </summary>
        /// <param name="prefab">The original prefab (used as pool key).</param>
        /// <param name="instance">The instance to return.</param>
        private void ReturnToPool(ParticleSystem prefab, ParticleSystem instance)
        {
            string key = prefab.name;

            instance.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
            instance.gameObject.SetActive(false);

            if (_pools.ContainsKey(key))
            {
                _pools[key].Enqueue(instance);
            }
        }

        // ─── Play Methods (call these from gameplay scripts) ────

        /// <summary>
        /// Plays the coin collect effect at the specified world position.
        /// Call this from your coin/collectible script when collected.
        /// </summary>
        /// <param name="position">World position where the coin was collected.</param>
        public void PlayCoinCollect(Vector3 position)
        {
            if (coinCollectPrefab == null)
            {
                Debug.LogWarning("[ParticleManager] Coin collect prefab not assigned!");
                return;
            }

            PlayEffectAtPosition(coinCollectPrefab, position);
        }

        /// <summary>
        /// Plays the crash/death effect at the specified world position.
        /// Call this when the player hits an obstacle.
        /// </summary>
        /// <param name="position">World position where the crash occurred.</param>
        public void PlayCrash(Vector3 position)
        {
            if (crashPrefab == null)
            {
                Debug.LogWarning("[ParticleManager] Crash prefab not assigned!");
                return;
            }

            PlayEffectAtPosition(crashPrefab, position);
        }

        /// <summary>
        /// Starts or updates the speed lines effect.
        /// Call this with the current player speed to modulate intensity.
        /// Pass 0 or call StopSpeedLines() to turn them off.
        /// </summary>
        /// <param name="normalizedSpeed">
        /// Speed value from 0 (stopped) to 1 (max speed).
        /// Controls emission rate and particle speed.
        /// </param>
        public void UpdateSpeedLines(float normalizedSpeed)
        {
            if (_activeSpeedLines == null) return;

            if (normalizedSpeed <= 0.1f)
            {
                // Speed too low — turn off speed lines
                if (_activeSpeedLines.gameObject.activeSelf)
                {
                    _activeSpeedLines.Stop();
                    _activeSpeedLines.gameObject.SetActive(false);
                }
                return;
            }

            // Activate if not already active
            if (!_activeSpeedLines.gameObject.activeSelf)
            {
                _activeSpeedLines.gameObject.SetActive(true);
                _activeSpeedLines.Play();
            }

            // Modulate emission rate based on speed
            // At normalizedSpeed = 0.5 we get ~15 particles/sec
            // At normalizedSpeed = 1.0 we get ~50 particles/sec
            var emission = _activeSpeedLines.emission;
            emission.rateOverTime = Mathf.Lerp(10f, 50f, normalizedSpeed);

            // Modulate particle speed
            var main = _activeSpeedLines.main;
            main.startSpeed = Mathf.Lerp(15f, 50f, normalizedSpeed);
        }

        /// <summary>
        /// Immediately stops the speed lines effect.
        /// </summary>
        public void StopSpeedLines()
        {
            UpdateSpeedLines(0f);
        }

        // ─── Internal Helpers ───────────────────────────────────

        /// <summary>
        /// Gets an instance from the pool, positions it, plays it,
        /// and schedules it to be returned to the pool when done.
        /// </summary>
        private void PlayEffectAtPosition(ParticleSystem prefab, Vector3 position)
        {
            ParticleSystem instance = GetFromPool(prefab);

            // Position the particle system at the target location
            instance.transform.position = position;
            instance.gameObject.SetActive(true);

            // Clear any leftover particles from a previous play
            instance.Clear();

            // Play the effect
            instance.Play();

            // Schedule return to pool after the effect finishes.
            // We use the main module's duration + startLifetime as our delay.
            float returnDelay = instance.main.duration + instance.main.startLifetime.constantMax;
            StartCoroutine(ReturnToPoolAfterDelay(prefab, instance, returnDelay));
        }

        /// <summary>
        /// Waits for the particle effect to finish, then returns it to the pool.
        /// </summary>
        private System.Collections.IEnumerator ReturnToPoolAfterDelay(
            ParticleSystem prefab,
            ParticleSystem instance,
            float delay)
        {
            yield return new WaitForSeconds(delay);
            ReturnToPool(prefab, instance);
        }

        // ─── Cleanup ────────────────────────────────────────────

        /// <summary>
        /// Stops all active particle effects. Call this on game over or scene reset.
        /// </summary>
        public void StopAllEffects()
        {
            StopAllCoroutines();
            StopSpeedLines();

            // Stop and return all active particles to their pools
            foreach (var kvp in _pools)
            {
                foreach (var ps in kvp.Value)
                {
                    if (ps != null && ps.gameObject.activeSelf)
                    {
                        ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
                        ps.gameObject.SetActive(false);
                    }
                }
            }
        }
    }
}

Script Breakdown

  • Object Pooling — The manager pre-creates a configurable number of each particle effect in Awake(). When a script calls PlayCoinCollect(), it grabs an inactive instance from the pool instead of calling Instantiate(). After the effect finishes, a coroutine returns it to the pool. This avoids garbage collection stutters.
  • Pool Growth — If all instances are in use (e.g., the player rapidly collects many coins), the pool creates a new instance on the fly and logs a warning. You can then increase the pool size to avoid runtime allocations.
  • Speed Lines — Unlike burst effects, speed lines are a single persistent instance. We modulate their emission rate and particle speed based on how fast the player is moving. The UpdateSpeedLines() method takes a normalized speed (0 to 1) for simplicity.
  • StopAllEffects() — A cleanup method called on game over to ensure no stray particles linger.

Integrating Particles with Events

In Chapter 7 we built an event system. Now we connect our particle effects to game events so they play automatically when things happen. Here is a "glue" script that listens for events and triggers the appropriate particle effects:

ParticleEventListener.csC#
using UnityEngine;
using InfiniteRunner.Core;
using InfiniteRunner.VFX;

namespace InfiniteRunner.VFX
{
    /// <summary>
    /// Listens for game events and triggers appropriate particle effects.
    /// Attach this to the [ParticleManager] GameObject.
    /// </summary>
    public class ParticleEventListener : MonoBehaviour
    {
        private void OnEnable()
        {
            // Subscribe to game events
            // (Assumes your event system uses static events or
            //  ScriptableObject channels from Chapter 7)
            GameEvents.OnCoinCollected += HandleCoinCollected;
            GameEvents.OnPlayerDied += HandlePlayerDied;
            GameEvents.OnSpeedChanged += HandleSpeedChanged;
            GameManager.Instance.OnGameStateChanged += HandleStateChanged;
        }

        private void OnDisable()
        {
            // Always unsubscribe to prevent memory leaks
            GameEvents.OnCoinCollected -= HandleCoinCollected;
            GameEvents.OnPlayerDied -= HandlePlayerDied;
            GameEvents.OnSpeedChanged -= HandleSpeedChanged;

            if (GameManager.Instance != null)
            {
                GameManager.Instance.OnGameStateChanged -= HandleStateChanged;
            }
        }

        /// <summary>
        /// When a coin is collected, play the gold burst at its position.
        /// </summary>
        private void HandleCoinCollected(Vector3 coinPosition)
        {
            ParticleManager.Instance.PlayCoinCollect(coinPosition);
        }

        /// <summary>
        /// When the player dies, play the crash explosion at their position.
        /// </summary>
        private void HandlePlayerDied(Vector3 playerPosition)
        {
            ParticleManager.Instance.PlayCrash(playerPosition);
            ParticleManager.Instance.StopSpeedLines();
        }

        /// <summary>
        /// When the player's speed changes, update speed lines intensity.
        /// </summary>
        private void HandleSpeedChanged(float normalizedSpeed)
        {
            ParticleManager.Instance.UpdateSpeedLines(normalizedSpeed);
        }

        /// <summary>
        /// When the game state changes, stop all effects if not playing.
        /// </summary>
        private void HandleStateChanged(GameState newState)
        {
            if (newState != GameState.Playing)
            {
                ParticleManager.Instance.StopAllEffects();
            }
        }
    }
}
Decoupled Design

Notice that the Collectible script does not need to know about particles. It simply raises an OnCoinCollected event. The ParticleEventListener hears that event and tells the ParticleManager to play the effect. If you later decide to remove particles, you delete this listener — no changes to the collectible code. This is the power of event-driven architecture.

Performance Tips for Particles

Particles are cheap individually, but they can add up quickly. Here are the most important optimization rules:

TipWhy It MattersRecommended Value
Limit Max ParticlesEach particle consumes memory and GPU draw time. Setting a cap prevents runaway effects.20–60 per effect
Use Simulation Space: WorldFor burst effects only. Prevents particles from following the emitter after play.World for bursts, Local for speed lines
Enable CullingIn the Renderer module, set Render Alignment to View and enable culling so off-screen particles are not rendered.Always enable
Short LifetimesParticles that live too long pile up. Keep lifetimes under 1 second for gameplay effects.0.2–1.0 seconds
Pool, Do Not InstantiateCreating and destroying GameObjects causes GC spikes. Our ParticleManager pools everything.Pool size 3–10 per type
Disable Sub-EmittersSub-emitters (particles that spawn more particles) look amazing but are expensive. Use sparingly.Only for key moments
Simple MaterialsUse Unlit or Additive particle shaders. Avoid complex lit shaders on particles.Particles/Standard Unlit
Mobile Performance

On mobile devices, particles can be a significant performance bottleneck. If you notice frame drops, reduce Max Particles first, then shorten lifetimes, and finally reduce emission rates. You can also create "low quality" variants of each effect and swap them in on low-end devices using Unity's Quality Settings.

Setting Up in the Scene

  1. Create an empty GameObject named [ParticleManager].
  2. Attach the ParticleManager script and the ParticleEventListener script.
  3. Drag your CoinCollectVFX prefab into the Coin Collect Prefab field.
  4. Drag your CrashVFX prefab into the Crash Prefab field.
  5. Drag your SpeedLinesVFX prefab into the Speed Lines Prefab field.
  6. Set Pool Size Per Effect to 5 (increase if you see the pool growth warning in the Console).
  7. Press Play and collect a coin — you should see gold sparkles burst at the coin's position.

What We Built

In this chapter, we added four distinct particle effects to our game:

  • Coin Collect — A burst of gold sparkles triggered by the OnCoinCollected event.
  • Crash Explosion — A dramatic debris burst triggered by OnPlayerDied.
  • Speed Lines — Looping stretched particles whose intensity scales with player speed.
  • Player Trail — A Trail Renderer that follows the player to reinforce motion.

We also built a ParticleManager that pools particle instances for performance and provides simple Play methods that any script can call. Finally, we connected everything to our event system so particles play automatically without tight coupling.

In the next chapter, we will bring our player character to life with animations — run cycles, jumps, slides, and death sequences using Unity's Animator system.

Save Your Progress

Particle effects add so much juice. Save it.

git add .
git commit -m "Add particle effects for coins, death, and speed lines"
git push