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 exactly one public API call changes any of it: execute, which takes a command and returns a CommandResult. The complete program at the end of the page is self-contained, and every snippet along the way comes from 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: build a Command, pass it to execute, read the result.

execute runs two phases. 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 checks every precondition before drawing a single die or changing a single field. If the handler finds a problem, it returns rejections and nothing changes: no command-log entry, no event-log entries, no RNG draws, no clock time. Only a command that clears every check changes anything: the session appends the command to the command log and its events to the event log, then runs any registered listeners in registration order, each one seeing the events so far and appending its own reactions to both the result and the log. An accepted command's CommandResult contains the complete chain: the handler's events and every listener's events, in the order they happened.

That includes commands a listener executes in response: those log their own events, and execute folds them into the result, each event exactly once, in log order. One MoveParty can come back with the move, the portcullis a trigger opened in response, and the journal entry that recorded the opening. Your front end renders all of it from one envelope without ever reading session.event_log.

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

Every command also has an optional source naming the authored object (a trigger or quest id) or the game system on whose behalf the command was issued. Execution never reads it, so a stamped command does exactly what the unstamped one does. The stamp goes into the log with the command and survives a save, a load, and a replay. The log records not just who acted but on whose behalf, so "why did the party get that item?" is answerable from the log alone:

# The source stamp annotates the log and changes nothing about execution.
session.execute(AddJournalEntry(text="The lever grinds.", source="trigger:lever-east"))
assert session.command_log[-1].source == "trigger:lever-east"
assert session.view(Visibility.PLAYER).journal[-1].text == "The lever grinds."

The library's Interpreter stamps every command it issues this way: trigger:{id} for a trigger's firing, quest:{id} for everything a quest causes (see Gates, triggers, and quests for authoring triggers and quests). A log left behind by authored content then reads as a transcript with attributions: this grant came from trigger:idol-lifted, that door opened for trigger:portcullis-rises, the coins came from quest:the-idol, and the record_note beside them records which consequence was dropped and why.

Session modes and mode gating

SessionMode is a small, closed set: town, exploring, encounter, battle, game_over, and victory. Every Command subclass declares which of these modes it's legal in as an allowed_modes class attribute. It's data, so you can inspect it 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. Some commands, like DropItems, span two modes: dropping treasure to distract pursuers works whether exploring or already in an encounter. Referee commands (GrantItem, SetFlag, AwardXP, AdvanceTime, the lifecycle commands, and the rest of the session-owned surface) are legal in every mode, the two terminal ones included. Three referee commands are the exception: each would resume play in a session that is over. PlaceParty teleports the party into exploring or town, and SpawnMonsters and SpawnNpcParty open an encounter. The two spawn commands are illegal in both terminal modes, game_over and victory. PlaceParty is illegal in victory alone and stays legal in game_over, because carrying the fallen party to town is the first step of the documented revival flow: PlaceParty(town) then PurchaseHealing with service="raise_dead", the clock still running on the revival window.

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 or 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 (the monsters are beaten, 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.

A lost battle is not the only way a session ends: any command whose events leave the party with nobody standing routes to game_over and reports it with the same GameOverEvent: a save-or-die trap sprung by a step into the wrong room, a fall, starvation on a long delve, a poison that resolves under a referee's AdvanceTime. victory is the other terminal mode, and it has exactly one entrance. CompleteQuest on a quest authored concludes_adventure=True is how the interpreter ends the adventure (see the completion rule and the ending). SessionMode.terminal answers "has this session ended?" for both terminal modes.

The lifecycle commands

The authored layer keeps its own books with a family of referee commands. Three of them are the trigger and journal vocabulary: MarkTriggerFired, AddJournalEntry, and RecordNote. The other four advance quest state:

They're ordinary commands: legal in every mode, logged, replayed, and stamped like any other. What they write (per-quest status and per-objective flags in session.quests) is engine-owned session state, so a replay with no listeners registered rebuilds it by re-executing the log.

Their ids are the one place the lifecycle family isn't uniform. MarkTriggerFired.trigger_id is an open domain: a mark records that something fired, needs no authored trigger behind it, and your game drives it with ids from its own systems. The four quest commands invert that. They resolve quest_id and objective_id against the adventure's own QuestSpecs, a closed domain, and reject an id that matches no spec (session.command.unknown_quest, session.command.unknown_objective). The state they advance projects into the player view, and an id with no spec behind it has no name, no offer, and no objective list to show. A command that contradicts the state it finds (activating a quest already active, completing an objective already complete) rejects with session.command.quest_state, naming the quest and the state the handler found.

CompleteQuest requires the quest to be active and does not check its completion rule: ruling a quest done is the referee's call, and the interpreter checks the rule before issuing the command. The command grants no rewards; whoever completes a quest issues the rewards afterwards.

Rejections versus exceptions

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 the facts your 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.

One rejection family carries authored player-facing text on top of its code. A gate refusal (exploration.door.gate_refused, exploration.transition.gate_refused) puts the author's refusal beat in its params, content data in a structured field rather than engine-baked English. A front end that renders rejections should show that line to the player. Gates, triggers, and quests shows how you author that refusal on a gate.

# 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: you broke the API contract, or the content you handed the library is malformed. The osrlib.errors hierarchy, rooted at OsrlibError, covers exactly that. ContentValidationError is malformed content (an adventure with a dangling monster id, a serialized command or event whose known type doesn't match its payload). SaveVersionError is a save written by a newer library than the one loading it, and ReplayVersionError is replaying a command log under a different engine version than the one that recorded it. Plain ValueError and TypeError cover a third case: ordinary programmer misuse, like a bad argument type or an out-of-range seed.

The three categories stay separate 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 turns the lookup failure into an ordinary rejection (session.command.unknown_member, session.command.unknown_item). 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 one example of that split.

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 has a code and a visibility. The code is two or more dot-separated snake_case segments namespaced by subsystem, like exploration.party.moved or combat.attack.hit, and Views and visibility covers the visibility field. format_message turns any event into a default English line by dispatching on its code. It never raises: an event whose code it doesn't recognize (from a newer engine version) still formats to something printable, the code string itself.

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 share the same discriminated-union shape: a frozen pydantic model with extra="ignore" and a single-valued string field that names its concrete class on the wire (command_type on Command, event_type on Event). parse_command and parse_any_event parse a dumped payload back into the right concrete type, and both tolerate unknown types: a command_type or event_type this version of the library doesn't define parses to None instead of raising. A save or network payload from a newer engine version can carry command and event kinds an older consumer has never seen; the consumer skips them 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

The tolerance extends only to types the parser doesn't recognize. 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 has the narrower parse_event, which recognizes only the kernel event classes. parse_any_event covers kernel and crawl events together, and it's 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.events import Visibility
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.crawl.adventure import Adventure, TownSpec
from osrlib.crawl.commands import AddJournalEntry, 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

# The source stamp annotates the log and changes nothing about execution.
session.execute(AddJournalEntry(text="The lever grinds.", source="trigger:lever-east"))
assert session.command_log[-1].source == "trigger:lever-east"
assert session.view(Visibility.PLAYER).journal[-1].text == "The lever grinds."

# 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