Skip to content

The kernel à la carte

osrlib.core is the rules kernel: dice, combat, treasure, spells, and the printed tables, as pure functions over frozen models. None of it depends on a running game — the dungeon-crawl framework in osrlib.crawl is one consumer of the kernel, built entirely on top of it, and a mass-combat simulator, a balance harness, or a content-validation script is just as valid a second consumer. The layering only runs one way: core never imports crawl, so anything built against osrlib.core keeps working no matter what the crawl layer does above it.

Away from a session you bring your own RngStreams and pass the stream each function asks for explicitly — there's no default stream and no hidden global RNG. The RNG streams reference lists the stream keys a running GameSession uses by convention, but à la carte code isn't bound by them: a stream's name is just a label, and determinism only requires that the same name draw the same sequence for a given master seed. This page tours four corners of the kernel: rolling dice, resolving an attack, generating treasure, and looking up a reaction. The complete program at the end runs all four in one script; every fragment above it is an excerpt.

The dice grammar

A dice expression is NdSN dice of S sides — with an optional +M/-M modifier and an optional ×K multiplier (x and * both work as ASCII aliases for ×). N defaults to 1, d% is an alias for d100, and S comes from the closed set {2, 3, 4, 6, 8, 10, 12, 20, 100}. Evaluation order is (sum of the dice + modifier) × multiplier — the modifier applies before the multiplier, which reads differently than ordinary arithmetic precedence: 2d6+1×10 means (2d6 + 1) × 10, the B/X treasure-roll convention, not 2d6 + 10. parse turns the string into a frozen DiceExpression; roll draws from an explicit stream and returns a RollResult carrying both the individual dice and the total:

# The dice grammar: NdS, an optional +/-modifier, an optional x multiplier. The
# modifier applies before the multiplier -- (sum of dice + modifier) x multiplier.
expression = parse("2d6+1×10")
assert (expression.count, expression.sides, expression.modifier, expression.multiplier) == (2, 6, 1, 10)

# Bring your own RngStreams -- no session, no crawl import anywhere in this file.
streams = RngStreams(master_seed=2026)
rolled = roll(expression, streams.get("scratch"))
assert rolled.total == (sum(rolled.rolls) + 1) * 10

roll also accepts the plain string directly (it calls parse for you), so roll("3d6", stream) is just as valid as parsing first.

Resolving an attack against a spawned monster

create_character and spawn_monster both work with no session behind them: a character needs only a Ruleset and a stream, and a monster needs only its MonsterTemplate from load_monsters (see the monster id index) plus an id — conventionally minted by an IdAllocator, the same monotonic counter a GameSession uses internally. resolve_attack then runs the whole pipeline — the attack roll, the immunity gate, the damage roll and its application — against an AttackContext you build yourself; attack=None resolves as an unarmed strike:

rules = Ruleset()

# Character creation is a kernel function too: no Party or GameSession required.
hero = create_character(
    name="Thistle",
    class_id="fighter",
    alignment=Alignment.LAWFUL,
    ruleset=rules,
    stream=streams.get(CHARACTER_CREATION_STREAM),
).character

# Spawn a monster from the compiled catalog and resolve one unarmed attack against it.
allocator = IdAllocator()
goblin_template = load_monsters().get("goblin")
goblin = spawn_monster(goblin_template, id=allocator.allocate("monster"), stream=streams.get(MONSTER_SPAWN_STREAM))
attack = resolve_attack(hero, goblin, None, context=AttackContext(), ruleset=rules, stream=streams.get(COMBAT_STREAM))
if attack.attack_roll.hit:
    assert attack.damage is not None and attack.damage >= 1
else:
    assert attack.damage is None

resolve_attack returns an AttackResult carrying the same typed events a session would append to its log — read them directly, or format them with format_message, entirely outside any session.

Rolling treasure for a type letter

generate_treasure takes a treasure-type letter straight off the SRD's tables (see the treasure type index) and rolls its full contents — coins, gems, jewellery, and magic items — end to end from one stream, in printed order, with an IdAllocator minting ids for whatever it generates. tier picks the Basic or Expert magic-item columns; a session derives it from the party's highest living level, but à la carte code just states it outright:

# Roll a treasure type letter's contents directly -- no dungeon, no keyed area.
treasure = generate_treasure("A", tier="basic", stream=streams.get(TREASURE_STREAM), allocator=allocator)
assert treasure.coins.value_cp >= 0

The result is a GeneratedTreasure: coins, valuables (gems and jewellery), and magic_items, every valuable and item instance carrying its own allocated id.

Looking up a reaction

The table lookups in osrlib.core.tables are plain functions over plain data — no stream, no side effect — so composing one from a roll is just calling roll and then the lookup. reaction_result resolves a 2d6 total against the loaded ReactionTable from load_combat_tables:

# A reaction roll composed from two lower-level kernel pieces: 2d6, then the table.
total = roll("2d6", streams.get("encounter")).total
reaction = reaction_result(load_combat_tables().reaction, total)
assert 2 <= total <= 12
assert reaction in ReactionResult

The combat kernel's own roll_reaction and check_morale wrap this same pattern — roll the dice, consult the table — for the common case where the intermediate total doesn't matter to the caller.

The complete program

from osrlib.core.alignment import Alignment
from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character
from osrlib.core.combat import COMBAT_STREAM, AttackContext, resolve_attack
from osrlib.core.dice import parse, roll
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, IdAllocator, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.core.tables import ReactionResult, reaction_result
from osrlib.core.treasure import TREASURE_STREAM, generate_treasure
from osrlib.data import load_combat_tables, load_monsters

# The dice grammar: NdS, an optional +/-modifier, an optional x multiplier. The
# modifier applies before the multiplier -- (sum of dice + modifier) x multiplier.
expression = parse("2d6+1×10")
assert (expression.count, expression.sides, expression.modifier, expression.multiplier) == (2, 6, 1, 10)

# Bring your own RngStreams -- no session, no crawl import anywhere in this file.
streams = RngStreams(master_seed=2026)
rolled = roll(expression, streams.get("scratch"))
assert rolled.total == (sum(rolled.rolls) + 1) * 10

rules = Ruleset()

# Character creation is a kernel function too: no Party or GameSession required.
hero = create_character(
    name="Thistle",
    class_id="fighter",
    alignment=Alignment.LAWFUL,
    ruleset=rules,
    stream=streams.get(CHARACTER_CREATION_STREAM),
).character

# Spawn a monster from the compiled catalog and resolve one unarmed attack against it.
allocator = IdAllocator()
goblin_template = load_monsters().get("goblin")
goblin = spawn_monster(goblin_template, id=allocator.allocate("monster"), stream=streams.get(MONSTER_SPAWN_STREAM))
attack = resolve_attack(hero, goblin, None, context=AttackContext(), ruleset=rules, stream=streams.get(COMBAT_STREAM))
if attack.attack_roll.hit:
    assert attack.damage is not None and attack.damage >= 1
else:
    assert attack.damage is None

# Roll a treasure type letter's contents directly -- no dungeon, no keyed area.
treasure = generate_treasure("A", tier="basic", stream=streams.get(TREASURE_STREAM), allocator=allocator)
assert treasure.coins.value_cp >= 0

# A reaction roll composed from two lower-level kernel pieces: 2d6, then the table.
total = roll("2d6", streams.get("encounter")).total
reaction = reaction_result(load_combat_tables().reaction, total)
assert 2 <= total <= 12
assert reaction in ReactionResult

Where next