32 Core Utilities – Ranges

12 min read

Bounded min and max pairs that carry their own containment, clamping and interpolation, so a damage range or a spawn delay travels as one value instead of two loose floats.

Summary

A range is a min and a max that travel together. IntRange, FloatRange and DoubleRange are three small structs that replace the pair of loose numbers a game otherwise carries around for every bounded quantity: the damage a weapon rolls, the delay between spawns, the health band where an enemy decides to flee, the altitude a biome occupies.

The reason to reach for one rather than two fields is that the operations come with it. A range knows whether a value is inside it, how to clamp a value into it, how to pick a random value from it, and where a value sits within it as a normalized 0..1 position. Those four operations are what you were going to write anyway, and writing them once against a named type is how minDamage and maxDamage stop drifting apart when someone adds a third weapon stat.

They serialize cleanly in the Inspector as a two-field struct, which makes them a natural shape for tuning data. A designer sees one “Damage” row with two numbers rather than two unrelated rows they have to remember belong together.

The three are deliberately not identical. IntRange is inclusive at both ends and counts discrete values, which is what a dice roll or a loot-quantity band wants. FloatRange and DoubleRange add Lerp and InverseLerp, because a continuous range is usually something you interpolate across rather than enumerate.

Use the range types for:

  • Weapon damage, healing amounts, and any stat that rolls between two bounds.
  • Spawn timers, cooldown jitter, and delays that should vary rather than being fixed.
  • Threshold bands: the health fraction where an AI flees, the distance where a sound fades out.
  • Mapping a value into a normalized position for a curve, a bar fill, or a shader parameter.
  • Inspector-authored tuning data where two numbers belong to one concept.

Features

  • Three types for three numeric needs. IntRange for discrete counts, FloatRange for the common continuous case, DoubleRange where the extra precision matters.
  • Containment and clamping. Contains answers whether a value falls inside, Clamp pulls an outside value to the nearest bound.
  • Random sampling built in. Random() picks a value from the range without you writing the Range(min, max) call and getting the inclusivity wrong.
  • Interpolation on the continuous types. Lerp maps a 0..1 parameter onto the range, and InverseLerp maps a value back to its normalized position.
  • Discrete counting on IntRange. Size is the span and Count is how many integers are in the range, which are different numbers and both useful.
  • Validity checks. IsEmpty and IsValid catch the inverted or degenerate range before it produces a silently wrong result.
  • Named constants. Zero, ZeroToOne, NegativeOneToOne and ZeroToTen cover the ranges that come up constantly.
  • Value semantics. All three are structs implementing IEquatable<T> with == and !=, so they compare by value and cost nothing to pass.

The three types

They share a core and differ at the edges. The important differences are inclusivity and what each one gives you beyond containment.

TypeBoundsExtra operationsNotes
IntRangeInclusive at both endsSize, CountThe discrete one. Random() returns a value that can equal Max, because it calls Unity’s exclusive integer overload with Max + 1. That inclusivity is what you want for a dice roll or “drop 2 to 4 potions”, and it is the opposite of the bare Random.Range(min, max) most people write from memory. Size is Max - Min; Count is Max - Min + 1, the number of distinct integers. Constants: Zero, ZeroToTen.
FloatRangeInclusiveLerp, InverseLerpThe one you will use most. Lerp(t) maps 0..1 onto the range and InverseLerp(value) goes back the other way, which together cover normalizing a stat for a UI bar or a shader parameter. Random() uses Unity’s float overload. IsEmpty compares the bounds with NumberUtil.Approximately rather than ==, so float noise does not make a genuinely empty range report otherwise. Constants: Zero, ZeroToOne, NegativeOneToOne.
DoubleRangeInclusiveLerp, InverseLerpThe same shape at double precision, for simulation values, accumulated time, or anything where float drift shows. Its Random differs from the other two: it takes a System.Random rather than using Unity’s global generator, so it is the only one of the three that can be made deterministic. Constants: Zero, ZeroToOne.

That Random asymmetry is worth remembering. IntRange.Random() and FloatRange.Random() draw from UnityEngine.Random, so they are main-thread only and cannot be seeded for a reproducible run. DoubleRange.Random(rng) takes the generator as an argument and will happily accept a seeded one.

Recipes

Each recipe stands alone, roughly ordered from the most common use to the most niche.

Roll weapon damage from designer-authored bounds

Problem. Every weapon in your game rolls damage between two numbers, and those numbers are tuning data a designer edits. Carrying them as separate minDamage and maxDamage fields means every new weapon stat is two more fields that can get out of sync.

Solution. Serialize one IntRange field per stat. The Inspector shows it as a single row, and the roll becomes a method call rather than a Random.Range you have to get right yourself.

[SerializeField] private IntRange _damage = new(8, 12);
[SerializeField] private IntRange _potionDrop = new(2, 4);

public int RollDamage()
{
    /* Inclusive at both ends, so 8 and 12 are both reachable. IntRange calls Unity's
       exclusive integer overload with Max + 1 internally so you do not have to. */
    return _damage.Random();
}

/* Count is how many distinct outcomes there are, which is what a drop-table weight or
   a "how many faces does this die have" calculation wants. */
var possibleRolls = _damage.Count;   /* 12 - 8 + 1 = 5 */

/* Size is the span, which is what a spread or variance calculation wants. */
var spread = _damage.Size;           /* 12 - 8     = 4 */

Jitter a spawn timer so waves do not feel mechanical

Problem. Enemies spawning on an exact interval reads as a metronome. You want the gap to vary, and you want the variation bounded so it never feels broken.

Solution. A single FloatRange covers the gap and gets sampled fresh on every spawn.

[SerializeField] private FloatRange _spawnGap = new(1.5f, 3.0f);

private IEnumerator SpawnLoop()
{
    while (_waveActive)
    {
        SpawnEnemy();

        /* A fresh value each time, bounded by the designer's numbers. The wave keeps
           its pacing without every gap being identical. */
        yield return new WaitForSeconds(_spawnGap.Random());
    }
}

/* Clamp is the guard for a value that came from somewhere else: a difficulty modifier,
   a player-facing slider, a save file. It pulls an out-of-range value to the nearest
   bound rather than letting a zero-second gap flood the arena. */
var moddedGap = _spawnGap.Clamp(baseGap * difficultyMultiplier);

Fill a health bar from a threshold band

Problem. Your boss changes phase when its health drops into a band, and the UI shows how far through that band it is. You need both the test and the normalized position, and you do not want the band’s numbers written twice.

Solution. One FloatRange answers both. Contains is the test, InverseLerp is the position.

/* The phase-two band: from 60% health down to 30%. */
private readonly FloatRange _phaseTwoBand = new(0.3f, 0.6f);

var healthFraction = boss.Health / (float) boss.MaxHealth;

if (_phaseTwoBand.Contains(healthFraction))
{
    /* InverseLerp maps the value back to its 0..1 position inside the range, which is
       exactly what a fill amount or a shader lerp parameter wants. */
    var throughPhase = _phaseTwoBand.InverseLerp(healthFraction);

    phaseBar.fillAmount = throughPhase;
    bossMaterial.SetFloat(RageAmountID, throughPhase);
}

/* Lerp is the reverse trip: a 0..1 parameter to a value in the range. Driving a value
   from an animation curve is the usual reason. */
var scriptedHealth = _phaseTwoBand.Lerp(cutsceneCurve.Evaluate(t));

Sample a range reproducibly for a seeded run

Problem. Your roguelike has daily seeds, so every roll has to reproduce exactly from the seed. IntRange.Random() and FloatRange.Random() draw from Unity’s global generator, which is not seedable in a way you control.

Solution. DoubleRange.Random takes the generator as an argument, so it is the one range type that can be made deterministic directly. For the other two, sample from Random Utils and map the result yourself with Lerp.

/* DoubleRange is the only one that accepts a generator, so a seeded System.Random
   makes its output reproducible. */
var seededRng = new System.Random(dailySeed);
var driftRange = new DoubleRange(-0.05, 0.05);
var todaysDrift = driftRange.Random(seededRng);

/* For IntRange and FloatRange, do not call Random(). Draw a normalized value from a
   seeded Scylla source and map it through Lerp, which keeps the range's bounds as the
   single source of truth while the randomness stays reproducible. */
var damageRange = new FloatRange(8f, 12f);
var roll = damageRange.Lerp(seededSource.NextFloat());

/* The integer equivalent, rounding into the inclusive range. */
var lootRange = new IntRange(2, 4);
var quantity = lootRange.Min + (int) (seededSource.NextFloat() * lootRange.Count);

Catch an inverted range before it produces nonsense

Problem. A range came from a config file or a designer typo and its min is above its max. Every operation on it will return something, and all of it will be wrong.

Solution. IsValid and IsEmpty make the bad state checkable rather than silent.

/* IsValid catches the inverted case: a range whose Min is greater than its Max. */
if (!tuning.DamageRange.IsValid)
{
    Log.Error($"Weapon '{tuning.Name}' has an inverted damage range.", LogCategory.Data);
    return;
}

/* IsEmpty catches the degenerate case where both bounds are the same. Often legitimate,
   which is why it is a separate question from validity: a fixed 5 damage is a range of
   (5, 5) and rolls 5 every time, exactly as intended. */
if (tuning.DamageRange.IsEmpty)
{
    Log.Info($"Weapon '{tuning.Name}' has fixed damage.", LogCategory.Data);
}

/* The named constants cover the ranges that come up over and over. */
var normalized = FloatRange.ZeroToOne;      /* 0 to 1,  for anything already normalized */
var bipolar    = FloatRange.NegativeOneToOne; /* -1 to 1, for axis input and offsets    */
var d10        = IntRange.ZeroToTen;        /* 0 to 10                                  */

API Sketch

All three live in Scylla.Core.Util.Ranges. They share most of their surface, so the sketch shows FloatRange in full and then only the differences.

/* --- The common shape, shown on FloatRange --- */
struct FloatRange : IEquatable<FloatRange> {
    float Min { get; }
    float Max { get; }
    bool  IsEmpty { get; }        // Min and Max are the same
    bool  IsValid { get; }        // Min is not above Max

    FloatRange(float min, float max);

    bool  Contains(float value);
    float Clamp(float value);
    float Random();               // UnityEngine.Random, main thread, not seedable
    float Lerp(float t);          // 0..1 parameter to a value in the range
    float InverseLerp(float value); // value to its 0..1 position in the range

    static readonly FloatRange Zero;
    static readonly FloatRange ZeroToOne;
    static readonly FloatRange NegativeOneToOne;
}

/* --- IntRange: discrete, inclusive, no interpolation --- */
struct IntRange : IEquatable<IntRange> {
    int  Min { get; }
    int  Max { get; }
    int  Size { get; }            // Max - Min, the span
    int  Count { get; }           // Max - Min + 1, how many integers
    bool IsEmpty { get; }
    bool IsValid { get; }

    IntRange(int min, int max);

    bool Contains(int value);
    int  Clamp(int value);
    int  Random();                // inclusive of Max; UnityEngine.Random

    static readonly IntRange Zero;
    static readonly IntRange ZeroToTen;
}

/* --- DoubleRange: same as FloatRange, but its Random takes a generator --- */
struct DoubleRange : IEquatable<DoubleRange> {
    double Min { get; }
    double Max { get; }
    bool   IsEmpty { get; }
    bool   IsValid { get; }

    DoubleRange(double min, double max);

    bool   Contains(double value);
    double Clamp(double value);
    double Random(System.Random rng);   // seedable, unlike the other two
    double Lerp(double t);
    double InverseLerp(double value);

    static readonly DoubleRange Zero;
    static readonly DoubleRange ZeroToOne;
}

See the API reference for full signatures, remarks, and exception contracts on every member.

Best Practices

  • Use a range wherever two fields describe one bounded concept. minDamage and maxDamage are one idea, and keeping them as one value stops a third stat from silently landing on only one of them.
  • Serialize ranges rather than pairs for tuning data. The Inspector shows a range as a single row, so a designer sees the concept rather than two numbers they have to know belong together.
  • Reach for Clamp on anything that came from outside. A modifier, a slider, a save file or a config value can all land outside the intended bounds, and clamping at the boundary is cheaper than tracing where a zero-second spawn gap came from.
  • Remember IntRange is inclusive. It differs from the bare Random.Range(min, max) most people write from habit, and it differs in the direction you usually want: a (1, 6) range rolls a real six-sided die.
  • Know whether you want Size or Count. The span and the number of values differ by one, and the wrong choice is an off-by-one at the edge of a distribution.
  • Do not use IntRange.Random() or FloatRange.Random() in anything reproducible. They run on Unity’s global generator. Sample from a seeded source and map through Lerp instead.
  • Prefer DoubleRange when you already have a System.Random to hand. It is the only one of the three that takes a generator, which makes it the path of least resistance for deterministic sampling.
  • Use InverseLerp rather than writing the normalization by hand. (value - min) / (max - min) is correct until the range is degenerate, and then it divides by zero.
  • Validate ranges at load, not at use. An inverted range from a data file will produce plausible-looking nonsense at every call site. One IsValid check where the data enters is cheaper than debugging the symptom.
  • Reach for the named constants. FloatRange.ZeroToOne and NegativeOneToOne cover normalized and bipolar values, which between them are most of the ranges a game actually uses.
  • Keep ranges out of the parts of the API that want units. A range is two bare numbers with no dimension. When the values are lengths, masses or durations that need converting, Unit Utils is the type that carries that meaning.

Pitfalls