Unity · ParticleSystem.MinMaxCurve

MinMaxCurve beyond particles

Most Unity code uses a Vector2 for random ranges. The particle system has shipped a better type for years, and nothing stops you from using it in gameplay code: prop scattering, enemy stats, loot.

For a long time I didn't see why the particle system had a mode called Random Between Two Curves. I understood what it did, but not when I would need it. So I built a few small demos until it clicked. The answer turned out to be more useful outside particles than inside them.

The Vector2 habit

This is how most of us write a random range:

[SerializeField] Vector2 scaleRange = new Vector2(0.5f, 2f);

float scale = Random.Range(scaleRange.x, scaleRange.y);

It works until the design changes. "Trees next to the road should be smaller." Now you need a second range, a distance check and a blend between them. "Make it a smooth falloff instead." Now you add an AnimationCurve. Every design change becomes a code change.

One field, four modes

ParticleSystem.MinMaxCurve is a serializable struct with a mode. Your code calls Evaluate and never changes. The mode is picked in the Inspector, with the same control the particle system uses.

[SerializeField] ParticleSystem.MinMaxCurve scale = new ParticleSystem.MinMaxCurve(1f);

// x: your input, normalized to 0..1.   r: your random value, 0..1.
float s = scale.Evaluate(x, r);
ModeUses xUses rWhat you get
Constant--One fixed value.
Two Constants-✓A random range. This is your Vector2.
Curve✓-A value that follows x, with no variety.
Two Curves✓✓A random range whose limits follow x.

Two Curves is the one people skip, and it's the one that replaces most of the extra code. Here is what it computes:

value = Lerp( curveMin(x), curveMax(x), r ) × curveMultiplier
x

The input. Not random.

Anything you can normalize to 0..1: distance to a road, the enemy's level, dungeon depth, time. Inside the particle system it's the particle's age in the ... over Lifetime modules, and the system's time within Duration for the Start properties.

r

The only random value.

0 gives curveMin, 1 gives curveMax, 0.5 the middle. Evaluate doesn't roll it for you. You pass it in, so a seeded System.Random gives you the same result every time.

One roll, not two

It's tempting to read Two Curves as two dice: one picks a point on the curve and the other picks a value. There is only one die, r. The x value is data you already have. The two curves decide how wide the die's range is at each x: 0.3 to 0.5 at one point, 0.6 to 2.2 at another.

Demo 1 · scattering

Trees along a road

x = distance to the road edge (0 to 20 m). Value = the prop's scale. The goal: small, similar bushes next to the road, and large trees that vary a lot further away. All four modes use the same props, positions and r values; only the mode changes.

Works
Top view, 48 × 30 m. Ground bands every 5 m from the road. Hover or tap a prop.

Demo 2 · progression

Enemy health by level

x = the enemy's level (1 to 20, normalized). Value = max health. The goal: enemies get stronger with level, and the spread gets wider too, so a high-level wave mixes weak enemies with elites.

Works
The same 8 enemies at the selected level. Shaded track: the possible range at this level. Tap a row to follow an enemy.

  

Each enemy rolls r once and keeps it. Drag the level slider: the strongest enemy stays the strongest at every level. If you re-rolled r on level-up, your elite could become the weakest one in the wave.

Demo 3 · where it comes from

Sparks with Size over Lifetime

x = the particle's age (Lifetime of 3 s). Value = size multiplier. This is the case the mode was built for: each spark starts at 0, grows to its own size and shrinks back to 0, without popping in or out.

Works
Game view. The ringed spark is the one traced in orange on the graph.

  

The same rule applies in your own code: if you evaluate a MinMaxCurve more than once for the same object, keep that object's r.

In your own scripts

A using alias keeps the name short. Both scripts below match the demos above.

using UnityEngine;
using MinMaxCurve = UnityEngine.ParticleSystem.MinMaxCurve;

public class RoadsideScatter : MonoBehaviour
{
    [SerializeField] float maxDistance = 20f;
    [SerializeField] int seed = 1234;

    // Keys stay in 0..1. curveMultiplier (2.2) is the largest scale.
    [SerializeField] MinMaxCurve scaleByDistance = new MinMaxCurve(
        2.2f,
        new AnimationCurve(new Keyframe(0f, 0.13f), new Keyframe(0.5f, 0.19f), new Keyframe(1f, 0.25f)),
        new AnimationCurve(new Keyframe(0f, 0.22f), new Keyframe(0.45f, 0.66f), new Keyframe(1f, 1f)));

    System.Random rng;

    void Awake() => rng = new System.Random(seed); // same seed, same forest

    public float PickScale(float distanceToRoad)
    {
        float x = Mathf.Clamp01(distanceToRoad / maxDistance); // input: not random
        float r = (float)rng.NextDouble();                      // the only random value
        return scaleByDistance.Evaluate(x, r);
    }
}
using UnityEngine;
using MinMaxCurve = UnityEngine.ParticleSystem.MinMaxCurve;

public class EnemyStats : MonoBehaviour
{
    [SerializeField] int maxLevel = 20;

    // Keys stay in 0..1. curveMultiplier (600) is the highest possible health.
    [SerializeField] MinMaxCurve healthByLevel = new MinMaxCurve(
        600f,
        new AnimationCurve(new Keyframe(0f, 0.133f), new Keyframe(0.5f, 0.283f), new Keyframe(1f, 0.5f)),
        new AnimationCurve(new Keyframe(0f, 0.167f), new Keyframe(0.5f, 0.55f), new Keyframe(1f, 1f)));

    float healthRoll; // this enemy's r: rolled once, kept for its whole life

    public void Init(System.Random rng) => healthRoll = (float)rng.NextDouble();

    public int MaxHealthAt(int level)
    {
        float x = Mathf.InverseLerp(1, maxLevel, level); // input: not random
        return Mathf.RoundToInt(healthByLevel.Evaluate(x, healthRoll));
    }
}

The same idea for colors

ParticleSystem.MinMaxGradient works the same way for colors, with Color, Gradient, Two Colors, Two Gradients and Random Color modes. For example, to tint props by distance to the road:

[SerializeField] ParticleSystem.MinMaxGradient tintByDistance;

Color tint = tintByDistance.Evaluate(x, r);

Things to know

  • Nothing clamps x for you. Past the last key the curve holds its end value, so an x of 3 reads the same as 1 and nothing warns you. Normalize it yourself. Mathf.InverseLerp is usually all you need.
  • Always pass r. Evaluate(x) with one argument uses r = 1, so Two Constants and Two Curves always return the maximum.
  • Keep curve keys in 0..1 and use curveMultiplier as the maximum. That's how the curve editor works, and it keeps curves readable when you rebalance.
  • Evaluate never rolls r. That's an advantage: you choose the random source, you can seed it, and you can store r per object.
  • It lives in the Particle System module. If you've disabled that built-in module in the Package Manager, the type isn't available.

When you need Two Curves

Ask two questions:

  1. Should each instance come out different? If not, use Constant or Curve.
  2. Does the range of possible values depend on something else (time, age, distance, level)? If not, Two Constants is enough.

If both answers are yes, you want Two Curves. Some places where that happens:

Casex (input)ValueShape of the band
Enemy healthlevel 1 to 20max healthLevel 1: 80 to 100. Level 20: 300 to 600. Both the average and the spread grow, so high levels mix weak enemies and elites.
Loot by depthdungeon floorgold per chestNarrow near the surface, wide deep down. Big rewards become possible on deep floors without being guaranteed.
Rocks by slopeterrain slopescaleFlat ground: similar pebbles. Steep slopes: large boulders of very different sizes.
Wave sizewave numberenemies to spawnEarly waves: always 4 or 5. Late waves: anywhere from 10 to 25, so pacing stays unpredictable.
Drifting smokeparticle age (Velocity over Lifetime X)sideways speedAt birth -0.1 to 0.1, so it rises straight. At the end -1.5 to 1.5, so each puff spreads its own way.
Explosionsystem time (Start Speed)initial speedFirst sparks 10 to 25: fast and uneven. Last ones 1 to 3: slow and similar.

One r for several fields

Massive Swarm System, my swarm asset for Unity, uses this for the values that differ per agent: max speed, acceleration, turn speed and a few more. In version 1.0 each of those fields is a MinMaxCurve with its own r. Each field looks right on its own, but not in combination. An agent can roll a low max speed and a high turn speed, and you get slow shamblers that spin around at full speed.

The fix is the enemy health rule from Demo 2, taken one step further. Each agent rolls one r when it spawns and every field reads that same r, so the slow agents are also the slow turners. The next update does that. It swaps MinMaxCurve for a small struct of its own with the same four modes, so saved profiles load unchanged, and adds a toggle on each field for the ones that should still roll alone.

You can do this with plain MinMaxCurve fields today, since Evaluate takes whatever r you give it. Decide which values should move together and pass them the same r.

Massive Swarm System Runs 1000+ swarm enemies at frame rate in Unity, without DOTS. Built for survivor, bullet-heaven and horde games. Unity 6 Ready See Massive Swarm System →