Random Utils

43 min read

Pluggable random algorithms with helpers for dice, IDs, Poisson sampling, and the seeded determinism that daily-seed roguelikes and replay systems depend on.

Summary

The Scylla random utilities provide the reliable randomization that drives many of Scylla’s randomness-requiring features. IRandomSource is a three-method interface (NextUInt32, NextUInt64, NextBytes), and every algorithm, every adapter, and every helper accepts it. It features seedable pseudo-random algorithms (xoshiro256**, xoroshiro128+, PCG32, SplitMix64, Mersenne Twister) and a cryptographic source for session tokens and save-file nonces, plus adapters for the legacy System.Random and UnityEngine.Random paths.

The helpers in RandomUtil and RandomExtensions cover the surface a game design reaches for. For example:

  • Bounded integers and floats
  • Fair coin flips and biased ones (the 5% mimic check)
  • Uniform directions and rotations (the bullet spread cone, the explosion debris)
  • Gaussian damage spread
  • Weighted loot tables
  • Deck shuffles
  • Poisson-distributed prop scatters (trees without clumping, etc.)
  • RPG-style dice notation (e. g. 2d6+strength)
  • GUID and short alphanumeric IDs (lobby codes, save slots, etc.)
  • Gibberish for placeholder NPC names

Every helper takes an IRandomSource, so that swapping algorithms or threading a seeded source through a gameplay subsystem is a one-line change.

The reason all of this matters is determinism. When driving combat, AI, loot, world-gen, and every other subsystem that consumes randomness from one seeded IRandomSource, the same seed produces the same game. Spelunky-style daily challenges (one date hashed into a seed, the same dungeon on every player’s machine), Slay the Spire’s seed-shareable runs, lockstep multiplayer netcode, save-and-load that resumes mid-combat, automated regression tests that actually reproduce, and bug reports that come with a seed instead of a screenshot all become tractable problems rather than trial and error. The cryptographic source covers the rest of your budget (session tokens, encryption nonces) without contaminating the gameplay determinism everywhere else.

Use the random utilities for:

  • Deterministic seeded randomness for replays, lockstep multiplayer netcode, save and load, automated regression tests, and bug-report reproduction from a seed.
  • Multi-stream independence so combat, loot, AI, and world-gen each consume their own sequence and a particle system never silently desyncs the boss fight.
  • A specific algorithm with known properties (state size, period, multi-stream support) chosen per subsystem rather than imposed globally.
  • The gameplay shapes that come up in every project: dice notation for CRPG damage formulas, GUIDs and short codes for save slots and lobby IDs, Poisson distributions for natural-looking prop scatter, weighted picks with depletion and repeat prevention for loot tables and card draws, gibberish for placeholder text.
  • Cryptographic randomness for session tokens, encryption keys, and challenge nonces without giving up the gameplay determinism on the rest of the project.

Features

  • IRandomSource interface. A three-method interface (NextUInt32, NextUInt64, NextBytes) that every algorithm, adapter, and helper builds on. Pass any source to any helper; swap algorithms in a single line.
  • Five seedable algorithms. Xoshiro256StarStar (default, 256-bit state, 2^256 period), Xoroshiro128Plus (lightweight 128-bit), PCG32 (native multi-stream), SplitMix64 (minimal-state seed expander), and MersenneTwister MT19937 (long-period offline simulation). Each is the right pick for a different trade-off between state size, speed, and quality.
  • String-seeded reproducibility. Every algorithm accepts a ulong seed or a stable cross-platform string seed hashed via FNV-1a and expanded with SplitMix64. The same string produces the same sequence on every platform and runtime, ideal for procedural worlds derived from human-readable names.
  • Multi-stream independence. PCG32 exposes 2^63 independent non-overlapping streams from one seed; Xoshiro256StarStar.Fork(streamID) derives an independent child sequence. Each gameplay system (combat, AI, loot, world generation) gets its own stream without interfering with the others.
  • State snapshot and restore. Every algorithm exposes GetState/SetState against a [Serializable] struct that round-trips through Unity serialization, JSON, and ConfigFile. Replays, lockstep multiplayer, and resumable simulation all become tractable.
  • Comprehensive primitive set. Booleans (fair and biased), signs, signed/unsigned integers in any range, 64-bit indices, floats and doubles in [0, 1) or bounded, raw byte buffers, and Gaussian samples via Box-Muller. The shape of every gameplay random is covered without manual math.
  • Geometric sampling done right. InsideUnitCircle/OnUnitCircle/InsideUnitSphere/OnUnitSphere produce true uniform samples in their target shape; NextRotation uses the Shoemake method for a true uniform sample over the rotation group. Avoids the box-corner bias and Euler-angle clustering that hand-rolled equivalents accumulate.
  • Domain helpers. DiceRoll parses RPG notation (3d6+2); RandomID generates numeric, hex, Base64, alphanumeric, and GUID identifiers; RandomText produces pronounceable gibberish with no dictionary dependency; PoissonDiskSampler produces clump-free 2D point sets via Bridson’s algorithm.
  • ProbabilityList<T> for stable weighted picks. O(1) alias-table picks with three selection strategies, four repeat-prevention modes (no-immediate, shuffle, history-windowed, off), a depletion system for one-shot drops, and pluggable per-entry influence providers that compute weights dynamically from game state.
  • Adapters for System.Random and UnityEngine.Random. FromSystemRandom and FromUnityRandom wrap legacy sources behind the IRandomSource interface so integration code can stay on the new API even when the upstream sequence is owned elsewhere.
  • Cryptographic source. CreateCrypto wraps the platform’s RandomNumberGenerator for session tokens, encryption keys, and nonces. Non-deterministic by design and clearly separated from the gameplay algorithms so the two contracts never leak into each other.
  • Backwards-compatible facade. RandomUtil.CreateDeterministic(seed) is an alias for the recommended xoshiro256 with SplitMix64 seeding, used by older code that does not pick an algorithm explicitly. Returns a struct, so a value copy forks the sequence cheaply.

Algorithms

A pseudo-random number generator (PRNG) is a small piece of state plus a step function that turns that state into the next state and an output value. Different algorithms trade off how much state they carry, how long they can run before the sequence repeats, how fast each step is, and how “random-looking” the output looks under statistical scrutiny. The five PRNGs in the box occupy different points in that trade-off space; the sixth source (CreateCrypto) wraps the platform’s cryptographic RNG and lives in its own category for tokens and nonces.

Two columns in the table below deserve a quick definition before picking one:

  • State is the amount of internal memory each generator holds. Larger states cost more memory per generator and produce larger save-file entries when serialized, but generally support longer periods and richer multi-stream behavior. For one project-wide source the difference is negligible; for thousands of per-entity sources (a 5000-enemy crowd sim where each enemy carries its own RNG) it matters.
  • Period is how many output values the algorithm produces before the sequence repeats. Modern PRNGs have periods so large (2^128 and beyond) that exhaustion is not a practical concern for any game; the column is informative rather than a real selection criterion. The classic System.Random (period roughly 2^31) is the only realistic case of running out on a long-lived simulation.
FactoryStatePeriodNotes
RandomUtil.CreateXoshiro256(seed)256-bit2^256 – 1Default and recommended. Fast, passes all known statistical tests, native 64-bit output. Supports forking independent streams via Fork. Best for general gameplay, procedural generation, and deterministic simulations.
RandomUtil.CreateXoroshiro128(seed)128-bit2^128 – 1Lightweight alternative when many independent RNG instances are needed or per-entity memory is constrained. Plus-output has known weaknesses in the lowest bits; prefer xoshiro256** when output quality matters more than memory.
RandomUtil.CreatePCG32(seed, stream)128-bit2^64 (per stream)Permuted Congruential Generator with native multi-stream support. A single seed combined with different stream IDs yields up to 2^63 fully independent, non-overlapping sequences. Compact serialized save states. Best for multi-stream systems and small per-entity RNGs.
RandomUtil.CreateSplitMix64(seed)64-bit2^64Minimal state and an extremely fast Next step. Primarily designed as a seed expander rather than a primary source; passes BigCrush but has a short period. Best for seeding other generators, hash-like randomness, and procedural pipelines that fork heavily.
RandomUtil.CreateMersenneTwister(seed)~2.5 KB2^19937 – 1MT19937. Enormous state and exceptionally long period; 623-dimensional equidistribution. Slower initialization and far larger save states than the modern algorithms. Best for scientific simulations and offline tooling; rarely the right choice for new gameplay code.
RandomUtil.CreateCrypto()n/an/aCryptographically secure (System.Security.Cryptography.RandomNumberGenerator). Non-deterministic, not seedable, slower than the gameplay algorithms. Use for session tokens, encryption keys, and nonces; never for gameplay or anything that should reproduce from a seed.
RandomUtil.FromSystemRandom(random)wrappedhostAdapter around an existing System.Random instance. Useful for integrating legacy code without rewriting the consuming side. The caller owns the underlying instance.
RandomUtil.FromUnityRandom()globalhostAdapter around UnityEngine.Random. Shares state globally with every other call to Unity’s RNG. Main-thread only. Use only when interop with Unity-specific APIs is unavoidable.

All non-adapter factories also accept a string seed which is hashed via stable platform-independent FNV-1a before being expanded with SplitMix64. The same string always produces the same sequence on every platform and runtime. The generic factory RandomUtil.Create(RandomAlgorithm algorithm, ulong|string seed) returns IRandomSource and picks the concrete type from the RandomAlgorithm enum, which is useful when the algorithm is configurable at runtime.

Save and restore state via algorithm-specific GetState/SetState on the concrete algorithm structs. Each algorithm declares its own state type (Xoshiro256State, Xoroshiro128State, PCG32State, SplitMix64State, MersenneTwisterState) that is [Serializable] and round-trips through Unity serialization, JSON, and ConfigFile.

Recipes

These recipes cover every public surface in the random subsystem. Each one is a self-contained answer to a single problem, so jump to the one that matches what you are building. The first recipe creates the generator that the rest of them reuse, so start there if you are new to the API.

Get a seeded random source

Problem. You want the reproducibility a roguelike daily challenge depends on: the same seed, hashed from the date, ships the same dungeon to every player, and a small community spends the day comparing strategies on the exact same run. Spelunky’s seed-share codes work the same way. Anything that wants this starts by acquiring an IRandomSource.

Solution. Pick a factory and seed it. The block below covers every factory the framework exposes (seeded algorithms, string-seeded variants, the generic enum-driven factory, the cryptographic source, adapters around System.Random and UnityEngine.Random, and the backwards-compatible facade) plus the state-snapshot and forking helpers you use for save games and replays.

/* The default seeded source. Use for the project's master RNG when one
 * stream is enough. 256-bit state, passes all known statistical tests,
 * the right pick when in doubt. */
var rng = RandomUtil.CreateXoshiro256(seed: 12345UL);

/* String-seeded variant for "share this dungeon" codes and named seeds.
 * The string is hashed via stable cross-platform FNV-1a, so a seed code
 * pasted from Discord reproduces the same content on every machine. */
var dailyChallenge = RandomUtil.CreateXoshiro256(seed: "2026-05-21-daily");

/* Lightweight 128-bit alternative. Reach for this when per-entity memory
 * matters: 5000 enemies in a crowd sim, each carrying its own RNG. */
var enemyRng = RandomUtil.CreateXoroshiro128(seed: 12345UL);

/* PCG32 with native multi-stream support. The stream parameter selects one
 * of 2^63 independent sequences from one seed; combat, AI, loot, and
 * world-gen each pick a distinct stream so a particle effect on the main
 * thread cannot silently desync the boss fight's loot drop. */
var combatRng = RandomUtil.CreatePCG32(seed: 12345UL, stream: 0UL);
var lootRng   = RandomUtil.CreatePCG32(seed: 12345UL, stream: 1UL);
var aiRng     = RandomUtil.CreatePCG32(seed: 12345UL, stream: 2UL);

/* SplitMix64. Tiny state and an extremely fast Next step. Best as a seed
 * expander for hash-derived per-chunk RNG in a Minecraft-style world,
 * where each chunk gets a deterministic seed from its (x, z) coordinates. */
var chunkRng = RandomUtil.CreateSplitMix64(seed: 12345UL);

/* Mersenne Twister MT19937. Enormous state (~2.5 KB), exceptionally long
 * period. Rarely the right pick for gameplay; reach for it when a scientific
 * simulator or an offline level-baking tool genuinely needs the period. */
var simRng = RandomUtil.CreateMersenneTwister(seed: 12345UL);

/* Generic factory: pick the algorithm at runtime from a config value. The
 * right shape when a per-build or per-mode setting decides which RNG to use. */
var configurableRng = RandomUtil.Create(RandomAlgorithm.Xoshiro256StarStar, 12345UL);

/* Cryptographically secure, non-deterministic. Use for online session tokens,
 * save-file encryption nonces, and anti-cheat challenges; never for gameplay.
 * Significantly slower than the gameplay algorithms. */
var cryptoSource = RandomUtil.CreateCrypto();

/* Adapter around an existing System.Random instance. Useful for integrating
 * an older system without rewriting its random-number consumers. */
var legacyRng = RandomUtil.FromSystemRandom(new System.Random(12345));

/* Adapter around UnityEngine.Random. Shares state globally with every other
 * Unity RNG call in the process. Use only for editor tooling, third-party
 * interop, or Unity-internal APIs that consume the global RNG. */
var unityRng = RandomUtil.FromUnityRandom();

/* Backwards-compatible facade (alias for xoshiro256** with SplitMix64 seeding).
 * Returned as a struct, so a value copy cheaply forks the sequence. */
var detRng = RandomUtil.CreateDeterministic(seed: 12345);

/* Snapshot and restore for save games or replay scrubbing. The state struct
 * is [Serializable] and round-trips through Unity's serializer, through JSON,
 * and through ConfigFile. The right primitive for "resume mid-combat" loads. */
Xoshiro256State savedState = rng.GetState();
/* ... advance the generator across a few frames, then later on load ... */
rng.SetState(savedState);

/* xoshiro256** supports forking: derive a child sequence that is independent
 * of the parent but reproducible from the same parent state plus stream ID.
 * Use one fork per gameplay system so a desync in combat does not bleed
 * into the loot table. */
Xoshiro256StarStar combatChild = rng.Fork(streamID: 7UL);

Roll the basic gameplay randoms

Problem. The moment any gameplay decision branches on probability, you reach for one of these calls. A crit-chance check resolves to a NextFloat() against an accuracy threshold; a random muzzle-flash sprite is a NextInt(0, flashCount); whether the chest spawns a Mimic is a NextBool(0.05f); the left-or-right component of a bullet spread is a NextSign().

Solution. These are the building blocks the rest of the page composes from: booleans (fair or biased), signs, bounded integers (32-, 64-, and unsigned), floats and doubles in [0, 1) or in a bounded range, raw byte buffers, and bare indices.

/* Raw 32-bit and 64-bit draws. The lowest-level entry point on the interface;
 * every higher-level helper builds on these. */
uint  bits32 = rng.NextUInt32();
ulong bits64 = rng.NextUInt64();

/* Fill an existing byte buffer in place (no allocation). Used by the GUID
 * helper, the Base64 ID helper, and any other "fill N random bytes" path. */
var buffer = new byte[16];
rng.NextBytes(buffer);                       /* fill the whole buffer */
rng.NextBytes(buffer, offset: 4);            /* fill from index 4 to the end */
rng.NextBytes(buffer, offset: 0, count: 8);  /* fill the first 8 bytes only */

/* Fair and biased coins. The first is a 50/50 flip; the second is the canonical
 * "5% Mimic chance on a treasure chest". */
bool isFairFlip   = rng.NextBool();
bool isMimicChest = rng.NextBool(probability: 0.05f);

/* Random sign: -1 or +1 with equal probability. Useful for flipping a velocity
 * or a direction without computing a normalized vector (e.g. randomising the
 * left/right component of a bullet spread). */
int spreadSign = rng.NextSign();

/* Bounded integers. Upper bound is exclusive. The two canonical cases: a d6
 * roll for a tabletop CRPG, and an index into a sprite array for a randomised
 * muzzle flash. */
int d6Roll        = rng.NextInt(1, 7);
int muzzleFlashID = rng.NextInt(10);     /* 0..9, shorthand for NextInt(0, 10) */

/* Bounded 64-bit and unsigned variants for indices into large arrays or for ID
 * generation. The big-array case shows up in a streaming open-world that
 * indexes millions of authored objects. */
long  bigIndex = RandomUtil.NextLong(rng,  0L, 1_000_000_000_000L);
uint  port     = RandomUtil.NextUInt(rng,  1024u, 65536u);
ulong slot     = RandomUtil.NextULong(rng, 0UL, ulong.MaxValue);

/* Floats and doubles in [0, 1). The unbounded form is the cheapest scalar draw
 * and the canonical "did the attack land?" call: roll, compare against the
 * hit chance, branch. */
float  hitRoll   = rng.NextFloat();
double precision = rng.NextDouble();        /* same shape, double precision */

/* Bounded floats and doubles for value ranges. Both endpoints inclusive. */
float  damage     = rng.NextFloat(5f, 12f);       /* 5..12 inclusive */
double burnDamage = rng.NextDouble(0.0, 100.0);

/* Util-level [0,1) helpers are also exposed directly when extension-method
 * resolution is awkward (generic code, ref-using context). */
float  f01b = RandomUtil.NextFloat01(rng);
double d01b = RandomUtil.NextDouble01(rng);

/* Pick a bare index without picking the element. Equivalent to NextInt(0, n)
 * but expresses intent ("which slot in the hotbar"). */
int randomHotbarSlot = RandomUtil.NextIndex(rng, count: 8);

Pick, shuffle, and weight from a collection

Problem. Pick-one-from-many comes up in almost every gameplay system that varies its output: picking an enemy type from this wave’s spawn pool, shuffling a deckbuilder’s pile after every battle, rolling three modifiers without repeats for a Risk of Rain run.

Solution. RandomUtil exposes those operations on every collection shape (IReadOnlyList<T>, ReadOnlySpan<T>, IList<T>), so your code reads the same way whether the source is a stack-allocated span or a long-lived list. PickWeighted is convenient for the occasional ad-hoc selection; when your weights are stable and you sample them many times in a row (a loot table, a wandering monster table), reach for ProbabilityList<T> instead (see the weighted-loot-table recipe below).

/* Uniform pick from a list. Every entry has equal probability. The canonical
 * "pick one enemy from this wave's spawn pool" call. */
var spawnPool = new[] { "Slime", "Bat", "Ghost", "Wraith" };
string spawned = rng.PickOne(spawnPool);

/* Safe variant that returns false on an empty collection instead of throwing.
 * Use whenever the pool might legitimately be empty (last wave already spawned). */
if (RandomUtil.TryPickOne(rng, spawnPool, out var safeSpawn))
{
    Log.Info($"Spawning {safeSpawn}", LogCategory.Module);
}

/* Pick from a ReadOnlySpan<T> without allocating an array. Useful for picking
 * from a stack-allocated transient pool (this frame's available attack slots,
 * for example) without GC pressure. */
ReadOnlySpan<int> hotbar = stackalloc int[] { 10, 20, 30, 40 };
int hotbarSlot = rng.PickOne(hotbar);

/* Pick N distinct elements without replacement. Returns a new list; each item
 * appears at most once. The shape of "roll three modifiers for this run" in
 * a Risk of Rain or Hades-style run-based game. */
List<string> runModifiers = rng.PickMultiple(spawnPool, count: 2);

/* Fisher-Yates shuffle in place. Every permutation has equal probability.
 * The canonical deckbuilder "reshuffle the discard back into the draw pile"
 * call. */
var drawPile = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8 };
rng.Shuffle(drawPile);

/* Weighted pick for one-off ad-hoc rolls. The canonical roguelike loot table.
 * For stable weights sampled many times, see the weighted-loot-table recipe. */
var lootTable = new[] { "Common", "Rare", "Epic", "Legendary" };
var weights   = new[] { 60f,      30f,    9f,      1f };
string drop = rng.PickWeighted(lootTable, weights);

/* Weighted pick by index when only the index is needed (e.g. for a sprite
 * lookup table where the index drives both the loot type and the visual). */
int dropIndex = RandomUtil.PickWeightedIndex(rng, weights);

Pick a random enum value

Problem. A lot of your game state ends up living in enums: loot rarity, weather type, boss phase, the room shape in a metroidvania’s procgen palette. Sometimes you just want one of those categories at random, with no weighting yet.

Solution. NextEnum<T> picks a uniformly random value from any enum type. It is convenient for one-off selection, but the call allocates an array of enum values per invocation, so in a hot path cache the values array yourself or reach for a ProbabilityList<T> over the enum members.

/* Pick a uniformly random value from any enum. Allocates the values array
 * once per call, so cache externally for hot paths or use ProbabilityList<T>
 * when the rarity needs proper weighting. */
public enum LootRarity { Common, Uncommon, Rare, Epic, Legendary }

LootRarity rarity = rng.NextEnum<LootRarity>();

Place things in the world without corner-clustering

Problem. You want to drop a wave of enemies in a ring around the player, spray bullet particles in a cone, spawn a treasure pile inside a trigger volume, or pick a random RGB tint for a damage flash. Every system that places things in 3D eventually needs a vector, a direction, or a rotation that does not clump at a box corner.

Solution. The geometric helpers (InsideUnit..., OnUnit...) produce true uniform samples in their target shape. Component-wise vectors produce a uniform distribution inside a box, which is rarely what you want for gameplay placement, so reach for the geometric helpers when you mean “a direction” or “a point in a disk”.

/* Component-wise random vectors. Each axis is independent and uniform.
 * The right shape for axis-aligned jitter (camera shake offsets, per-axis
 * dither for a screen effect). Not the right shape for a "random direction"
 * because the result clusters at box corners. */
Vector2 cameraJitter2D = rng.NextVector2(min: -0.5f, max: 0.5f);
Vector3 cameraJitter3D = rng.NextVector3(min: -0.05f, max: 0.05f);

/* Geometric sampling. Uniformly distributed inside or on the unit circle and
 * sphere. The right shape for picking a position around the player, a
 * direction for an arrow, a velocity for an explosion debris piece. */
Vector2 dropOffset = rng.InsideUnitCircle();  /* "loot drops near player": within radius 1 */
Vector2 spawnRing  = rng.OnUnitCircle();      /* "enemies spawn in a ring": on radius 1 */

Vector3 particleOffset    = rng.InsideUnitSphere(); /* "debris inside a fireball sphere" */
Vector3 particleDirection = rng.OnUnitSphere();     /* "shrapnel in every direction" */

/* Semantic aliases for 2D and 3D directions. Same output as the on-unit
 * helpers; the name reads better when the intent is a heading. */
Vector2 aiHeading2D    = rng.NextDirection2D();
Vector3 aiHeading3D    = rng.NextDirection3D();

/* Uniformly random rotation via the Shoemake method. Every orientation in 3D
 * space has equal probability. The right shape for a random pickup tumble,
 * a random asteroid orientation, or a random NPC face direction at level start. */
Quaternion asteroidSpin = rng.NextRotation();

/* Random colors. Alpha defaults to 1; pass true to randomize alpha too. The
 * common case is a random damage-flash tint or a procedural particle color. */
Color damageTint  = rng.NextColor();
Color smokePuff   = rng.NextColor(randomAlpha: true);

Cluster values around an average

Problem. Plenty of your gameplay values feel right when they cluster around a mean with the occasional outlier rather than spreading uniformly across a range. An AI’s reaction time should mostly land near 250 ms with the rare 100 ms reaction that reads as an elite catching the player off guard; a JRPG’s damage roll should sit near its expected value with rare highs and lows that read as crits or glancing blows; an explosion’s particle velocity should be roughly uniform but not a hard cliff.

Solution. Bell-curve sampling via the Box-Muller transform is the shape for any “mostly around average, sometimes far from it” value. The mean controls where values cluster; the standard deviation controls how tightly. The result is unbounded in principle, so clamp it when you need a hard upper or lower bound.

/* Standard normal: mean 0, standard deviation 1. About 68% of samples fall in
 * [-1, 1], 95% in [-2, 2], 99.7% in [-3, 3]. The raw shape; gameplay code
 * usually scales and shifts to a meaningful range below. */
float z = rng.NextGaussian();

/* AI reaction time: mostly around 250 ms, occasionally faster (an alert
 * guard) or slower (a distracted one). One standard deviation = 50 ms means
 * roughly 68% of reactions land in [200, 300]. */
float aiReactionMs = rng.NextGaussian(mean: 250f, standardDeviation: 50f);

/* JRPG damage spread: most attacks land near 25, with rare highs and lows
 * that read as "lucky hit" or "glancing blow". The clamp prevents the rare
 * tail samples from producing negative damage or implausibly large numbers. */
float meleeDamage = Mathf.Clamp(rng.NextGaussian(mean: 25f, standardDeviation: 5f), 10f, 40f);

Roll RPG dice notation

Problem. If you have shipped a CRPG you know the dice formula is the spine of every action: a longsword does 1d8 + strength damage, a fireball does 8d6 to everyone in radius, a saving throw is 1d20 + proficiency against a target DC. Baldur’s Gate 3, Pathfinder: Kingmaker, and Pillars of Eternity all live or die on this set of rolls.

Solution. The DiceRoll family parses the standard RPG notation, rolls against any IRandomSource, exposes per-die results for crit detection, and reports min / max / average for your tooltip and balance work. Up to 100 dice per expression.

/* Quick rolls by face count. RollD<N> covers d4, d6, d8, d10, d12, d20, d100. */
int initiativeRoll = rng.RollD20();         /* d20 for combat order */
int chestLoot      = rng.RollD100();        /* d100 for percentile loot tables */

/* Multiple dice of the same face count, summed. 2d6 for a fireball damage,
 * 3d8 for a heavy weapon swing. */
int fireballHit = rng.RollD6(diceCount: 2);
int greatswordSwing = rng.RollD8(diceCount: 3);

/* Arbitrary face count via RollDie. Face count is clamped to >= 2. The right
 * shape when the game's economy uses an unusual die size (a d30 for monthly
 * events, a d50 for a slot machine reel). */
int monthlyEvent = rng.RollDie(faces: 30);
int slotReel     = rng.RollDie(faces: 30, diceCount: 4);

/* Explicit count + faces + modifier. Equivalent to "3d6+2" without the string
 * parsing cost. Use when the formula is constructed from data fields. */
int monkUnarmed = rng.RollDice(diceCount: 3, faceCount: 6, modifier: 2);

/* Standard RPG dice notation: NdF[+/-M]. Throws on malformed input. The
 * notation comes straight from a CRPG's data file or a player-readable
 * tooltip ("Magic Missile: 1d4+1 force damage per missile"). */
int magicMissile = rng.RollDice("1d4+1");
int fireball     = rng.RollDice("8d6");
int sneakAttack  = rng.RollDice("3d8-2");

/* Safe variant: returns false on malformed input instead of throwing. Use when
 * the dice expression comes from user input (a mod) or from a hand-edited
 * config file that may contain typos. */
if (rng.TryRollDice("3d6+2", out var rolled))
{
    Log.Info($"Rolled {rolled}", LogCategory.Module);
}

/* DiceRoll struct directly when parsing once and rolling many times. The
 * right shape for an ability's damage formula that fires repeatedly in
 * combat: parse during data load, roll per cast. */
DiceRoll spellDamage = DiceRoll.Parse("4d10+5");
int   minHit  = spellDamage.MinResult;   /* 9  (four 1s + 5)  - shown in tooltip */
int   maxHit  = spellDamage.MaxResult;   /* 45 (four 10s + 5) - shown in tooltip */
float avgHit  = spellDamage.Average;     /* ~27               - used for balance work */

for (var cast = 0; cast < 100; cast++)
{
    int hit = spellDamage.Roll(rng);
    /* apply the damage to the target */
}

/* Capture each individual die roll. Used to detect a "natural 20" crit on a
 * d20 attack roll, or to display "8, 6, 6, 2 + 5 = 27" damage breakdown above
 * the target's head. */
int totalHit = spellDamage.Roll(rng, out int[] dieResults);

Generate IDs and lobby codes

Problem. Sometimes the smallest systems carry a surprising amount of game feel. Your co-op session needs a short room code two players can read aloud without confusion; your save slot needs a stable numeric ID that survives serialisation; a persistent entity needs a GUID that does not collide across machines when the session syncs.

Solution. RandomID covers the five formats that come up in practice: non-zero numeric IDs (zero reserved as an “empty” sentinel), lowercase hex strings, URL-safe Base64, short alphanumeric codes, and RFC 4122 GUIDs. Every helper accepts any IRandomSource, so the same code path gives you deterministic IDs for tests and replays and unpredictable IDs when you pair it with CreateCrypto.

/* Numeric IDs for persistent entities. Zero is reserved as a sentinel for
 * "empty / unset"; GenerateID32 and GenerateID64 never return zero, so a
 * zero-valued field in a serialised save reliably means "no entity here". */
uint  entityID32 = rng.GenerateID32();
ulong worldUID   = rng.GenerateID64();

bool unsetSmall  = RandomID.IsEmpty(entityID32);  /* false; never zero */
bool unsetLarge  = RandomID.IsEmpty(worldUID);
uint emptySlot   = RandomID.Empty32;              /* 0; the sentinel value */

/* Lowercase hex strings. Default length 16. The canonical save-slot ID
 * shape: long enough to be globally unique, short enough to fit in a
 * filename without quoting. */
string saveSlotID = rng.GenerateHex();             /* 16 hex chars */
string longToken  = rng.GenerateHex(length: 64);   /* 64 hex chars */

/* URL-safe Base64 strings derived from N random bytes. Output is roughly
 * 4/3 * byteLength characters (no padding, no slashes). The right shape for
 * a deep-link payload that needs to survive being pasted into a URL. */
string sessionTag = rng.GenerateBase64();              /* 12 bytes -> 16 chars */
string deepLink   = rng.GenerateBase64(byteLength: 24);

/* Alphanumeric strings drawn from {a-z, A-Z, 0-9}. The canonical online-coop
 * room code: 6 characters is enough entropy for a private lobby and short
 * enough to read over voice chat. */
string roomCode = rng.GenerateAlphanumericID(length: 6);

/* RFC 4122 version 4 GUID. Use for cross-machine persistent identities
 * (a saved entity that travels between players in co-op, an asset that needs
 * to survive a re-import). Not cryptographically secure unless the source
 * is a CryptoRandomSource. */
Guid persistentHandle = rng.GenerateGUID();

Generate placeholder names and gibberish text

Problem. There is always some part of your game that needs words before the real words exist. Temporary NPC names while the writer is still building the cast, procedural graffiti on the dungeon walls, an unknown-item display string until the player identifies the scroll, Lovecraftian alien runes that need to look like a language without being one.

Solution. Pronounceable gibberish (no dictionary, no copyright concerns) fits the case: it reads as language without claiming to mean anything. Use it for placeholder UI in prototypes, for procedural in-game text like names and room labels and signage, and for fuzz-testing your text renderer’s edge cases.

/* Single characters. Useful for fuzz-testing text rendering edge cases
 * (a runtime atlas that has to support every ASCII letter and digit) and for
 * one-character procedural decoration (a runic glyph on a tile). */
char glyph         = rng.NextLetter();                  /* a-z lowercase */
char shoutGlyph    = rng.NextLetter(uppercase: true);   /* A-Z */
char numericGlyph  = rng.NextDigit();                   /* '0'-'9' */
char anyChar       = rng.NextAlphanumeric();            /* a-z, A-Z, or 0-9 */

/* Gibberish words built from consonant-vowel pairs. The canonical
 * "placeholder NPC name" shape. Roll a name when the writer has not got to
 * this NPC yet; replace later with a final-pass review. */
string npcName    = rng.NextWord(length: 5, capitalize: true);
string runeWord   = rng.NextWord(length: 5);              /* lowercase rune fragment */
string anyLength  = rng.NextWord();                       /* random length 3..8 */

/* Sentences. First word capitalized, ends with a period. The right shape for
 * placeholder body text in a UI prototype before the localisation files exist. */
string blurb     = rng.NextSentence(wordCount: 5);
string anyBlurb  = rng.NextSentence();                    /* random 4..10 words */

/* Paragraphs. Space-separated sentences. Used in design-doc placeholders,
 * journal entries before final writing, and the "unknown scroll" display
 * shown until the player identifies the item. */
string scrollText  = rng.NextParagraph(sentenceCount: 4);
string anyDocText  = rng.NextParagraph();                 /* random 3..6 sentences */

Scatter props without clumping

Problem. If you have tried to scatter trees across a forest level with plain uniform random, you have hit the clumping problem: a typical pass produces three trees stacked on each other, then a 20-metre bald patch, then another clump. A manually-placed forest reads better because the artist intuitively spaces the trunks apart, and you want that spacing for free.

Solution. Poisson disk sampling (Bridson’s algorithm) does the spacing automatically: every pair of returned points is at least minDistance apart, which gives a natural clump-free distribution. Use it for trees, rocks, enemies, collectibles, ambient props on terrain, and any other “spread these around without overlap” placement. Three overloads cover a circle, an axis-aligned rectangle, and an arbitrary region defined by a BoundChecker delegate.

/* Scatter trees in a 50-unit forest clearing. Every pair of trunks is at
 * least 2.5 units apart, so no two trees clip. The candidate count k
 * controls density: 30 is the standard from Bridson's paper, lower values
 * pack the region less densely, higher values pack more. */
List<Vector2> trunkPositions = rng.SampleCircle(
    center:      Vector2.zero,
    radius:      50f,
    minDistance: 2.5f,
    k:           30);

/* Scatter enemy spawn points across an arena. The rectangle defines the
 * playable area; minDistance prevents enemies from spawning on top of each
 * other and giving the player one easy AoE kill. */
List<Vector2> spawnPoints = rng.SampleRectangle(
    center:      new Vector2(100, 50),
    halfExtents: new Vector2(40, 25),
    minDistance: 3f);

/* Scatter loot pickups inside an arbitrary polygon (a hand-drawn dungeon
 * room, a navmesh patch, a masked region from a texture). The BoundChecker
 * delegate is called once per candidate to test whether the point is inside. */
PoissonDiskSampler.BoundChecker isInsideRoom = (Vector2 p) =>
{
    return dungeonRoom.Contains(p);
};

List<Vector2> lootDrops = rng.SamplePoisson(
    startPoint:  new Vector2(0, 0),  /* must be inside the region; sampling expands outward */
    isInBounds:  isInsideRoom,
    minDistance: 1.5f,
    k:           30);

Build a reusable weighted loot table

Problem. Three apparently different gameplay shapes turn out to be the same problem underneath. Your roguelike’s loot table is the canonical case (60% common gear, 25% rare, 14% epic, 1% legendary, with the legendary dropping at most once per run); your deckbuilder’s card draw is the same shape with repeat prevention enforcing “no two of the same card in a row”; a gacha pull is the same shape again with depletion modelling the one-off pity drop.

Solution. ProbabilityList<T> is the container for all three. Build it once and picks are O(1) with the default alias-table strategy. It also supports repeat prevention (never-the-same-twice, deck-style shuffle, history windows), depletable items (one-shot drops), dynamic influence providers (probabilities computed from game state at pick time), and live edits with lazy rebuilds.

/* The canonical roguelike loot table. Build once at level-load, pick on
 * every chest open. Default AliasTable strategy gives O(1) picks. */
var lootTable = new ProbabilityList<string>();
lootTable.Add("Gold",          probability: 60f);
lootTable.Add("Health Potion", probability: 25f);
lootTable.Add("Rare Gem",      probability: 14f);
lootTable.Add("Artifact",      probability: 1f);

/* Single pick. The hot-path call: chest opens, one drop comes out. */
string drop = lootTable.Pick(rng);

/* Index-only variant when the index drives both the loot entry and a
 * parallel array of sprites or stats. */
int dropIndex = lootTable.PickIndex(rng);

/* Pick multiple distinct drops, writing into a caller-owned list (no
 * allocation). The shape of "open a bonus chest that drops 5 items". */
var bonusDrops = new List<string>(capacity: 5);
lootTable.PickMultiple(rng, count: 5, bonusDrops);

/* Switch selection strategy when the loot list shape calls for it. */
lootTable.Strategy = SelectionStrategy.LinearScan;      /* best when count < ~8 */
lootTable.Strategy = SelectionStrategy.BinarySearchCDF; /* O(log n), medium lists */
lootTable.Strategy = SelectionStrategy.AliasTable;      /* default, O(1) picks */

/* Repeat prevention. "Never the same drop twice in a row" stops the chain
 * of three Gold drops that always feels suspicious. "Shuffle" mode deals
 * each entry once before repeating; the right shape for a card-draw pile
 * that should burn through the deck before recycling. */
lootTable.RepeatPrevention = RepeatPreventionMode.NoImmediate;
lootTable.RepeatPrevention = RepeatPreventionMode.Shuffle;
lootTable.ShuffleIterations = 2;  /* replay the same deck N times before reshuffle */

/* Depletable items. The Artifact drops exactly once per run; after that the
 * 1% slot is empty and the other probabilities renormalise automatically. */
lootTable.IsDepletable = true;
lootTable.SetDepletable(index: 3, maxUnits: 1);

if (lootTable.IsDepleted(index: 3))
{
    Log.Info("Artifact already dropped this run", LogCategory.Module);
}

lootTable.RefillEntry(index: 3); /* restore one Artifact (e.g. on rest at a bonfire) */
lootTable.RefillAll();           /* restore the whole list (new run started) */

/* Live edits at runtime. The alias table is rebuilt lazily on the next pick,
 * so a player buff that "doubles gold chance" is a single SetProbability call. */
lootTable.SetProbability(index: 0, probability: 50f);
lootTable.SetEnabled(index: 2, enabled: false);   /* exclude Rare Gems for this room */
lootTable.SetLocked(index: 1, locked: true);      /* freeze Health Potion probability */
lootTable.Normalize();                            /* rescale to sum to 1 */
lootTable.ResetProbabilities();                   /* restore the originally-set values */

/* Inspect state without picking. Useful for UI tooltips ("rare drops left: 1"). */
int totalEntries = lootTable.Count;
bool allGone     = lootTable.AllDepleted;
float effective  = lootTable.GetEffectiveProbability(index: 0);
lootTable.ClearHistory();

/* Influence providers compute a probability dynamically per pick. Useful when
 * the weight depends on game state: a "Luck" stat that boosts rare drops, a
 * "Friday the 13th" event that boosts the Mimic chance, a time-of-day modifier
 * on nocturnal enemy spawns. */
lootTable.SetInfluenceProvider(index: 2, provider: new LuckBonusInfluence(player));
lootTable.SetInfluenceSpread(index: 2, min: 0.5f, max: 2.0f); /* clamp the influence factor */

Generate secure tokens and nonces

Problem. Some random numbers in your project carry a different contract entirely: they must not be guessable, must not be predictable, and must not be reproducible from a seed. Online lobby session tokens, anti-cheat challenge nonces, and save-file encryption keys all fall into that category, and a seedable gameplay generator is exactly the wrong tool for them.

Solution. The cryptographic source is non-deterministic and significantly slower than the gameplay algorithms, which is the same reason you keep it out of everything else. The stateful source implements IDisposable; the fire-and-forget helpers manage the underlying RNG internally and are convenient when you only need a handful of bytes.

/* Stateful crypto source. Same IRandomSource surface, but every byte comes
 * from the platform's cryptographic RNG. Use for an online session that
 * issues many tokens over its lifetime. */
var cryptoSource = RandomUtil.CreateCrypto();
string sessionToken    = cryptoSource.GenerateBase64(byteLength: 32);
uint   antiCheatNonce  = cryptoSource.NextUInt32();
cryptoSource.Dispose(); /* CryptoRandomSource implements IDisposable */

/* Fire-and-forget crypto helpers when only a few bytes are needed and the
 * source does not need to be reused. Internally manages the underlying RNG.
 * The canonical "fill the save-file nonce buffer" call: 12 bytes for an
 * AES-GCM IV, called once per save. */
var saveFileNonce = new byte[12];
RandomUtil.CryptoFillBytes(saveFileNonce);

/* Six-digit verification code for an account-link confirmation. */
int verificationCode = RandomUtil.CryptoNextInt(100_000, 1_000_000);

Keep determinism around third-party UnityEngine.Random code

Problem. Eventually your project pulls in a piece of code that talks to UnityEngine.Random directly and you cannot change it: a third-party prefab spawner with insideUnitSphere baked into it, an older asset that scatters Random.Range calls throughout 200 source files, certain Unity APIs (some Instantiate paths, the legacy particle system) that consume the global RNG internally. Left alone, those calls make your “seeded” game non-deterministic.

Solution. Capture UnityEngine.Random.state before the third-party call and restore it after, so the rest of your project’s RNG sequence stays untouched. The Scylla algorithms stay your recommended source for new gameplay code; the helpers below are an interop seam for code you cannot change, not a primary path.

/* Capture Unity's global state, set it to a known value, do the work, restore.
 * The deterministic-friendly way to bracket a call to a third-party system
 * that uses UnityEngine.Random internally. */
UnityEngine.Random.State savedState = UnityEngine.Random.state;
RandomUtil.SetUnityRandomState(savedState);

/* Scope an action against a specific UnityEngine.Random state, then restore.
 * The canonical "call this third-party prefab spawner with a known seed,
 * then put Unity's RNG back the way it was" shape. */
RandomUtil.WithUnityRandomState(savedState, () =>
{
    /* Code inside this block runs against the saved Unity RNG state.
     * The third-party spawner's internal Random.insideUnitSphere calls
     * now reproduce across replays, even though the spawner does not
     * know about Scylla's seeded source. */
    Instantiate(thirdPartyPrefab, UnityEngine.Random.insideUnitSphere * 10f, Quaternion.identity);
});

API Sketch

The surface groups into one core interface, a RandomUtil facade with factories and static helpers, an extension class that mirrors most helpers as instance methods, and feature classes for dice, IDs, text, Poisson sampling, and weighted selection. Every helper accepts any IRandomSource, so the same code path works with a seeded gameplay generator, a cryptographic source, or an adapter around System.Random or UnityEngine.Random.

public interface IRandomSource
{
    uint  NextUInt32();
    ulong NextUInt64();
    void  NextBytes(byte[] buffer, int offset = 0, int count = -1);
}

public static class RandomUtil
{
    /* Algorithm-specific factories. Each returns the concrete struct type so
     * algorithm-specific state APIs remain accessible. */
    public static Xoshiro256StarStar CreateXoshiro256(ulong seed);
    public static Xoshiro256StarStar CreateXoshiro256(string seed);
    public static Xoroshiro128Plus   CreateXoroshiro128(ulong seed);
    public static Xoroshiro128Plus   CreateXoroshiro128(string seed);
    public static PCG32              CreatePCG32(ulong seed, ulong stream = 0);
    public static PCG32              CreatePCG32(string seed, ulong stream = 0);
    public static SplitMix64RNG      CreateSplitMix64(ulong seed);
    public static SplitMix64RNG      CreateSplitMix64(string seed);
    public static MersenneTwister    CreateMersenneTwister(ulong seed);
    public static MersenneTwister    CreateMersenneTwister(string seed);

    /* Generic factory and adapters. */
    public static IRandomSource      Create(RandomAlgorithm algorithm, ulong seed);
    public static IRandomSource      Create(RandomAlgorithm algorithm, string seed);
    public static IRandomSource      FromSystemRandom(System.Random random);
    public static IRandomSource      FromUnityRandom();
    public static CryptoRandomSource CreateCrypto();

    /* Backwards-compatible facade. */
    public static DeterministicRNG   CreateDeterministic(int seed);
    public static DeterministicRNG   CreateDeterministic(ulong seed);
    public static DeterministicRNG   CreateDeterministic(string seed);

    /* Basic primitives. */
    public static bool   NextBool(IRandomSource rng, float pTrue = 0.5f);
    public static int    NextSign(IRandomSource rng);
    public static float  NextFloat01(IRandomSource rng);
    public static double NextDouble01(IRandomSource rng);
    public static int    NextInt(IRandomSource rng, int min, int maxExclusive);
    public static long   NextLong(IRandomSource rng, long min, long maxExclusive);
    public static uint   NextUInt(IRandomSource rng, uint min, uint maxExclusive);
    public static ulong  NextULong(IRandomSource rng, ulong min, ulong maxExclusive);
    public static float  NextFloat(IRandomSource rng, float min, float max);
    public static double NextDouble(IRandomSource rng, double min, double max);
    public static int    NextIndex(IRandomSource rng, int count);

    /* Collections. */
    public static T    PickOne<T>(IRandomSource rng, IReadOnlyList<T> list);
    public static bool TryPickOne<T>(IRandomSource rng, IReadOnlyList<T> list, out T value);
    public static void Shuffle<T>(IRandomSource rng, IList<T> list);
    public static int  PickWeightedIndex(IRandomSource rng, IReadOnlyList<float> weights);
    public static T    PickWeighted<T>(IRandomSource rng, IReadOnlyList<T> items, IReadOnlyList<float> weights);

    /* Geometry. */
    public static Vector2    InsideUnitCircle(IRandomSource rng);
    public static Vector2    OnUnitCircle(IRandomSource rng);
    public static Vector3    InsideUnitSphere(IRandomSource rng);
    public static Vector3    OnUnitSphere(IRandomSource rng);
    public static Quaternion RotationUniform(IRandomSource rng);

    /* Crypto and Unity interop. */
    public static void CryptoFillBytes(byte[] buffer, int offset = 0, int count = -1);
    public static int  CryptoNextInt(int minInclusive, int maxExclusive);
    public static void SetUnityRandomState(UnityEngine.Random.State state);
    public static void WithUnityRandomState(UnityEngine.Random.State state, Action action);
}

public static class RandomExtensions
{
    /* Mirrors most RandomUtil helpers as IRandomSource extension methods, plus
     * extras: NextVector2/3 (with range overloads), NextRotation, NextColor,
     * NextDirection2D/3D, NextEnum<T>, NextGaussian, RollDice/RollD*, all
     * GenerateID* and Generate* string helpers, NextLetter/Digit/Word/
     * Sentence/Paragraph, SampleCircle/Rectangle/Poisson, PickMultiple. */
}

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

Helpers

HelperPurpose
DiceRollImmutable readonly struct that parses standard RPG dice notation (NdF[+/-M], e.g. 2d6+5, d20, 3d8-2) and rolls against any IRandomSource. Provides MinResult, MaxResult, and Average properties for tooltip and balance work, plus a Roll(rng, out int[]) overload that exposes the individual die results for crit detection or UI animation. Up to MAX_DICE (100) per expression.
RandomIDGenerates IDs in five common formats: non-zero 32-bit and 64-bit integers (zero reserved as an “empty” sentinel, detectable via IsEmpty), lowercase hexadecimal strings of arbitrary length, URL-safe Base64 strings (- and _ substitutions, no padding), alphanumeric strings, and RFC 4122 version-4 GUIDs. Accepts any IRandomSource, so the same API produces deterministic IDs for tests and replays and unpredictable IDs when paired with CreateCrypto.
RandomTextGenerates random letters, digits, alphanumeric characters, pronounceable gibberish words (with optional capitalization and explicit or random length), sentences (with optional word count, capitalized first letter, terminating period), and paragraphs (with optional sentence count). Useful for placeholder content in UI prototypes, fuzz-testing text rendering, and procedural in-game text such as NPC names or graffiti.
PoissonDiskSamplerBridson’s algorithm for spatial point sets in which every pair is at least minDistance apart. Three overloads cover a circular region, an axis-aligned rectangle, and an arbitrary region defined by a BoundChecker delegate (use it for polygons, terrains, masked images, or anything runtime-computed). The candidate count k defaults to 30, the standard from the paper; lower values are faster but leave more gaps, higher values pack the region more densely.
ProbabilityList<T>A serializable weighted-pick container with three selection strategies (alias table for O(1) picks on large lists, binary-search CDF for medium lists, linear scan for very small or rapidly changing lists), four repeat-prevention modes (off, no-immediate, shuffle, history-windowed), a depletion system (per-entry unit counts with RefillAll / RefillEntry), and pluggable per-entry IProbabilityInfluence providers that compute weights dynamically from game state. Lazy rebuilds keep edits cheap; sample state once configured is allocation-free.
RandomSeederStatic utilities for seed expansion: stable cross-platform HashString (FNV-1a) for converting human-readable seeds, SplitMix64Mix and SplitMix64Next for expanding a single 64-bit seed into independent state words, EnsureNonZero to guard the all-zero degenerate state used by xoshiro/xoroshiro, and RotL for the bit rotations the algorithms share. Public so custom RNG implementations can share the same seeding contract.

Best Practices

  • Pick one algorithm per project. Xoshiro256StarStar is the default for a reason: fast, excellent statistical quality, and a 256-bit state that covers every reasonable game-side budget. Switch only with a specific reason (PCG32 streams for multi-system isolation, xoroshiro128+ for per-entity memory pressure, Mersenne Twister for an offline scientific simulator).
  • Run one seeded IRandomSource per gameplay system. Combat, loot, AI, world-gen, and UI cosmetics each get their own seeded source. Mixing them into one stream makes “the replay desyncs on level 3” almost impossible to diagnose. PCG32 streams or Xoshiro256StarStar.Fork are the clean ways to derive these from one project seed; the same seed plus a stream ID gives every subsystem its own deterministic sequence without cross-contamination.
  • Seed from stable strings, not from DateTime.Now. Stable string seeds (level name, world name, daily-challenge date, room ID) produce reproducible content for free. Time-based seeds are reproducible only by accident, which is exactly the kind of bug that does not surface until QA.
  • Save the seed and the state. For replays and lockstep multiplayer, persist both the original seed and the current state of every generator. Restoring only the seed replays from the beginning; restoring the state resumes from the exact frame. The combination is what makes “load mid-combat” actually mid-combat instead of “from the start of the encounter”.
  • Cache IRandomSource references; do not look them up per call. The factories allocate. The per-call helpers do not. The right pattern is to acquire the source once at module initialisation and pass it into the gameplay subsystem that needs it.
  • Build a ProbabilityList<T> once and reuse it. Avoid PickWeighted in hot paths with stable weights; a ProbabilityList<T> with the default alias table is O(1) per pick after construction. The classic case is a loot table that is sampled hundreds of times per dungeon run.
  • Avoid NextEnum<T> in hot paths. It calls Enum.GetValues per invocation, which allocates. Cache the values array externally or build a ProbabilityList<T> over the enum members. The visible cost shows up at 1000+ calls per frame, which a roguelike’s procgen pass routinely hits.
  • Crypto sources are not gameplay sources. Never seed a CryptoRandomSource. Never use it for anything that should reproduce from a seed. Reserve it for session tokens, save-file encryption keys, anti-cheat challenge nonces, and similar security values; dispose it when finished.
  • Prefer NextFloat() / NextDouble() over NextInt(0, 100) / 100f. The float helpers produce uniformly distributed values without quantisation artifacts. The integer-then-divide pattern looks identical but introduces a 1% bias that bites in a hit-chance roll comparison once the play tester notices the 99th percentile drift.
  • Snapshot before forking. When taking a Fork(streamID) on Xoshiro256StarStar, save the parent’s state first if the parent will keep generating; otherwise a save-game later cannot reproduce both streams.
  • Validate dice notation at load time. DiceRoll.Parse throws on malformed strings; TryParse returns false. Use TryParse whenever notation comes from a mod, a user-supplied config, or a hand-edited data file. A friendly error at load is dramatically better than a runtime exception during combat.

Pitfalls