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, everything a front end supplies — rendering, input handling — is ordinary application code written against the public surface, and the game's content, its fetch quest included, is authored adventure data the library's own interpreter plays. 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. The loop is a plain iteration over result.events because the envelope already carries everything: whatever a nested listener-issued command logged — the interpreter's reactions above all — folds into the result, in log order, so a front end never needs to read session.event_log to see the whole chain. A rejection prints its code, plus the authored refusal text when a gate wrote one — the one rejection family carrying words the player is meant to read:

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

    The result envelope already carries the whole chain — everything a nested
    listener-issued command logged, the interpreter's reactions included, folds
    into `result.events` in log order — so rendering is a plain iteration.
    A rejection prints its code, and the authored refusal text when a gate wrote
    one.
    """
    result = session.execute(command)
    if not result.accepted:
        reasons = []
        for rejection in result.rejections:
            refusal = rejection.params.get("refusal")
            reasons.append(f"{rejection.code}{refusal}" if refusal else rejection.code)
        print("  (refused: " + ", ".join(reasons) + ")")
        return result
    for event in result.events:
        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 21 --script examples/tui_crawler/scripts/milestone.txt) opens like this:

> enter
  The party enters dungeon barrow (level 1).
  A new quest: The Jade Idol. The temple wants the Jade Idol off the barrow king's altar and back on its own.
> 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 20' — the party is surprised.
  The monsters' bearing: uncertain.

The second line is already the result envelope earning its keep: crossing the threshold activated the adventure's quest, and what printed it was a command the interpreter issued inside the player's enter — folded into the same result the enter came back with.

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 and journal commands take a coarser approach: they ask the session for a whole snapshot built for players, rather than reaching into referee-only state themselves:

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 ""))
    # Active quests only: a completed quest leaves the projection, its record kept
    # by the journal.
    for quest in view.quests:
        objectives = ", ".join(f"{objective.name} {objective.state}" for objective in quest.objectives)
        print(f"  Quest: {quest.name}" + (f" — {objectives}" if objectives else ""))


def _journal(session) -> None:
    view = session.view(Visibility.PLAYER)
    if not view.journal:
        print("  (the journal is empty)")
        return
    for entry in view.journal:
        print(f"  [round {entry.rounds}] {entry.text}")

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. _status also walks PlayerView.quests: the active quests only, each with its revealed objectives by display name and state, which is why the closing status after victory lists no quest at all — a finished quest leaves the projection, and its record is the journal. _journal renders PlayerView.journal, the authored record in order of discovery, each beat stamped with the clock round it landed at. Both verbs are pure view reads: they execute no command, draw nothing, and log nothing, so a script may sprinkle them anywhere without changing the game. 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, a two-level barrow, and the errand that ends it, assembled from the same authoring models Building an adventure walks through. A keyed area binds content — descriptive text, an encounter, features — to a set of cells; the shrine below binds prose and the cache that holds the quest's MacGuffin, named by id so that taking it is something the quest can match on (the goblins are keyed to a different room):

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),
            item_ids=(IDOL_ID,),
        ),
    ),
),

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: authored data, not front-end code

The barrow's hook — "the temple pays 200 gp for the Jade Idol's return" — is part of the adventure, not part of the crawler. The idol is a bundled GearTemplate the shop never stocks, dropped into the shrine cache by id, so picking it up reports a catalog id anything can match on:

JADE_IDOL = GearTemplate(id=IDOL_ID, name=IDOL_NAME, cost_gp=0)
"""The MacGuffin as a bundled item: an id the shop never stocks and the temple wants
back. Carrying it is a fact the quest can match on and a condition it can test."""

The quest itself is a QuestSpec in the same file — an activation clause, two objectives, three rewards, and the marker that says finishing it finishes the adventure:

def _fetch_quest() -> QuestSpec:
    """The temple's errand, authored as data: take the idol, bring it home.

    Both clauses are the trigger vocabulary the library already speaks — an
    acquisition matched on the bundled idol's catalog id, and a homecoming narrowed
    by a condition that asks whether the party is still carrying it. Walking back
    empty-handed is not a return; the second objective simply does not fire.
    """
    return QuestSpec(
        id="the-idol",
        name="The Jade Idol",
        activation=TriggerClause(pattern=DungeonEnteredPattern(dungeon_id="barrow")),
        objectives=(
            ObjectiveSpec(
                id="recover-idol",
                name="Recover the idol",
                when=TriggerClause(pattern=ItemAcquiredPattern(item_id=IDOL_ID)),
                narrative=NarrativeBlock(progress="The idol comes up out of the hollow, cold as well-water."),
            ),
            ObjectiveSpec(
                id="return-home",
                name="Bring it home",
                when=TriggerClause(
                    pattern=TownEnteredPattern(),
                    conditions=(HasItemCondition(item_id=IDOL_ID),),
                ),
                narrative=NarrativeBlock(progress="Threshold's gate shuts behind you with the idol inside it."),
            ),
        ),
        rewards=(
            GrantCoins(character_id=FIRST_LIVING_SELECTOR, coins=Coins(gp=QUEST_REWARD_GP)),
            AwardXP(character_id=PARTY_SELECTOR, amount=QUEST_BONUS_XP),
            SetFlag(key="quest.idol", value="recovered"),
        ),
        concludes_adventure=True,
        narrative=NarrativeBlock(
            offer="The temple wants the Jade Idol off the barrow king's altar and back on its own.",
            completion="The almoner counts out the reward without looking up. The idol is home.",
            speaker="the temple almoner",
        ),
    )

Nothing in the crawler tracks any of it. __main__.py registers the library's Interpreter 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(Interpreter(session))

The interpreter is an ordinary Listener: it runs after every command, matches the events against the adventure's triggers and quests, and acts the only way anything outside the engine may — by executing referee commands, each stamped with what it acted for: source="quest:the-idol" on every command this quest causes, source="trigger:{id}" when an authored trigger fires, so the command log answers why on its own. Two moments from the end of the same milestone run show it, rendered from typed events by the same formatter as everything else. Emptying the shrine cache:

> take idol_shrine
  character-0001 acquires 13 gp in coin.
  character-0002 acquires 13 gp in coin.
  character-0003 acquires jade-idol and 12 gp in coin.
  character-0004 acquires 12 gp in coin.
  Quest The Jade Idol: objective Recover the idol is done. The idol comes up out of the hollow, cold as well-water.

Then, four move w steps later, the homecoming:

> town
  The party enters town town.
  The adventure ends: 0 XP from monsters and 50 XP from treasure — 12 XP to each of 4 survivor(s).
  character-0001 gains 12 XP (base 12), now level 1.
  character-0002 gains 9 XP (base 12), now level 1.
  character-0003 gains 13 XP (base 12), now level 1.
  character-0004 gains 13 XP (base 12), now level 1.
  Quest The Jade Idol: objective Bring it home is done. Threshold's gate shuts behind you with the idol inside it.
  Quest complete: The Jade Idol. The almoner counts out the reward without looking up. The idol is home.
  The adventure is over: The Jade Idol is finished. The almoner counts out the reward without looking up. The idol is home.
  character-0001 acquires 200 gp in coin.
  character-0001 gains 1260 XP (base 1200), now level 1.
  character-0002 gains 960 XP (base 1200), now level 1.
  character-0003 gains 1320 XP (base 1200), now level 2.
  character-0003 advances to level 2 (Footpad): +4 hp (rolled 4).
  character-0004 gains 1320 XP (base 1200), now level 1.

Two details of that output are the whole chapter in miniature. The cache spreads across the party by the ordinary loot rules, so the thief is the one carrying the idol when the party walks home — and the objective's has_item condition asks whether the party carries it, not who. And the completion beat appears twice, on the quest's own event and again on the adventure's, because each event carries the authored line and the formatter appends whatever beat rides the event it is given.

Why the milestone makes two trips

The homecoming objective is a town_entered pattern narrowed by a has_item condition, so walking back empty-handed is not a return — the objective simply does not fire. That one clause is what gives scripts/milestone.txt its shape:

  1. Down, for the goblins, their lair hoard, and the rival party prowling level 2.
  2. Home without the idol. The return banks the end-of-adventure award, and the party sells its haul and buys a temple healing — town commands that are legal here and nowhere later, because the adventure has not ended yet. Coin weighs a coin apiece, so the seller spreads the purse with give before anybody walks again.
  3. Down again, for the idol alone.
  4. Home with it, which completes the second objective, completes the quest, and — the quest carrying concludes_adventure=True — ends the session in victory. The rewards land after that transition: the 200 gp, the party's XP, and the quest.idol flag the crawler prints on its way out.

A concluded session still takes referee commands and refuses play, so the closing status reads [victory] and any further move would be wrong_mode. SessionMode.terminal is the loop condition a front end checks — true in victory and game_over alike, it answers "has this session ended?" in one read, and the LLM referee page shows it guarding an agent loop. This crawler deliberately does not break on it: the loop stays open after victory so the script's closing journal and status can still be read, which is exactly the referee-side access a terminal mode preserves.

Two beats of authoring discipline fall out of the reward ordering and are worth copying. Put the town business before the concluding return, while play commands are still legal. And put the story's thanks in AwardXP rather than in coin: under the default on-return timing, treasure converts to XP when the party comes home, and the concluding return's award has already resolved by the time the rewards issue — so the temple's 200 gp arrives as real, spendable coin, but no XP will ever be minted from it.

Listeners and flags covers the listener contract the interpreter follows, and Gates, triggers, and quests covers authoring quests of your own.

Where next