Skip to content

Sessions, commands, and events

A GameSession is a running game. It owns every piece of mutable state — the party, the dungeon map as explored so far, the RNG streams, the clock, the live monster registry, the mode — and it exposes exactly one way to change any of it: execute. Hand it a command, get back a CommandResult. Nothing else in the public API mutates a session. This page walks the loop in depth: the modes that gate which commands are legal, the difference between a rejected command and a raised exception, and the event log those accepted commands leave behind. The complete program appears at the end of the page; every fragment along the way is an excerpt of it.

The command loop

GameSession.new validates the adventure, assigns each party member a session id, and returns a session in town at round 0. From there every turn is the same shape: build a Command, pass it to execute, read the result.

execute runs a strict two-phase discipline. First, a mode check: if the session's current mode isn't one of the command's declared allowed_modes, the command is rejected immediately with no further work. Otherwise the command's handler runs — every handler is validate-then-mutate, checking every precondition before drawing a single die or changing a single field. If the handler finds a problem, it returns rejections and the session is untouched: no command-log entry, no event-log entries, no RNG draws, no clock time. Only when a command clears every check does it actually change anything: the command is appended to the command log, its events are appended to the event log, and any registered listeners run in registration order, each one seeing the events so far and appending its own reactions to both the result and the log. The CommandResult a caller receives after an accepted command carries the complete chain — the handler's events and every listener's events, in the order they happened.

Listeners are how a game adds its own reactive rules (a quest tracker, an achievement log) without touching the kernel; see Listeners and flags for the extension point itself.

Session modes and mode gating

SessionMode is a small, closed set: town, exploring, encounter, battle, and game_over. Every Command subclass declares which of these modes it's legal in as an allowed_modes class attribute — data, not a side effect, so it can be inspected directly:

# Each command declares its legal modes as data - MoveParty works only while exploring.
assert MoveParty.allowed_modes == frozenset({SessionMode.EXPLORING})

Most dungeon-movement commands (MoveParty, TurnParty, OpenDoor, Search, and the rest) are legal only while exploring. Commands that make sense both at rest and on the move (ReorderParty, LightSource, Rest, CastSpell) are legal in town or exploring. Encounter-only commands (Parley, Evade, EngageBattle, Wait, TurnUndead) require encounter, and ResolveBattleRound requires battle. A handful, like DropItems, span two modes on purpose — dropping treasure to distract pursuers works whether the party is still exploring or already in an encounter. Referee commands (GrantItem, SetFlag, SpawnMonsters, PlaceParty, AdvanceTime, and the rest of the session-owned surface) declare no restriction at all, so they run in every mode, game_over included — a referee correcting the world doesn't stop just because the party fell.

The modes form a loop, not a line: EnterDungeon moves the party from town to the dungeon entrance and switches the session to exploring; stepping into a keyed area's cells, a wandering-monster check, or a referee's SpawnMonsters / SpawnNpcParty opens an encounter and switches to encounter; EngageBattle opens full combat and switches to battle; a battle ends back in encounter (the party broke off and a pursuit begins), in exploring (victory — the encounter closes and play continues), or, if the whole party falls, in the terminal game_over. TravelToTown is the return trip, switching exploring back to town.

Rejections versus exceptions

A rejected command is a normal outcome, not a failure. Validation is a pure pre-phase: it never draws randomness, never advances the clock, never mutates anything, and a rejected command never enters the command log. Rejection is a small structured model — a dotted snake_case code plus params — carrying exactly the facts a front end needs to render the refusal, never baked English prose. Moving into a wall, trying to pick a lock without thieves' tools, casting a spell in the wrong mode: these are all rejections, and CommandResult.rejections is where they land.

# The party starts in town: MoveParty is out of mode and comes back rejected, not raised.
result = session.execute(MoveParty(direction=Direction.EAST))
assert result.accepted is False
assert result.rejections[0].code == "session.command.wrong_mode"
assert session.mode is SessionMode.TOWN
assert session.clock.rounds == 0
assert session.event_log == []

Once the mode is right, the same command mutates and reports what happened as Event instances — never English text:

# EnterDungeon switches the session to exploring; the same move is now legal and mutates.
session.execute(EnterDungeon(dungeon_id="crypt"))
assert session.mode is SessionMode.EXPLORING
result = session.execute(MoveParty(direction=Direction.EAST))
assert result.accepted
lines = [format_message(event) for event in result.events]
assert lines  # every accepted command's events format to a default English line

A raised exception means something different: the caller broke the API contract, or the content handed to the library is malformed. The osrlib.errors hierarchy, rooted at OsrlibError, is reserved for exactly that: ContentValidationError for malformed content (an adventure with a dangling monster id, a serialized command or event whose known type doesn't match its payload), SaveVersionError for a save written by a newer library than the one loading it, and ReplayVersionError for replaying a command log under a different engine version than the one that recorded it. None of these are in-fiction outcomes — they're the library telling a caller that what it was handed doesn't make sense as input at all. Plain ValueError and TypeError cover a third case, ordinary programmer misuse (a bad argument type, an out-of-range seed) that isn't even worth a typed exception.

The three categories stay cleanly separated at the command boundary. A referee command that names an unknown character or item id — GrantItem with a stale character_id, say — doesn't raise: the handler catches the lookup failure and turns it into an ordinary rejection (session.command.unknown_member, session.command.unknown_item), because a referee typo naming a character who left the party is exactly the kind of thing a front end needs to handle gracefully, not crash on. A front end serving osrlib over a network boundary needs to make this same three-way split at its edge — see the FastAPI pattern for how one example does it.

The event log and message codes

Every accepted command's events land in session.event_log, in the order they happened: the handler's own events first, then each registered listener's, in registration order. Every event carries a code — two or more dot-separated snake_case segments namespaced by subsystem, like exploration.party.moved or combat.attack.hit — and a visibility (see Views and visibility for what that second field means). format_message turns any event into a default English line by dispatching on its code; it's total and pure, so an event whose code it doesn't recognize (from a newer engine version) still formats to something printable — the code string itself — rather than raising.

The full catalog of shipped event classes and message codes lives in the events reference and the message code reference; every rejection code the engine can emit is in the rejection code reference.

The wire discriminators

Commands and events both mirror the same discriminated-union shape: a frozen pydantic model with extra="ignore" and a single-valued string field — command_type on Command, event_type on Event — that names its concrete class on the wire. parse_command and parse_any_event parse a previously-dumped payload back into the right concrete type, and both are deliberately tolerant of unknown types: a command_type or event_type this version of the library has never heard of parses to None instead of raising. That's the additive-schema guarantee in practice — a save or a network payload produced by a newer engine version can carry command and event kinds an older consumer has never seen, and the older consumer skips them cleanly instead of crashing.

# Commands and events round-trip through their wire discriminator; unknown types parse to None.
move_payload = MoveParty(direction=Direction.EAST).model_dump(mode="json")
assert parse_command(move_payload) == MoveParty(direction=Direction.EAST)
assert parse_command({"command_type": "some_future_command"}) is None

event_payload = result.events[0].model_dump(mode="json")
assert parse_any_event(event_payload) == result.events[0]
assert parse_any_event({"event_type": "some_future_event", "code": "x.y"}) is None

Tolerance only extends to types the parser has never seen. A payload whose command_type or event_type is recognized but whose fields don't validate — a required field missing, a value of the wrong shape — is malformed data, not a forward compatibility case, and raises ContentValidationError instead of returning None:

# A malformed payload of a *known* type is a broken API contract: it raises, never rejects.
try:
    parse_command({"command_type": "move_party"})  # missing the required 'direction'
except ContentValidationError:
    pass
else:
    raise AssertionError("expected ContentValidationError")

osrlib.core.events ships the narrower parse_event, which only recognizes the kernel event classes; parse_any_event covers kernel and crawl events together and is what the session's own log uses.

The complete program

from osrlib.core.alignment import Alignment
from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character
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, SessionMode, parse_command
from osrlib.crawl.dungeon import Direction, DungeonSpec, Edge, EdgeKind, LevelSpec
from osrlib.crawl.events import parse_any_event
from osrlib.crawl.party import Party
from osrlib.crawl.session import GameSession
from osrlib.errors import ContentValidationError
from osrlib.messages import format_message

# Each command declares its legal modes as data - MoveParty works only while exploring.
assert MoveParty.allowed_modes == frozenset({SessionMode.EXPLORING})

rules = Ruleset()
creation = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM)
hero = create_character(
    name="Hild",
    class_id="fighter",
    alignment=Alignment.LAWFUL,
    ruleset=rules,
    stream=creation,
)
party = Party(members=[hero.character])

crypt = DungeonSpec(
    id="crypt",
    name="The Old Crypt",
    levels=(
        LevelSpec(
            number=1,
            width=2,
            height=1,
            entrance=(0, 0),
            edges={"1,0:west": Edge(kind=EdgeKind.OPEN)},
        ),
    ),
)
town = TownSpec(name="Threshold", travel_turns={"crypt": 1})
adventure = Adventure(name="A First Delve", town=town, dungeons=(crypt,))
session = GameSession.new(party, adventure, seed=7)

# The party starts in town: MoveParty is out of mode and comes back rejected, not raised.
result = session.execute(MoveParty(direction=Direction.EAST))
assert result.accepted is False
assert result.rejections[0].code == "session.command.wrong_mode"
assert session.mode is SessionMode.TOWN
assert session.clock.rounds == 0
assert session.event_log == []

# EnterDungeon switches the session to exploring; the same move is now legal and mutates.
session.execute(EnterDungeon(dungeon_id="crypt"))
assert session.mode is SessionMode.EXPLORING
result = session.execute(MoveParty(direction=Direction.EAST))
assert result.accepted
lines = [format_message(event) for event in result.events]
assert lines  # every accepted command's events format to a default English line

# Commands and events round-trip through their wire discriminator; unknown types parse to None.
move_payload = MoveParty(direction=Direction.EAST).model_dump(mode="json")
assert parse_command(move_payload) == MoveParty(direction=Direction.EAST)
assert parse_command({"command_type": "some_future_command"}) is None

event_payload = result.events[0].model_dump(mode="json")
assert parse_any_event(event_payload) == result.events[0]
assert parse_any_event({"event_type": "some_future_event", "code": "x.y"}) is None

# A malformed payload of a *known* type is a broken API contract: it raises, never rejects.
try:
    parse_command({"command_type": "move_party"})  # missing the required 'direction'
except ContentValidationError:
    pass
else:
    raise AssertionError("expected ContentValidationError")

Where next