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:
- Down, for the goblins, their lair hoard, and the rival party prowling level 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
givebefore anybody walks again. - Down again, for the idol alone.
- Home with it, which completes the second objective, completes the quest, and
— the quest carrying
concludes_adventure=True— ends the session invictory. The rewards land after that transition: the 200 gp, the party's XP, and thequest.idolflag 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
- Building an adventure — the dungeon geometry and authoring models the barrow is built from.
- Gates, triggers, and quests — the authored layer behind the fetch quest, and how to write your own.
- 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.