Skip to content

osrlib.core.character

The player character: the model, creating one step by step, and saving one to a document.

Start here when you need a party. create_character is the entry point: hand it a name, a class id, an alignment, a Ruleset, and a seeded random-number stream from osrlib.core.rng, and it returns a first-level Character together with the raw dice it rolled. Put the characters you get into a Party to play them, or use them on their own with the combat, magic, and item functions of the core kernel.

Character is a mutable pydantic model. Its derived values, which are the ability modifiers, both armour classes, movement rate, and the language list, are properties computed from stored state rather than stored fields, so they cannot fall out of step with the state they come from. Validation on the model is structural: score ranges, a level within the class's bounds, current hit points no higher than maximum. Whether a creation step was legal is checked by the creation functions when the step happens, because a finished character keeps no record of the choices that made it.

Creation follows the OSE SRD's Creating a Character steps as pure functions you drive one at a time when a player is making the choices: roll_ability_scores, validate_class_choice, apply_adjustment, validate_starting_spells with choose_starting_spells, roll_hit_points, validate_extra_languages, roll_starting_gold, and then buying and equipping gear through osrlib.core.items. Each validating step returns a list of Rejection records rather than raising, so you can show the player what went wrong and let them choose again. Creation emits no events: it happens before a session starts, and the first events belong to play. create_character runs the whole sequence in one call when every choice is known upfront, and raises instead of returning rejections.

Advancement, which is leveling up, energy drain, and experience awards, lives in osrlib.core.classes, the module that also defines the class a character plays. Saving a character to disk goes through to_document and party_to_document, whose output osrlib.persistence writes as part of a whole-game save.

Two stream keys are the naming convention every session adopts: CHARACTER_CREATION_STREAM for creation draws and ADVANCEMENT_STREAM for in-play level-up hit point rolls. They stay separate so that a change to the creation rules never shifts advancement draws already recorded in a save.

Typical usage:

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.party import Party

streams = RngStreams(master_seed=2)
stream = streams.get(CHARACTER_CREATION_STREAM)
result = create_character(
    name="Rurik",
    class_id="fighter",
    alignment=Alignment.LAWFUL,
    ruleset=Ruleset(),
    stream=stream,
    purchases=[("sword", 1), ("leather", 1)],
    equip_ids=["sword", "leather"],
)
party = Party(members=[result.character])
print(party.members[0].name, party.members[0].max_hp, party.members[0].armour_class)
# Rurik 8 6

ABILITY_ROLL_ORDER module-attribute

The order roll_ability_scores draws the six abilities.

This is the SRD's listing order, and it is fixed. Read it when you are displaying rolled scores in the order the dice came up, or when you are reproducing a draw sequence by hand from a recorded seed. The order is part of what makes a seeded creation reproducible, so a caller cannot change it.

ADVANCEMENT_STREAM module-attribute

ADVANCEMENT_STREAM = StreamName.ADVANCEMENT

The stream key every session uses for in-play advancement draws.

Pass streams.get(ADVANCEMENT_STREAM) as the stream argument of level_up, apply_xp, and drain_levels, which roll hit dice on it.

It is separate from CHARACTER_CREATION_STREAM so that a change to the creation rules, which would consume a different number of draws, never shifts advancement rolls a save has already recorded.

CHARACTER_CREATION_STREAM module-attribute

CHARACTER_CREATION_STREAM = StreamName.CHARACTER_CREATION

The stream key every session uses for creation draws.

A stream key names one independent random-number sequence inside an RngStreams set. Pass streams.get(CHARACTER_CREATION_STREAM) as the stream argument of create_character and of the stepwise creation functions, which draw ability scores, the first-level hit die, and starting gold from it in that order.

Use a different key only when you want creation draws kept apart from the ones a session already records, for example when you roll throwaway characters beside a live game. Pass your own key to streams.get; do not change this constant, because a save replays every stream by the key that produced it.

AbilityScoreRolls

Bases: BaseModel

The six rolled ability scores, with the individual dice kept so you can show them.

roll_ability_scores returns this, and create_character includes it in its result. Frozen: a roll is history and does not change.

scores instance-attribute

scores: dict[AbilityScore, int]

The total of each ability's three dice, keyed by AbilityScore. All six are present, each 3 through 18.

rolls instance-attribute

The three raw d6 results behind each score, in the order they were rolled, so a character sheet can show the dice a player watched come up.

Character

Bases: BaseModel

A player character: the stored state of one played person, and the values derived from it.

Get one from create_character, from the stepwise creation functions in this module, or from from_document when reloading a save. Construct one directly only when you already have every value, as in a test fixture.

Put characters in a Party to explore with them, hand one to the combat functions in osrlib.core.combat as an attacker or a target, to osrlib.core.spells as a caster, and to level_up or apply_xp to advance it. A MonsterInstance exposes the same combatant surface, so those functions take either.

The model is mutable and validates on assignment: setting a field that breaks a rule raises rather than storing the bad value. Validation is structural only. It checks that the six scores are present and in range, that the level is within the class's maximum, and that current hit points do not exceed maximum hit points. It does not check that the character was created legally, because a finished character keeps no record of its own creation.

Nothing derived is stored. THAC0, attack bonus, saving throws, ability modifiers, both armour classes, and the language list are properties recomputed from the stored fields every time you read them, so a level change or a swapped piece of armour shows up immediately and nothing can fall out of step.

Examples:

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

stream = RngStreams(master_seed=2).get(CHARACTER_CREATION_STREAM)
character = create_character(
    name="Rurik",
    class_id="fighter",
    alignment=Alignment.LAWFUL,
    ruleset=Ruleset(),
    stream=stream,
).character
print(character.level, character.thac0, character.saves.death)
# 1 19 12
character.current_hp -= 3
print(character.current_hp, character.max_hp)
# 5 8

id class-attribute instance-attribute

id: str | None = None

The entity id, which a GameSession assigns from its IdAllocator when the character joins. None until then, and events fall back to the name while it is unset.

name class-attribute instance-attribute

name: str = Field(min_length=1)

The character's name. At least one character long.

class_id instance-attribute

class_id: str

The class this character plays, like "fighter". Valid ids come from load_classes; see the class id index. Read definition to get the class itself.

race class-attribute instance-attribute

race: str = Field(pattern='^[a-z][a-z0-9_]*$')

The character's people, like "human" or "dwarf", as a lowercase identifier. Creation copies it from the class. No rules procedure reads it: racial abilities resolve through the class's ability tags instead, so a new race needs no code.

level class-attribute instance-attribute

level: int = Field(ge=1)

The experience level, 1 through the class's maximum.

xp class-attribute instance-attribute

xp: int = Field(ge=0)

Experience points accumulated. Advancement compares this against the thresholds in the class's progression table; award XP with apply_xp rather than by assigning here.

scores instance-attribute

scores: dict[AbilityScore, int]

The six ability scores, each 3 through 18, keyed by AbilityScore. All six must be present.

alignment instance-attribute

alignment: Alignment

Lawful, neutral, or chaotic. It also fixes alignment_tongue.

extra_languages class-attribute instance-attribute

extra_languages: tuple[str, ...] = ()

The extra language ids a high INT granted at creation, validated by validate_extra_languages.

max_hp class-attribute instance-attribute

max_hp: int = Field(ge=1)

Maximum hit points, at least 1.

current_hp class-attribute instance-attribute

current_hp: int = Field(ge=0)

Current hit points, from 0 up to max_hp. Reaching 0 means the character has dropped. Death itself is the dead condition, applied by kill, rather than a hit point value.

inventory class-attribute instance-attribute

inventory: Inventory = Field(default_factory=Inventory)

Everything carried, worn, and wielded, plus the purse. See Inventory and the buying and equipping functions in osrlib.core.items.

carrying_treasure class-attribute instance-attribute

carrying_treasure: bool = False

Whether the character is carrying enough treasure to slow them down. Basic encumbrance leaves the threshold to the referee, so osrlib leaves it to you: set this flag and movement_rate drops the rate a step. Ignored under the other encumbrance modes.

conditions class-attribute instance-attribute

conditions: tuple[ActiveCondition, ...] = ()

The conditions in effect, like poisoned or paralyzed. Apply and clear them through osrlib.core.effects; test one with has_condition.

stat_modifiers class-attribute instance-attribute

stat_modifiers: tuple[ActiveModifier, ...] = ()

Timed bonuses and penalties from spells and items, applied by osrlib.core.effects.

spell_book class-attribute instance-attribute

spell_book: tuple[str, ...] = ()

The spell ids an arcane caster can memorize from, in the order they were learned. Empty for clerics, whose spells come from their deity, and for non-casters.

memorized_spells class-attribute instance-attribute

memorized_spells: tuple[MemorizedSpell, ...] = ()

The prepared copies a caster may cast, as MemorizedSpell records in memorization order. The order matters: casting spends the first matching copy, and energy drain forgets the newest first. How many of each level fit is derived from the class's progression row, not stored, so leveling up and being drained both recompute it.

definition property

definition: ClassDefinition

The class this character plays, looked up from the loaded catalog by class_id.

Read it for anything that depends on the class: the progression table, the armour and weapon policies, the class abilities, the level titles. The catalog is loaded once and cached, so reading this property repeatedly costs nothing.

thac0 property

thac0: int

The number the character must roll to hit armour class 0, from the current level's row.

Descending armour class is the SRD's default presentation. Use attack_bonus instead if your front end shows ascending armour class. The two describe the same attack.

attack_bonus property

attack_bonus: int

The bonus added to an attack roll under ascending armour class, from the current level's row.

The ascending presentation of thac0. Both come from the same progression row, so they always agree.

saves property

saves: SavingThrows

The five saving throw targets for the current level.

Roll a d20 against the relevant field and succeed on that number or higher. Leveling and energy drain both change this the moment they change the level, because it is read from the progression row rather than stored.

melee_modifier property

melee_modifier: int

The STR modifier added to melee attack rolls and to melee damage, from −3 to +3.

open_doors_chance property

open_doors_chance: int

The chance in 6 of forcing a stuck door open, from STR.

Roll 1d6 and succeed on this number or less. Force-door attempts in a crawl go through osrlib.crawl.exploration, which reads this for you.

missile_modifier property

missile_modifier: int

The DEX modifier added to missile attack rolls, from −3 to +3. It does not change damage.

initiative_modifier property

initiative_modifier: int

The DEX modifier to an individual initiative roll, from −2 to +2.

It applies only when the individual_initiative flag of the Ruleset is on. Group initiative, the default, rolls once per side and ignores it.

hit_point_modifier property

hit_point_modifier: int

The CON modifier added to each Hit Die rolled, from −3 to +3.

Creation and level_up add it per die and floor the gain at 1 hit point, so a poor CON never costs a character hit points outright.

magic_save_modifier property

magic_save_modifier: int

The WIS modifier applied to saving throws against magical effects, from −3 to +3.

npc_reaction_modifier property

npc_reaction_modifier: int

The CHA modifier applied to NPC reaction rolls, from −2 to +2.

Reaction rolls during a crawl read it for you; see osrlib.crawl.encounter.

alignment_tongue property

alignment_tongue: str

The secret language shared by everyone of this alignment, as a language id.

Every character speaks the tongue of their own alignment and no other. The id is alignment_ followed by the alignment's wire value, so a lawful character speaks "alignment_lawful". These are not entries in the language catalog that load_languages returns. They are derived here, so changing a character's alignment changes the tongue in the same moment.

languages property

languages: tuple[str, ...]

Every language the character speaks, as ids, in a fixed order.

The alignment tongue comes first, then the languages the class grants with Common at the front, then the extras a high INT bought at creation. Compare against another speaker's list to decide whether two people can talk to each other. The ids other than the alignment tongue are catalog ids from load_languages; see the language id index.

armour_class property

armour_class: int

The character's armour class in the descending presentation, where lower is better.

Unarmoured is 9. Worn armour sets the base, shields and always-active magic items subtract their bonus, and the DEX modifier subtracts on top. Equipping or removing armour changes this at once, because it is computed from the inventory rather than stored. Use armour_class_ascending if your front end shows ascending armour class.

armour_class_ascending property

armour_class_ascending: int

The character's armour class in the ascending presentation, where higher is better.

Unarmoured is 10, and every bonus adds. It describes the same defense as armour_class. The two presentations always agree.

movement_rate

movement_rate(ruleset: Ruleset) -> int

Return how far this character moves in one exploration turn, in feet.

What the rate depends on is the ruleset's encumbrance mode: nothing at all under none, the worn armour category and the carrying_treasure flag under basic, and the total weight carried under detailed. A character loaded past the maximum cannot move and gets 0.

A party moves at its slowest living member's rate, so for a party call Party.movement_rate instead of calling this per member.

Parameters:

Name Type Description Default
ruleset Ruleset

The ruleset in play, whose encumbrance mode governs what counts.

required

Returns:

Type Description
int

The rate in feet per turn: 120, 90, 60, 30, or 0.

Examples:

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

ruleset = Ruleset()
stream = RngStreams(master_seed=2).get(CHARACTER_CREATION_STREAM)
character = create_character(
    name="Rurik",
    class_id="fighter",
    alignment=Alignment.LAWFUL,
    ruleset=ruleset,
    stream=stream,
    purchases=[("plate_mail", 1)],
    equip_ids=["plate_mail"],
).character
print(character.movement_rate(ruleset))
# 60

to_document

to_document() -> dict[str, object]

Return this character as a JSON-ready document stamped with its schema and engine versions.

The stamp is what lets a later version of osrlib decide whether it can read the document. Use this to store one character on its own, and use party_to_document for a whole party, and the save functions in osrlib.persistence to store a session, which already includes its characters.

Read it back with from_document.

Returns:

Type Description
dict[str, object]

The stamped envelope, whose payload is the serialized character. Every value is a

dict[str, object]

JSON type, so you can hand the result straight to

dict[str, object]

Examples:

from osrlib.core.alignment import Alignment
from osrlib.core.character import CHARACTER_CREATION_STREAM, Character, create_character
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset

stream = RngStreams(master_seed=2).get(CHARACTER_CREATION_STREAM)
character = create_character(
    name="Rurik",
    class_id="fighter",
    alignment=Alignment.LAWFUL,
    ruleset=Ruleset(),
    stream=stream,
).character
document = character.to_document()
print(sorted(document))
# ['engine_version', 'kind', 'payload', 'schema_version']
print(Character.from_document(document).name)
# Rurik

from_document classmethod

from_document(document: Mapping[str, object]) -> Character

Rebuild a character from a document written by to_document.

Fields in the payload that this version does not recognize are ignored, so a document written by a later release that only added fields still loads. A document whose schema version is newer than this release understands is refused instead, because the meaning of what it does know may have changed.

Parameters:

Name Type Description Default
document Mapping[str, object]

The stamped envelope, as returned by to_document or read back from JSON.

required

Returns:

Type Description
Character

The character, with every derived value recomputed from the loaded state.

Raises:

Type Description
ContentValidationError

If the envelope is not a character document, or the payload does not validate as a character.

SaveVersionError

If the document's schema version is newer than this library understands.

CharacterCreationResult

Bases: BaseModel

What create_character returns: the character and its dice.

The rolls are here so a front end can show a player how their character came out rather than only the finished numbers. Frozen.

character instance-attribute

character: Character

The finished Character, at first level with its purchases bought and its equipment worn.

ability_rolls instance-attribute

ability_rolls: AbilityScoreRolls

The ability scores as rolled, before any adjustment the caller asked for. Compare against character.scores to show what the adjustment moved.

hit_point_roll instance-attribute

hit_point_roll: HitPointRoll

The first-level hit die, or dice if the re-roll option was on.

gold_roll instance-attribute

gold_roll: RollResult

The 3d6 × 10 starting money roll. Its total is the gold the character began with, before the purchases were paid for.

HitPointRoll

Bases: BaseModel

A first-level hit point roll: every die that was thrown, and the total it came to.

roll_hit_points returns this. Frozen.

rolls instance-attribute

rolls: tuple[int, ...]

Every raw die result, in the order thrown. With the hp_reroll_at_first_level option on, the rejected 1s and 2s are here too, and the last entry is the die that stood.

hit_points class-attribute instance-attribute

hit_points: int = Field(ge=1)

The hit points the character starts with: the die that stood plus the CON modifier, floored at 1.

choose_starting_spells

choose_starting_spells(
    character: Character, definition: ClassDefinition, catalog: SpellCatalog, spell_ids: Sequence[str]
) -> list[Rejection]

Write an arcane caster's starting spell book onto the character.

Use it when you are creating a character step by step and the player has picked their first spell. It checks the pick with validate_starting_spells and refuses a character whose book is already written, then stores the ids on spell_book. Nothing is written when anything is refused, so the character is never left half-changed. create_character does this for you when you pass starting_spell_ids.

A written book is not yet a memorized spell. To prepare spells for casting, go through osrlib.core.spells, which fills the character's memorization slots from the book.

Parameters:

Name Type Description Default
character Character

The character to write to. Mutated in place when the pick is legal.

required
definition ClassDefinition

The character's class.

required
catalog SpellCatalog

The spell catalog from load_spells.

required
spell_ids Sequence[str]

The chosen spell ids; see the spell id index.

required

Returns:

Type Description
list[Rejection]

One Rejection per problem, empty when the book was

list[Rejection]

written.

Examples:

from osrlib.core.alignment import Alignment
from osrlib.core.character import (
    CHARACTER_CREATION_STREAM,
    choose_starting_spells,
    create_character,
)
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.data import load_classes, load_spells

magic_user = load_classes().get("magic_user")
stream = RngStreams(master_seed=3).get(CHARACTER_CREATION_STREAM)
character = create_character(
    name="Miri",
    class_id="magic_user",
    alignment=Alignment.NEUTRAL,
    ruleset=Ruleset(),
    stream=stream,
    starting_spell_ids=["sleep"],
).character
rejections = choose_starting_spells(character, magic_user, load_spells(), ["magic_missile"])
print([rejection.code for rejection in rejections])
# ['magic.book.already_chosen']
print(character.spell_book)
# ('sleep',)

create_character

create_character(
    *,
    name: str,
    class_id: str,
    alignment: Alignment,
    ruleset: Ruleset,
    stream: RngStream,
    adjustment: AbilityAdjustment | None = None,
    starting_spell_ids: Sequence[str] = (),
    extra_languages: Sequence[str] = (),
    purchases: Sequence[tuple[str, int]] = (),
    equip_ids: Sequence[str] = ()
) -> CharacterCreationResult

Create a first-level character, making every choice you pass in one call.

This is where a new caller starts. Give it a name, a class, an alignment, a ruleset, and a seeded stream, and it rolls a whole character: ability scores, hit points, starting gold, and the gear you asked it to buy. Put the characters you get into a Party and you have something to play with.

To get a stream, build an RngStreams set from a seed and ask it for the creation stream: RngStreams(master_seed=2).get(CHARACTER_CREATION_STREAM). The same seed always produces the same character, which is what makes a game replayable and a test repeatable. Pass the same stream to several calls to roll a whole party from one seed, and each call continues where the last one left off.

Every decision is yours to supply, because the same call has to serve a player picking from a menu and a script rolling a hundred characters. Drive the stepwise functions in this module instead when a person is choosing as they go: those hand back Rejection records you can show, where this function raises on the first illegal choice and reports nothing more.

The steps run in the SRD's order: roll the six scores, check them against the class requirements, apply the ability adjustment, write the spell book, roll hit points, check the extra languages, roll starting gold, then buy and equip. Draws come off the stream in that order, so scores are always drawn first and gold last. Writing the spell book and checking languages consume no draws.

Parameters:

Name Type Description Default
name str

The character's name.

required
class_id str

The class to play, like "fighter", from load_classes; see the class id index.

required
alignment Alignment

Lawful, neutral, or chaotic.

required
ruleset Ruleset

The ruleset in play. Its hp_reroll_at_first_level flag governs whether a poor hit die is thrown again.

required
stream RngStream

The stream for the creation draws, conventionally streams.get(CHARACTER_CREATION_STREAM).

required
adjustment AbilityAdjustment | None

An optional trade of points between abilities, built as an AbilityAdjustment. It lowers one or more of STR, INT, and WIS to raise a prime requisite, which is how a player buys a better experience bonus.

None
starting_spell_ids Sequence[str]

The spells written in an arcane caster's book, from load_spells; see the spell id index. It must contain exactly what the class can memorize at first level, which is one first-level spell for both the magic-user and the elf. Leave it empty for every other class.

()
extra_languages Sequence[str]

The extra languages a high INT bought, from load_languages; see the language id index. An INT of 12 or less allows none.

()
purchases Sequence[tuple[str, int]]

What to buy from the starting gold, as (item_id, lots) pairs bought in the order given. item_id comes from load_equipment; see the equipment id index. A lot is the catalog's unit of sale: weapons and armour sell one at a time, so lots is how many, while gear and ammunition sell in fixed bundles, and one lot of torches is six torches.

()
equip_ids Sequence[str]

Which of the bought items to wear or wield, in order. An item must have been bought first, and the class's armour and weapon policies must allow it.

()

Returns:

Type Description
CharacterCreationResult

The finished character together with the dice creation rolled, so you can show a player how

CharacterCreationResult

they came out.

Raises:

Type Description
ValueError

On the first illegal decision: an unknown class, spell, language, or item id; scores that miss the class requirements; an adjustment the class forbids; a spell book of the wrong size; more languages than the INT allows; a purchase the starting gold cannot cover; or an item the class may not equip. Drive the stepwise functions yourself when you need to know which choices failed and why.

Examples:

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.party import Party

streams = RngStreams(master_seed=2)
stream = streams.get(CHARACTER_CREATION_STREAM)
result = create_character(
    name="Rurik",
    class_id="fighter",
    alignment=Alignment.LAWFUL,
    ruleset=Ruleset(),
    stream=stream,
    purchases=[("sword", 1), ("leather", 1)],
    equip_ids=["sword", "leather"],
)
character = result.character
print(character.name, character.level, character.max_hp, character.armour_class)
# Rurik 1 8 6
print(character.inventory.worn_armour.template.id, character.inventory.purse.gp)
# leather 30
print(result.gold_roll.total, result.hit_point_roll.rolls)
# 60 (7,)

party = Party(members=[character])
print(party.movement_rate(Ruleset()))
# 90

party_from_document

party_from_document(document: Mapping[str, object]) -> list[Character]

Rebuild the characters in a document written by party_to_document.

Each character is validated on the way in, so a malformed member fails the whole load rather than producing a half-built roster.

Parameters:

Name Type Description Default
document Mapping[str, object]

The stamped envelope, as returned by party_to_document or read back from JSON.

required

Returns:

Type Description
list[Character]

The characters, in the order they were stored.

Raises:

Type Description
ContentValidationError

If the envelope is not a party document, if its payload contains no "characters" list, or if a member does not validate as a character.

SaveVersionError

If the document's schema version is newer than this library understands.

party_to_document

party_to_document(characters: Sequence[Character]) -> dict[str, object]

Return a group of characters as one JSON-ready document, stamped with its versions.

Use this to store a roster you build once and reuse, like a set of pre-generated characters a front end offers at the start of a game. It writes the characters and nothing else: marching order, shared light sources, and the rest of a playing party's state belong to Party and are saved with the session by osrlib.persistence.

Read it back with party_from_document, which returns the characters in the order you passed them.

Parameters:

Name Type Description Default
characters Sequence[Character]

The characters to store, in the order you want them back.

required

Returns:

Type Description
dict[str, object]

The stamped envelope, whose payload contains the serialized characters under

dict[str, object]

"characters". Every value is a JSON type.

Examples:

from osrlib.core.alignment import Alignment
from osrlib.core.character import (
    CHARACTER_CREATION_STREAM,
    create_character,
    party_from_document,
    party_to_document,
)
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset

stream = RngStreams(master_seed=2).get(CHARACTER_CREATION_STREAM)
roster = [
    create_character(
        name=name,
        class_id="fighter",
        alignment=Alignment.LAWFUL,
        ruleset=Ruleset(),
        stream=stream,
    ).character
    for name in ("Rurik", "Alia")
]
document = party_to_document(roster)
print([member.name for member in party_from_document(document)])
# ['Rurik', 'Alia']

roll_ability_scores

roll_ability_scores(stream: RngStream) -> AbilityScoreRolls

Roll 3d6 for each of the six abilities, in the SRD's order: STR, INT, WIS, DEX, CON, CHA.

This is the first step of creating a character by hand. Show the result to the player, then pass the scores to validate_class_choice to find out which classes they qualify for. If the player wants to trade points between abilities, run apply_adjustment after the class is chosen, the order the SRD sets.

Call create_character instead when the choices are already known and you only want the finished character.

The draw order never changes. It is what makes a seeded creation reproducible, so the same stream at the same position always yields the same six scores.

Parameters:

Name Type Description Default
stream RngStream

The stream to draw from, conventionally streams.get(CHARACTER_CREATION_STREAM). Eighteen draws are consumed.

required

Returns:

Type Description
AbilityScoreRolls

The six scores and the three dice behind each.

Examples:

from osrlib.core.character import CHARACTER_CREATION_STREAM, roll_ability_scores
from osrlib.core.rng import RngStreams

stream = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM)
rolled = roll_ability_scores(stream)
print({ability.value: score for ability, score in rolled.scores.items()})
# {'str': 14, 'int': 6, 'wis': 9, 'dex': 10, 'con': 11, 'cha': 11}

roll_hit_points

roll_hit_points(definition: ClassDefinition, con_modifier: int, ruleset: Ruleset, stream: RngStream) -> HitPointRoll

Roll a first-level character's hit points: the class hit die plus the CON modifier, at least 1.

Call it after the class is chosen and the scores are adjusted, so the CON modifier you pass is the final one. Get that modifier from load_ability_tables().hit_point_modifier(score), or read hit_point_modifier off a character that already exists.

Use level_up for hit points gained later. This function is for first level only, and it is the only one that honours the re-roll option.

With hp_reroll_at_first_level on in the ruleset, a die showing 1 or 2 is thrown again, and again, until it shows 3 or more. The test is on the raw die, before the CON modifier, and every throw is kept in the result.

Parameters:

Name Type Description Default
definition ClassDefinition

The character's class, whose progression row gives the hit die.

required
con_modifier int

The CON hit point modifier for the final scores. It may be negative, and the total is floored at 1 either way.

required
ruleset Ruleset

The ruleset in play, read for the hp_reroll_at_first_level option.

required
stream RngStream

The stream to draw from, conventionally streams.get(CHARACTER_CREATION_STREAM).

required

Returns:

Type Description
HitPointRoll

Every die thrown and the hit points to start with.

Examples:

from osrlib.core.character import CHARACTER_CREATION_STREAM, roll_hit_points
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.data import load_classes

fighter = load_classes().get("fighter")
stream = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM)
print(roll_hit_points(fighter, 0, Ruleset(), stream))
# rolls=(2,) hit_points=2

rerolling = Ruleset(hp_reroll_at_first_level=True)
stream = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM)
print(roll_hit_points(fighter, 0, rerolling, stream))
# rolls=(2, 8) hit_points=8

roll_starting_gold

roll_starting_gold(stream: RngStream) -> RollResult

Roll a new character's starting money: 3d6 × 10 gold pieces.

This is the last draw of creation. Put the total into the character's purse (character.inventory.purse.gp), then spend it with validate_purchase and purchase from the equipment catalog. create_character does all of that for you when you pass purchases.

Parameters:

Name Type Description Default
stream RngStream

The stream to draw from, conventionally streams.get(CHARACTER_CREATION_STREAM). Three draws are consumed.

required

Returns:

Type Description
RollResult

The RollResult, whose total is the starting gold in gold

RollResult

pieces, between 30 and 180.

Examples:

from osrlib.core.character import CHARACTER_CREATION_STREAM, roll_starting_gold
from osrlib.core.rng import RngStreams

stream = RngStreams(master_seed=11).get(CHARACTER_CREATION_STREAM)
rolled = roll_starting_gold(stream)
print(rolled.rolls, rolled.total)
# (3, 1, 3) 70

validate_class_choice

validate_class_choice(scores: dict[AbilityScore, int], definition: ClassDefinition) -> list[Rejection]

Check whether a set of rolled scores meets a class's minimum requirements.

Call it after roll_ability_scores and before the ability adjustment, which is the order the SRD sets: a player picks a class they qualify for, then trades points. Run it over every class in load_classes().classes to build the list of classes to offer.

It reports rather than raises, so you can show a player why a class is closed to them. Each failure names the ability, the minimum the class requires, and the score that fell short. The demi-human classes are the ones with requirements: the dwarf and the halfling require CON 9, the elf requires INT 9, and the halfling also requires DEX 9.

Checking before adjustment is safe for the Classic classes, because the adjustment step can only lower STR, INT, and WIS and never below 9, which is every requirement's minimum. A class added later with a higher minimum on a lowerable ability would need a second check after the adjustment.

Parameters:

Name Type Description Default
scores dict[AbilityScore, int]

The rolled scores, before any adjustment.

required
definition ClassDefinition

The class the player chose, from load_classes().get(class_id).

required

Returns:

Type Description
list[Rejection]

One Rejection per unmet requirement, empty when the

list[Rejection]

class is open to these scores.

Examples:

from osrlib.core.abilities import AbilityScore
from osrlib.core.character import validate_class_choice
from osrlib.data import load_classes

scores = dict.fromkeys(AbilityScore, 12)
scores[AbilityScore.CON] = 7
rejections = validate_class_choice(scores, load_classes().get("dwarf"))
print([(rejection.code, rejection.params) for rejection in rejections])
# [('creation.class.requirements_not_met', {'class': 'dwarf', 'ability': 'con', 'minimum': 9, 'score': 7})]
print(validate_class_choice(scores, load_classes().get("fighter")))
# []

validate_extra_languages

validate_extra_languages(definition: ClassDefinition, int_score: int, choices: Sequence[str]) -> list[Rejection]

Check the extra languages a high INT lets a character pick.

A character speaks their alignment tongue and whatever their class grants for free. An INT of 13 or more buys extra languages on top, one to three of them. An INT of 12 or less buys none. Ask load_ability_tables().additional_languages(int_score) how many the player may take, offer the choosable entries from load_languages, then check the picks here before storing them on extra_languages.

A pick is refused when it is not a choosable language, when it repeats another pick, when the class already grants it, or when the player took more than the score allows. Each refusal names the language, so you can show the player which pick to change.

Parameters:

Name Type Description Default
definition ClassDefinition

The chosen class. Its own languages may not be taken again as extras.

required
int_score int

The final INT score, after any adjustment.

required
choices Sequence[str]

The chosen language ids, from load_languages; see the language id index.

required

Returns:

Type Description
list[Rejection]

One Rejection per problem, empty when every pick

list[Rejection]

stands.

Examples:

from osrlib.core.character import validate_extra_languages
from osrlib.data import load_ability_tables, load_classes

fighter = load_classes().get("fighter")
print(load_ability_tables().additional_languages(13))
# 1
print(validate_extra_languages(fighter, 13, ["elvish"]))
# []
rejections = validate_extra_languages(fighter, 9, ["elvish"])
print([(rejection.code, rejection.params) for rejection in rejections])
# [('creation.languages.too_many', {'allowed': 0, 'chosen': 1})]

validate_starting_spells

validate_starting_spells(
    definition: ClassDefinition, catalog: SpellCatalog, spell_ids: Sequence[str]
) -> list[Rejection]

Check a starting spell book against what the class may have at first level.

An arcane caster, which in the Classic classes means the magic-user and the elf, begins play with as many spells written in the book as they can memorize, so at first level that is exactly one first-level spell. Offer the player the first-level spells of their class's list from load_spells, check the pick here, then write it with choose_starting_spells. Whether the player or the referee picks is the game's decision. This function only says whether a pick is legal.

A pick is refused when the spell id is unknown, when it repeats, when it belongs to the other spell list, or when the number chosen at any level does not match the capacity exactly. Too few is refused as well as too many, because the SRD gives a starting book a fixed size. A cleric starts with no book at all, since clerical spells come from a deity rather than from writing, so any pick for a cleric or for a non-caster is refused outright.

Parameters:

Name Type Description Default
definition ClassDefinition

The character's class.

required
catalog SpellCatalog

The spell catalog from load_spells.

required
spell_ids Sequence[str]

The chosen spell ids; see the spell id index.

required

Returns:

Type Description
list[Rejection]

One Rejection per problem, empty when the book is

list[Rejection]

legal.

Examples:

from osrlib.core.character import validate_starting_spells
from osrlib.data import load_classes, load_spells

catalog = load_spells()
magic_user = load_classes().get("magic_user")
print(validate_starting_spells(magic_user, catalog, ["sleep"]))
# []
rejections = validate_starting_spells(magic_user, catalog, ["sleep", "magic_missile"])
print([(rejection.code, rejection.params) for rejection in rejections])
# [('magic.book.capacity_mismatch', {'spell_level': 1, 'capacity': 1, 'chosen': 2})]
cleric = load_classes().get("cleric")
print([rejection.code for rejection in validate_starting_spells(cleric, catalog, ["cure_light_wounds"])])
# ['magic.book.not_arcane']