Skip to content

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.

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.

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

It is an FNV-flavored fold, not textbook FNV-1a; the only contract is that the same (seed, name) always gives the same child.

  1. Hash starts at 2166136261, the FNV-1a 32-bit offset basis.
  2. For each byte of the name, the low 8 bits of Hash XOR byte are added to (Hash % 16777216) * 16777619 (the FNV prime), and the result is reduced to 32 bits.
  3. Truncating Hash to 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.
  4. 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.

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.

src/Server/Game/Services/LootService.luau
--!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 untouched
end
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()
end
end
return LootService
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.

  • A fork is derived from the stored Seed value, before Random truncates it. Rng.new(0.5) and Rng.new(0) draw the same stream but fork different children. Another reason to seed with integers.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.