Skip to content

Determinism, saves, and replay

You want a bug report you can reproduce, a golden test that never flakes, and a save you can trust. osrlib's central promise is that a game is a pure function of its seed and its command sequence: every random draw comes from a named RngStream forked from a session's master seed, so the same seed, the same sequence of commands, and the same engine version always produce the same game. That guarantee pays for itself several times over: a bug report needs only a seed and a short command log to reproduce exactly, golden tests can assert on exact game state instead of approximate behavior, and a saved game reconstructs byte-for-byte — or replays from scratch — and lands in the identical place either way. The complete program at the end of this page runs every claim made here; every fragment above it is an excerpt.

The determinism contract

Randomness in osrlib never comes from the stdlib random module or a module-level default — every roll takes an explicit stream, and every stream is one of the small named set forked from the session's master seed (see RNG streams for the full list and what each one governs). Two sessions built from the same seed and driven through the same accepted commands consume every stream in the same order and land on the same draws, so their saves are byte-for-byte identical, not merely equivalent in effect:

# Same seed, same commands: two independently built sessions save identically.
session_a = play(seed=7)
session_b = play(seed=7)
assert save_game(session_a) == save_game(session_b)

Saves

save_game serializes a running GameSession to a JSON-compatible dict: the party, the embedded adventure content, dungeon state, the clock, every exported RNG stream position, the master seed, the session-state blocks the extension and authored layers write — the flag store, each registered listener's state slot, the trigger fired-marks, the journal, and quest state (see Listeners and flags and Gates, triggers, and quests) — the accepted-command log, and — unless called with include_event_log=False — the event log. So the answer to "does my authored progress survive?" is yes, all of it, by construction. load_game reconstructs a session from that dict by restoring each piece exactly, RNG stream positions included, so a loaded game continues drawing from precisely where the saved game left off:

# The whole session round-trips through JSON: save -> load -> save is the identity.
document = save_game(session_a)
restored = load_game(document)
assert save_game(restored) == document

The event log is the one piece a save doesn't need: it's a record for a front end to display, never a dependency load_game reconstructs state from, which is why include_event_log=False is safe to compact a save with — state reconstructs exactly whether the log rides along or not.

Replay from a seed and a command log

replay_game takes the same seed, the starting party, the adventure, and the accepted-command log, and re-executes every command from scratch through a fresh session — no saved state at all. It raises ReplayVersionError when the log's recorded engine version doesn't match the running engine — a check that runs only when the caller passes the version a save recorded, through the recorded_engine_version argument — and ContentValidationError if a logged command is rejected on replay — a divergence, since the log holds only commands that were accepted the first time.

Because both paths are deterministic, they meet in the middle: restoring a session from its save, and replaying the same seed against the same command log, land in the identical state. That equivalence is the standing test the engine holds itself to, and it's also the practical payoff for consumers — a bug report, an audit trail, or a spectator replay needs only the tiny seed-plus-commands pair, not a full save file:

# load(save) and replay(seed, commands) are two different paths to the identical state:
# the party document must be the *pre-session* party, the same starting point the
# original session assigned ids from.
pre_session_party = party_to_document(new_party(seed=7).members)
replayed = replay_game(
    seed=7,
    party_document=pre_session_party,
    adventure=build_adventure(),
    ruleset=Ruleset(),
    commands=session_a.command_log,
)
assert save_game(replayed, include_event_log=False) == save_game(session_a, include_event_log=False)

replay_game wants the pre-session party document — party_to_document's output from before the party ever joined a session — because GameSession.new assigns member ids itself, in party order, the same way both times.

Replay runs with no listeners

replay_game builds its session with no listeners registered, and that is sufficient: every reaction a listener issued live — the interpreter's trigger consequences, a game listener's awards — was an ordinary command, accepted and logged, so re-executing the log rebuilds its every effect. The commands are already there; nothing needs to react again.

The corollary is worth scoping precisely, because the two halves point in opposite directions. After load_game, re-register your listeners before executing new commands — a restored session that will keep playing needs its code attached again. During a replay, the rule splits by what the listener does: one that only observes — accumulating listener_state, returning annotation events — may be registered and reproduces its state exactly, but one that reacts by issuing commands — the Interpreter above all — must not be, because the log already carries every command it issued live, and a second issuer would issue them again and diverge from the recorded game.

Schema versions and migrations

This page is the documented home of osrlib.versioning. Every serialized document — saves, commands, events — is wrapped in an envelope carrying a kind, a schema_version, and an engine_version, produced by stamp_document and read back by check_document. SCHEMA_VERSION is currently 3, and it's one integer shared by every document kind, independent of the package's own release version.

The promise a schema version makes is additive-only: within one version, only new event types and new optional fields are allowed to appear. Anything else — a rename, a removal, a change in what a field means — bumps SCHEMA_VERSION, and a bump comes with a migration: load_game runs a document's payload through the ordered chain in MIGRATIONS before touching it, so a document stamped at an older schema version still loads. Both shipped migrations are concrete. The 1 → 2 step drops a recovered_treasure field the version-2 payload no longer carries, and adds the empty npcs list that arrived with version 2. The 2 → 3 step is a lossless rewrite: version 3 rejects trigger="enter" on a treasure trap — a value the cache path never read — so the migration rewrites it to "open", the one springing action a cache has. A document saved at the floor, schema version 1, runs the whole chain and loads the same way a fresh one does:

# A version-1 document -- no "npcs" key, and the ledger field version 2 dropped --
# still loads: the migration adds npcs=[] and discards the stale field.
legacy_payload = dict(document["payload"])
legacy_payload.pop("npcs")
legacy_payload["recovered_treasure"] = {"gp": 100}
legacy_document = {
    "kind": "save",
    "schema_version": 1,
    "engine_version": document["engine_version"],
    "payload": legacy_payload,
}
migrated = load_game(legacy_document)
assert migrated.npcs == {}

engine_version stamps the exact installed package version alongside the schema version — separate from it on purpose. SCHEMA_VERSION governs whether a document still parses; the engine version governs whether a replay still produces the same draws, since replay_game refuses to run a command log recorded under a different engine.

The complete program

from osrlib.core.alignment import Alignment
from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character, party_to_document
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.crawl.adventure import Adventure, TownSpec
from osrlib.crawl.commands import EnterDungeon, MoveParty
from osrlib.crawl.dungeon import Direction, DungeonSpec, Edge, EdgeKind, LevelSpec
from osrlib.crawl.party import Party
from osrlib.crawl.session import GameSession
from osrlib.persistence import load_game, replay_game, save_game
from osrlib.versioning import SCHEMA_VERSION, engine_version


def build_adventure() -> Adventure:
    # The smallest adventure: a town and a one-corridor dungeon, two cells joined west-east.
    passage = LevelSpec(number=1, width=2, height=1, entrance=(0, 0), edges={"1,0:west": Edge(kind=EdgeKind.OPEN)})
    crypt = DungeonSpec(id="crypt", name="The Old Crypt", levels=(passage,))
    town = TownSpec(name="Threshold", travel_turns={"crypt": 1})
    return Adventure(name="A First Delve", town=town, dungeons=(crypt,))


def new_party(seed: int) -> Party:
    # Rolling from the same seed always rolls the same character: no session involved yet.
    rules = Ruleset()
    creation = RngStreams(master_seed=seed).get(CHARACTER_CREATION_STREAM)
    fighter = create_character(
        name="Hild", class_id="fighter", alignment=Alignment.LAWFUL, ruleset=rules, stream=creation
    )
    return Party(members=[fighter.character])


def play(seed: int) -> GameSession:
    session = GameSession.new(new_party(seed), build_adventure(), seed=seed)
    session.execute(EnterDungeon(dungeon_id="crypt"))
    session.execute(MoveParty(direction=Direction.EAST))
    return session


# Same seed, same commands: two independently built sessions save identically.
session_a = play(seed=7)
session_b = play(seed=7)
assert save_game(session_a) == save_game(session_b)

# The whole session round-trips through JSON: save -> load -> save is the identity.
document = save_game(session_a)
restored = load_game(document)
assert save_game(restored) == document
assert document["schema_version"] == SCHEMA_VERSION
assert document["engine_version"] == engine_version()

# load(save) and replay(seed, commands) are two different paths to the identical state:
# the party document must be the *pre-session* party, the same starting point the
# original session assigned ids from.
pre_session_party = party_to_document(new_party(seed=7).members)
replayed = replay_game(
    seed=7,
    party_document=pre_session_party,
    adventure=build_adventure(),
    ruleset=Ruleset(),
    commands=session_a.command_log,
)
assert save_game(replayed, include_event_log=False) == save_game(session_a, include_event_log=False)

# A version-1 document -- no "npcs" key, and the ledger field version 2 dropped --
# still loads: the migration adds npcs=[] and discards the stale field.
legacy_payload = dict(document["payload"])
legacy_payload.pop("npcs")
legacy_payload["recovered_treasure"] = {"gp": 100}
legacy_document = {
    "kind": "save",
    "schema_version": 1,
    "engine_version": document["engine_version"],
    "payload": legacy_payload,
}
migrated = load_game(legacy_document)
assert migrated.npcs == {}

Where next