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);
| Mode | Uses x | Uses r | What 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:
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.
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.
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.
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.
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.
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.
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.InverseLerpis 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
curveMultiplieras the maximum. That's how the curve editor works, and it keeps curves readable when you rebalance. Evaluatenever 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:
- Should each instance come out different? If not, use Constant or Curve.
- 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:
| Case | x (input) | Value | Shape of the band |
|---|---|---|---|
| Enemy health | level 1 to 20 | max health | Level 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 depth | dungeon floor | gold per chest | Narrow near the surface, wide deep down. Big rewards become possible on deep floors without being guaranteed. |
| Rocks by slope | terrain slope | scale | Flat ground: similar pebbles. Steep slopes: large boulders of very different sizes. |
| Wave size | wave number | enemies to spawn | Early waves: always 4 or 5. Late waves: anywhere from 10 to 25, so pacing stays unpredictable. |
| Drifting smoke | particle age (Velocity over Lifetime X) | sideways speed | At 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. |
| Explosion | system time (Start Speed) | initial speed | First sparks 10 to 25: fast and uneven. Last ones 1 to 3: slow and similar. |