The FastAPI pattern
The library's second example front end puts the TUI crawler's barrow adventure behind an HTTP API — the same authored content behind a terminal and a web server, which is the point: osrlib doesn't care what's on the other side of the GameSession. This page teaches the server patterns the example exists to demonstrate: the per-session lock, player visibility enforced at the wire, saves that never leave the server, and the mapping from osrlib's typed exceptions to HTTP statuses — this last one makes the page the home of osrlib.errors. Run instructions live in the example's README on GitHub.
The example is small — five endpoints in examples/fastapi_crawler/app.py — and every server fragment below is excerpted directly from that file, so the page cannot drift from the code it teaches. Server fragments don't run standalone; the page's one self-contained runnable block is the exception demonstration.
One session, one lock
A GameSession executes one command at a time and is not safe to share across threads — while FastAPI runs plain def endpoints in a threadpool, so any two requests may execute concurrently. The store resolves that tension by pairing every session with its own lock:
_store_lock = threading.Lock()
_sessions: dict[str, tuple[GameSession, threading.Lock]] = {}
_saves: dict[str, dict] = {}
The session's lock is held across every execute and every view read, so one session's commands serialize while separate sessions proceed in parallel; the outer _store_lock only guards the dictionaries themselves. The endpoints are deliberately synchronous: the engine is synchronous and CPU-bound, so an async facade would add nothing — the threadpool provides the concurrency, and the lock provides the safety.
Creating and restoring sessions
A session begins with a stamped party document — the JSON envelope party_to_document produces and party_from_document validates — or with a save id from an earlier server-side snapshot. Exactly one of the two, which the request model enforces before the handler ever runs:
@app.post("/sessions")
def create_session(request: CreateSession) -> dict:
"""Create a session from a party document, or restore one from a server-side save.
The response carries the schema handshake and the new session id — never the
seed.
"""
if request.save_id is not None:
with _store_lock:
document = _saves.get(request.save_id)
if document is None:
raise HTTPException(status_code=404, detail=f"unknown save id {request.save_id!r}")
session = restore_session(document)
elif request.party_document is not None:
members = party_from_document(request.party_document)
seed = request.seed if request.seed is not None else secrets.randbits(63)
session = new_session(Party(members=members), seed=seed)
else: # unreachable: the body model requires exactly one of the two
raise HTTPException(status_code=422, detail="exactly one of party_document or save_id is required")
session_id = secrets.token_hex(8)
with _store_lock:
_sessions[session_id] = (session, threading.Lock())
return {"session_id": session_id, "schema_version": SCHEMA_VERSION, "engine_version": engine_version()}
Two details carry the trust story:
- The master seed is a server secret. By default the server draws it (
secrets.randbits(63)) and no response ever contains it — a client that knows the seed can predict every roll the dungeon will ever make. The optionalseedfield exists for reproducible demos and tests; even when the client supplies it, it never comes back. - The response is the schema handshake.
schema_versionandengine_versioncome fromosrlib.versioning, so a client can detect a server whose wire schema is ahead of its own before sending anything else. Determinism, saves, and replay covers what each version stamp guarantees.
The command endpoint
One endpoint accepts every command in the engine's registry — all 44 of them, each a typed model with its own JSON Schema (see the command schema reference). parse_command turns the wire payload into a typed command, returning None for a command_type it has never heard of:
@app.post("/sessions/{session_id}/commands")
def execute_command(session_id: str, body: dict) -> dict:
"""Parse and execute one command under the session lock.
An unknown `command_type` is a 422 (the additive-schema contract: this server
doesn't understand the command, so it never reaches the engine). A rejected
command is a 200 — rejections are game feedback, and they cost the client
nothing: no draws, no clock time, no log entry.
"""
session, lock = _session_or_404(session_id)
command = parse_command(body)
if command is None:
raise HTTPException(status_code=422, detail=f"unknown command type {body.get('command_type')!r}")
with lock:
result = session.execute(command)
return {
"accepted": result.accepted,
"rejections": [rejection.model_dump(mode="json") for rejection in result.rejections],
"events": [event.model_dump(mode="json") for event in result.events if event.visibility is Visibility.PLAYER],
}
Three distinct fates for a request, in order:
- An unknown
command_typeis a 422 before the engine is ever consulted: schemas grow additively, so a newer client may know commands this server doesn't, and the honest answer is "I don't understand", not a guess. - A known command with a malformed payload (a direction that doesn't exist, a negative quantity) raises
ContentValidationErrorinsideparse_command, which the exception handler below maps to 422. - A well-formed command executes under the session lock and returns the
CommandResultenvelope:accepted, the rejections, and the events — filtered toVisibility.PLAYERon their way out. Referee-visibility events (hidden rolls, referee bookkeeping) never cross the wire.
Rejections are results, not errors
The split that decides every status code on this page: an in-fiction rejection is a 200. When the party tries to walk through a wall, the game said no — that's a rules outcome the client should render, not a transport failure. The response arrives with accepted: false and a machine-readable rejection code (see the rejection code reference), and it costs the client nothing: a rejected command draws no dice, advances no clock, and appends nothing to the log, so a confused client can probe freely without corrupting the game. Sessions, commands, and events teaches the rejection contract in depth.
Exceptions are the opposite case: the caller broke the out-of-fiction contract — sent a malformed document, replayed an incompatible save — and those map to 4xx/5xx statuses.
The exception hierarchy and the status map
osrlib.errors is a deliberately small, typed hierarchy reserved for out-of-fiction failures:
OsrlibError— the root. Catching it catches everything osrlib raises on its own authority.ContentValidationError— malformed rules content at a library boundary: a dice expression that doesn't parse, an adventure that failsvalidate_adventure, a serialized document whose structure or kind isn't what the loader expects.SaveVersionError— a document whoseschema_versionis newer than this library understands, raised bycheck_documentrather than silently misreading the payload.ReplayVersionError— a command log replayed under a different engine version, where any rules change may legitimately alter outcomes. The example never raises it (it exposes no replay endpoint), but a front end that replays command logs owns the same mapping decision.
Two failure families are deliberately outside the hierarchy: programmer misuse (bad argument types, out-of-range values) raises stdlib ValueError or TypeError — a bug in the calling code, not a condition to map — and in-fiction refusals, as above, aren't exceptions at all.
The example registers one handler per exception type it expects, plus a 404 helper for ids that miss the store:
@app.exception_handler(ContentValidationError)
def _content_validation_error(request: Request, error: ContentValidationError) -> JSONResponse:
"""Malformed content — a bad party document or command payload — is a 422."""
return JSONResponse(status_code=422, content={"detail": str(error)})
@app.exception_handler(SaveVersionError)
def _save_version_error(request: Request, error: SaveVersionError) -> JSONResponse:
"""A document from a newer engine is a conflict with this server's, a 409."""
return JSONResponse(status_code=409, content={"detail": str(error)})
def _session_or_404(session_id: str) -> tuple[GameSession, threading.Lock]:
with _store_lock:
entry = _sessions.get(session_id)
if entry is None:
raise HTTPException(status_code=404, detail=f"unknown session id {session_id!r}")
return entry
Everything the wire can answer, in one table:
| Outcome | Where it comes from | Status |
|---|---|---|
| Rejected command | the game said no: accepted is false |
200 |
Unknown command_type |
parse_command returns None |
422 |
ContentValidationError |
malformed party document, command payload, or save | 422 |
SaveVersionError |
a document stamped by a newer library | 409 |
| Unknown session or save id | the store lookup misses | 404 |
| Anything else | a bug, by definition | 500 |
The hierarchy is easy to exercise without a server — this block runs as written:
from osrlib.errors import ContentValidationError, OsrlibError, ReplayVersionError, SaveVersionError
from osrlib.persistence import load_game
from osrlib.versioning import SCHEMA_VERSION, check_document
# The typed hierarchy: every failure osrlib raises on its own authority is an OsrlibError.
assert issubclass(ContentValidationError, OsrlibError)
assert issubclass(SaveVersionError, OsrlibError)
assert issubclass(ReplayVersionError, OsrlibError)
# A structurally broken document is malformed content: ContentValidationError (the app's 422).
try:
load_game({"kind": "not-a-save"})
raise AssertionError("expected ContentValidationError")
except ContentValidationError:
pass
# A document stamped by a newer library is a version conflict: SaveVersionError (the app's 409).
newer = {"kind": "save", "schema_version": SCHEMA_VERSION + 1, "payload": {}}
try:
check_document(newer, expected_kind="save")
raise AssertionError("expected SaveVersionError")
except SaveVersionError:
pass
The player view at the wire
The only game-state read the API offers is the player projection — session.view(Visibility.PLAYER), serialized verbatim:
@app.get("/sessions/{session_id}/view")
def player_view(session_id: str) -> dict:
"""The player projection — the only game-state read the API offers."""
session, lock = _session_or_404(session_id)
with lock:
view = session.view(Visibility.PLAYER)
return view.model_dump(mode="json")
There is no referee-view endpoint at all, and that absence is the pattern: never trust the client. The PlayerView is an enumerated whitelist — explored cells, public character sheets, masked magic items, monster groups without hit points — so unexplored geometry, undiscovered secret doors, monster internals, session flags, and the seed can't leak, because they were never in the projection to begin with. A client that renders only what this endpoint returns literally cannot cheat. Views and visibility walks the whitelist field by field.
Saves stay on the server
A save document contains everything the wire withholds — the master seed, referee state, the full logs — so the example never sends one anywhere. Snapshots go into a server-side store, and only an opaque id crosses the wire:
@app.post("/sessions/{session_id}/save")
def save_session(session_id: str) -> dict:
"""Snapshot into the server-side save store; only the opaque id crosses the wire."""
session, lock = _session_or_404(session_id)
with lock:
document = save_game(session)
save_id = secrets.token_hex(8)
with _store_lock:
_saves[save_id] = document
return {"save_id": save_id}
Restoring is the save_id path through POST /sessions above: the server calls load_game, re-registers its listeners (listeners are live game objects, so a restored session needs them attached again), and hands back a fresh session id. The in-memory store is a deliberate simplification — swapping in a database changes nothing about the pattern.
Where next
- Sessions, commands, and events — the command loop this API wraps: modes, rejections, the event log.
- Views and visibility — exactly what the player projection contains and why.
- The command schema reference — every command this endpoint accepts, with its JSON Schema and legal modes.
- LLM referees — the consumer on the other side of the visibility doctrine: an agent that's supposed to see everything.