Skip to content

The TUI crawler

The barrow crawler is a complete, playable game built on osrlib and nothing else — no curses, no Textual, no web framework, just input(), print(), and the standard library. It exists to make one claim concrete: everything a session needs to run — rules, dice, state, the event log — lives in the library, and everything a front end supplies — rendering, input handling, authored content, even a whole quest — is ordinary application code written against the public surface. The same GameSession this example drives could sit behind a web API or a graphical client instead; nothing about it assumes a terminal.

This page walks that split section by section, excerpting the crawler's real source. For the commands the game understands and how to run it yourself, see the example's own README on GitHub — one command starts an interactive game: uv run python -m examples.tui_crawler.

Reading commands, rendering events

The crawler's loop is a dispatch function that turns one line of typed text into a command, and a run helper that executes it and prints whatever comes back. Parsing is entirely the game's problem — the library has no idea "move e" is a sentence:

    command = None
    if verb == "enter":
        command = EnterDungeon(dungeon_id=args[0] if args else "barrow")
    elif verb == "move" and args:
        command = MoveParty.model_validate({"direction": _DIRECTIONS.get(args[0], args[0])})

_DIRECTIONS maps single letters to the compass words MoveParty expects. Once a command exists, running it is the same three steps as everywhere else in osrlib — execute, check acceptance, format the events — with one addition: the crawler prints the delta of the session's event log rather than just the result's own events, so a quest listener's reactions (nested commands it executes on the game's behalf) show up in the transcript too:

def _run(session, command):
    """Execute one command and print every player-visible event it logged.

    Printing the event-log delta (rather than the result's events) shows the
    quest listener's reactions too: its nested commands append to the same log.
    """
    mark = len(session.event_log)
    result = session.execute(command)
    if not result.accepted:
        print("  (refused: " + ", ".join(rejection.code for rejection in result.rejections) + ")")
        return result
    for event in session.event_log[mark:]:
        if event.visibility is Visibility.PLAYER:
            print("  " + format_message(event))
    return result

Every event carries a Visibility; filtering on Visibility.PLAYER here is what keeps referee-only bookkeeping out of the player's terminal. Running the milestone transcript (--seed 203 --script examples/tui_crawler/scripts/milestone.txt) opens like this:

> enter
  The party enters dungeon barrow (level 1).
> move e
  The party moves to (1, 0), facing east.
> move e
  The party moves to (2, 0), facing east.
  The party enters area guard_room (level 1).
  Encounter: 2 × Goblin at 50' — the party is surprised.
  The monsters' bearing: uncertain.
  The monsters' bearing: hostile.

Every printed line is format_message rendering a typed event — a different front end could format the same events into JSON, a chat message, or nothing at all (see the message code reference).

The player's view

The event-level Visibility check above hides individual referee-only lines. The crawler's status command takes a coarser approach: it asks the session for a whole snapshot built for players, rather than reaching into referee-only state itself:

def _status(session) -> None:
    view = session.view(Visibility.PLAYER)
    print(f"[{view.mode}] round {view.clock_rounds}")
    for member in view.party:
        print(f"  {member.name} ({member.class_id} {member.level}) HP {member.current_hp}/{member.max_hp}")
        purse = member.inventory["purse"]
        valuables = ", ".join(v["name"] or v["kind"] for v in member.inventory["valuables"])
        print(f"    gold {purse['gp']} gp" + (f"; carrying {valuables}" if valuables else ""))

GameSession.view returns a frozen PlayerView when called with Visibility.PLAYER — hit points, gold, and carried valuables, and nothing a referee-only view would add. The crawler never touches session.party or session.monsters directly to render status; it renders the same view any other front end would get by asking for one. Views and visibility covers what a PlayerView includes and how it differs from the referee's.

The authored adventure

content.py builds the game's whole world: a town and a two-level barrow, assembled from the same authoring models Building an adventure walks through. A keyed area binds descriptive text, an encounter, and a feature to a set of cells — here, the shrine room holding the quest's MacGuffin, a named valuable tucked inside a treasure cache:

            AreaSpec(
                id="shrine",
                name="Shrine of the Barrow King",
                description="A toppled altar; something green glints beneath it.",
                cells=((4, 0),),
                features=(
                    FeatureSpec(
                        id="idol_shrine",
                        kind="treasure_cache",
                        description="The idol rests in a hollow under the altar stone.",
                        cell=(4, 0),
                        coins=Coins(gp=50),
                        valuables=(
                            ValuableSpec(kind="jewellery", name=IDOL_NAME, value_gp=IDOL_VALUE_GP, weight_coins=10),
                        ),
                    ),
                ),
            ),

Level 1 also keys a goblin-guarded guard room, but level 2 keys no monsters at all — its only area is an unguarded vault. Instead, level 2's WanderingSpec overrides both the odds and the interval so a check happens on every turn, against a custom EncounterTable of rival adventuring parties rather than the compiled monster table:

def _rival_party_table() -> EncounterTable:
    """A wandering table of rival adventurers: every d20 row fields a Basic pair."""
    rows = tuple(
        EncounterTableRow(
            roll=roll,
            name="Basic Adventurers",
            entry=NpcPartyEncounterEntry(party_kind="basic"),
            count_fixed=2,
        )
        for roll in range(1, 21)
    )
    return EncounterTable(id="barrow_rivals", label="Barrow halls", min_level=2, rows=rows)

Level 1's own WanderingSpec(chance_in_six=0) disables wandering checks there entirely — every encounter on that level is the keyed goblins, and every encounter on level 2 is a rolled rival party. Both are ordinary AreaSpec and EncounterTable instances; nothing about authoring them is specific to a terminal front end.

Building the party

create.py drives character creation two ways: an interactive one that prompts for a name, class, and alignment per slot, and a scripted one that builds a fixed roster from starting gold. Both call the same create_character function used in the quickstart; only where the choices come from differs. The scripted party — one of each core class, fighter, cleric, thief, and magic-user, kitted out from its own starting gold — is what the non-interactive --script mode always builds, which is why it plays back identically every time:

# The scripted party: one of each role, kit bought from starting gold.
_SCRIPT_PARTY = (
    (
        "Brakka",
        "fighter",
        Alignment.LAWFUL,
        (("sword", 1), ("chainmail", 1), ("shield", 1)),
        ("sword", "chainmail", "shield"),
        (),
    ),
    ("Wynn", "cleric", Alignment.LAWFUL, (("mace", 1), ("chainmail", 1)), ("mace", "chainmail"), ()),
    ("Sable", "thief", Alignment.NEUTRAL, (("sword", 1), ("leather", 1)), ("sword", "leather"), ()),
    ("Elandril", "magic_user", Alignment.NEUTRAL, (("dagger", 1),), ("dagger",), ("sleep",)),
)
def scripted_party(stream: RngStream, ruleset: Ruleset) -> Party:
    """Build the fixed script party — the non-interactive and test path."""
    members = []
    for name, class_id, alignment, purchases, equip_ids, spells in _SCRIPT_PARTY:
        result = create_character(
            name=name,
            class_id=class_id,
            alignment=alignment,
            ruleset=ruleset,
            stream=stream,
            starting_spell_ids=spells,
            purchases=purchases,
            equip_ids=equip_ids,
        )
        members.append(result.character)
    return Party(members=members)

The fetch quest: a listener, not a library change

The barrow's hook — "the temple pays 200 gp for the Jade Idol's return" — is tracked entirely in the example's own code. quest.py defines a listener and __main__.py registers it on the session right after creating it, alongside the housekeeping that lines up the session's RNG streams with the ones character creation already drew from:

    session = GameSession.new(party, adventure, seed=arguments.seed, ruleset=ruleset)
    session.streams.restore_states(streams.export_states())
    session.register_listener(FetchQuestListener(session))

A registered Listener runs after every command, watching the events that command produced. FetchQuestListener watches for an ItemAcquiredEvent naming the idol and a LocationEnteredEvent back in town. It never mutates session state itself — every effect it has goes through the same commands any front end could issue, which is why replays and saves stay honest:

class FetchQuestListener:
    """Recover the Jade Idol and bring it home — a quest tracker as a listener."""

    key = "fetch_quest"

    def __init__(self, session) -> None:
        """Bind the listener to the session it issues referee commands through."""
        self._session = session
        self._reacting = False

    def _idol_carrier(self):
        for member in self._session.party.members:
            for valuable in member.inventory.valuables:
                if valuable.name == IDOL_NAME:
                    return member
        return None

    def handle(self, events: Sequence[Event], state: dict) -> tuple[list[Event], dict]:
        """React to one command's events (see the session listener contract)."""
        if self._reacting:
            return [], state
        state = dict(state)
        acquired = any(isinstance(event, ItemAcquiredEvent) for event in events)
        if acquired and not state.get("reward_granted"):
            carrier = self._idol_carrier()
            if carrier is not None:
                state["reward_granted"] = True
                self._reacting = True
                try:
                    self._session.execute(GrantCoins(character_id=carrier.id, coins=Coins(gp=QUEST_REWARD_GP)))
                finally:
                    self._reacting = False
        returned_to_town = any(
            isinstance(event, LocationEnteredEvent) and event.location_kind == "town" for event in events
        )
        if returned_to_town and state.get("reward_granted") 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=QUEST_BONUS_XP))
            finally:
                self._reacting = False
        return [], state

The reward is granted the moment the idol is picked up, in the dungeon — not on the later town-return event — because that ordering lets the end-of-adventure treasure award count the coin. A town-return grant would land one event too late to be counted. Watching the transcript, the reward shows up as a second acquisition line immediately after the idol itself:

> take idol_shrine
  character-0001 acquires valuable-0005 and 50 gp in coin.
  character-0001 acquires 200 gp in coin.

On the walk back to town, the listener sets a flag and awards each survivor bonus experience — again through ordinary commands, not by reaching into the party directly. Listeners and flags covers the listener contract and the flag store this pattern relies on in full.

Where next