Skip to content

Listeners and flags

Command handlers implement the SRD's rules: movement, combat, searching, spellcasting, and everything else a GameSession resolves by itself. They have no rule for a fetch quest, for a lever in a guard room, or for your game's win condition. That logic belongs to your game, not the engine, and two mechanisms let you add it without forking the library: listeners, which watch every command's events and react by executing more commands, and flags, a small piece of session state your game reads and writes directly.

If you'd like to jump right to the code, the complete program at the end is self-contained and runnable, and every snippet along the way comes from it.

Listeners: reacting to committed events

A listener satisfies the Listener protocol: a key string that identifies its entry in the session's state, and a handle method with this signature:

def handle(self, events: Sequence[Event], state: dict) -> tuple[list[Event], dict]:
    ...

Register an instance with GameSession.register_listener:

session.register_listener(MoveCounter())

GameSession.execute runs every registered listener, in registration order, immediately after a command is accepted, applied, and logged. A rejected command never reaches a listener at all, because rejection mutates nothing and appends nothing to the log. Each listener's handle receives two things:

  • events - the accumulated events for that one command: the command handler's events, plus whatever any earlier-registered listener already returned. A listener registered second sees a first listener's authored events alongside the handler's.
  • state - the state that listener returned the last time handle ran, or {} the first time (and after a fresh registration). The session never inspects this dict. It's the listener's private bookkeeping.

handle returns a pair: a list of events to append to the command's result and to the session's event log, and the state to keep for next time.

That returned-events list is for events a listener authors directly. A listener that reacts by executing commands must return an empty list. A nested session.execute(...) call already appends that command's events to the session's event log. Returning them again from handle would log the same event twice.

Returning an empty list hides nothing from the caller. execute notes where the event log ends before it calls each listener, then adds everything logged while that listener ran to the result it returns. That's the nested commands' events, however deeply they nest, each exactly once and in log order, followed by whatever the listener authored. So the CommandResult from a player's MoveParty includes the events for the portcullis opening and the journal entry that recorded the opening, and your front end renders the whole chain from one result.

The nested execute call has a second effect: it re-enters the entire dispatch pipeline, listener loop included. If a listener issues a command from inside handle, every registered listener, itself included, runs again against that command's events, with whatever state happens to be stored in session.listener_state at that moment. The outer handle call hasn't stored its state update yet: execute only writes listener_state[key] = state after handle returns, and the outer call is still running. A listener whose trigger condition could look "not yet handled" with that stale state needs a re-entrancy guard, or it repeats its reaction over and over. The fetch quest below includes that guard.

listener_state: what survives, what doesn't

A listener's state dict is the only part of it a save file contains. register_listener reserves an empty entry for the listener's key on registration, and every save and load round-trips listener_state verbatim as plain JSON-compatible data. osrlib never serializes the listener object, because it's code and not data, so after loading a saved game you must call register_listener again, with the same listeners in the same order, before any of them will see another event. For more information about how loading and replay work, see Determinism, saves, and replay.

Flags: referee-only session state

Flags solve a smaller version of the same problem: content setup that isn't a rule the engine enforces, like "pulling the lever in the guard room opens the portcullis in the crypt." A flag is one string key mapped to a str, int, or bool value. The referee command SetFlag sets one:

session.execute(SetFlag(key="crypt.lever_pulled", value=True))

SetFlag is accepted in every session mode and always succeeds. Its handler writes the value into session.flags and emits a FlagSetEvent with the key and the value. Flags are referee-only state: like listener state, they round-trip through saves (under session.flags), but neither flags nor listener state appear in the PlayerView that a player-facing front end reads. For more information, see Views and visibility. A front end that needs a flag's value back (to decide whether to narrate the portcullis creaking open, for example) reads session.flags directly when it has the session, or session.view(Visibility.REFEREE).flags when it works from views alone.

Lifecycle commands: fired-marks, the journal, and notes

Flags are one way a listener records what happened. Three more referee commands cover the records that authored triggers and quests need, and all three behave exactly like SetFlag: legal in every mode, never rejected, issued through execute, and logged and replayed like any other command.

  • MarkTriggerFired records that an authored trigger has fired, appending its id to session.fired_triggers. Once-only triggers depend on that list. Marking a trigger that has already fired is accepted, appends nothing, and still emits its TriggerFiredEvent, so every firing of a repeatable trigger shows up in the log while the state stays a list of ids in first-fired order.
  • AddJournalEntry appends an entry to session.journal, with the clock position when it was added. The journal is the only one of these records the players see: the PlayerView includes it verbatim, and its JournalEntryAddedEvent is player-visible.
  • RecordNote records an annotation with no state effect at all. It's the mechanism for machine-issued records (a consequence that was dropped, a cascade cut short) and for a referee's margin notes alike. Its event is referee-visibility, like the event from MarkTriggerFired.

session.fired_triggers and session.journal are both session state that the engine manages. They persist in saves, and because only commands write them, a replay rebuilds them exactly by re-executing the log, even though a replay runs with no listeners registered. That's also why a listener must act by issuing commands instead of changing session state directly.

The optional source stamp (see Sessions, commands, and events) ties these records together. A listener that stamps the commands it issues with its quest or trigger id leaves a log that shows why every entry is there. The library's interpreter is built on these same commands, and a listener you write uses them the same way.

The interpreter

Interpreter is a listener like any other, and it's a complete example of everything above. Register one, once, after the session exists, and again after loading a save, because listeners are code and a save contains only data:

session.register_listener(Interpreter(session))

From then on it watches every command's events, matches them against the adventure's authored triggers and its QuestSpecs, and reacts the only way a listener may: by executing referee commands, each stamped source="trigger:{id}" or source="quest:{id}". Three properties are worth copying into your own listeners:

  • It returns no events. Each event it causes comes from a command it executed, and execute adds those to the result from the log. handle returns [], {} unconditionally.
  • It keeps no state. Its listener_state entry exists, because register_listener creates one, and stays the empty dict for the life of the session. Trigger marks live in session.fired_triggers, journal entries in session.journal, and everything else in the world state the commands changed. That's what makes a triggered game replay exactly: a replay runs with no listeners at all, and re-executing the log rebuilds all of that state.
  • It has no re-entrancy guard, on purpose. The fetch quest below needs one because its trigger condition can look unsatisfied from inside its reaction. The interpreter instead marks the trigger fired before running its consequences, so the trigger is already marked when one of its consequences matches it again. When a consequence's events re-enter the interpreter, they can fire the next trigger, and a limit on depth, not a guard, stops a cascade. For more information, see When a consequence fails.

A fetch quest, worked

Most fetch quests belong in the adventure document, where QuestSpec defines what to fetch and the interpreter above runs it. Gates, triggers, and quests covers that, and the TUI crawler's Jade Idol is authored that way (see the complete front end). The same errand also works as an example of logic that belongs to your game, because it needs nothing beyond this page: a listener that watches events, keeps its objective state, and acts through commands. The complete program below includes this listener whole and runs it.

class FetchQuestListener:
    """Recover an item and bring it home — a quest tracker as a listener."""

    key = "fetch_quest"

    def __init__(self, session) -> None:
        self._session = session
        self._reacting = False

    def _carrier(self):
        for member in self._session.party.members:
            if member.inventory.carried_item("jade-idol") is not None:
                return member
        return None

    def handle(self, events: Sequence[Event], state: dict) -> tuple[list[Event], dict]:
        if self._reacting:
            return [], state
        state = dict(state)
        acquired = any(isinstance(event, ItemAcquiredEvent) for event in events)
        if acquired and not state.get("recovered") and self._carrier() is not None:
            state["recovered"] = True
        home = any(isinstance(event, LocationEnteredEvent) and event.location_kind == "town" for event in events)
        if home and state.get("recovered") and not state.get("completed"):
            state["completed"] = True
            self._reacting = True
            try:
                self._session.execute(SetFlag(key="quest.idol", value="recovered"))
                for member in self._session.party.living_members():
                    self._session.execute(AwardXP(character_id=member.id, amount=1200))
            finally:
                self._reacting = False
        return [], state

A few points about the listener above:

  • state["recovered"] and state["completed"] track the quest's objectives, and they live entirely inside session.listener_state["fetch_quest"]. The session never interprets these keys. It stores whatever dict handle returns.
  • self._reacting is the re-entrancy guard from the previous section. The commands this listener issues emit events too, and AwardXP on the last member would otherwise re-enter handle while the state entry still contained the value from before completion.
  • The handle method returns [], state unconditionally. Every event this listener causes goes through self._session.execute(...), which already logs it, so there's nothing left for the returned-events list to contain.
  • Nothing here reaches into party state to change it. The flag and the XP both arrive as ordinary commands, which is why a save, a load, and a replay all agree about what happened.

The interpreter does all of this for you when the quest is adventure data instead: the objective state lives in session.quests, the reward commands are stamped source="quest:{id}", and the listener's state entry stays empty. Use a listener like the one above when your game's systems track the objective, and QuestSpec when the adventure defines it.

The complete program

Three listeners on one small session: the move counter from the top of the page, the fetch quest worked above (run end to end, from acquiring the idol through the walk home, where the flag and the XP arrive as commands), and the library's interpreter, registered beside them. Registering the interpreter is allowed but has no effect here, because this adventure has no triggers or quests. The program also sets a flag and reads it back two ways, and it runs the lifecycle commands:

from collections.abc import Sequence

from osrlib.core.alignment import Alignment
from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character
from osrlib.core.events import Event, Visibility
from osrlib.core.items import GearTemplate
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,
    AwardXP,
    EnterDungeon,
    GrantItem,
    MarkTriggerFired,
    MoveParty,
    RecordNote,
    SetFlag,
    TravelToTown,
)
from osrlib.crawl.dungeon import Direction, DungeonSpec, Edge, EdgeKind, LevelSpec
from osrlib.crawl.events import ItemAcquiredEvent, LocationEnteredEvent, PartyMovedEvent
from osrlib.crawl.interpreter import Interpreter
from osrlib.crawl.party import Party
from osrlib.crawl.session import GameSession


class MoveCounter:
    """Counts accepted party moves into its listener_state, keyed "move_counter"."""

    key = "move_counter"

    def handle(self, events: Sequence[Event], state: dict) -> tuple[list[Event], dict]:
        state = dict(state)
        moved = sum(1 for event in events if isinstance(event, PartyMovedEvent))
        state["moves"] = state.get("moves", 0) + moved
        return [], state


class FetchQuestListener:
    """Recover an item and bring it home — a quest tracker as a listener."""

    key = "fetch_quest"

    def __init__(self, session) -> None:
        self._session = session
        self._reacting = False

    def _carrier(self):
        for member in self._session.party.members:
            if member.inventory.carried_item("jade-idol") is not None:
                return member
        return None

    def handle(self, events: Sequence[Event], state: dict) -> tuple[list[Event], dict]:
        if self._reacting:
            return [], state
        state = dict(state)
        acquired = any(isinstance(event, ItemAcquiredEvent) for event in events)
        if acquired and not state.get("recovered") and self._carrier() is not None:
            state["recovered"] = True
        home = any(isinstance(event, LocationEnteredEvent) and event.location_kind == "town" for event in events)
        if home and state.get("recovered") and not state.get("completed"):
            state["completed"] = True
            self._reacting = True
            try:
                self._session.execute(SetFlag(key="quest.idol", value="recovered"))
                for member in self._session.party.living_members():
                    self._session.execute(AwardXP(character_id=member.id, amount=1200))
            finally:
                self._reacting = False
        return [], state


# The quickstart's one-corridor crypt, plus the idol the fetch quest wants: a
# bundled item, so acquiring it reports a catalog id the listener can look for.
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,),
    items=(GearTemplate(id="jade-idol", name="Jade idol", cost_gp=0),),
)

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

session = GameSession.new(party, adventure, seed=7)
session.register_listener(MoveCounter())
session.register_listener(FetchQuestListener(session))
session.register_listener(Interpreter(session))

session.execute(EnterDungeon(dungeon_id="crypt"))
session.execute(MoveParty(direction=Direction.EAST))
blocked = session.execute(MoveParty(direction=Direction.EAST))  # the corridor ends here
assert not blocked.accepted  # a rejection never reaches a listener
session.execute(MoveParty(direction=Direction.WEST))

# The listener's own state survived three commands, one of them rejected.
assert session.listener_state["move_counter"] == {"moves": 2}

# Flags are plain session state: referee-only, set by command, read directly.
assert session.flags == {}
session.execute(SetFlag(key="crypt.lever_pulled", value=True))
assert session.flags == {"crypt.lever_pulled": True}

# A front end working from views alone reads flags off the referee view instead.
referee_view = session.view(Visibility.REFEREE)
assert referee_view.flags == {"crypt.lever_pulled": True}

# The lifecycle vocabulary: mark the trigger, write the beat, annotate the margin. The
# source stamp says on whose behalf each command was issued.
session.execute(MarkTriggerFired(trigger_id="crypt.lever", source="trigger:crypt.lever"))
session.execute(AddJournalEntry(text="The lever grinds.", source="trigger:crypt.lever"))
session.execute(MarkTriggerFired(trigger_id="crypt.lever", source="trigger:crypt.lever"))
session.execute(RecordNote(text="The portcullis consequence had nothing to open."))

# A re-mark appends nothing; the journal is player-visible state, the marks are not.
assert session.fired_triggers == ["crypt.lever"]
assert [entry.text for entry in session.journal] == ["The lever grinds."]
assert session.view(Visibility.PLAYER).journal == tuple(session.journal)
assert session.command_log[-1].source is None  # the note was the referee's own

# The fetch quest, end to end: the idol lands in a pack, and the walk home
# completes the errand — the flag and the XP both landing as ordinary commands.
hero = session.party.members[0]
granted = session.execute(GrantItem(character_id=hero.id, item_id="jade-idol"))
assert granted.accepted
assert session.listener_state["fetch_quest"] == {"recovered": True}

home = session.execute(TravelToTown())
assert home.accepted
assert session.listener_state["fetch_quest"] == {"recovered": True, "completed": True}
assert session.flags["quest.idol"] == "recovered"
assert hero.xp > 0  # the award applied, prime-requisite modifier and all

Where next