Building an adventure
Adventures are plain data: frozen pydantic models you assemble in code, or load from your own file format, and hand to the session.
The shape of the tree:
Adventure- the root: a name, aTownSpec, and one or more dungeonsDungeonSpec- one dungeon: an id and one or more levelsLevelSpec- a grid of 10-foot cells with edges, keyed areas, features, and transitionsAreaSpec- a keyed room or cave over some cells, with its encounter, trap, and treasure bindings
The grid and its edges
A level is a width × height grid. Cells are addressed (x, y) with x increasing east and y increasing south from (0, 0) at the northwest corner.
Walls are the default. You declare the exceptions (open edges and doors) in the edges map. Everything absent from the map is solid wall, including the level boundary. The key "1,0:west" is the west side of cell (1, 0). The edge_key helper computes the canonical key for any cell and direction:
# The level: a 4x1 corridor, entered at the west end, with a door at the far end.
level = LevelSpec(
number=1,
width=4,
height=1,
entrance=(0, 0),
edges={
"1,0:west": Edge(kind=EdgeKind.OPEN),
"2,0:west": Edge(kind=EdgeKind.OPEN),
"3,0:west": Edge(kind=EdgeKind.DOOR, door=DoorSpec()),
},
An Edge is open, wall, or door. A door edge has a DoorSpec: normal or secret, stuck, locked, or gated. For gates, see Gates, triggers, and quests. entrance is the cell where EnterDungeon lands the party.
Keyed areas
Cells not covered by any area are corridor. An AreaSpec names a region (a room, a cave, a shrine) and attaches content to it: descriptive prose for your front end, an encounter, a trap, treasure. The party triggers an area's content by stepping into any of its cells:
areas=(
AreaSpec(
id="guard_post",
name="Guard post",
description="Two goblins crouch over a game of knucklebones.",
cells=((3, 0),),
encounter=KeyedEncounter(monsters=(KeyedMonster(template_id="goblin", count_fixed=2),)),
),
),
)
A KeyedEncounter lists its monsters by template id, each with a fixed count or count dice. A template id is any id from load_monsters, listed in the monster id index, or the id of a monster the adventure bundles (see Bundling custom monsters with an adventure). You can also set the monsters' awareness, stance, or alignment.
Beyond encounters, an area (or the level itself) can contain:
FeatureSpec- a treasure cache, a construction trick, or custom content for your front end. A cache contains hand-placed items, magic items (any id from the magic item id index, like asword_plus_1in a chest), coins, and named valuables (ValuableSpec).TrapSpec- room traps on areas, treasure traps on caches. A room trap springs when the party steps in or, withtrigger="open", when the party opens one of the area's doors.AreaTreasureSpec- generated treasure: explicit treasure type letters (see the treasure type index) or the level's unguarded-treasure band.TransitionSpec- stairs, trapdoors, and chutes, which the party takes withUseStairs, and passages, which the party walks through withMoveParty. A passage joins two cells that a map shows side by side but the grid can't, like the floors of two levels that meet. Transitions live on the level, not the area.WanderingSpec- the level's wandering-monster check: 1-in-6 every two turns by default, with an optional custom table.
The dungeon, the town, and the root
You put the level into a DungeonSpec, and the dungeon into an Adventure beside the TownSpec. The town is the safe base where the party rests, buys equipment, and sells treasure. travel_turns maps each dungeon id to the town-to-entrance travel cost in exploration turns:
barrow = DungeonSpec(id="barrow", name="The Barrow", levels=(level,))
town = TownSpec(name="Threshold", travel_turns={"barrow": 2})
adventure = Adventure(
name="The Barrow of the Knucklebone Goblins",
town=town,
dungeons=(barrow,),
)
The root also contains the adventure's behavior on the items, triggers, and quests fields: its own item templates, its triggers, and its quests. For all three, see Gates, triggers, and quests.
Validate before play
validate_adventure checks the whole tree against the compiled catalogs and raises ContentValidationError naming every problem at once: unknown monster or item ids, out-of-bounds cells, transitions to nowhere, missing entrances. GameSession.new runs the same validation, so a session can never start on broken content. Calling validate_adventure yourself fails faster while you author:
# Validation catches unknown ids and broken geometry before play ever starts.
validate_adventure(adventure, load_monsters(), load_equipment())
The complete program
The party enters the barrow, walks east to the door, opens it, and steps into the guard post, where the encounter begins:
from osrlib.core.alignment import Alignment
from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.crawl.adventure import Adventure, TownSpec, validate_adventure
from osrlib.crawl.commands import EnterDungeon, MoveParty, OpenDoor, SessionMode
from osrlib.crawl.dungeon import (
AreaSpec,
Direction,
DoorSpec,
DungeonSpec,
Edge,
EdgeKind,
KeyedEncounter,
KeyedMonster,
LevelSpec,
)
from osrlib.crawl.party import Party
from osrlib.crawl.session import GameSession
from osrlib.data import load_equipment, load_monsters
# The level: a 4x1 corridor, entered at the west end, with a door at the far end.
level = LevelSpec(
number=1,
width=4,
height=1,
entrance=(0, 0),
edges={
"1,0:west": Edge(kind=EdgeKind.OPEN),
"2,0:west": Edge(kind=EdgeKind.OPEN),
"3,0:west": Edge(kind=EdgeKind.DOOR, door=DoorSpec()),
},
areas=(
AreaSpec(
id="guard_post",
name="Guard post",
description="Two goblins crouch over a game of knucklebones.",
cells=((3, 0),),
encounter=KeyedEncounter(monsters=(KeyedMonster(template_id="goblin", count_fixed=2),)),
),
),
)
barrow = DungeonSpec(id="barrow", name="The Barrow", levels=(level,))
town = TownSpec(name="Threshold", travel_turns={"barrow": 2})
adventure = Adventure(
name="The Barrow of the Knucklebone Goblins",
town=town,
dungeons=(barrow,),
)
# Validation catches unknown ids and broken geometry before play ever starts.
validate_adventure(adventure, load_monsters(), load_equipment())
rules = Ruleset()
creation = RngStreams(master_seed=11).get(CHARACTER_CREATION_STREAM)
hero = create_character(name="Brakka", class_id="dwarf", alignment=Alignment.LAWFUL, ruleset=rules, stream=creation)
session = GameSession.new(Party(members=[hero.character]), adventure, seed=11)
session.execute(EnterDungeon(dungeon_id="barrow"))
session.execute(MoveParty(direction=Direction.EAST))
session.execute(MoveParty(direction=Direction.EAST))
opened = session.execute(OpenDoor(direction=Direction.EAST))
assert opened.accepted
result = session.execute(MoveParty(direction=Direction.EAST))
assert result.accepted
# Stepping into the keyed area creates the goblins and starts an encounter.
assert session.mode is SessionMode.ENCOUNTER
assert len(session.monsters) == 2
Where next
- Gates, triggers, and quests - authored behavior: gated doors, triggers, quests.
- The TUI crawler - a complete authored adventure: a two-level barrow, a fetch quest, a custom wandering table, a hand-placed MacGuffin. The FastAPI pattern serves the same barrow over HTTP.
- Sessions, commands, and events - the command loop: modes, rejections, the event log.
- Authoring custom classes, spells, monsters, and items - extending the content catalogs.