Skip to content

osrlib.core.rng

Where every random number in osrlib comes from: named, seeded, repeatable streams.

RngStreams is the entry point. Make one from a master seed, the single integer a whole game's randomness is derived from, ask it for a stream by name with get, and pass that stream to whatever you call: roll, the combat and treasure functions, character creation. A GameSession builds its own container from the seed you give it and hands out the right stream for each rule, so during ordinary play you never touch this module.

A stream is named by a plain string, and each name draws its own independent sequence. Rolling a hundred treasure hoards doesn't change what the next attack rolls. Add a new draw to one subsystem and no other subsystem's results move, which is what lets a saved game replay and a bug reproduce. StreamName names every stream a running session draws from, and each *_STREAM constant in the library is one of its members. The RNG streams reference says what each one covers. Standalone code isn't bound to those names. A name is a label, and all that matters is that you ask for the same one each time.

Two draws made with the same master seed and the same stream name come out the same, in this release and in every later one. The generator is PCG64, in the pcg_setseq_128_xsl_rr_64 form with 128 bits of state and a 64-bit output, the same generator numpy calls PCG64 rather than its PCG64DXSM. Each next_uint64 advances the state first and then takes the output from the new state, following the C implementation numpy follows. Streams are forked from the master seed as SHA-256(master_seed_bytes + b":" + stream_key_utf8), with the master seed written as 16 bytes, most significant first. Reimplementing any of that, even in a way that looks equivalent, shifts draws and breaks saved games.

Nothing in osrlib reaches for Python's random module or keeps a generator of its own. If you're writing a rule of your own to run beside osrlib's, take a stream as an argument the same way.

Typical usage:

from osrlib.core.dice import roll
from osrlib.core.rng import RngStreams

streams = RngStreams(master_seed=42)

# Each name is its own sequence, so one subsystem's draws never move another's.
attack = roll("1d20", streams.get("combat"))
gold = roll("2d6×100", streams.get("treasure"))
assert attack.total == 14
assert gold.total == 600

# Same seed, same name, same draws.
assert roll("1d20", RngStreams(master_seed=42).get("combat")).total == 14

RngStream

RngStream(initstate: int, initseq: int)

One named sequence of random numbers, which you pass to whatever needs to roll.

Get one from RngStreams.get rather than building it, unless you're writing a test or using a single stream on its own. Every function in the kernel that rolls anything takes one of these, and it's the only source of randomness in the library.

Drawing advances the stream, so two calls give two different results and the order of your calls is part of what the seed determines. Pass the stream itself, never a copy. Draw with randbelow for a bounded number, or let roll do it from a dice expression.

Constructing one directly runs PCG64's own initialization from an (initstate, initseq) pair, which is what derive_init_pair produces from a master seed and a name.

Examples:

from osrlib.core.rng import RngStreams

stream = RngStreams(master_seed=42).get("combat")
assert stream.randbelow(20) + 1 == 14

Start the stream from a PCG64 init pair.

The initialization is PCG's own: set the state to 0, set the increment to (initseq << 1) | 1, step, add initstate, step, everything modulo 2**128. It drops the top bit of initseq, which is what the reference implementation does and not a defect to work around.

Parameters:

Name Type Description Default
initstate int

The 128-bit init state, from 0 up to but not including 2**128.

required
initseq int

The 128-bit sequence selector, in the same range. Two streams with the same init state and different selectors draw different sequences.

required

Raises:

Type Description
ValueError

If either argument is outside the allowed range.

from_seed_material classmethod

from_seed_material(master_seed: int, key: str) -> RngStream

Build the named stream for a master seed, in one step.

Use this when you want a single stream and no container. RngStreams is the better choice when you want several, because it remembers each one and can save them all together.

Parameters:

Name Type Description Default
master_seed int

The game's master seed, from 0 up to but not including 2**128.

required
key str

The stream's name.

required

Returns:

Type Description
RngStream

A stream at the start of its sequence. The same seed and name always give a

RngStream

stream that draws the same numbers.

Examples:

from osrlib.core.rng import RngStream

stream = RngStream.from_seed_material(42, "combat")
assert stream.randbelow(20) + 1 == 14

restore classmethod

restore(snapshot: RngStreamState) -> RngStream

Rebuild a stream at the position a snapshot recorded.

Use it when you're loading a game you saved yourself. load_game restores a session's streams for you.

Parameters:

Name Type Description Default
snapshot RngStreamState

A position from export_state.

required

Returns:

Type Description
RngStream

A stream whose next draw is the one the saved stream would have made.

export_state

export_state() -> RngStreamState

Record where the stream has reached, so you can come back to it.

Pair it with restore. To save a whole game's worth of streams at once, call RngStreams.export_states instead. Exporting draws nothing and leaves the stream where it was.

Returns:

Type Description
RngStreamState

A frozen record of the position, ready to serialize.

next_uint64

next_uint64() -> int

Draw the next raw 64-bit number from the stream.

This is the generator's own output, with no bound applied. For a die or any bounded value, call randbelow instead: taking a remainder of this number yourself biases the result, and it draws a different count of raw numbers than osrlib does, which puts the stream out of step with a replay.

The stream advances first and then produces the output from its new state, following the reference C implementation.

Returns:

Type Description
int

A number from 0 up to but not including 2**64, each equally likely.

randbelow

randbelow(n: int) -> int

Draw a number from 0 up to but not including n, each equally likely.

This is the bounded draw everything in osrlib is built on. For a die, add 1: stream.randbelow(6) + 1 is a d6. For a dice expression, call roll instead and let it do the arithmetic.

How many raw numbers a single call consumes varies. The method takes the top bits of a raw draw and throws the candidate away if it lands at or above n, which is what keeps every result equally likely. A bound that's a power of two never throws anything away, and bounds of 3, 6, 10, 12, 20, and 100 sometimes do, so a stream's position after a roll depends on which values came up rather than on how many times you called. randbelow(1) returns 0 and still uses a draw.

Parameters:

Name Type Description Default
n int

The bound, which the result stays below. Must be positive.

required

Returns:

Type Description
int

A number from 0 up to but not including n.

Raises:

Type Description
ValueError

If n is zero or negative.

Examples:

from osrlib.core.rng import RngStreams

stream = RngStreams(master_seed=42).get("combat")

# A d20 is a draw below 20, plus one.
assert stream.randbelow(20) + 1 == 14

RngStreamState

Bases: BaseModel

Where a stream had got to, in a form you can write to disk.

RngStream.export_state returns one and RngStream.restore takes it back, so a game saved halfway through a dungeon resumes on the very next draw rather than starting the sequence over. save_game and load_game do this for every stream a session has touched, so you only build one of these yourself when you're saving a game without a session.

The two numbers are the generator's internals. Read them if you're comparing implementations. Don't compute them.

Examples:

from osrlib.core.rng import RngStream, RngStreams

stream = RngStreams(master_seed=42).get("combat")
stream.randbelow(20)

# Save the position, draw on, and a restored copy continues from the save.
snapshot = stream.export_state()
expected = stream.randbelow(20)
assert RngStream.restore(snapshot).randbelow(20) == expected

state class-attribute instance-attribute

state: int = Field(ge=0, lt=_SEED_BOUND)

The generator's 128-bit state: how far along the sequence the stream has got.

inc class-attribute instance-attribute

inc: int = Field(ge=0, lt=_SEED_BOUND)

The generator's increment, which is what makes one stream's sequence differ from another's.

Always odd, by the way PCG builds it, and a pydantic ValidationError says so if you pass an even number.

RngStreams

RngStreams(master_seed: int)

All of a game's random number streams, forked from one master seed.

Make one with the seed you want the game to run on, then call get for each stream you need. A stream is built the first time you ask for it and kept, so asking again gives you the same stream at the position you left it. Which streams exist is up to you, and a name you've never used gets a stream of its own the first time you ask.

Keep one container for the whole game and pass streams out of it. Two containers on the same seed draw the same numbers as each other, which means handing out streams from a second container quietly repeats rolls the first one already made.

A GameSession keeps one of these, so you only build your own when you're running the kernel without a session. Save a game's worth of positions with export_states and put them back with restore_states. The RNG streams reference lists the names a session uses.

Examples:

from osrlib.core.rng import RngStreams

streams = RngStreams(master_seed=42)
combat = streams.get("combat")

# The same name gives back the same stream, mid-sequence.
assert streams.get("combat") is combat
assert combat.randbelow(20) + 1 == 14

Create the container for a master seed.

Parameters:

Name Type Description Default
master_seed int

The game's master seed, from 0 up to but not including 2**128. Any integer in range works. Record the one you used if you want to replay the game.

required

Raises:

Type Description
ValueError

If master_seed is outside the allowed range.

master_seed property

master_seed: int

Return the master seed every stream in this container is forked from.

Read it to record what a game was seeded with, so you can rebuild the same container later. It cannot be changed: a container's seed is fixed when you make it.

get

get(key: str) -> RngStream

Return the stream with this name, making it the first time you ask.

Pass what comes back to whatever draws.

Parameters:

Name Type Description Default
key str

The stream's name, such as "combat" or "treasure". Any string works; the RNG streams reference lists the ones a session uses.

required

Returns:

Type Description
RngStream

The stream for that name, at whatever position it has reached. Asking twice

RngStream

gives the same stream, not a copy.

Examples:

from osrlib.core.rng import RngStreams

streams = RngStreams(master_seed=42)

# Two names, two independent sequences.
assert streams.get("combat").randbelow(20) + 1 == 14
assert streams.get("treasure").randbelow(6) + 1 == 4

export_states

export_states() -> dict[str, RngStreamState]

Record where every stream you've used has reached, keyed by name.

Write the result into your save file alongside the master seed, and put it back with restore_states when you load. save_game does this for a session, so call it yourself only when you're saving a game you built without one.

A name you've never asked for is left out. A stream like that has drawn nothing, and it rebuilds itself from the master seed the first time you use it.

Returns:

Type Description
dict[str, RngStreamState]

One position per stream that has been used, in sorted name order so two saves

dict[str, RngStreamState]

of the same game are byte for byte the same.

Examples:

from osrlib.core.rng import RngStreams

streams = RngStreams(master_seed=42)
streams.get("combat").randbelow(20)

# Only the stream that was used is recorded.
assert sorted(streams.export_states()) == ["combat"]

restore_states

restore_states(states: dict[str, RngStreamState]) -> None

Put saved stream positions back, so a loaded game draws on from where it stopped.

Call it on a container built with the same master seed the game was saved under. A stream named in states is replaced. One that isn't is left alone, and a stream that has never been used rebuilds itself from the seed.

Parameters:

Name Type Description Default
states dict[str, RngStreamState]

Positions from export_states.

required

Examples:

from osrlib.core.rng import RngStreams

streams = RngStreams(master_seed=42)
streams.get("combat").randbelow(20)
saved = streams.export_states()
expected = streams.get("combat").randbelow(20)

# A fresh container on the same seed picks up the next draw, not the first.
loaded = RngStreams(master_seed=42)
loaded.restore_states(saved)
assert loaded.get("combat").randbelow(20) == expected

StreamName

Bases: StrEnum

Every stream name a running session draws from.

A member is its own string, so StreamName.COMBAT and "combat" are the same stream to RngStreams.get and the same key in a save file. Draw with a member rather than a string you type out. A mistyped name raises no error: get forks a stream of its own for it and draws plausible numbers from that stream instead of the one you meant.

Each public *_STREAM constant takes its value from the member named beside it, so the constant and the member are one object. The RNG streams reference says what each stream covers and which rules draw on it.

A rule you write yourself isn't limited to these names. Any string you hand RngStreams.get gets a stream of its own. These are the names a session replays.

Examples:

from osrlib.core.rng import RngStreams, StreamName

streams = RngStreams(master_seed=42)

# A member is its own string, so the member and the written-out key are one stream.
assert StreamName.COMBAT == "combat"
assert streams.get(StreamName.COMBAT) is streams.get("combat")

CHARACTER_CREATION class-attribute instance-attribute

CHARACTER_CREATION = 'character_creation'

Creation draws: ability scores, the first-level hit die, and starting gold. Its public name is CHARACTER_CREATION_STREAM.

ADVANCEMENT class-attribute instance-attribute

ADVANCEMENT = 'advancement'

Hit dice rolled when a character gains or loses a level. Its public name is ADVANCEMENT_STREAM.

COMBAT class-attribute instance-attribute

COMBAT = 'combat'

Battle resolution: attacks, damage, saves, morale, initiative, reactions. Its public name is COMBAT_STREAM.

EFFECTS class-attribute instance-attribute

EFFECTS = 'effects'

Draws inside the effects engine: onsets, rolled durations, revival countdowns. Its public name is EFFECTS_STREAM.

MONSTER_SPAWN class-attribute instance-attribute

MONSTER_SPAWN = 'monster_spawn'

Hit points rolled as a monster instance is spawned from its template. Its public name is MONSTER_SPAWN_STREAM.

NPC_PARTY class-attribute instance-attribute

NPC_PARTY = 'npc_party'

NPC adventuring parties: composition, class and level, scores, hit points, spells. Its public name is NPC_PARTY_STREAM.

MAGIC class-attribute instance-attribute

MAGIC = 'magic'

Spell resolution: targeting, damage, forced saves, dispel survival, turning undead. Its public name is MAGIC_STREAM.

TREASURE class-attribute instance-attribute

TREASURE = 'treasure'

Treasure generation, from the presence roll to each coin, gem, and magic item. Its public name is TREASURE_STREAM.

WANDERING class-attribute instance-attribute

WANDERING = 'wandering'

Wandering monsters: the check die, the table roll, group counts, variant picks. Its public name is WANDERING_STREAM.

ENCOUNTER class-attribute instance-attribute

ENCOUNTER = 'encounter'

Encounter setup: surprise, distance, reaction, distraction, commanded group counts. Its public name is ENCOUNTER_STREAM.

EXPLORATION class-attribute instance-attribute

EXPLORATION = 'exploration'

Exploration: forcing doors, listening, searching, traps, tinder, thief skills. Its public name is EXPLORATION_STREAM.

MONSTER_ACTION class-attribute instance-attribute

MONSTER_ACTION = 'monster_action'

A monster action policy's own draws: which action a group takes and at whom. Its public name is MONSTER_ACTION_STREAM.

ADJUDICATION class-attribute instance-attribute

ADJUDICATION = 'adjudication'

The referee's freeform dice, kept off every stream a rule draws from. Its public name is ADJUDICATION_STREAM.

derive_init_pair

derive_init_pair(master_seed: int, key: str) -> tuple[int, int]

Work out the two numbers PCG64 needs to start a named stream.

You rarely call this. RngStreams.get calls it for you, and RngStream.from_seed_material wraps it in one step. Reach for it when you're checking osrlib's forking against another implementation, or building the same stream in another language.

The seed material is SHA-256(master_seed_bytes + b":" + stream_key_utf8), with the master seed written as exactly 16 bytes, most significant first. The digest's first 16 bytes become the init state and its last 16 the sequence selector, each read most significant byte first.

Parameters:

Name Type Description Default
master_seed int

The game's master seed, from 0 up to but not including 2**128.

required
key str

The stream's name, such as "combat" or "treasure".

required

Returns:

Type Description
int

The (initstate, initseq) pair, ready for

int

Raises:

Type Description
ValueError

If master_seed is outside the allowed range.

Examples:

from osrlib.core.rng import RngStream, derive_init_pair

initstate, initseq = derive_init_pair(42, "combat")

# The pair is what RngStreams.get builds its stream from.
assert RngStream(initstate, initseq).next_uint64() == 12816652903456971652