Random Number Generators¶
TL;DR: Use PRNG.Instance for 10-15x faster random generation than UnityEngine.Random, with a rich API for vectors, colors, weighted selection, and more.
Overview¶
Unity Helpers provides 20+ high-performance pseudo-random number generators (PRNGs) through a unified IRandom interface. Each generator carries a [RandomGeneratorMetadata] quality rating spanning fast-but-weak toys through generators that clear BigCrush. Check the rating before choosing; the table below summarizes.
Key Features¶
- 10-15x faster than
UnityEngine.Random(see benchmarks) - Thread-safe access via
PRNG.Instance(thread-local) - Rich API: vectors, colors, Gaussian distributions, weighted selection, subset sampling
- Feel-good randomness: exact-average PRD, pity timers, and weighted shuffle bags
- Seedable: reproducible results for replays and testing
- IL2CPP compatible: no reflection, AOT-safe
Quick Start (60 Seconds)¶
Choosing a Generator¶
| Use Case | Recommended Generator | Why |
|---|---|---|
| General gameplay | PRNG.Instance | Thread-local default, excellent quality |
| Procedural generation | PcgRandom | Reproducible, excellent statistical properties |
| High-throughput effects | SplitMix64 | Fastest with good quality |
| Cryptographic seeding | N/A | Use System.Security.Cryptography instead |
| Legacy compatibility | UnityRandom | Matches UnityEngine.Random behavior |
Saving and restoring a generator¶
Every generator answers InternalState with a RandomState snapshot, and every generator has a constructor that takes one back. Snapshot mid-stream, store the snapshot in your save file, and the restored generator resumes the exact sequence, verified for all of them by GeneratorSnapshotRestoreTests.
For the 20 managed generators, Copy(), snapshot construction, and protobuf restoration retain pending Gaussian samples and partly consumed bool and byte reservoirs. Mixed draws continue with the same values and state, whether those caches are empty or primed. WyRandom.Copy() now preserves these caches too, so copying after a bool, byte, or Gaussian draw no longer changes the continuation.
UnityRandom resumes too, and it is worth knowing how. Its position belongs to UnityEngine.Random's engine globals rather than to the object, so the snapshot carries that position and restoring one writes UnityEngine.Random.state back. Anything else drawing from UnityEngine.Random is moved with it, which is the same global that new UnityRandom(seed) already resets through InitState. A snapshot written before 3.6 carries no position; restoring one of those leaves the engine exactly where it is, and so does a payload that is not an engine position at all; assigning one would leave UnityEngine.Random stuck returning a single value for the rest of the run.
UnityRandom snapshots and Copy() retain cached Gaussian samples, including zero, alongside the bit and byte reservoirs. Saving a RandomState through JSON or protobuf preserves those reservoirs as well. Copies still share the engine's global stream; they do not become independent generators. The snapshot's second state word now distinguishes seeded and unseeded snapshots, leaving the Gaussian field for the actual cached sample. Older snapshots used that field as a seed marker: loading them preserves the seed metadata and available engine position, but clears the marker instead of returning a fabricated Gaussian zero. A cached Gaussian discarded by an older snapshot cannot be recovered (#728).
Available Generators¶
All generators implement the IRandom interface:
| Generator | Speed | Quality | Period | Best For |
|---|---|---|---|---|
LinearCongruentialGenerator | Fastest | Poor | 2^32; bit k only 2^(k+1) | Non-critical effects only |
WaveSplatRandom | Very Fast | Experimental | 2^64 (author's claim, unverified) | Throwaway effects; no formal test results published |
SplitMix64 | Fast | Very Good | 2^64 (published) | High-throughput generation |
BlastCircuitRandom | Fast | Good | unpublished; 251/256 state bits live (measured) | Bulk effects, chaotic mixing |
PcgRandom | Fast | Excellent | 2^64 (published) | General purpose, seeded generation |
FlurryBurstRandom | Fast | Excellent | unpublished; 192/192 state bits live (measured) | All-around alternative to PCG |
IllusionFlow | Moderate | Excellent | unpublished; 108/160 state bits live (measured) | Balanced speed and quality |
XoroShiroRandom | Fast | Good | 2^128-1 (published) | Bulk placement, shuffles, procedural noise |
RomuDuo | Fast | Good | no guaranteed period (Romu is non-linear); 128/128 state bits live (measured) | Alternative to PCG |
Sfc64Random | Moderate | Very Good | unpublished; counter forbids a repeat before 2^64 draws; 204/256 state bits live (measured) | Published-pedigree general purpose; one advance per 64 bits |
Xoshiro128StarStar | Moderate | Excellent | 2^128-1 (published) | NextBool/low-bit masks; WebGL and other 32-bit targets |
Xoshiro256StarStar | Moderate | Excellent | 2^256-1 (published) | NextDouble/NextUlong-heavy work (one advance per 64 bits); the PRNG.Instance default |
StormDropRandom | Moderate | Excellent | unpublished; 28,407 state bits live (measured) | Long streams from a large 1024-word state |
XorShiftRandom | Fast | Fair | 2^32-1 (published) | Legacy compatibility |
WyRandom | Slow | Very Good | 2^64 (published) | Hash-based scenarios |
SquirrelRandom | Slow | Fair | 2^32 (32-bit position counter) | Noise-based generation |
PhotonSpinRandom | Slow | Excellent | unpublished; 734 state bits live (measured) | Maximum quality needed |
UnityRandom | Very Slow | Fair | 2^128-1 (Unity documents Xorshift 128) | Match Unity behavior |
SystemRandom | Very Slow | Poor | unpublished; 1,717 state bits live (measured) | .NET compatibility |
DotNetRandom | Very Slow | Poor | runtime-dependent (System.Random); not fixed | Bridging System.Random code to IRandom |
WDoomRandom | Very Slow | Poor | 1024 draws (measured: 10 state bits live) | Retro feel, deterministic replays |
Reading the Period column¶
A period of 2^128 cannot be observed, so every value in that column is a claim and the column says whose. (published) quotes the algorithm's specification. (author's claim, unverified) is a number the upstream author states that nothing here has checked. And where nothing is published at all, the value reports what was measured instead: state bits live (measured) is the count of state bits observed to change over 3,000 NextUint() draws, out of the generator's declared state width.
Read a live-state count as a lower bound on state width, not a period. Bits that did not move in 3,000 draws may still be live, and a wide state does not by itself guarantee a long cycle. It is there because it is the strongest honest statement available for a generator whose author published no period, and because four Excellent ratings in this roster came from repositories that are now offline, so a quoted period with no source is exactly the failure this column is meant to avoid.
Each generator declares its own value through [RandomGeneratorMetadata(period: "...")], readable at run time as RandomGeneratorMetadataRegistry.Snapshot(type).Period. A contract test fails the build if a generator declares none, or if this table and the annotation disagree.
Both ** generators are rated Excellent: the scrambler leaves no weak output bit, so unlike the + scramblers they are safe for NextBool and low-bit masks.
One state advance per 64-bit draw¶
NextUlong(), and therefore NextLong(), NextDouble() and NextUlong(max), used to cost two state advances on every generator: the shared base class built a 64-bit value out of two 32-bit draws. BlastCircuitRandom, RomuDuo, Sfc64Random, SplitMix64, WyRandom and Xoshiro256StarStar each compute a whole 64-bit word internally, so they answer a 64-bit draw with one advance and return that word directly, measured at 2.49x on Unity 6000.4.6f1 (Mono), 1.32 ns against 3.28 ns.
XoroShiroRandom deliberately does not: xoroshiro128+ is a + scrambler with no strong 64-bit word to hand back, so it keeps composing a 64-bit draw out of two strong halves.
A generator that answers 64-bit draws in one advance produces a different sequence for the same seed than 3.5.1 did. See Seeded streams that moved.
For detailed benchmarks, see Random Performance.
Creating Seeded Generators¶
For reproducible sequences (replays, procedural generation, testing):
Keyed seed derivation¶
Use RandomSeeds.Derive when each gameplay decision or parallel job needs its own reproducible stream. Changing one decision then leaves unrelated streams unchanged, and scheduling jobs in a different order does not move their results.
Numeric keys are folded in order, so (room, attempt) differs from (attempt, room). Domain text is hashed as UTF-8 and remains stable across processes; a null domain is the empty domain. RandomSeeds.Mix64 exposes the SplitMix64 finalizer for callers that already have their own key-composition scheme; the finalizer is a bijection over 64-bit values. These helpers are deterministic mixers, not cryptographic hashes.
Seeded streams that moved¶
Reproducibility is a promise about a given version. Two corrections in this release change what some generators return for a seed they were already given, so a replay, a saved procedural world or a golden test recorded under 3.5.1 will not reproduce with them:
| generator | what moved | why |
|---|---|---|
XoroShiroRandom | every draw | It returned the linear low half of its word and now returns the strong high half. |
BlastCircuitRandom, RomuDuo, SplitMix64, WyRandom | NextUlong, NextLong, NextDouble, NextUlong(max) | One state advance per 64-bit draw instead of two. NextUint, Next, NextBool and NextFloat are unchanged. |
Xoshiro256StarStar | nothing | Added in this same unreleased cycle. |
Every other generator is untouched. If you need the old stream, pin the package version that produced it; a save format that must survive a generator change should record the drawn values, or the generator's InternalState, rather than a seed.
API Reference¶
Basic Generation¶
Infinite float and double bounds select finite representable bit patterns, rather than a uniform real-number distribution over an unbounded interval. NextFloat(float.PositiveInfinity) and NextDouble(double.PositiveInfinity) use that same rule as their two-bound overloads. The interval from negative infinity to the most negative finite value has only negative infinity below its exclusive maximum: the legacy overload returns that value without consuming entropy, while TryNextDouble returns false with a default output because no finite candidate exists. An infinite range ending at either sign of zero excludes both zero encodings; its largest allowed value is the smallest negative subnormal (-float.Epsilon or -double.Epsilon).
For saved simulations upgrading from an earlier version, finite-bound draw sequences and raw streams are unchanged. Calls with infinite bounds can now consume different draws and produce different values; replay files using those calls should record their package version.
Exact sampling and stalled sources¶
AbstractRandom.TryNextUint, TryNextUlong, TryNextDouble and TryNextGaussian return false and write default for invalid inputs, exhausted rejection sampling, or a floating-point result that cannot satisfy the requested contract. Always check the boolean before using the output. Infinite-range TryNextDouble also reports failure when its bounded integer sampler exhausted its cap, even if that sampler's fallback would produce a finite number. It stops on that failure rather than multiplying the nested retry budgets. Legacy infinite-double sampling also stops when exhausted inner sampling yields a nonfinite candidate, returning its existing deterministic fallback. Finite-range sampling rejects a result rounded to its exclusive maximum; Gaussian sampling rejects an overflow to infinity. Neither case clamps the result or reports a substitute as success. These methods are on AbstractRandom; IRandom remains unchanged.
| C# | |
|---|---|
The existing methods retain their degraded, non-throwing response to a stalled source. They can return a modulo reduction, a fixed in-range value, or a Gaussian substitute after their rejection caps. Invalid-bound exceptions remain. Use the Try methods when a fallback is unacceptable. A bounded integer request permits an initial draw followed by 65,536 retries for 32 bits or 1,048,576 retries for 64 bits. Gaussian sampling permits 1,048,576 candidate pairs before taking its fallback path. A failed Gaussian draw does not cache a fallback partner as an exact sample.
Raw NextUint() and NextUlong() streams, power-of-two reductions, and serialized continuation are unaffected by these Try guards. A failed Try call still consumes its attempted draws; a Gaussian transform that overflows retains its finite cached partner for the next request. A failed exact request can now stop earlier than the legacy fallback path, so subsequent draws after that failure can start at an earlier stream position. Next() and NextLong() exclude their signed maximum; the earlier correction to those methods intentionally changed the rare draw that previously returned that maximum. These generators are not cryptographic random sources.
Arithmetic verification is separate from statistical quality testing. Install the pinned proof dependency and run the executable contract with a C++17 compiler exposed as c++ on PATH:
| Bash | |
|---|---|
It enumerates every word for every nonzero 8-bit and 16-bit bound, verifies exact results and equal accepted bucket counts, and rejects modulo, threshold and high-word model mutants. Z3 proves unsigned wrap, product decomposition, the threshold remainder identity, result bounds, and the carry reconstruction parsed from the production multiply-high body. Source mutations to a partial product, limb extraction and carry shift must each produce a counterexample. Result bounds and limb expansion use integer arithmetic; carry reconstruction and unsigned wrap use bit vectors. Unsupported production syntax or an inconclusive solver result fails the command. The Unity AbstractRandomBoundedContractTests fixture independently checks production acceptance and outputs against BigInteger, including exact retry boundaries and nested rejection failures. These checks do not replace raw-stream continuation tests or Mono, IL2CPP and WebGL verification.
Ranges a Designer Authored¶
Next(min, max) and NextFloat(min, max) throw when max <= min, which is the right contract for a computed range. It is the wrong one for two [SerializeField] floats: collapsing both ends onto the same value is the obvious way to ask for no spread, so an inspector's most natural "turn this off" gesture is exactly the input those overloads reject. And these draws usually sit inside a coroutine or a periodic tick, where an exception ends the loop permanently; the system it drove just stops existing, with one line in the console.
Every ranged draw has one, and they all answer the low bound:
| Strict (throws on an empty range) | Non-throwing sibling |
|---|---|
Next(min, max) | NextIntInRange(low, high) |
NextUint(min, max) | NextUintInRange(low, high) |
NextShort(min, max) | NextShortInRange(low, high) |
NextByte(min, max) | NextByteInRange(low, high) |
NextLong(min, max) | NextLongInRange(low, high) |
NextUlong(min, max) | NextUlongInRange(low, high) |
NextFloat(min, max) | NextFloatInRange(low, high) |
NextDouble(min, max) | NextDoubleInRange(low, high) |
NextIntSkewed(min, max, target, iterations) draws an integer toward a preferred value. It averages the requested number of uniform draws with two copies of target, truncates the mean, and clamps the result to [min, max]. The default is three uniform draws. Zero iterations returns the clamped target without drawing. The public MaxSkewedIterations limit is 1,024 draws. A null generator, empty, inverted or float-indistinguishable bounds, an iteration count outside the supported range, or a NaN target returns min without drawing.
| C# | |
|---|---|
The float and double siblings also answer the low bound when either bound is NaN. The strict NextFloat and NextDouble overloads reject NaN bounds with ArgumentException before drawing. Their existing support for infinite two-bound ranges and bounded-sampling fallbacks is unchanged.
Weighted span selection¶
Weighted selection rejects NaN and infinite weights before drawing. Array and tuple overloads also reject negative weights and totals that overflow float; the IReadOnlyList<float> overload continues treating finite negative weights as zero and sums in double. NextBool(probability) requires a probability in [0, 1], including rejection of NaN.
For large or highly skewed tables, TryNextWeightedIndex(ReadOnlySpan<double>, out int) normalizes before summing so finite weights near double.MaxValue remain usable. The ByRace index overloads consume one random draw per slot and give each slot an independent exponential clock. Changing one weight therefore leaves every other slot's clock stable, which is useful for seeded procedural content. The subset overload returns the earliest clocks without replacement:
| C# | |
|---|---|
All Try weighted methods reject a null generator and any non-finite weight before drawing. A selection requiring a winner also rejects empty or wholly non-positive input; a zero-winner subset succeeds without inspecting weights or drawing. Non-positive slots still consume a race draw after validation so adding weight to an existing slot does not shift the other slots' seeded clocks. The weights and destination spans must not overlap for either subset overload, including differently typed views over shared bytes. The explicit scratch overload is allocation-free and additionally requires its scratch span not to overlap either input. The convenience subset overload uses bounded stack scratch for up to 1,024 winners and a pooled buffer above that.
Exact clock ties resolve to the lower index. The race uses Math.Log, whose last bits can differ by runtime, so an extremely close non-exact tie can resolve differently across Mono, IL2CPP, and WebGL. Record selected indices rather than relying on cross-runtime replay when weights and draws can produce near-ties.
NextNoiseMap requires positive finite scale, persistence, lacunarity and octave offset range, and a finite base offset. Sphere surface sampling returns its center for nonfinite radius, matching the volume sampling helpers.
They answer the low bound, not zero. These are a range, not a scatter: an author who writes 3 .. 3 means three seconds. A symmetric [-s, s] whose collapse genuinely is zero is a different shape and keeps its own guard. A null generator answers the low bound too, so a field that has not been wired up yet degrades to the authored minimum rather than throwing.
Use the throwing overloads for a range your code computed; use these for one a human typed.
Vector Generation¶
| C# | |
|---|---|
Color Generation¶
Distributions¶
| C# | |
|---|---|
Feel-Good Randomness¶
Use these helpers when independent rolls are mathematically fair but feel bad to players because they create long streaks or clumps.
ExactAveragePrd intentionally rejects very small non-zero targets below ExactAveragePrd.MinimumPositiveTargetChance; use BadLuckProtection or a WeightedShuffleBag<T> for ultra-rare rewards. The stateful helpers expose restore APIs (TrySetFailuresSinceSuccess, TryRestoreRemaining, and copy helpers for bag tickets) so save/load systems can persist pity and deck state explicitly.
Choose the helper by design goal:
| Goal | Helper | Behavior |
|---|---|---|
| Preserve exact long-run chance while reducing streaks | ExactAveragePrd | Chance rises after failures by a solved coefficient and resets on success |
| Guarantee eventual success after a dry streak | BadLuckProtection | Chance ramps by a fixed amount and can force a success after N failures |
| Avoid repeated clumps in finite weighted sets | WeightedShuffleBag<T> | Draws without replacement until every weighted ticket has appeared |
Collection Operations¶
| C# | |
|---|---|
Use NextEnumExcept with an array when the exclusions are determined at runtime. Import WallstopStudios.UnityHelpers.Core.Random; the extension works on both IRandom and concrete generators without adding a member to the interface.
| C# | |
|---|---|
A null or empty array excludes nothing. Duplicate and undefined exclusions follow the generator's existing rules; the built-in generators ignore them and throw InvalidOperationException when no enum value remains. An existing array is passed through without copying or modifying it. A null generator returns the default enum value. Existing calls listing individual exclusions keep using the interface overloads: one through four exclusions create no params array, while more than four keep the existing tail-array allocation. Calling the extension explicitly as RandomUtilities.NextEnumExcept(random, ...) with individual values can create a params array.
Thread Safety¶
PRNG.Instance provides thread-local instances, making it safe for multithreaded code without locks:
| C# | |
|---|---|
For explicit thread-local control:
| C# | |
|---|---|
Perlin Noise¶
For procedural generation, use the seedable Perlin noise generator:
| C# | |
|---|---|
Best Practices¶
- Use
PRNG.Instancefor most cases: it's fast, thread-safe, and well-tested - Seed generators explicitly when reproducibility matters (replays, tests)
- Avoid
newin hot paths: cache generator instances - Don't use for security: these are PRNGs, not CSPRNGs
| C# | |
|---|---|