Rng
Seeded, forkable random streams over Roblox's Random, so the same seed replays the same sequence on every machine.
- Realm
- Shared
- Require
ReplicatedStorage.ChloeKernel .Rng - Since
- v0.7.0
- Source
- Rng.luau
Rng.new(seed) wraps Roblox’s Random in an object that remembers its seed. The method that defines it is
fork(name): any subsystem derives its own child stream from the parent seed and a name, so an extra draw in one place
(one more loot roll, a retried path) never shifts the sequence anywhere else. Loot, procedural maps, NPC decisions and
effects seeded from the same value replay identically on the server and every client. Projectiles
use the same idea for curved paths: a Path(seed, age, ...) both machines evaluate from the shot’s seed.
Mental model
Section titled “Mental model”An Rng holds two fields: Seed, the number it was built with, and a Random.new(seed) that every draw calls into.
float, int, index, choice and shuffle are direct calls on that Random; Rng adds no generator of its own.
Determinism comes from Random, which produces the same sequence for the same seed on every platform. Rng makes that
easy to keep across many independent consumers.
Random truncates a seed toward zero (0 and 0.99 give the same stream) and treats seeds outside
±9,007,199,254,740,991 as 0. Seed with integers in that range.
Deriving a fork’s seed
Section titled “Deriving a fork’s seed”fork(name) does not share the parent’s stream. It derives a new seed from (parent seed, name) and returns
Rng.new(childSeed):
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) % 4294967296endIt is an FNV-flavored fold, not textbook FNV-1a; the only contract is that the same (seed, name) always gives the same
child.
Hashstarts at 2166136261, the FNV-1a 32-bit offset basis.- For each byte of the name, the low 8 bits of
Hash XOR byteare added to(Hash % 16777216) * 16777619(the FNV prime), and the result is reduced to 32 bits. - Truncating
Hashto 24 bits before the multiply keeps the product under about 2.8 × 10^14, inside the 2^53 range where doubles hold integers exactly. The full 32-bit hash times the prime would reach about 7.2 × 10^16 and lose precision. - The last step mixes the parent seed back in through 2654435761 (the 32-bit golden-ratio constant), so the same name forked from two different parents diverges.
deriveSeed is a pure function. fork("Chests") on the same parent seed gives the same child every time, even from
two separately built Rngs, and "Chests" and "Npcs" forked from one parent diverge.
Why forks matter
Section titled “Why forks matter”One shared Random is a shared cursor. Every draw anywhere advances it, so the 51st draw depends on how many draws came
before, from code that may be unrelated. Add one debug roll and every NPC decision after it changes, which breaks replays
and any server and client pair meant to agree.
Forks remove that coupling. A parent Rng for the session forks one child per subsystem ("Chests", "Npcs",
"WeatherFX"). Each child’s sequence depends only on the parent seed and its own name, never on how often siblings or
the parent drew. An extra chest roll consumes the "Chests" stream and nothing else.
--!strict-- LootService: seeded chest loot and NPC moods from one world seed.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Rng = require(ReplicatedStorage.ChloeKernel.Rng)
local WorldSeed = 90210
local LootService = { Name = "LootService",}
function LootService.init(self: any, kernel: any) self.Kernel = kernel local World = Rng.new(WorldSeed) -- Independent streams: draining one never moves the others self.Chests = World:fork("Chests") self.Moods = World:fork("Npcs") self.EncounterOrder = World:shuffle({ "Goblins", "Wolves", "Bandits" }) -- a copy; the literal is untouchedend
function LootService.start(self: any) local Kernel = self.Kernel self.Connection = Kernel.Bus:subscribe("Chest.Opened", function(_topic, session, lootTable: { string }) local Item = self.Chests:choice(lootTable) -- nil for an empty table if Item then Kernel.Bus:publish("Loot.Rolled", session, Item) end end)end
function LootService.pickMood(self: any): string local Moods = { "Patrol", "Investigate", "Flee" } return Moods[self.Moods:index(#Moods)]end
function LootService.stop(self: any) if self.Connection then self.Connection:disconnect() endend
return LootServiceAPI reference
Section titled “API reference”| Member | Description |
|---|---|
Rng.new(seed: number) -> Rng |
Wraps Random.new(seed) and stores Seed. |
rng:float(min?, max?) -> number |
No arguments: uniform in [0, 1]. One argument: [0, max]. Two: [min, max]. Both ends are inclusive, because Random:NextNumber includes them. |
rng:int(min, max) -> number |
Uniform integer in [min, max], both inclusive. |
rng:index(count) -> number |
Uniform integer in [1, count]: a valid index into a list of count items. Asserts count is a whole number of at least 1. Added in 0.9.0. |
rng:choice(list) -> T? |
Uniform pick from list. Returns nil for an empty list. |
rng:shuffle(list) -> { T } |
Fisher-Yates over a table.clone of list. The input is never mutated. |
rng:fork(name: string) -> Rng |
A new Rng seeded from (rng.Seed, name). The same pair always gives the same child. |
rng.Seed |
The seed this instance was built with. Log it to reproduce a run. |
Rng publishes nothing and has no hooks. Every call is synchronous.
Design notes
Section titled “Design notes”- A fork is derived from the stored
Seedvalue, beforeRandomtruncates it.Rng.new(0.5)andRng.new(0)draw the same stream but fork different children. Another reason to seed with integers.