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
- Building an adventure — the dungeon geometry and authoring models the barrow is built from.
- Views and visibility — what a player's view includes, and how it's built from referee-only state.
- Listeners and flags — registering listeners, the flag store, and the contract quest and achievement systems rely on.
- The FastAPI pattern and LLM referees — the
same
GameSession, driven by different front ends entirely.