Rng
Rng.new(seed) wraps Roblox’s Random as a first-class object instead of a bare number generator. The design decision that defines it is fork(name): any subsystem can derive its own independent child stream from a parent seed, so consuming randomness in one place — an extra loot roll, a retried pathfind — never perturbs the sequence anywhere else. Loot, procedural maps, NPC decisions, and effects seeded from the same value replay identically across a server and every client.
Mental model
Section titled “Mental model”An Rng is a thin wrapper: one field is the Seed it was constructed with, the other is a real Random.new(seed) instance that every draw delegates to. float, int, choice, and shuffle are direct calls into that Random — Rng adds no PRNG algorithm of its own. Determinism comes from Roblox’s Random, which is specified to produce the same sequence for the same seed on every platform; Rng’s job is to make that guarantee easy to use correctly at scale, across many independent consumers in one game.
Deriving a fork’s seed
Section titled “Deriving a fork’s seed”fork(name) doesn’t hand out a second reference to the same stream — it derives a brand-new seed from (parentSeed, name) and constructs a fresh Rng.new(childSeed) from it. The derivation is an FNV-flavored fold over the fork name, folded with the parent seed — the module’s own header comment is explicit that this is not textbook FNV-1a, only that the same (seed, name) pair always derives the same child:
local function deriveSeed(seed: number, name: string): number local Hash = 2166136261 for Index = 1, #name do Hash = (Hash % 16777216) * 16777619 + bit32.bxor(Hash % 4294967296, string.byte(name, Index)) % 256 Hash %= 4294967296 end return (seed * 2654435761 + Hash) % 4294967296endWalking it byte by byte:
Hashstarts at2166136261— the standard FNV-1a 32-bit offset basis.- For each byte of
name: XOR the current hash against the byte, keep only the low 8 bits of that XOR (% 256) — this folds in one byte of the name — then add it to(Hash % 16777216) * 16777619(the FNV-1a prime,16777619) and reduce back into 32 bits. - Truncating
Hashto 24 bits (% 16777216) before multiplying is what keeps the multiply exact:16777216 * 16777619 ≈ 2.81 × 10^14, comfortably inside the 53-bit integer range Luau doubles represent exactly. Multiplying the full 32-bit hash by the same prime would land near7.2 × 10^16— past that range, and no longer exact. - The final fold,
(seed * 2654435761 + Hash) % 4294967296, mixes the parent seed back in through another multiplicative constant (2654435761, the 32-bit golden-ratio constant used in several hash functions) so that the same name forked from two different parent seeds diverges.
The result: deriveSeed is a pure function of (seed, name). Call fork("Chests") on the same parent seed twice — even from two unrelated Rng instances constructed independently — and you get the same child seed, hence the same draw sequence. Fork "Chests" and "Npcs" from the same parent and they diverge. Both properties are spec-verified in Foundations.spec.luau.
Why forking matters
Section titled “Why forking matters”A single shared Random object is a shared mutable cursor: every draw anywhere advances it, so the 51st draw depends on how many draws happened before, from code that may have nothing to do with the current one. That coupling is exactly what breaks replay and cross-system determinism — add one debug roll, and every NPC decision downstream shifts.
Forking removes the coupling structurally. A parent Rng for the whole session forks a child per subsystem ("Chests", "Npcs", "WeatherFX"); each child’s sequence depends only on the parent seed and its own name, never on how many times siblings or the parent were drawn from. Rolling an extra loot chest consumes draws from the "Chests" fork and nothing else — NPC AI decisions elsewhere, drawing from "Npcs", are untouched.
-- src/Server/Bootstrap.luaulocal Rng = require(game:GetService("ReplicatedStorage").ChloeKernel.Rng)
return function(kernel) local WorldSeed = 90210 local Roll = Rng.new(WorldSeed)
-- Independent streams for independent concerns local ChestRoll = Roll:fork("Chests") local NpcRoll = Roll:fork("Npcs")
kernel.Bus:subscribe("Chest.Opened", function(_, session, chest) -- Draining this stream as many times as chests get opened never -- shifts NpcRoll's next decision, or Roll's own next draw. local Loot = ChestRoll:choice(chest.LootTable) if Loot then kernel.Bus:publish("Loot.Granted", session, Loot) end end)
kernel.Bus:subscribe("Npc.Think", function(_, npc) local Decision = NpcRoll:choice({ "Patrol", "Investigate", "Flee" }) npc:setBehavior(Decision) end)
-- A fresh shuffled encounter order, independent of both forks above local EncounterOrder = Roll:shuffle({ "Goblins", "Wolves", "Bandits" }) print(EncounterOrder[1])endAPI reference
Section titled “API reference”| Member | Description |
|---|---|
Rng.new(seed: number) → Rng |
Constructs an Rng wrapping Random.new(seed). Seed is stored on the instance. |
rng:float(min: number?, max: number?) → number |
No args: uniform [0, 1). One arg: treated as max, with min defaulting to 0 — float(5) is [0, 5). Both args: uniform [min, max). |
rng:int(min: number, max: number) → number |
Uniform integer, inclusive of both min and max. |
rng:choice(list: { T }) → T? |
Uniform pick from list. Returns nil for an empty list rather than erroring. |
rng:shuffle(list: { T }) → { T } |
Fisher-Yates shuffle over a table.clone of list — the input list is never mutated; the shuffled copy is returned. |
rng:fork(name: string) → Rng |
Derives a child seed from (rng.Seed, name) via the FNV-1a-style hash above and returns a new Rng.new(childSeed). Deterministic: the same (seed, name) always forks the same child. |
rng.Seed |
The seed this instance was constructed with. Read directly for logging or to fork manually. |
Rng has no bus topics — every operation is a synchronous, pure read against the wrapped Random.