Skip to content

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.

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.

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) % 4294967296
end

Walking it byte by byte:

  1. Hash starts at 2166136261 — the standard FNV-1a 32-bit offset basis.
  2. 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.
  3. Truncating Hash to 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 near 7.2 × 10^16 — past that range, and no longer exact.
  4. 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.

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.luau
local 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])
end
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 0float(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.